Scan the QR printed on a delivery note, see what it is, and act on it:
reprint, photograph the signed copy, or walk it toward invoicing. It is its
own route group outside `(app)` so it gets a clean full-bleed layout and its
own manifest — "add to home screen" should give the operator a scanner, not
the CRM. Mirrors the pattern `(counter)` already uses.
Two detectors, not one. `BarcodeDetector` is fast and allocation-free but is
Chrome-only: Safari/iOS still has it "Under Consideration" (WebKit bug
#254573) and Firefox does not ship it. A scanner that only used it would
simply not work on an iPhone. So native where it exists, jsQR everywhere
else — pure JS, so no 2 MB WASM payload on a weak warehouse signal, and the
delivery-note QR is always a QR code. Both run against the same canvas frame
so the caller never branches. Also requires HTTPS, which the page says
plainly when it isn't.
The QR carries the human number, so the first call is a new resolve endpoint
mapping number -> PB id. Everything after that speaks ids, which is what every
existing route already takes. The number is shape-validated before any network
call, so a supplier's packing label can't fire a lookup.
STATUS LADDER. mark-invoiced hard-refuses anything that isn't po_received, and
customer-po refuses a note that isn't sent/loaded — so rather than offering a
button that 500s, each action only appears when the server would accept it, and
the card shows which step comes next. It routes draft -> loaded -> po_received
-> invoiced via the existing load-dn, NOT draft -> sent, because draft -> sent
means "create a quote and email it" and a warehouse scan must never trigger
that silently.
The signed-copy upload deliberately uses the upload-only endpoint, not
/api/v1/deliveries/mark-signed-copy — that one flips the note to `completed`
in the same write, which would jump a note straight past invoicing.
Manual entry is always available, so the page works on a device with no camera
or a browser that won't scan.
Tests decode the REAL delivery-note QR through the production fallback path at
print size, and assert the shape check rejects a supplier QR, a random string
and an over-long number.
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
An account sale at the till used to produce a delivery-note-shaped slip that
was rendered entirely on the device and printed on the 80mm thermal printer.
Nothing reached the server, so the paperwork the client signed had no record
behind it. Cash / card / EFT are untouched and still get the thermal slip.
An account sale now creates a real `delivery_notes` record. The DN is raised
server-side INSIDE `checkoutSale` rather than by a follow-up call from the
till, because the offline queue (`flushQueue`) re-posts `/checkout` and
nothing else — a client-side second call would be silently dropped for every
sale taken while the network was down, which is exactly when a till is
flakiest.
`pos_sales.delivery_note` is the idempotency anchor that makes it
exactly-once: on a replay it hands back the existing DN instead of making a
second one, and a sale whose first attempt left the field blank retries. It
self-heals.
Deliberately non-fatal: if the DN can't be created the sale still succeeds.
The money and the stock movement are real by then; failing the checkout would
leave the drawer disagreeing with the stock ledger. The till is told instead.
Also in here:
- DN number collision retry. The generator reads the highest existing number
and adds one, which is not atomic, while PB holds a unique index on it. The
POS adds a second concurrent writer, so two creates could collide and 500
on a benign condition.
- GET /api/v1/pos/sales now expands customer, customer.company and
delivery_note so history.html can rebuild the DN card on a reprint. The
nested customer.company matters — customer.company is itself a relation,
so without it the card printed a raw PB id where the name belongs.
- "Print slip" is hidden for account sales; that tender produces a delivery
note, not a slip.
New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind
assertPosAuth, so the till's bearer key stays its only credential. It does not
point at the CRM's unauthenticated invoicing route.
Verified end to end against a live note: DN26100401, correct company, both
lines with pricing, PDF attached, stock decremented once, and a replay of the
same client_ref returned the same DN without a duplicate.
`iframe.contentWindow.print()` does not work for a PDF in Chrome. Chrome backs
every PDF — src URL or blob URL — with its internal `pdf_embedder` extension
rather than a real DOM document, so the frame's `contentDocument` is an empty
shell:
<html><head><link rel="stylesheet"
href="chrome-extension://…/pdf_embedder.css"></head>
<body></body></html>
The PDF *paints* — you can see it in the preview — but `print()` runs against
that empty body, so the operator gets a print dialog full of blank pages and
no error to explain why. Read straight out of a live page to confirm.
A new tab gets Chrome's real PDF viewer, whose own print button works. Same
document, no blank pages.
Affects the delivery-note preview, the quote PDF viewer, and the second quote
viewer in the same file. Popup-blocked is now reported explicitly rather than
appearing to do nothing.
Every renderer funneled the PDF through a JS string and re-encoded it as
latin1. Three separate problems, only the first of which was ever real:
1. `pdf(el).toBuffer()` is NOT the buffer API — it resolves to the PDFKit
*stream* (react-pdf carries a TODO to rename it `toStream`), and under
jsdom it resolves to a bare PDFDocument handle. That is the jsdom
breakage an old comment described, but it applies to that method, not
to the module-level `renderToBuffer()` helper.
2. `toString()` renders with `compress: false`. react-pdf's own source
says why: "when rendering to string if compress=true the document is
blank". The method is flagged upstream as buggy (#2112, #2095).
3. The string round-trip mangles binary streams. Text is ASCII in the
content stream so it survives; a raster image is a compressed blob
that does not — the image object lands in the file but is never
painted. Poppler was already reporting "Bad FCHECK in flate stream".
It stayed latent because the only image in these documents is the letterhead
logo, and that logo is an SVG. SVG is text, so it round-tripped perfectly and
hid the whole thing. `0xFD` in the old comments is the fingerprint: latin1's
truncation of U+FFFD, the Unicode replacement char.
`renderToBuffer()` fixes all three in a single pass and works under jsdom, so
no fallback is needed. Note you cannot "try toBuffer, fall back to toString" —
calling `toBuffer()` first consumes the render and a later `toString()` on the
same instance returns a document with the content missing.
Three more instances of the same pattern lived outside render.tsx and are fixed
here too: the print-bundle, delivery-note-print and picking-slip routes.
`render-image-pipeline.test.ts` is the regression guard. Its size assertion is
expressed against the old uncompressed baseline rather than a round number
picked when the document was smaller — an absolute byte count is a tripwire
that fires on every feature addition, which is how a real regression gets
tuned away. Guard verified by reintroducing the old path: 2 tests fail.
A long batch (e.g. 16 stock_items.create) made the approval card taller than
the page; it sits outside the message scroller, so the Approve button was
clipped and unreachable. The card now has a bounded height (60dvh), a pinned
header holding Approve/Reject, and only the list of changes scrolls.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
actor.id must be a 15-char user id (stock_ledger.created_by is a relation to
users; a non-user id left the cost patched but unaudited). Each updates[i].id
is validated, actor.name defaults to the actor id, and the Giellie prompt now
says to pass both selling_price and markup on create and to read the item
before a partial price change. Adds tests for null / numeric-string prices
and the explicit markup:0 default-markup behaviour.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Adds the MCP tool stock_items.set_pricing (batched, max 100, snake_case in,
wraps updateStockItemsPricing, requires actor.id, audits cost changes in
stock_ledger). stock_items.create now accepts cost_price / selling_price /
markup (validated >= 0) and applies the default-markup helper like bulk
import. Giellie's prompt gets a pricing playbook (POS reads selling_price,
ask for missing cost/markup, never apply an unconfirmed markup); the tool is
classified as an approval-gated write. Tests cover route, policy, real
catalogue, bridge id guard and prompt.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An array under an id key stringifies unencoded into same-origin URL paths
(`${["../x"]}`), bypassing the string-only guard. Id-keyed values must now be
plain safe id strings; plural id arrays (item_ids, delivery_note_ids, ...) are
validated element by element. RFC 5322 message-id keys and attachment tokens
are left alone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Also log the upstream assertBrowserSession 401s for orders.review,
orders.find_duplicates and dashboard.daily_summary as an open MCP request.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- decision endpoint requires pendingKey; mismatch with the stored pending is a 409
- refuse to continue chats over 1.5M chars; compact pending.completed on save
- SSE ": ping" heartbeat every 15s, cleared on finish/cancel
- in-flight set lives on globalThis (survives HMR, shared across bundles)
- resolveOrigin ignores non-http(s) env values; log sanitised cause on tool-load 503
- approval card shows the confirm reason; trust caption updated
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- deny quotes.re_run_ai (wipes every quote item); orders.cancel and
delivery_notes.cancel/combine always ask; orders.archive is a read tool
- classifyToolCall escalates writes that touch email/contact fields to confirm,
with a reason stored on the pending tool use
- system prompt: real cancel tools, SAST date
- bridge rejects @collection./@request. filter references and path-shaped ids
- agent: blank-text responses become the placeholder, duplicate pending ids
answered once, clearer "result unavailable" wording
- catalogue test checks the policy against the real MCP tool list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field.
- Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability.
- Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications.
- Enhanced the `package.json` with a new migration script for setting up the audit log functionality.
This feature improves accountability and transparency in the management of delivery notes and orders.
The per-row in-stock checkbox flips item_status to ready_invoice
(legacy behaviour, no PO created by that path). The previous
markInStockReady filter required item_status in
[pending, processed] and the render used that same filter to
gate the button count. Result: the moment a line was ticked
in-stock via the checkbox, it dropped out of that filter and
the {supplier} stock PO button disappeared, leaving the
operator no way to fire the paper-trail PO from the same page.
Loosen both filters to in_stock=true AND purchase_order is
empty, regardless of item_status. The bottom button now stays
clickable after a per-row tick, and re-clicking is a no-op
(the purchase_order guard skips already-linked lines).
Lifecycle for the item is unchanged: ready_invoice, qty_collected
= quantity, no driver collection. The PO is the only thing that
gets created when this button fires.
markInStockReady on the order-process page now creates a real
purchase_orders row + v1 PDF for the in-stock subset, addressed
to the supplier field on those lines, alongside the existing
ready_invoice + qty_collected flip.
The item lifecycle is unchanged: item_status still lands at
ready_invoice, so no driver gets dispatched. The PO is purely a
paper trail the internal stock supplier can invoice us against.
Mirrors sendPoForSupplier's create + PDF flow: status='draft',
order_item back-linked, total_amount = Σ cost_price *
(order_qty ?? quantity), then POST /api/v1/purchase-orders/[id]/pdf
to render v1 + flip to 'sent'. Pre-existing POs the lines used
to belong to are still trimmed + auto-cancelled when empty.
UI: button label '{supplier} in-stock (n)' -> '{supplier} stock
PO (n)'; Send POs card help text updated to mention the new PO.
- Enhanced `nss-fasteners-pb-import.json` to compute `selling_price` as `cost_price * (1 + 80%)` and included `markup` in the records.
- Updated `nss-fasteners-pb-transform.ts` to allow configurable CSV and output file names via environment variables, improving flexibility for different product runs.
- Added support for uploading photos in the repair detail page, including validation for file types and sizes, and implemented a dialog for creating customer quotes linked to repairs.
- Introduced a new `customer_quote` field in the `Repair` type to link generated quotes back to their respective repairs.
These changes streamline the pricing process and enhance the user experience in managing repairs and quotes.
New page lets a rep see their own rep_visits on a Leaflet map.
Defaults to last 7 days; toggleable to 14 / 30 / 90.
/rep/visits ── server-side fetch + re-export to client
/rep/visits map ── per-customer cluster, count badge, last-visit
label, popup with full per-visit timeline + notes
+ 'manual' badge, side-list of customers in window
+ search filter + heatmap toggle
Service + route (no pb-helpers):
visits-service.getVisitsForRep(repId, {from?, to?, limit?})
- admin PB (session-scoped at the route layer)
- expands customer relation, filters position_source != 'tracker'
server-side, PB date format gotcha (T → space), 5000-row cap
/api/v1/rep-visits/list GET, browser-session auth, scopes repId
from user.id (never a query param — same
hardening as POST /api/v1/visits)
Heatmap:
leaflet.heat wrapper at src/components/leaflet-heatmap-layer.tsx
- useMap() + useEffect lifecycle
- brand gradient (deep blue → green → amber → yellow → crimson)
- reads on both light OSM and dark CARTO tile sets
- maxZoom default 10 — leaflet.heat scales intensity by
1/2^(maxZoom-currentZoom); without this the OSM default maxZoom
of 18 makes points render at ~5% opacity at our default zoom 10
Other fixes landed mid-feature:
- KPI cards collapsed from 3 into 1 (clusters count is a
zoom-dependent implementation detail, not a glanceable stat)
- added 'View my visits on a map' nav link under /rep header
Dep: +leaflet.heat@^0.2.0, +@types/leaflet.heat@^0.2.5
(react-leaflet@5, leaflet@1.9.4 already present)
Tests: 50 + 34 = 84 pass in the touched (rep) area + visits-service
+ rep-visits API. Pre-existing failures on main (sidebar,
pocketbase-admin, quotes-service) confirmed unrelated by
stashing this branch.
Bulk-pin a list of customers to a PocketBase site from a rep's visit log
(typically Gary's driving-mode WhatsApp pins). Encodes the PB quirks we
hit during the Khuseleka batch:
- PB filter field on customers is 'customer' (not 'name'); fuzzy match
with customer~"X"
- coordinates is a geoPoint on both customers and rep_visits
({"lat": X, "lon": Y} GeoJSON, not a flat string)
- site_location is free-text on customers and does NOT exist on
rep_visits (PB silently drops unknown keys on writes)
- position_source is a select with ["tracker", "manual"]; manual is
the backfill marker
- PB admin auth email lives in .env.local (PB_ADMIN_EMAIL), never
hardcode
- local pb_schema-2.json snapshot is stale (missing site_location) —
live PB is source of truth
- SAST = UTC+2; convert WhatsApp timestamps before stamping
- single-letter driving-mode typos → pin the canonical PB record,
ask before assuming
- zero-hit names → skip by default; ask before creating new
customers
Same folder as wfdash-giellie; available to any agent that loads the
project's skills folder.
Without a height constraint, the base-ui bottom-sheet's
`data-[side=bottom]:h-auto` sizes the sheet to its content.
The picker renders 678 customers, so the sheet grew to
46,373px in an 809px viewport — the inner overflow-y-auto
container had nothing to scroll within, and the list rendered
above the visible area (confirmed on 2026-09-20).
Fix: add `max-h-[90dvh]` at the picker SheetContent call site.
`dvh` (dynamic viewport height) accounts for mobile browser
chrome so the sheet never gets pushed off a small screen.
`flex-1 overflow-y-auto` on the inner modal-shell__body was
already there from the picker sort/toast refactor — it just
needed a parent height to scroll within.
Regression guard: src/app/(rep)/rep/rep-picker-height.test.ts
asserts the picker SheetContent keeps the max-h cap, uses dvh
(not vh), and the inner list container still has flex-1 +
overflow-y-auto so the cap actually scrolls.
The rep check-in screen now treats pinned and unpinned customers through a
single interface instead of two stacked sections. The bottom-sheet picker
opens from a new 'Add a customer' button below the nearby list, shows every
customer with unpinned rows first, and stamps a 'no location yet — saving
will pin this spot' badge on each so the rep sees what ticking will do.
The success toast now reports the pin count when any selected customer was
unpinned (e.g. '5 visits logged · saved this spot for 2 new customers'),
giving the rep visible confirmation that coordinates were written.
Pure front-end change on top of the existing pinCustomer path — no server
or schema work. New helpers:
- picker-sort: sortCustomersForPicker + filterPickerCustomers
- visit-toast: formatVisitToast (plurals, pin suffix, offline hint)
19 new tests; rep + visits scope stays at 66/66 green.
There is exactly one printer setting (pos.printerSettings, written only
by receipt.html's "Save printer settings"), and its form lives on the
slip screen behind the "Printer: …" row — not on the Settings page.
Both cash-up error paths told the operator to open "Printer settings",
where they'd find nothing. Name the actual location instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The button has never worked. cashup.html wires it with an inline
onclick="printCashup()", but the page's whole script is IIFE-wrapped, so
printCashup was never a global — the click threw "ReferenceError:
printCashup is not defined" into the console and the button silently did
nothing. No alert, no dialog, no feedback.
Dead since 46d5d54 swapped the button from onclick="window.print()" (a
real global, so it worked) to the ePOS-Print path. receipt.html hit this
exact bug earlier and fixed it by exporting its handlers to window — with
a comment noting the buttons "all silently do nothing". cashup.html never
got the same line. It does now.
On the network transport the cash-up slip already mirrored the sale slip
(resolve 'cashup' blocks -> letterheadLogoRaster at 384 -> printViaNetwork),
so the export is the whole fix for a till on a network printer.
Also here:
- 20c and 10c added to the drawer count, with the total rounded to whole
cents. Neither is exact in binary floating point, so 3x10c would
otherwise have summed to 0.30000000000000004 and printed a variance
that reads as a rounding bug on the slip.
- The button restored its label with textContent, which dropped its
printer <svg> permanently on first use. Restores innerHTML instead.
- The Bluetooth branch quietly called window.print() (OS dialog, not the
thermal printer). getSettings() DEFAULTS to bluetooth, so any till with
cleared settings would land there and look dead all over again. It now
fails loudly, matching EposPrint.printTest()'s stance. No ESC/POS
cash-up renderer yet — only the sale slip speaks Bluetooth.
pos-app.js had the same defect in four more places: the edit-line modal's
Save / Cancel / Remove and the customer "Remove" chip are all inline
onclick handlers declared inside its IIFE, and only openEditLine was
exported — so the modal could be opened but not dismissed or committed.
Confirmed by parsing the file (one top-level statement, no top-level
function declarations), fixed with four additive export lines.
src/lib/pos-inline-handlers.test.ts guards the class across every POS
page: any identifier called from an inline on* attribute must be a
builtin, a namespaced call, or exported on window. It was red on exactly
these bugs and is green now.
SW cache bumped to pos-v26 so the tills pick the files up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In-stock rows were toggled to `item_status="ready_invoice"` without
setting `qty_collected`, so the Invoicing page's `min(qty_collected,
quantity)` rule pushed a 0-qty line to QuickBooks for every in-stock
row. The operator couldn't invoice — the goods were physically on
the shelf but the system thought nothing had been received.
Single-row `toggleInStock` and bulk `markInStockReady` now both stamp
`qty_collected = quantity` on the flip. In-stock rows still don't go
through a supplier PO (`purchase_order=""`) — the fix is purely on
the "we have these on our shelves" signal that feeds invoicing.
Backfill: ran `scripts/nss-backfill-in-stock-qty.ts` across the org.
Found 316 stale `in_stock=true` rows out of 324, stamped
`qty_collected=quantity` on each. Idempotent (second run reports 0
to do). ZA10PO26011801 (Master Drilling, M16 rod) and Q202609445
(Denmar Steel — 8 lines including nuts/washers at qty 170) verified
in PB.
Companion fix to the earlier in-stock totals exclusion: the
Invoicing page now gets the full qty for each in-stock row, while the
process-page PO totals still exclude them (since no PO is sent). Both
behaviours line up.
The invoice qty rule used to read the customer's order qty regardless
of how much the driver actually collected. QuickBooks ended up billing
8-of-10 on one line and 7-of-10 on the other for Kusasalethu PO 390588
(INV10924) — a 5-unit overbilling that the mine's receiving team flags
as suspicious.
The rule now caps the invoice qty at min(qty_collected, quantity):
- Short pickup → bill what's collected, the row stays at
item_status="ready_invoice" with qty_invoiced < quantity, and a
follow-up invoice fires automatically when the rest arrives.
- MOQ overflow → still capped at the customer's order qty; the
surplus-book flow continues to route the overflow to stock.
The Invoicing page now shows "N short" / "N units short" badges on
short-pickup rows, a "N hidden" badge when supplier-side rows are
suppressed, and a "Show supplier-side lines" toggle (default OFF) so
the invoicing operator doesn't see kit-component remainders unless she
needs to. A split-remainder dialog on the order process page lets the
operator explicitly flag a remainder as exclude_from_invoice=true when
the original line was a kit (e.g. "M20 bolt comes with nut and washer"
split across 3 suppliers).
PB schema additions (one-time, run scripts/nss-add-invoice-columns.ts):
- order_items.qty_invoiced (number)
- order_items.customer_invoice_number (text)
- order_items.invoiced_date (date)
These three columns were referenced throughout the codebase but never
existed on the live schema, so writes were being silently dropped. The
schema fix unblocks the reconcile flow that stamps them after every
QB webhook returns.
Backfill for the live Kusasalethu order: scripts/nss-fix-kusasalethu-inv10924.ts
updates order_items.quantity 10→8 / 10→7 to match what was collected,
stamps the new columns, and flips order.invoiced=true.
New server helper reconcileOrderItemInvoicedStatus() runs after each
QB webhook and per-row decides:
- item_status = "invoiced" if qty_invoiced >= quantity (fully billed)
- item_status = "ready_invoice" otherwise (still partial — stays in
the to-invoice tab for a follow-up invoice once the rest arrives)
Wired into both the bulk invoicing page and the delivery-note-invoice
route. markOrderInvoiced now compares against qty_invoiced vs
quantity rather than the flat item_status check, and its error
message names the outstanding shortfall per row.
The active/inactive customer switch was buried under the
'Details' section after the sales rep and description — easy
to miss when the only thing the operator wants to do is mark
a customer as inactive.
Moved it to the second row of the form, immediately after
the customer name field, where it's the first decision the
operator makes. Visual treatment bumped to match the new
prominence: a green-tinted container when active, muted
grey when inactive, so the state is readable from across
the room instead of requiring the operator to read the
trigger label.
state semantics (unchanged from bbfa888):
- 'status' is a PB bool, true = active, false = inactive
- new customers default to true via the form's
'status: customer?.status ?? true' initializer
- legacy rows with status undefined render as Active on
both the detail and list pages (same default-active
contract on read) — no migration needed
- the only way to render an inactive customer is to flip
the switch and save it; PB then stores false
This commit enhances the stock item management by introducing automatic calculation of selling price and markup based on the cost price. Key changes include:
- Integration of `applyDefaultMarkupToPatch` to automatically set `selling_price` and `markup` at 80% of the cost price when they are not explicitly provided during stock item creation and updates.
- Updates to the `create` and `update` functions in the stock items service to ensure that existing values are respected and not overwritten if they are already set by the operator.
- Tests added to verify the correct behavior of auto-markup during both creation and updates of stock items, ensuring that the functionality works as intended across various scenarios.
This feature aims to streamline the pricing process for stock items, reducing manual input and potential errors.
Two new fields added to the customer add/edit form, both
operator-facing for the active/inactive customer workflow:
- customers.status (bool): flips the customer active/inactive.
Legacy rows pre-date the field and load as undefined; both
the form and the read side collapse that to active=true so
the migration date doesn't silently flip the existing base.
- customers.site_location (text): free-text site/shaft label
keyed off the customer's company. Distinct from the existing
'site' free-text mine tag owned by the pin tool. Storage is
plain text on purpose, with a /api/v1/customers/sites
endpoint returning distinct existing values for an input
+ datalist autocomplete under the company combobox.
surfaces:
- Active/Inactive badge on customer detail and list rows
- site_location row in the customer Details card and under
the company line on the list (desktop + mobile card)
- Switch under the sales-rep section of the form to flip
status; existing customers stay active by default
also fixes a UX trap on edit: previously opening the edit
form showed 'Select company...' / 'Select rep...' as placeholders
for an instant until the lookup lists resolved, making a real,
saved company/rep look unlinked. CompanySearch and UserSearch
now accept a selectedLabel fallback; CustomerForm passes the
resolved customer.expand.company / customer.expand.rep names
through so the triggers display the linked names immediately.
listDistinctSiteLocations helper added to customers-service.ts
alongside the existing listDistinctSites.
Doubles down on the /stock list to make catalogue and inventory
edits one-click for the operator.
What's new:
- /stock: clickable column headers on Cost / Markup / Selling
cycle none → asc → desc → none and feed through to the PB sort
param. The apply bar now commits pricing AND qty in parallel
(Promise.all against /set-pricing and /set-quantities) with
per-row failure tracking and focused error toasts. In edit
mode the row is no longer a button — inputs render in-place
for current_qty, min_qty, and target_qty alongside the existing
pricing inputs, and the row click is gated off so clicks on an
input never navigate to the detail page.
- stock-quantities-service + /api/v1/stock/items/set-quantities
(POST): bulk-update current_qty / min_qty / target_qty on a
set of stock_items rows. Whitelist-validated patches, per-row
results with stable failure reasons, and a stock_ledger row
written FIRST whenever current_qty actually moves so the audit
trail stays ahead of the cached total (same pattern as
stock-surplus and stock-booking-service).
- scripts/apply-default-markup.ts: one-shot operator tool that
seeds selling_price = round(cost_price * 1.80, 2) and markup = 80
on stock_items rows whose cost_price is set but selling_price
is zero. Bypasses STOCK_ITEM_PATCHABLE_FIELDS (operator-initiated
bulk action); idempotent (never overwrites a non-zero shelf
price or markup).
- scripts/list-irda-stock.ts: read-only inspector for the
IRIDA spray-caint line with summary counts (without
selling_price, without cost_price, zero stock) so the
operator can see exactly which rows the backfill tools above
will touch.
Tests: 81 new test cases across /stock page, stock-quantities
service + route, and apply-default-markup.
Closes the pre-Fix-#1 stock pricing gap so the till and the POS see
prices on day one.
What's new:
- price-list-service: addPriceListItems now mirrors the upserted
selling_price + markup onto the matching stock_items row when
(and only when) the stock row has no price yet. Sync failures
are returned as warnings — never as a failed price_list write,
so a single broken stock row can't poison a 500-item batch.
- stock-quick-draft-service: addQuickDraftLine seeds cost_price +
bootstraps a one-entry cost_history on inline-create when the
buyer supplies newStockItem.costPrice. Existing-item path
backfills cost_price when zero and always appends a
cost_history entry so the line addition shows up in the audit
log without waiting on bookStockFromInvoice.
- stock/items/create (POST): accepts optional costPrice,
sellingPrice, and markup on the standalone create endpoint so
the operator can stamp a price and bootstrap cost_history in
one round-trip. Empty bodies still produce an empty
cost_history (legacy callers unchanged).
- scripts/backfill-stock-prices.ts: one-shot operator tool that
mirrors price_list rows onto existing stock_items rows whose
selling_price is still zero. Pure decision function exported
for unit tests; --commit / --limit / --sku CLI; idempotent
(never overwrites a non-zero shelf price or cost).
- vitest.config.ts: pull scripts/**/*.test.ts into the vitest
include list so backfill-stock-prices.test.ts runs in
`npx vitest run` without a per-script config.
Tests: 65 new test cases across price-list-service,
stock-quick-draft-service, stock/items create, and
backfill-stock-prices.
Two operator-facing bugs on /deliveries:
1. Page grouped rows by customer instead of company. Added a
CompanyGroup layer above CustomerGroup with a new
groupByCompanyCustomerOrder() that builds
Company → Customer → Order → Items + DNs (4-level hierarchy).
Each level sorted stably so the most recent work sits on top.
2. The delivery-note expand panel said 'No items loaded on this
delivery note' for every DN, even when the same DN had items
on the full /delivery-notes/{id} page. Two root causes:
a. Wrong join key. The page filtered by
i.delivery_note === dn.id (the never-populated relation
field), so the comparison was always null === id and
never matched. The full DN page joins on the text column
delivery_note_number — switched the page to the same key.
b. Items missing from the response. The page-level items
list is filtered by item_status (invoiced / with_driver /
checked_in) per tab, so items still in the 'delivery_note'
lifecycle state (paper sent, invoice not yet created)
weren't in the page-wide list. Added a third PB query in
the service that runs after the DN list lands and pulls
every item whose delivery_note_number matches one of the
page's DNs, regardless of item_status. Returned as a new
dn_items field and threaded through to the expand panel
as a 'dnItems' prop. The main items list still uses the
status-filtered query so bulk actions stay scoped to the
tab.
The first letterhead-persistence fix (1e3121d) gated the WHOLE PB
write for generateQuotePdf's operator-choice persistence behind
"quote.status is not terminal" -- because terminal statuses
(approved/emailed/complete/cancelled/sending) deliberately skip the
pdf_generated_at re-stamp.
But regenerating a PDF on an already-terminal quote (e.g. clicking
"ISherwood" on an already-`emailed` quote from the process page) is
a normal, supported operator action -- confirmed live: it renders
the right letterhead and toasts "quote kept at emailed". That regen
hit the terminal branch and skipped the PB write entirely, so
company_letterhead never updated. A later auto-regen (sendQuoteEmail's
staleness check, e.g. triggered by a markup edit) then fell back to
the stale stored value and shipped the wrong letterhead again --
reproducing the original bug through a path the first fix didn't
cover.
Verified against the live quote via a direct PocketBase read
(bypassing app/MCP caching): company_letterhead was unchanged by the
first fix's terminal-status regen, and is now correctly persisted
while pdf_generated_at/status stay untouched as intended.
Split the letterhead write out from the timestamp/status write so it
always persists on an explicit operator choice regardless of quote
status. Added a regression test for the terminal-status + explicit
letterhead case.
Also syncs the n8n "Quote Outbound Send" workflow snapshot with two
more live fixes made during testing:
- Build attachments list: this.helpers.getBinaryDataBuffer() takes
an item INDEX (number), not an item object -- per n8n's own docs.
Was passing the item object, which threw an opaque non-Error value
on every real send.
- Send Email (SMTP): the html field injected the plain-text
email_message (with literal \n breaks) straight into HTML, so
clients collapsed every paragraph into one block. Now converts \n
to <br> first.
Also captures that the four WhatsApp-notify nodes and the workflow
itself are currently disabled/inactive in the live instance (manual
n8n edits during testing, not part of this fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit addresses the silent letterhead-flip bug reported on 2026-09-09, where the operator's selected letterhead was not being saved, leading to incorrect letterhead being used in subsequent PDF generations. The operator's choice is now persisted in the quote row during PDF generation, ensuring that the correct letterhead is used for future auto-regens and email sends.
Changes include:
- Updated the `generateQuotePdf` function to include the operator's letterhead choice in the database write.
- Adjusted the API response to surface the persisted letterhead for UI synchronization.
- Added tests to verify that the letterhead choice is correctly saved and not overwritten by empty or invalid inputs.
This fix enhances the reliability of the quote generation process and improves user experience by maintaining the operator's selections.
The 2026-09-09 attachment split (cd359b7) moved outbound/internal
attachments to `quotes.outbound_attachments` / `internal_attachments`,
migrating the approvals and process pages but missing the main quote
detail page. It was still reading/writing the legacy
`quote_attachments` collection (`direction="outbound"/"internal"`),
which nothing downstream reads anymore.
Effect: the Attachments tab and Approve & Send modal on this page
couldn't see files uploaded via the new field, so the modal's
"selected attachments" checkboxes were built from an empty list —
operator-attached outbound files silently never made it into
`email_outbound_attachment_ids`, and the customer got an email with
no attachments even when the operator had attached one.
- Outbound/internal tabs, upload, and delete now go through
`/api/v1/quotes/[id]/outbound-attachments` and `/internal-attachments`
instead of writing `quote_attachments` rows directly.
- Send modal's attachment list is synthesized from the quote row's
token fields (same pattern as the approvals page), so
`selectedAttachmentIds` actually match what the server validates.
- Attachment preview dialog fetches via the `/api/v1/files/quotes/...`
proxy instead of PB's raw `/api/files/quote_attachments/...`, which
pointed at the wrong collection/id after this fix.
Also fixes the n8n "WFDASH — Quote Outbound Send" workflow: three
Code nodes filtered quote_attachments rows to filenames containing
'Quote-CNL' or 'Quote-ISHERWOOD' before picking the latest PDF. Since
PDF filenames are `Quote-{letterheadCode}-{number}.pdf` and there's a
third valid letterhead (WESTSCOTT GROUP OF COMPANIES), any quote sent
under that letterhead would starve the whole send chain of an input
item and silently no-op. The upstream PB query already scopes
correctly by quote id + kind, so the filter was redundant as well as
wrong — removed it, kept the latest-PDF-per-quote dedup. Repo
snapshot in n8n_workfows/ synced to match the deployed workflow
(PIEAkPYYYZLGl6Pj).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Splits quote attachments into internal (operator-only references) and
outbound (customer-facing files), wires the operator upload UI, fixes
the deployed Outbound Send workflow to actually attach operator files,
flips the failure status to mail_failed so retries work, and ships the
Email Monitor reply-branch + reply-detection fixes.