Searching for a supplier returned nothing even though the order existed
with that supplier set and "TRUCO" in the line description.
Root cause is not search coverage — it's a stale safety cap. The search
path fetched the active candidate set with a fixed `perPage: 500`, on the
comment's reasoning: "91 active orders out of 4,782, so the cap is
unreachable in practice." Measured on live PB 2026-10-14 the active set
is 610 orders. Because /api/v1/orders/list sorts `created` ASCENDING
(oldest first, so the buyer works the queue top-down), the slice kept
the 500 oldest and silently discarded the 110 newest.
Verified against live PB before the fix:
truco -> 0 matches
skirting -> 0 matches
tiaan -> 57 matches
After:
truco -> 1 match (ORD-26-10-13-SCALPERS, collection_pool)
skirting -> 1 match
tiaan -> 76 matches (19 were being dropped too)
It presented as "some searches work and some don't": only matches on
OLD orders survived, so it looked like a missing supplier field rather
than truncation, and the supplier lookup added in efb5a53 inherited the
bug rather than causing it.
Fix: `perPage: activeIds.length` so the candidate set is never sliced.
The set is already bounded by COMPLETED_WINDOW_DAYS plus the non-terminal
statuses, so it grows with daily throughput, not total history.
Regression test seeds 520 active orders with the needle on the newest,
so an ascending sort puts it past the old cap. Verified it FAILS against
the old code and passes against the new.
The /orders search box only matched order number, company, customer and
line description, and only did price matching when the whole query parsed
as a clean number. Typing "afrisam" returned nothing because
order_items.supplier stores a PB record id and wasn't even fetched.
Scope is unchanged and still correct: the candidate set is the ACTIVE
order set (getActiveOrderIds) — items not completed plus anything
completed in the last 30 days. Supplier search inherits that window for
free, so "what did Afrisam supply for us?" now returns the live work plus
the last 30 days, and nothing older (that stays on /orders/archive).
Now searchable: supplier name, SKU, contact name, rep, RFQ number, order
description/notes, company address + vendor, customer email/buyer email/
phone, item notes, customer invoice number, quantity, cost_price.
Prices are matched as a substring of the plain digit string, so "12.2"
finds 12.22 and "R 1 250" finds 1250.00. Two guards keep the widened set
usable: a 1-2 char numeric query no longer probes prices ("50" is a
substring of 1250, 350 and 50, which made every price a hit), and
zero-priced placeholder lines never match a price probe. Quantity is an
exact compare for the same reason.
Supplier ids resolve to names via a targeted, chunked lookup (PB 400s on
an inline id filter past ~106 ids) rather than an expand, which would add
an IN query to every page load and realtime refetch. The supplier fetch
and the widened item field list (sku/supplier/notes) are both gated
behind an active search, so plain page loads keep the lean payload.
Tests: 9 new cases covering supplier name/partial/case, the 30-day
window, SKU, price shapes, the short-numeric and zero-price guards,
metadata fields, quantity exactness, and the no-search/no-query guard.
Full suite 3902 passing.
Three changes to the POS sale pipeline, all in one commit because
pos-service.ts / types.ts / pos-app.js are each touched by all three and
splitting them would only produce intermediate broken states.
VAT is now charged on top, not backed out
Catalogue prices are ex-VAT: `selling_price` is derived as
`cost_price x markup` from the supplier's ex-VAT cost, with no VAT
uplift anywhere (verified — median selling/cost across the catalogue is
exactly 1.8). The till nevertheless treated them as VAT-inclusive and
recovered the net with `total / 1.15`, so every sale was recorded with
an understated net and an understated VAT line.
An R100 ex-VAT line now rings up as R115. The rate comes from the till's
letterhead, read server-side rather than accepted off the wire, and is
stored per sale so a later letterhead rate change cannot rewrite what
was charged. All 3,623 catalogue prices are untouched.
Ad-hoc "custom" lines
A cashier can now ring up something that was never a catalogue item —
"bolt cutting" at R20 agreed at the counter — via a new
"+ Add a custom item" composer. The line prints on the slip and lands in
pos_sale_items, but carries no stock_item relation and takes no stock
movement: no ledger row, no current_qty decrement, no catalogue entry.
pos_sale_items.stock_item is no longer required at the schema level and
an explicit `is_custom` flag marks the line, rather than inferring it
from a blank relation — so "how much did we make on bolt cutting this
month" is a plain query, and a future stock-less line type cannot be
confused with a corrupt one.
Custom lines are refused on account sales: those raise a delivery note
the customer signs for and feed their statement, so an ad-hoc charge
must not slip onto one. Enforced server-side, not just in the UI.
Discounts are now 20% or 40%, nothing else
The R-off / %-off pills and the free-typed number box are gone. The
server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses
anything outside it rather than coercing it, so a stale till cannot
invent a discount. The discount lands on the ex-VAT subtotal, making
"20% off" a true 20% off both the net and the VAT on it.
Two defects found while doing this
pos_sales was missing six fields that checkoutSale has been writing all
along (gross_subtotal, gross_vat, gross_total, discount_amount,
discount_kind, discount_input). PocketBase accepts unknown fields
without error and silently drops them, so all 192 historical sales lack
them and a discounted sale reprinted as though no discount had been
applied. Added, plus vat_percent.
History also passed gross_total (VAT-inclusive) where the receipt
derives the gross VAT from an ex-VAT base — a reprint double-counted
VAT. Corrected.
Verification: 3860 tests pass (14 new); typecheck, lint and production
build clean; live PocketBase probe confirms a pos_sale_items row with
stock_item="" and is_custom=true round-trips, with the probe removed
afterwards.
The DN->quote batch copied cost and selling but never wrote markup, so
the approval screen showed 0% beside differing cost/selling. Write it at
creation, and derive it on the approval page for older lines.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit addresses issues with PDF handling in the invoicing process. The application now correctly uploads the PDF returned from QuickBooks to the appropriate order item, ensuring that the PDF is available for preview. If no PDF is provided, the system will return a clear message to the user instead of displaying a blank frame.
Additionally, the `readQbWebhookInvoice` function has been updated to include a `pdfBase64` field, allowing for better handling of PDF data. The `PdfPreviewDialog` component has been modified to manage the display of the PDF or an informative message when the PDF is unavailable.
Tests have been added to verify the new functionality and ensure that the application behaves as expected when handling PDF uploads and errors.
The operator's report: change the quantities, press Save & show, and
the document still carries the old numbers — it "just reverts back".
It wasn't reverting. **The button never saved anything.** Its handler
was:
onOpenChange(false);
onSaved?.(deliveryNoteNumber);
which is exactly what Cancel does — close the dialog, show the PDF. The
`save()` call lived only on "Save changes". So the operator changed the
quantities, pressed the button, got the OLD document, and drew the
reasonable conclusion that their edit hadn't taken.
Both buttons now go through `save()`, which takes a flag for whether
to show the document afterwards. The document opens only after the
save resolves, so what she sees is what she just saved.
## Ruled out along the way
Three plausible alternatives, each checked and cleared rather than
assumed:
- **Browser cache.** No — the PDF route already sends
`Cache-Control: private, no-store`.
- **Attachment stacking.** No — `regenerateDeliveryNotePdf` replaces
the file rather than appending; every item on a regenerated note
carries exactly one attachment, so reading index [0] is correct.
- **Silent regeneration failure.** No — `pdfRegenerated: true` comes
back from the PATCH, and the file's last-modified advances on each
save.
Verified in the browser on DN26100904: quantity 2 -> 9 via "Save & show
document" now persists to the database AND advances the PDF's
last-modified, i.e. the regenerated document carries the new number.
Data restored to 150/150/150/2 afterwards.
Full suite 3832 passing, tsc clean.
"QuickBooks webhook returned 400" on DN26100303, surfaced as an opaque
502. n8n (and QuickBooks) reject an invoice line with qty 0, so the send
could never succeed.
## The bug
`resolveInvoiceLineDefaults` caps the invoice qty at what was actually
COLLECTED, and guards the "nothing collected yet" case with
`collected === null`. Ad-hoc counter delivery notes don't come through
with null — they come through with `qty_collected = 0`. So they fell
through to `min(ordered, collected)` = min(11, 0) = 0.
Found by capturing the exact payload the app posts, by pointing the
webhook at a local echo server instead of calling n8n. All three lines
of DN26100303 went out as `"qty": 0`.
(That echo trick is what should have been used the first time I went
to test reachability, instead of posting a live payload to the real
webhook and creating INV30011.)
## Why the old behaviour was wrong, not just different
An existing test asserted qty 0, with the intent "don't bill for goods
that haven't arrived — show the operator a zero line and make them
follow up with the driver first."
That intent is sound. The mechanism was not: a zero-quantity line is
rejected, so the operator never saw the zero line and never got to
follow up. The intent now lives in a warning instead — the invoice
route returns `uncollectedLines`, so the operator can see which lines
have no recorded pickup before anything reaches QuickBooks.
Also fixed in the same line: `ordered ?? 1` passes 0 straight through
(`??` only falls back on null/undefined), so a row with an ordered qty
of 0 still produced qty 0. An ordered qty of 0 now falls back to 1.
Three new tests, including the invariant that no line may ever be
built with qty 0, and the exact case from your data (ordered 11,
collected 0 -> 11).
Full suite 3820 passing, tsc clean.
## Delivery note = document, Edit note = the editor
The "Delivery note" button used to open the editor, so the most common
intent — print the note, or read what it says — started in a pricing
grid. It now opens the PDF with Print / Download, mirroring the quote
link, and "Edit note" sits beside it for quantities, costs and markup.
Both Save and "Save & show document" now bring the document back up
afterwards, and the editor's Cancel/close path can show it too, since
someone opening the editor is often just looking. Six controls still
fit the row at 546px.
## The QuickBooks 502 was undiagnosable by design
The route returns n8n's own error text in an `upstream` field for a
502, and the modal threw it away. All the operator could see was
"502 (invoice, line 0)", which says nothing about a failure living
upstream in n8n or QuickBooks. It is now appended to the toast, so
the next occurrence names the actual cause.
### Not a localhost problem
Worth recording because it was the natural guess:
- the n8n webhook answers from this machine — an empty POST returns a
validation 400, not a connection error
- a well-formed POST returns 200 and creates the invoice
- `NEXT_PUBLIC_QB_INVOICE_WEBHOOK_URL` IS present in the compiled
server bundle, so the "not configured" 502 branch is not it either
So it is neither config nor reachability. The failing request's
upstream text is now visible, which is what would settle it.
NOTE: diagnosing that cost a real invoice. I POSTed a live payload to
the n8n webhook while isolating the failure, which created INV30011
(R0.00, order_number DN26100904) in the live QuickBooks book. It needs
voiding. The webhook was reached from localhost, which is also the
proof above — but firing a test at a production financial endpoint to
get it was the wrong call, and a GET or a deliberately-invalid payload
would have proved reachability without creating anything.
Full suite 3817 passing, tsc clean.
Four things, three of them the operator asked for and one a bug I
introduced earlier in this session.
## Delivery note editor, first in the flow
New "Delivery note" button ahead of "Create quote": quantity, cost,
markup and selling price per line, one Save for the whole note.
Sits FIRST deliberately. The quote is priced from the DN's lines, so
a wrong quantity or a stale cost here becomes a wrong number on a
customer-facing document. Fixing it at the source is the point.
It saves through the existing
`POST /api/v1/delivery-notes/items/[id]/pricing`, which takes cost +
selling + quantity in one call and re-renders the DN PDF. No new
endpoint. One Save rather than per-line saves, so a half-typed cost
can't be left behind on one line while the others commit.
Cost and markup are the inputs; selling is derived, same rule as the
quote review modal. A line that already carries a selling price KEEPS
it and shows the markup that price actually implies — verified live:
a line at R259.74 selling on R200 cost opened showing 29.87%, not
the 25% default, so a deliberate price is never overwritten.
## The QuickBooks invoice error
The modal refused to send when the order number was empty, and a
counter note has no parent order, so it opened blank and blocked. The
operator could not tell that an empty field was the only thing wrong.
The field now seeds with the DELIVERY NOTE number (which is what
`order_number` did before, so nothing changes for notes that have one).
QuickBooks needs a number and the DN number is real, unique and
customer-recognisable. Still editable. Verified live: a note with no
order now opens with DN26100814.
## The signed copy — stated, not toggled
The operator asked for an option to include the signed copy. There
isn't one to offer: when a signed copy exists it REPLACES the rendered
PDF at quote-creation time, so the attachment was already the signed
document. A checkbox there would be a control that cannot change
anything — the exact complaint that started this work.
Instead the send modal now says so plainly when the outgoing PDF is
the customer's signed copy.
## My bug
The editor's first fetch was a bare `fetch()` with no Authorization
header. `assertBrowserSession` 401s without it — the same mistake as
earlier this session, caught in the browser because the dialog hung on
"Loading items…". Fixed and re-verified.
Full suite 3817 passing, tsc clean, no lint warnings.
Quoteers reported markup changing to a random number after saving or
clicking away, and lines carrying markup = -100 that nobody had
edited. 51 quote_items rows were affected (40 of them exactly -100),
all with a real cost and selling_price = 0. All 51 reproduced exactly
from ((selling - cost) / cost * 100) — none were typed by a human.
Two independent defects, both price-rounding:
1. The approvals screen priced with Math.round(cost * (1 + m/100)) and
no precision argument — round to WHOLE RAND. A 30% markup on a
R0.07 washer became R0.00, and a selling price of 0 re-derives as
exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10.
A ceiling can never collapse a positive price to zero, so that
failure mode is structurally unreachable rather than merely patched.
2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07
rounded to R0.09, which re-derives as 28.6% — the "random number".
Rounding up means the price can only drift in the customer's favour.
Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost,
applied in one place so every screen inherits it. Scoped to priced
lines: an unpriced draft (no cost) is undecided, not 0% margin, and
stays writable — 1,181 live rows depend on that. Applied at derivation
(not just on save) so -100 can no longer be computed, plus
server-side in review-approve as the authoritative gate.
order_items already carried markup min=0 plus a Math.max(0, …) clamp,
which is why no order row was ever affected. quote_items had neither.
Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all
16 of the real broken rows re-priced through the new logic resolve to a
valid markup and a non-zero selling price (was 16/16 broken).
Existing negative rows are left untouched — re-pricing live quotes is a
separate, deliberate decision.
Lines were getting stuck on To Invoice cards because the page pulls
every sibling line of an order that has one ready line, and outstanding
is defined as "not ready_invoice and not invoiced". Nothing auto-advances
checked_in or collected to ready_invoice, so a card can sit there for
months with no way to express "this order is dead, take it off my
screen". Live data: 24 checked_in, 6 with_driver, 5 collected, 3
po_generated across the 16 orders that currently have ready lines.
Adds a destructive "Cancel order" button next to Invoice in QuickBooks
and Delivery Note. Order-level rather than per-line, because line items
aren't editable from this page — the buyer owns those.
The reason is required and stamped into order_item_details.cancellation
on every cancelled row. Lines already invoiced, on a delivery note, or
cancelled are left alone; the dialog states that split before you commit.
Also fixes the reason the button would otherwise be worthless:
buildOrderInvoiceCards treated cancelled and removed as outstanding, so
flipping item_status to cancelled would leave the line sitting right
where the operator was trying to get rid of it. Both now drop out.
Two requests. The second turned out to be much smaller than it
sounded.
## 1. Link an existing invoice (the backlog)
Hundreds of delivery notes were invoiced by hand before this app
existed. The invoice is already in QuickBooks; all the app needs is
the link. New "Link invoice" button beside "Create invoice" opens a
dialog that records the number and completes the note.
`POST /api/v1/delivery-notes/[id}/link-invoice` does two writes, and
the second is the one that's easy to forget:
1. `reconcileOrderItemInvoicedStatus` — stamps
`customer_invoice_number` + `invoiced_date` on the note's
order_items and moves them out of ready-to-invoice.
2. `markDeliveryNoteCompletedByInvoice` — flips the DN to
`completed` so it leaves the Active list.
Without (1) the note closes but its lines stay "ready to invoice"
forever, so the Invoicing page still shows the work as outstanding —
done in one place, unfinished in another. Items go FIRST: if that
fails the note is left open and retryable, whereas closing first
would leave a completed note with no obvious way back.
It reuses `buildReconcileRows`, the same quantity rule as the
create-in-QuickBooks path, so the invoice qty is still capped at
min(qty_collected, quantity) and a short pickup isn't stamped as
fully invoiced. A second copy of that rule is how the two paths
would disagree about what "invoiced" means.
Deliberately a SEPARATE button from "Create invoice", not a mode of
it: one fires a webhook and creates money, the other records a
number. Folding them together is how a customer gets invoiced twice.
## 2. The invoice comes back to the screen
Not a new integration at all. n8n already downloads the QuickBooks
invoice PDF and attaches it to the note's order_items as
`qb_invoice_pdf` — 1,209 items carry one today. The Invoicing page
has been printing them from PocketBase all along.
The create route now returns `invoice: { number, date, pdfFileName,
pdfRecordId }` alongside the updated DN (additive — existing callers
are untouched), and the list opens that PDF in the same preview
dialog the quote uses. Print and Download, no navigation.
The `pdfRecordId` matters: the file hangs off the ORDER_ITEM, not
the delivery note, and PB's file URL is
`/api/files/<collection>/<recordId>/<fileName>` — anchoring on the
delivery note would 404.
Verified in the browser as David Els: both buttons render in order
(Create quote · Send · Link invoice · Create invoice), the link
dialog opens on the list, and the URL never leaves /delivery-notes.
Full suite 3783 passing, tsc clean, no lint warnings.
A customer who brings goods back needs the sale reversed: the items go
back into stock and the money comes back off the till. There was no way
to mark a POS sale as refunded, so nothing stopped the same sale being
refunded twice — which credits the customer twice and puts the same
stock back on the shelf twice.
Refunds are WHOLE-SALE only by operator decision; no partial refunds.
That is what lets `pos_sales.refunded` be a boolean instead of a
per-line refund table. Three columns: `refunded`, `refunded_at`,
`refund_amount`, stamped from the sale's post-discount total, which is
what the customer actually paid.
DOUBLE-REFUND GUARD, twice over, because this is the one failure that
costs real money:
- `refunded` is read before anything else, and a flagged sale returns
409 with no stock writes at all
- the flag is stamped BEFORE any stock write, so a second concurrent
tap sees it and bails. Writing stock first and flagging last would
let both taps through.
A stock write that fails part-way does NOT roll the flag back. The
customer has their money either way, so re-running would double-credit
them; the failed lines come back in `stockErrors` for a manual count
correction, which is strictly better than a second refund.
Stock needs no schema: `return` was already in STOCK_REASON_VALUES,
just never used. Rows are written with POSITIVE change_qty and
reference_type "pos_sale_refund", leaving the original
customer_fulfillment rows untouched so the sale's own audit trail still
shows what was originally rung up.
cashup.html had expected drawer = sum of cash sales, so a cash refund
would have shown as a false variance on every cash-up after it. It now
subtracts cash refunds, and refunded sales contribute nothing to
takings for any tender.
The tender is read off the sale, never off the request — an earlier
version accepted it in the body, which would have let a caller assert
that a cash sale was really an EFT and talk the drawer out of balance.
At the till: tap still reprints, hold a sale row for 600ms to refund —
moving money sits behind a gesture you cannot do by accident. Refunded
sales stay in the list, dimmed and badged, because deleting them would
destroy the audit trail. The sheet names the items and says plainly what
happens next ("Hand back R10.57 in cash. The drawer count for this day
will drop by that amount.").
The credit slip reuses the sale renderer and the whole printer path with
`refund: true`, so there is no second printer to configure — but note
there were THREE render paths to fix (on-screen, Bluetooth text, ePOS
XML), and missing the third would have printed "TOTAL" on a credit note.
Migration applied to live PB and verified idempotent; field shapes
round-tripped on a real sale and restored. 10 new tests, 148 POS tests
pass, tsc clean, build passes. sw.js v34 -> v35 (history, cashup,
receipt and epos-print are all cache-first in the shell).
The account-sale picker filtered on three customer columns — name,
number, email. `company` on a customer row is a RELATION id, not a
name, so it could never be text-matched; the company name was only ever
resolved afterwards by `expand` for display. Typing "Sibanye" returned
nothing even though 52 people work there.
- the filter now traverses the relation: `company.company ~ q`.
Verified live: "Sibanye - Rustenburg" 0 → 52, "Adal" 0 → 2,
"Alano" 0 → 1, while "Botha" and "Smit" are unchanged at 6 and 4.
- each row carries `viaCompany`, and name matches sort above
company-only matches. PB's own sort is by name across the whole
result set, which would interleave the two bands.
- /customers returns { items, total, shown } and the till renders a
count header: "52 matches", or "Showing 100 of 122 — keep typing to
narrow". The old `slice(0, 8)` showed 8 of 52 people with nothing
to suggest 44 were missing; 15 companies have more staff than that.
The picker click handler bound to every `div` in the results box, so
adding a count header would have made it selectable and picked a
customer with an empty id. Now scoped to `.cust-row`.
Two bugs the tests caught rather than the code:
- the display cap was first set to 50 while its own comment claimed
it sat above the largest company — which has 52 staff. That
silently re-created the truncation this change removes. Now 100,
with a test asserting a 52-person company comes back whole.
- PocketBase has no escape for a literal backslash: `\'` is
accepted, but one, two or three backslashes all fail to parse and
500 the request, so typing `\` in the search box errored. No
escaping survives, so backslashes are stripped before the filter
(`M20\Nut` searches `M20Nut`) and a backslash-only query returns
nothing rather than matching every customer.
13 new tests cover the company clause, the 52-staff case, the cap,
ranking order, backslash stripping and the empty-after-strip case.
Verified live against PocketBase across 10 queries — the ranking
guarantee held on every one.
sw.js v33 -> v34: pos-app.js and pos.html are cache-first in the shell.
The "Create invoice" button used to navigate to the note's own page
with ?invoice=1, which opened the invoice modal there. One click, but
it threw her out of the list she was working through. It now opens
in place, on the list.
The ~180-line QuickBooks modal moved out of the detail page into
`QbInvoiceDialog` and both screens mount it — the same pattern used
for the action buttons and the readiness rule, both of which had
drifted precisely because they existed twice.
## The order number
Duplicated from the invoicing screen, which has had it all along:
"Invoice order number", seeded from the note's order, editable, with
a hard stop if it's empty when the operator tries to send. The
backend already accepted `orderNumberOverride` — the route resolves
`override || order.order_number || fallback` — so only the UI was
missing, which is exactly what the operator asked for.
That last fallback matters more here than on the invoicing screen: a
counter delivery note usually has NO parent order, so this field is
often the only place the number on the QuickBooks invoice can come
from. A blank one would silently become `QB-<dn>-<timestamp>`, which
is not a number anyone recognises on an invoice.
## The auto-search could only ever fail
The modal auto-loaded customers when it opened. The n8n webhook
rejects an empty `customer_query` with a 400, the proxy turns that
into a 502, so opening the dialog produced a red toast before the
operator had touched anything. Verified against the live webhook:
{"customer_query":""} -> 400 "customer_query is required"
{"customer_query":"Caliber"} -> 200, real customers back
There is now no auto-search: the operator types (the box is seeded
for a note with an order) and presses Enter or Search.
The loading flag also had to be rewritten. It was first derived from
`loadedFor !== requestKey`, which meant typing in the search box
flipped the spinner on while nothing re-ran the fetch, and it spun
forever until Enter. `loading` now means "a request is genuinely in
flight" and nothing else.
Verified in the browser as David Els: Create invoice opens on the
list with the order number pre-filled (Q202609244), a search for
"Caliber" returns Caliber Machine and Fabrication (Pty) Ltd., and the
URL never leaves /delivery-notes.
Full suite 3676 passing, tsc clean.
The operator asked for one action at the end of the row that walks
her through the work, instead of a strip of unlabelled icons.
[ Create quote ] [ Send ] [ Create invoice ]
Once a quote exists the FIRST slot becomes the quote number itself,
clickable, so she never hunts for where the quote went. Clicking it
opens the quote PDF in a popup with Print and Download — she stays on
the delivery-notes list, which is the whole point.
Deliberately not a fixed step 1 -> 2 -> 3: the counter also has
customer-to-customer work that never needs a quote, so "Create
invoice" is always offered and never depends on the quote having
happened. A fixed sequence would strand that path.
Send is mounted in place rather than by navigating to the quote page,
using the existing purpose-built modal (loud To field, alt-email
toggle, rep auto-CC, PDF attached, retry banner on a bounce). The
quote fetch now expands `customer,company` because that modal reads
`quote.expand.customer.rep` to pre-fill CC — without it, sends went
out silently with no CC and the office never saw a copy.
Everything else (signed copy, capture PO, mark invoiced, cancel,
regenerate PDF) stays on the detail page. That is a deliberate
hierarchy, not the drift this component was extracted to prevent: the
list is for scanning plus the three things you do to every note.
--- the bug this uncovered ---
The flow control could never show a quote, because the quote map was
always empty. `getQuotesForDeliveryNotes` built ONE PocketBase filter
of `delivery_note = "x" || ... ` across every id. The list fetches 500
notes, so that filter carried ~250 clauses, and PocketBase rejects it
with a bare `400 Something went wrong`. Measured on this instance: 75
clauses succeed, 100 fail.
The caller treats that fetch as non-fatal and swallows the error, so
it was completely silent. For the life of the feature:
- the Q icon never switched to "open quote" mode,
- `hasQuote` was always false,
- the blue "quoted" row tint never appeared, and
- the Send action could never be enabled.
A whole session of 3676 tests passed without catching it — they all
use a handful of ids, far under the limit. Only driving the real app
surfaced it.
Now chunked at 50 ids per request. Five sequential requests for a
250-note list is nothing next to the full delivery_notes page already
fetched alongside it.
Verified in the browser as David Els: DN26100599 now shows
Q202610102 in slot 1, Send is enabled, and clicking the quote opens
the generated PDF in a popup showing the priced line (1/2" X 2 1/4"
UNF BOLT, qty 10, R150.00, total R1 500.00) with Print and Download.
Full suite 3676 passing, tsc clean.
Found by running the real flow end-to-end in a browser as David Els.
The list page opened the pricing review modal; the detail page did
not — its "Review pricing" button still POSTed the create directly.
So the same button did two different things on two screens, and on
the detail page it created the quote blind, which is exactly what the
review modal exists to prevent.
It now opens the same `QuoteReviewDialog`, keyed on the note id so
its grid state can't leak between notes.
Also fixed a message that outlived the check it described. Removing
the supplier requirement left the refusal still reading "Every item
needs: processed ✓, supplier ✓, cost price, and selling price" — so
an operator was told to set a supplier on every line, which changes
nothing now, while the three real blockers went unnamed. The message
is now derived from the same `issues[].missing` array that drives the
check, so the two cannot drift again.
E2E evidence (live PocketBase, David Els / CNL Industrial Mining
Supplies):
DN26100599 -> pricing review -> Q202610102
cost R120 -> markup 25 -> selling R150, qty 10
is_processed = true in the database
PDF: 200 ok, 82,184 bytes, %PDF-1.3, 2 pages, valid
DN26100508 -> blind create -> Q202610101
PDF: 422 items_not_ready
missing: ["not processed", "cost price", "selling price"]
That is the whole bug reproduced on both sides: reviewed quotes render,
blind ones are refused, and the refusal now names what is actually
missing.
Also verified in the browser: the stock typeahead returns name, SKU,
unit, qty and supplier from the new route; picking a stock item
auto-fills the supplier (the field that used to block the PDF); and
the create-delivery-note dialog is unchanged.
Full suite 3676 passing, tsc clean.
Found by driving the real app in a browser as David Els, not by
reading the code. Two of these were invisible to typecheck, build and
3670 unit tests.
1. The action icons were clipped off the right edge
`preview-dn` and `download-pdf` were the SAME action — on the list
both opened the DN PDF preview, on the detail both called
`setPreviewOpen(true)`. A duplicate I had introduced in the
canonical set. Even after removing it the row still overflowed:
the Company column was ~294px, the widest thing in the table, and
every row already sits inside a company group header spelling the
same name out. Removing the column removed the redundancy AND the
overflow. Table is now 1140 in a 1140 container, 0px clipped.
2. Entering a first cost produced a ZERO-MARGIN line
A counter note opens with no cost on file, so the line starts at
cost 0 / markup 0 / selling 0. Typing a cost left the markup at 0,
which derived a selling price exactly equal to the cost — a line
that looks priced and earns nothing, on the commonest path in this
workflow. Entering a first cost now seeds the default markup, so
the operator gets R125 selling on R100 cost and a visible 20%
margin. Seeding only happens when the line had no cost AND no
markup, so it can never overwrite a margin someone chose.
Verified in the browser: cost 100 -> markup 25 -> selling R125,00
-> line total R1 250,00 -> margin R250,00 (20.0%).
The 20% on a 25% markup is the distinction the whole pricing
module exists for, and it renders correctly.
3. The actions column also gets `w-px whitespace-nowrap` so the cell
sizes to its icons instead of being squeezed by its neighbours.
Also pinned with 6 tests (repriceWithCostEntry): seeds on first cost,
yields a margin, does NOT reseed on an already-costed line, does not
override an explicit 0, collapses when the cost is cleared, and is a
no-op without a cost. Verified they bite: removing the seed fails 2.
Known limitation, reported rather than hidden: item DESCRIPTIONS are
not searchable. The list endpoint returns headers only and never
loads items, so "brazing" finds nothing while "kobus" (the customer
on the same row) finds 9. Making descriptions searchable means
loading items for every row.
Full suite 3676 passing, tsc clean, build compiles.
Two things stopped the operator quoting a delivery note. Neither was
friction; both were bugs.
1. "The status is not right"
`createQuoteFromDeliveryNote` opened with `if (dn.status !== "draft")`
and refused everything else. A note the office had already sent, or
that the customer had put a PO against, could not be quoted at all,
and the whole error message was that condition.
Now any status except `cancelled` can be quoted — cancelled is the
one genuinely unquotable state, because the work was abandoned and
pricing it would put dead goods on a customer-facing document.
Critically, the status write is now conditional. It used to force
`sent` on every create, which with a relaxed gate would have dragged
a po_received note BACKWARDS to sent and erased the fact that the
customer had sent their PO. Pricing a note is not a reason to move
it. The detail page's Create quote button was also `draft`-only and
now matches the server.
2. "It needs a supplier and this and that"
The quote was being created fine — the PDF was refusing to render.
The readiness check required four things per line, and the two the
modal already supplied were the only two that were satisfiable:
- `is_processed` was never set on a delivery-note quote, and
NOTHING could set it: the quote_items update whitelists exclude
it, and the only writer was /quotes/[id]/process. So a DN-created
quote could not produce a PDF without leaving the flow entirely.
It now derives from the confirmed pricing — cost AND selling
both present — the same rule the process page uses. A line the
operator left unpriced still blocks, so the check keeps its teeth.
- The supplier block is gone. A counter note has no parent order,
so its lines frequently carry no supplier; demanding one refused
the PDF for a field the counter form never asked for. The
supplier is still printed when present.
Signed copy is never a completion signal. The upload route only ever
wrote `signed_copy` — nothing was writing `completed` — and that is now
pinned by a test asserting create-quote never sets `completed` for any
input status, so the relaxed gate cannot introduce it.
One action set, not two
The list and the detail page each hand-wrote the same action set and
had drifted twice: the list got a pricing-review modal the detail page
never received; the detail page got Mark complete, Cancel, Download /
Regenerate PDF and Upload signed that the list never received. The
status gates drifted with them.
`DeliveryNoteActions` now owns the set, the order and the gating. Both
pages render it — icons on the list, labelled buttons on the detail —
and each supplies its own handlers, so behaviour stays where the state
lives but "which buttons, and when" is answered in one place. An
action with no handler is not rendered, so neither presentation can
invent or hide one.
Tests: 27 new. 19 pin the action set and gating, 8 cover the relaxed
gate / no-backwards / is_processed / no-supplier rules. Verified they
bite by regressing create-quote to draft-only and by restoring the
old supplier block. Full suite 3670 passing, tsc clean, no new lint
warnings.
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.
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.
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.
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.
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>
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>
- 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.
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.
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.