Tabs
- Cancelled no longer has its own tab. The operator asked for it to
sit under the done heading with a filter between the two, rather
than spending a tab on a small cold set nobody visits. Tabs are
now Active | Completed | All.
- Inside Completed, an always-visible Completed / Cancelled / Both
sub-filter, each option carrying its own count. It is rendered in
the open, not behind a click — an invisible control on this screen
was the original "what does that do?" complaint, so a hidden
filter would have reintroduced it. Defaults to "both" so the tab's
count and its rows always reconcile.
- Cancelled keeps a red badge, and no row tint. It now shares a tab
with Completed, so the two must be distinguishable at a glance
without opening each row.
Search (was quietly broken)
- It only covered `delivery_note_number` and `notes`. Customer name,
company and order number were NOT searched — so "what's
outstanding for Acme", the first thing anyone types on a list
grouped by company, returned nothing.
- The search term was never escaped into the PocketBase filter,
while the `repId` clause directly below it escapes its quotes. A
customer called `Acme "Ltd"`, or any stray double quote, produced
a malformed filter and a list that failed to load.
- Search is now client-side over the already-fetched notes, which
carry customer / company / order expanded. Covers DN number, order
number, customer, company, the customer's own PO and notes. Every
word in a multi-word query must match somewhere on the row, so
"acme bolt" narrows rather than returning nothing. No round trip
per keystroke.
Also removed
- Dead pagination state. The page fetched a single wide page and
handed the DataTable page={1} totalPages={1} with a no-op
onPageChange, so page/setPage/totalPages could never do anything.
That also retires the `setPage(1)` the search handler was calling.
Empty state now distinguishes "nothing matches your search" (and
names what is searchable) from "nothing on this tab".
Tests
- 30 in delivery-note-stage.test.ts, covering the search matcher and
the tab/sub-filter bucketing. Verified they bite by reverting the
search to its old two-field coverage: 5 fail.
- matchesDoneFilter refuses an in-flight status even under "both".
It didn't need to — the filter only runs inside the Completed tab —
but a function that would return a draft for "both" relies on its
call site staying correct, so it now guards itself.
Full suite: 3630 passing. tsc clean. No new lint warnings.
The Create quote button fired blind: the quote appeared with whatever
pricing happened to be on the order items, and a wrong price was only
discoverable by finding the quote afterwards. A new review modal puts
the check in front of the create.
Modal (components/delivery-notes/quote-review-dialog.tsx)
- Letterhead picker. DN-backed quotes never set company_letterhead, so
the renderer defaulted every one of them to CNL.
- Cost and markup per line; selling price is DERIVED, never typed, so
the two cannot disagree. See src/lib/quote-pricing.ts.
- A no-cost line shows "No cost on file" and stays at R0.00 rather
than deriving a fake price off a zero cost, which reads as a
deliberate free-of-charge item.
- Bulk "set all markups" field — typing 25% into eight lines one at a
time is the tedium the modal exists to remove.
- Totals, with margin as a share of REVENUE not cost: 25% markup is a
20% margin, and conflating them quietly under-prices a job.
- Nothing persists until Generate. Cancel discards the whole grid.
Backend
- create-quote now accepts { letterhead, items: [{ orderItemId,
costPrice, markupPercent }] } and derives selling server-side.
- Overrides are keyed by order_item id and validated against THIS
note's lines. An unknown id aborts the create rather than moving
another order's goods into this customer's quote.
- Quote items now go out as one PocketBase batch instead of a loop of
individual creates. The batch is transactional: the old loop could
fail on line 5 of 8 and leave a real quote header attached to half
its lines. The header still has to be created first (the batch API
can't reference a record created in the same batch), so a failed
batch deletes the orphaned header rather than leaving it visible.
- Malformed bodies are 400s the operator can read, not 500s. "A quote
already exists" and "not in draft" are now 409s.
Tests
- 33 new: pricing maths, and the route's validation guards. Verified
the guards bite by removing them (2 fail) and restored.
- The existing service test's PB mock gained createBatch, modelled as
genuinely all-or-nothing — the compensation path depends on that
guarantee being real.
Full suite: 3615 passing. tsc clean.
The delivery-notes list was close to unusable: company groups initialised
to an empty Set, so `isOpen` was false for every group and the page landed
on a stack of company headers with nothing under them. The code comment
claimed the opposite of what the code did.
Screen
- Track *collapsed* group names instead of open ones. Open is now the
default with no initialisation, and it survives a list re-fetch.
- Rows expand to show the real line items (description, qty, supplier,
SKU, price, subtotal) via the existing /detail read model, cached after
first open. Ported from /quotes, which already had this affordance.
- Whole-row click removed so expanding can't be mistaken for opening; the
DN number and "Open full delivery note ->" navigate instead.
- Row tint as a derived progress ladder — no new stored statuses:
none = draft · yellow = signed copy · blue = quoted · green = po_received
`invoiced` leaves the Active tab, so Active is now genuinely what is
outstanding. Hovering the status badge explains the colour.
- DataTable gained an optional `selectionHeaderLabel`; the checkboxes
feed one action (Combine) and now say so.
Stock search
- The create-delivery-note dialog was `getFullList`-ing every stock item
with expand=supplier on each open, before a character was typed. Now a
debounced typeahead against /api/v1/stock-items/search.
- Deliberately NOT the existing quick-draft search route: that one is
served with an admin client and no session check, so its result isn't
gated on the listRule. The new route uses assertBrowserSession.
Fixes
- The PDF preview modal was clamped to 384px wide and 900px tall despite
passing max-w-[1100px]. DialogContent's default `sm:max-w-sm` is a
*variant*, so twMerge kept both and the variant won on cascade order
(verified against the compiled stylesheet: 88,536 vs 258,517). Both
preview variants now repeat the `sm:` prefix so the conflict resolves.
- Expand-panel fetch was missing the Authorization header. Dashboard auth
lives in localStorage, so route handlers cannot read it from cookies and
the request 401'd. A 401 now reports an expired session instead of a
bare status code.
Notes
- 25 new unit tests. The stage ladder and the PB filter construction are
the two places where a silent wrong answer looks like a UI bug.
- Unauthenticated write routes on delivery-notes (create, combine,
create-quote, customer-po, mark-invoiced) have no session check. Left
as-is and reported separately — locking them is an auth-behaviour change.
Whether a counter sale is invoiced or quoted is a PER-CUSTOMER
commercial decision, and it belongs to whoever is at the delivery
notes list in WFDASH — not to someone standing at the van with a
phone. So the scanner is now capture-only, and the choice lives in
the list.
The scanner keeps exactly three things: read the note, reprint it,
photograph the signed copy. The ladder buttons (Mark as loaded,
Capture PO, Move to ready to invoice) are gone from it, along with
the state they needed.
In the list, the `$` action was a permanently-disabled placeholder
reading "Invoice — coming soon (v3-D)". It is now real, sitting next
to Q (create quote) on the same row so the operator picks one or the
other per customer.
Getting there needed one substantive change. The invoice route had a
deliberate guard:
"Ad-hoc delivery notes (no parent order) cannot be invoiced
through this endpoint."
which is why the button was a placeholder — a POS-raised counter
sale has `order: null`, so it could only ever be quoted, never
invoiced. The stated reason was that "the QB webhook requires an
order_number to attach the invoice to", but that requirement is
about a non-empty reference, not about it coming from an order:
`buildQbInvoicePayload` types `orderNumber` as optional and falls
back to "". The route now always supplies one — operator override,
else the parent order's number, else the delivery note number, which
is stable, unique and traceable. That same resolved value replaces
the `dn.expand.order.order_number` read in the synthetic invoice-
number fallback, which would otherwise have thrown on an ad-hoc note.
The detail page's "Invoice via QuickBooks" button was also gated on
`expand?.order`; it now shows for any non-terminal note and opens
via `?invoice=1`, so the list links to it rather than duplicating a
financial modal (QuickBooks customer search + billing-company toggle
+ order number) that would then have to be kept in step.
Also adds a Signed column to the list. No new status value: the
`draft` status is left alone as agreed, and "paperwork is back" is
shown from `signed_copy` itself, linking straight to the file. That
is what makes a scanned-and-signed note distinguishable from a
freshly-created one in the list.
No lint errors added — both set-state-in-effect errors in the two
delivery-note pages are pre-existing and verified unchanged against
HEAD.
TWO bugs, one of which was hiding the other.
1. "No signed copy on file" for a note that had just been signed,
scanned and uploaded. The upload was never the problem — the READ
path was. `delivery_notes.signed_copy` is a PB file field with
`maxSelect: 1`: we WRITE it as an array (`signed_copy: [file]`),
but PocketBase RETURNS A BARE STRING when exactly one file is
attached. Verified on the live record after a real upload:
typeof : string
Array.isArray : false
value : "image_ar52u2hjfq.jpg"
value[0] : "i" <-- indexing yields a CHARACTER
Every read site assumed `string[]`, and not one of them threw:
- signed-copy GET: `Array.isArray(x) ? x[0] : null` returned null
for notes that demonstrably had a copy — the reported error
- delivery-note detail tooltip: `signed_copy[0]` rendered the
first LETTER of the filename
- delivery-notes-service: treated every signed copy as absent, so
the quote went out with the UNSIGNED pdf attached
- deliveries-service: reported an empty filename
- deliveries page: `.join(", ")` on a string — this one THREW,
on a page people open daily
Adds `src/lib/file-fields.ts` (firstFileName / fileNames / hasFile)
and routes all of them through it. The type was the root of the
wrong assumptions, so it is now `string | string[]` with a comment
pointing at the helper.
2. QR 28pt -> 52pt, moved to the top-right corner. Measured by
rasterising the real PDF at 200 dpi and decoding: it now reads at
1.24 px/module, where the old size needed ~2.5 — roughly double the
distance and angle tolerance.
It could not simply go at top:40: the logo is an absolute 160x100pt
block in the same corner, so the QR landed on top of it. That is
not only ugly, it breaks the QR's quiet zone — logo ink inside the
light margin makes the code refuse to decode. The logo now steps
left (right: 112 -> 106, width 160 -> 132) to give the QR the true
corner, and the company name is width-bounded ONLY when a logo is
present. The Westscott letterhead has no matching .svg in
public/letterhead/, so in practice the name still gets the full
width and stays on one line.
Nine tests pin the file-field shapes, including one that asserts the
old `.join(", ")` call really does throw on a string, so the string
case cannot be quietly dropped again.
Two separate reasons the phone could not use the scanner, only one of
which is about auth.
1. NO LOGIN PATH. `(app)/layout.tsx` is the app's auth gate — it
redirects an unauthenticated user to /login. /scan is a separate
route group and inherits only the ROOT layout, which provides
`AuthProvider` — and `AuthProvider` merely EXPOSES the session, it
never renders a login form. So /scan rendered the scanner for
anyone, the first API call came back "Not authenticated.", and
there was no way forward from the page: the operator had to guess
that /login existed somewhere else in the app. Now gated to
`/login?next=/scan`, with the path run through `isSafeNextPath`.
The gate lives in its own client component because `metadata`
cannot be exported from a "use client" module — caught by loading
the page, not by tsc.
2. THE LAN ENTRY WAS BLOCKED, and the allowlist could never have
worked. `allowedDevOrigins` is matched by `isCsrfOriginAllowed`,
which splits the host on "." and compares label by label, treating
`*` as exactly ONE label. Checked against Next 16.2.6:
isCsrfOriginAllowed("192.168.1.35", ["*"]) -> false
isCsrfOriginAllowed("192.168.1.35", ["192.168.*"]) -> false
isCsrfOriginAllowed("192.168.1.35", ["192.168.1.*"]) -> true
So the `"*"` that had been sitting in the list for months did
nothing, and the list hard-coded three individual LAN addresses
(.42, .122, .128) that go stale whenever the router hands out a new
DHCP lease. When the lease moved to .35 the dev server refused the
phone's dev resources and the page hung on the spinner forever.
Now wildcards the last label of the subnet.
Also separates the two failure states the UI conflated: "Camera
blocked — this page is not on HTTPS" is now distinct from "this device
has no camera", because on a phone over plain HTTP those look
identical and the first is the one you can actually fix.
Scanning felt like it never resolved. It was not slow — it was being
downscaled into unreadability.
The printed QR is 28pt on a 210mm page: 4.7% of the page width. The loop
down-scaled the camera frame to 640px wide before decoding, so a phone
pointed at a full A4 sheet rendered that QR at ~30px total, about 1 pixel
per module. Measured against the real rendered page, jsQR cannot read it
at any crop size:
cap px/module whole frame bottom-45% ROI
640 1.04 MISS MISS
1280 2.08 miss miss
1600 2.60 HIT 69ms HIT 28ms
1920 3.12 HIT 98ms HIT 39ms
2560 4.16 HIT 171ms HIT 39ms
Above ~2.5px per module it is reliable, so the cap is now 1920. No amount
of speeding up the loop could have fixed this; the pixels were not there.
- requestAnimationFrame instead of a 120ms setInterval. rAF fires
straight after a painted frame, so the decoder gets a fresh one
rather than whichever frame happened to be current at the tick; it
also removes the 120ms floor on every detection and lets a
readyState stall recover on the next frame instead of a timer tick.
- Two regions instead of one. The delivery-note QR sits in the bottom
band of the page, so that band is scanned every frame at roughly a
third of the cost of the full frame (39ms vs 98ms). The full frame
runs every 6th as a safety net for a tilted page. It is an
accelerator, not a restriction.
- Native BarcodeDetector is now EXCLUSIVE when present. It used to
fall through to jsQR on a miss "because jsQR sometimes succeeds
where native blurs", which doubled the cost on every miss — the
common case. The native detector is Google's own ML stack; when the
browser ships it, use it alone.
- Screen wake lock while scanning, re-taken on visibilitychange
because the browser drops it whenever the tab hides. Otherwise the
phone locks mid-queue.
- 3s debounce per code, and the camera keeps running after a hit, so a
stack of notes can be worked through without a tap per sheet.
The viewfinder is now ONE <video> mounted for the life of the page,
resized when a note loads. A second copy in the note branch would have
unmounted the first — and since a MediaStream is attached to the
ELEMENT, the replacement video would have come up blank while the stream
kept running into nothing.
Four new tests pin the resolution floor, including one that documents
that a full A4 page framed at 640px is UNREADABLE, so nobody lowers
MAX_SCAN_EDGE back to 640 thinking it is just a performance knob.
Account sales at the POS now raise a real delivery note instead of a
device-rendered thermal slip, every delivery note prints a scannable QR of
its number, and a standalone /scan PWA turns that QR into reprint /
capture-signed-copy / ready-to-invoice.
Carries two fixes found on the way:
- the PDF pipeline rendered through a JS string, which left every
document uncompressed and silently dropped any raster image
- printing a PDF iframe printed Chrome's empty pdf_embedder shell,
i.e. a dialog of blank pages
Migration 2026-10-03 (pos_sales.delivery_note) is idempotent and has
already been applied to live PocketBase.
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.