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.
Operator report: "If I double click on it and I put in 20 there, goes
to 18... if I take everything out, I start typing, automatically change
it to 18. I need to be able to add any value there and when I click
save and the value is less than 18, it should go to 18."
The floor (`2387a71`) was correct in principle and wrong in WHERE it
sat. It was applied inside `repriceLine`, which runs on every
keystroke in both pricing grids — so the moment a sub-floor digit was
typed the state was raised to 18 and the box snapped.
Typing "20" is the clearest demonstration: the "2" is sub-floor, so it
became 18 mid-keystroke; the next character then APPENDED to "18" and
produced 180. What the operator saw as "goes to 18" was the first
keystroke, and "it jumps around" was the second.
She asked for the floor on SAVE, not while typing. So:
- `repriceLine` no longer floors. Markup is also rounded to 2dp on
the way in, so the box holds `80.06` rather than the float noise
a back-solved markup produces (`80.05999755859375`).
- The floor applies on BLUR and on SAVE, in both the delivery-note
editor and the quote review modal, via `applyMarkupFloor` — the
same function, still the only thing between a typed value and the
database, so the -100-row hole stays closed.
- `step="0.5"` replaced with `step="any"`. A back-solved 80.06 is
not a multiple of 0.5, which left those boxes `:invalid` — and a
number input in an invalid state fights the caret. This is very
likely a second contributor to "I can't do anything there".
- The bulk "set all markups" field gets the same treatment.
Verified with real keystrokes in the browser, not synthetic events
(which had masked this — they set a value in one shot, so the
mid-keystroke floor never fired):
type "20" -> stays 20, selling R605,40 (504,50 x 1,20)
type "15" -> stays 15 while typing
blur -> 18, selling R595,31 (504,50 x 1,18)
Three existing tests asserted the per-keystroke floor. They are
repointed, not deleted: the commit path is now pinned directly, and
`applyMarkupFloor` has its own tests so the database protection is
still asserted.
No data changed — the editor work above was dialog-local state only.
Full suite 3833 passing, tsc clean.
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.
## 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.
"Could not save line 'Husqvarna 578BTF backpack leaf blower' (405)" —
and nothing was written.
`/api/v1/delivery-notes/items/[id]/pricing` exports PATCH only. The
editor POSTed to it, so every save 405'd. The DN detail page has been
PATCHing the same URL correctly all along; I should have matched it
rather than assuming POST.
## Why it got through my verification
My browser check opened the editor, typed a cost, watched the totals
recompute — and stopped there. The save path is entirely separate
from the render path, and I never pressed Save, so I verified the one
half that was never broken.
Redone properly: changed a quantity 150 -> 7, pressed Save, reopened
the editor and read 7 back (the other three lines untouched at
150/150/2), then put it back to 150 so the data is as found. That is
the check that matters on these screens — display and persistence are
different code paths, and a perfectly-rendering dialog proves nothing
about either.
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.
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.
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.
Two things the operator hit straight away.
1. Creating a quote parked her on the quote detail page.
She is working through notes one after another, so that pulled her
out of the list for a document she had just reviewed in the modal.
`afterCreate` now defaults to "list": the modal closes, the toast
confirms, and she is back where she was with the row's first slot
now showing the quote number.
2. "Couldn't load this quote's PDF" — blaming a supplier.
The message was right about the failure and wrong about the cause.
The readiness rule existed in TWO places: `quotes-service.ts` for
the send path, `quote-pdf-service.ts` for the PDF route. When the
supplier requirement was dropped I changed one of them and left the
other enforcing it. So a counter note whose lines never had a
supplier could be quoted and sent but never rendered.
Both copies now call one function, `quoteItemReadinessGaps`, so a
third copy cannot appear. Verified in the browser: Q202609631 now
fails with "Every item still needs: cost price, not processed,
selling price" — no supplier — and that quote's line genuinely has
cost 0, selling 0 and is_processed false, so refusing it is correct.
The phantom requirement is gone; the real blockers are named.
`is_processed` is deliberately KEPT. It is the flag that says a
person reviewed the line, and nothing else can set it on a
delivery-note quote — the quote_items update whitelists exclude it.
Dropping it would let a quote go out with lines nobody looked at.
The quotes-service test mock now spreads the real module, so these
tests run against the real rule rather than a stub that could agree
with the implementation while production disagreed.
Full suite 3676 passing, tsc clean, no new lint errors.
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 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.
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.
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.
`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.
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>
- 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.
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.
The invoice qty rule used to read the customer's order qty regardless
of how much the driver actually collected. QuickBooks ended up billing
8-of-10 on one line and 7-of-10 on the other for Kusasalethu PO 390588
(INV10924) — a 5-unit overbilling that the mine's receiving team flags
as suspicious.
The rule now caps the invoice qty at min(qty_collected, quantity):
- Short pickup → bill what's collected, the row stays at
item_status="ready_invoice" with qty_invoiced < quantity, and a
follow-up invoice fires automatically when the rest arrives.
- MOQ overflow → still capped at the customer's order qty; the
surplus-book flow continues to route the overflow to stock.
The Invoicing page now shows "N short" / "N units short" badges on
short-pickup rows, a "N hidden" badge when supplier-side rows are
suppressed, and a "Show supplier-side lines" toggle (default OFF) so
the invoicing operator doesn't see kit-component remainders unless she
needs to. A split-remainder dialog on the order process page lets the
operator explicitly flag a remainder as exclude_from_invoice=true when
the original line was a kit (e.g. "M20 bolt comes with nut and washer"
split across 3 suppliers).
PB schema additions (one-time, run scripts/nss-add-invoice-columns.ts):
- order_items.qty_invoiced (number)
- order_items.customer_invoice_number (text)
- order_items.invoiced_date (date)
These three columns were referenced throughout the codebase but never
existed on the live schema, so writes were being silently dropped. The
schema fix unblocks the reconcile flow that stamps them after every
QB webhook returns.
Backfill for the live Kusasalethu order: scripts/nss-fix-kusasalethu-inv10924.ts
updates order_items.quantity 10→8 / 10→7 to match what was collected,
stamps the new columns, and flips order.invoiced=true.
New server helper reconcileOrderItemInvoicedStatus() runs after each
QB webhook and per-row decides:
- item_status = "invoiced" if qty_invoiced >= quantity (fully billed)
- item_status = "ready_invoice" otherwise (still partial — stays in
the to-invoice tab for a follow-up invoice once the rest arrives)
Wired into both the bulk invoicing page and the delivery-note-invoice
route. markOrderInvoiced now compares against qty_invoiced vs
quantity rather than the flat item_status check, and its error
message names the outstanding shortfall per row.
The active/inactive customer switch was buried under the
'Details' section after the sales rep and description — easy
to miss when the only thing the operator wants to do is mark
a customer as inactive.
Moved it to the second row of the form, immediately after
the customer name field, where it's the first decision the
operator makes. Visual treatment bumped to match the new
prominence: a green-tinted container when active, muted
grey when inactive, so the state is readable from across
the room instead of requiring the operator to read the
trigger label.
state semantics (unchanged from bbfa888):
- 'status' is a PB bool, true = active, false = inactive
- new customers default to true via the form's
'status: customer?.status ?? true' initializer
- legacy rows with status undefined render as Active on
both the detail and list pages (same default-active
contract on read) — no migration needed
- the only way to render an inactive customer is to flip
the switch and save it; PB then stores false
Two new fields added to the customer add/edit form, both
operator-facing for the active/inactive customer workflow:
- customers.status (bool): flips the customer active/inactive.
Legacy rows pre-date the field and load as undefined; both
the form and the read side collapse that to active=true so
the migration date doesn't silently flip the existing base.
- customers.site_location (text): free-text site/shaft label
keyed off the customer's company. Distinct from the existing
'site' free-text mine tag owned by the pin tool. Storage is
plain text on purpose, with a /api/v1/customers/sites
endpoint returning distinct existing values for an input
+ datalist autocomplete under the company combobox.
surfaces:
- Active/Inactive badge on customer detail and list rows
- site_location row in the customer Details card and under
the company line on the list (desktop + mobile card)
- Switch under the sales-rep section of the form to flip
status; existing customers stay active by default
also fixes a UX trap on edit: previously opening the edit
form showed 'Select company...' / 'Select rep...' as placeholders
for an instant until the lookup lists resolved, making a real,
saved company/rep look unlinked. CompanySearch and UserSearch
now accept a selectedLabel fallback; CustomerForm passes the
resolved customer.expand.company / customer.expand.rep names
through so the triggers display the linked names immediately.
listDistinctSiteLocations helper added to customers-service.ts
alongside the existing listDistinctSites.
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).
Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.
API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`
MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.
Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.
Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
(`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
`email_outbound_attachment_ids`, downloads each from
`/api/files/quotes/<id>/<token>`, and emits
`options.attachments: [{name, content, type}]` consumed by
`Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
`retryQuoteEmail` can find failed sends (it only accepts the
latter). Previously failed sends were stranded.
Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
(/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
and current `Q202609246` formats; canonicalised digits-only for
PB `~` substring match.
Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.
Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.
Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes
Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
The sprint boards are disabled pending a rebuild. Operators
process quotes through /quotes/[id]/process and orders through
/orders/[id] until they are re-enabled. The original nav
entries are commented in place so they can be restored by
uncommenting a single line each.
The sprint-board pricing modal was writing operator edits only to
quote_items.quote_description, but the quote detail page renders
quote_items.description. The 'process and open' flow writes to
description directly, which is why edits appeared there but not
after saving from the sprint board.
Mirror the trimmed description into both quote_description and
description on save, matching the process page's write path.
Add three regression guards (edited description, cleared
description, untouched save) to sprint-item-pricing-modal.test.tsx.
The rep app: open it on site, it finds who's around you, tick who you
saw, done. Third instance of the per-route-group PWA pattern already
running here (/m boss phone, /counter), so it reuses MobileAuthGate and
gets its own manifest + service worker.
Phone GPS is the primary position source, not the vehicle tracker. The
tracker knows where the bakkie is; the phone knows where the rep is,
and those diverge by a few hundred metres once the rep walks into the
plant. GPS also needs no network, which matters because mine shafts
have poor signal. The tracker is the secondary path
(GET /api/v1/visits/position) for when location is denied or
unavailable. The independently-collected vehicle trail corroborates
check-ins afterwards, so honesty doesn't depend on the phone.
Matching runs on the phone, not the server. The app caches a compact
customer snapshot and runs rankNearbyCustomers locally, so there's one
code path that behaves identically online and off — rather than a
server path plus an offline fallback that only gets exercised in the
places it's hardest to debug.
- (rep)/rep/ layout, page, check-in view, outbox
- visits-service.ts getCustomerSnapshot, recordVisits
- POST /api/v1/visits record a check-in
- GET /api/v1/visits/customers snapshot for the offline cache
- GET /api/v1/visits/position tracker fallback
- public/manifest-rep.json, public/sw-rep.js, pickSwPath() branch
These routes authenticate with the browser session, not AGENT_API_KEY —
the caller is a logged-in rep. The rep id comes from the validated
session rather than the request body, so a client can't log a visit as
someone else.
The outbox uses localStorage rather than IndexedDB deliberately: the
payloads are tiny and localStorage is synchronous, which matters when
the rep hits Save and immediately pockets the phone. Entries are only
removed once the server acknowledges them, so a failed flush retries
instead of dropping a visit — duplicates are preferred over loss, and
surfaced rather than merged. Permanently-rejected entries (4xx other
than 401/408/429) are dropped so one bad row can't wedge the queue.
recordVisits writes each visit independently: a stale customer id
shouldn't cost the other four visits in a queued batch.
BLOCKED until one PB admin change: rep_visits.position_source does not
yet allow "phone" — verified against the live instance, PB returns
400 "Invalid value phone". Add it to the select. Separately,
accuracy_m is silently accepted and discarded (PB ignores unknown
fields), so GPS accuracy is thrown away until that number field is
added; not blocking.
47 new tests. Verified on :4001: /rep serves 200 with the right
metadata, manifest-rep.json and sw-rep.js are served.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Radius-based rep check-in has nothing to match against until customers
carry real positions: 0 of 616 had any — every `coordinates` geoPoint
was PocketBase's `{lon: 0, lat: 0}` default, and most company addresses
are P.O. Boxes, so geocoding can't seed them either. This is the tool
that seeds the ~20 main West Rand shafts by hand.
- /visits landing page; a launcher until the visit-history
view has data to show
- /visits/pin-tool pick a customer, click the map, save
- setCustomerCoordinates() in customers-service.ts, with lat/lng range
validation so a string off a form input can't write a poisoned row
- PATCH /api/v1/customers/[id]/coordinates for agent/MCP callers
- Visits entry in the sidebar's Management group, plus MOBILE_TITLE_MAP
The browser writes through a Server Action, not the /api/v1/ route:
that route sits behind `assertAgentAuth` and a browser has no
AGENT_API_KEY. Both paths share `setCustomerCoordinates`. Mirrors the
existing saveAiSettingsAction split.
Pure view logic lives in pin-tool-helpers.ts so it can be tested away
from the Leaflet runtime.
Also refreshes the PB schema export — `rep_visits`, `rep_stops`,
`rep_vehicle_positions` and `users.tracker_vehicle_id` now exist on the
live instance.
32 tests. The suite's 9 pre-existing failing files are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
Updated the service worker registration logic to allow for specific service workers based on the route. Added support for a mobile service worker at `/m`, while maintaining existing functionality for `/counter` and `/new-order` routes. This enhances the PWA experience by serving optimized service workers for different app sections.
`order_items.order_qty` is the SUPPLIER-facing quantity — what prints
on the PO. Five call sites read it as the customer quantity, which is a
money bug in both directions:
- supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6
- shorted line (customer 100, supplier covers 40) → invoiced 40,
under-billing 60 until the operator splits the remainder
The invoicing screen never fetched `quantity` at all (zero occurrences
in page.tsx), so it could not bill the customer quantity even in
principle. Added to the PB field projection, which is load-bearing:
drop it and PB returns undefined and every line silently falls back to
order_qty. A page-level test now fails if the projection drifts.
`maxSurplusFor` measured the surplus as `collected - order_qty`, which
on an MOQ line is always 0 — the overflow was mathematically unbookable
and vanished from inventory by construction, not by operator error.
Four call sites built delivery-note lines by hand; two passed
`quantity` and two forgot it, so the same DN printed the customer's 4
via the service layer and the supplier's 10 via the API routes. All
four now share `buildDeliveryNoteLine`, so they cannot diverge again.
`order_qty` had no doc comment at all next to an 11-line one on
`quantity` — that absence is why five subsystems guessed differently.
Now documented, with a new `src/lib/order-item-qty.ts` holding
`customerQty` / `supplierQty` as the single definition.
driver-stops.ts and collection-confirm/po-card.tsx read `order_qty`
CORRECTLY (they check what the supplier delivered against what we
ordered from them) and carry comments saying so, so the next cleanup
doesn't "fix" them into breaking.
23 new tests. Full suite: no regressions — the 15 failures are
identical before and after (verified by stashing and re-running).
Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shared presentation of a paired quote line — description (when it
differs from the order), buyer note, line attachments, quote
attachments, plus a 'possible match' warning banner for the
operator's confirm button.
Used by both the order detail table (Task 13) and the process page
(Task 12) so the two never drift. The process page used to own the
only copy; the detail page showed nothing.
tier='possible' framing is the load-bearing part: a buyer is never
shown a note from the wrong line without being told it might be
wrong.
Co-Authored-By: Claude <noreply@anthropic.com>
Pure move — bodies copied verbatim from process/page.tsx into
src/components/orders/attachment-list.tsx. The order detail page
(Task 13) will now import the same component instead of growing a
second copy.
The 4 icon imports (FileText, Image as ImageIcon, FileQuestion,
ExternalLink) became unused after the move and were removed from
process/page.tsx. Re-exported from the new file alongside the
MatchedAttachment type, attachmentHref helper, and inferKind helper.
No behaviour change. tsc clean. No new lint errors in the touched
files.
Co-Authored-By: Claude <noreply@anthropic.com>
When a mine puts a spot out to tender, several engineering companies
each ask us to price the same item list. Before pricing a new one the
operator wants to see what the earlier quote went out at.
The per-line answer already existed: populateItemIntel powers the ⓘ
popover on each row and returns recent_quote (prior quote number,
company, selling price, days ago). This adds no new matching logic —
it runs that same lookup across every line of a quote and rolls the
results up, so the operator gets one panel instead of clicking ⓘ on
each row.
What the roll-up adds over the per-line popover:
- "N of M lines have been quoted before" at a glance
- relatedQuotes ranked by HOW MANY of this quote's lines they also
carried; a prior quote covering 5 of 5 is very likely the same
tender from a different bidder, one covering 1 of 5 is a shared
commodity line
Two honesty details, both driven by what the first live run showed:
- match confidence is surfaced as close/likely/loose. The underlying
matcher accepts anything above 0.5, and a live check returned a
R150 hit against a magnetic base drill — a loose row must not look
as trustworthy as a close one.
- an unpriced prior quote renders "unpriced", never R0.00, which
would read as "we quoted it at zero".
Read-only: passes write:false so viewing the panel doesn't persist
intel onto rows the operator only looked at. On-demand rather than
auto-loading, since it costs one intel computation per line
(~400-650ms each, batched 5 at a time, capped at 40 lines).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The archive's search fetched the entire recency-windowed matching set
(thousands of rows once "Include older" is on) and substring-filtered
it in memory, taking several seconds per keystroke; it now delegates
to the same ranked pushdown search service the main /quotes list uses.
Also fixes pagination being hidden while searching (results past page
1 were unreachable) and the match count showing the current page's
size instead of the true total.
Adds the chevron item-preview toggle to archive rows (extracted into
a shared QuoteItemsExpanded component, backed by a new GET
/api/v1/quotes/[id]/items route), removes a duplicate "Customer or
Company" label in the new-quote form, and drops the stray
warning-colored search box styling that leaked from DataTable onto
every list page that uses it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Updated the search input handling to improve responsiveness by integrating a debounced search mechanism, reducing latency from 550 ms to approximately 300 ms.
- Added keyboard shortcut support for focusing the search box, enhancing accessibility and usability for frequent users.
- Improved the search placeholder to reflect all searchable fields, providing clearer guidance to operators.
- Introduced a clear-search button to streamline user interactions, allowing quick resets of the search input.
- Enhanced the display of search results with a live result count badge, providing immediate feedback on search outcomes.
These changes collectively enhance the search experience for operators, making it more intuitive and efficient.
Typecheck cleanup after the deploy blocker push — `tsc --noEmit` was
silent on `d11a184` baseline but ~60 errors hid across test files
that only the compiler surfaces (the build was clean because test
files are excluded from the prod bundle). Touched:
- src/app/api/mcp/route.test.ts (16) — generic on `vi.fn()` for
tuple inference; `as unknown as` two-step cast on record literals
- src/app/api/mcp/po-agent-workflow.test.ts (1) — spread instead of
duplicate key
- src/components/buyer-tasks/buyer-task-group-card.test.tsx (5) —
`PurchaseOrderEmailPayload` now requires `subject`/`body`
- src/components/collection-confirm/confirm-stock-dialog.test.tsx (1)
— removed shadowed `id` from `makeRow` overrides
- src/components/item-intel-popover.test.tsx (2) — missing row
fields + boolean→object for `verified`
- src/components/quotes/process-item-card.test.tsx (2) — full
QuoteItem stub; generic on `vi.fn()`
- src/components/sprint-item-pricing-modal.test.tsx (3) — full
SprintItem/QuoteItemAttachment/QuoteAttachment stubs
- src/lib/server/orders-service-review.test.ts (~60) — annotated
`getOne` return type to break union widening on the expand option
- src/lib/server/orders-service.test.ts (2) — removed bogus
`includeArchivedCounts` param; `rfq_number` now whitelisted on Order
- src/lib/server/quotes-service.test.ts (3) — two-step cast on
Quote header; `unit?` now on QuoteItem
- src/lib/server/stock-purchase-items-service.test.ts (~10) — typed
`pbMock` via `StockPurchaseItemsPb` so `*WithPb(pb,...)` checks
Also added two optional fields to `src/lib/types.ts`:
- `QuoteItem.unit?` — pre-2026 rows have no unit, so it's optional
- `Order.rfq_number?` — populated by the n8n email agent, inherited
verbatim by `splitOrder`
Build: clean. Typecheck: 0 errors. Tests: 14 pre-existing runtime
failures + 1 pre-existing unhandled error in `supplier-form.test.tsx`
remain — confirmed they exist on `d11a184` baseline, so they are
outside the cleanup scope and unchanged by this commit.
Co-Authored-By: Claude <noreply@anthropic.com>
Step A — move the 'Add item' button from the top of the items
section to the bottom of the items list.
Step B — match the quote-form.tsx pattern: every item card is
followed by its own ghost + dashed-border 'Add another item'
button, so the operator never has to scroll back to the top
to add the next line. The dashed-border styling keeps it from
being mistaken for a submit/finish affordance.
The data-testid="dn-adhoc-add-item" selector is preserved on
the per-card buttons so any future test selectors continue to
work.
Fixes PINW-18
- Updated the orders page to use `OrdersReviewModalMount` for improved order review functionality.
- Removed the inline `orderAttachementsToQuotes` function to simplify the code structure and enhance maintainability.
- Introduced a review modal in the order detail page, allowing users to trigger the review process with a dedicated button.
- Ensured consistent behavior between the orders list and detail pages regarding order review interactions.
- Updated the `scoreQuote` function to incorporate price scoring, allowing quotes to be evaluated based on selling and cost prices when the query is numeric.
- Introduced new weights for item selling and cost prices, ensuring they contribute to the overall score only when relevant.
- Enhanced the test suite to cover new price matching scenarios, ensuring accurate scoring behavior for quotes based on price inputs.
- Refactored related functions to support the new price scoring logic, maintaining consistency across the scoring process.
- Introduced a new toggle in the quotes and archive pages to allow users to include older quotes beyond the default 90-day cutoff.
- Updated the search parameters in the quotes and archive APIs to handle the new `includeOlder` flag, enabling full history searches.
- Enhanced the user interface to reflect the current search scope, providing clear feedback on whether the search is limited to recent quotes or includes the full archive.
- Adjusted the quotes search service to support the new functionality, ensuring efficient retrieval of older quotes when requested.
- Added relevant tests to verify the correct behavior of the new search feature and its integration with existing functionality.
- Updated the TV dashboard to include a new section for driver stops, displaying active deliveries and pickups with GPS coordinates.
- Refactored the dashboard data structure to incorporate year-over-year performance metrics alongside existing monthly and weekly stats.
- Replaced the supplier map with a driver stops map for improved clarity on driver activities.
- Enhanced the user performance metrics to include detailed counts for quotes and orders processed by each representative.
- Removed the obsolete supplier map component to streamline the dashboard's functionality.
- Updated the `DraftQuoteItem` interface to allow `quantity` to be an empty string, enabling operators to type counts directly without needing to delete a default value.
- Modified the `QuoteFormItemCard` to handle empty input gracefully, falling back to 1 on submission if the field is left blank.
- Adjusted the `addItem` function in `QuoteForm` to initialize `quantity` as an empty string, enhancing user experience during item addition.
- Adjusted the `SheetContent` width in multiple form components to improve responsiveness.
- Enhanced the `CompanyForm` layout by reorganizing fields for better clarity and usability, including the addition of coordinates input.
- Updated the `CustomerForm` and `SupplierForm` to maintain consistency in design and structure.
- Improved the `DataTable` component's card styling for better visual appeal and user interaction.
- Refined the `QuotesPage` card styling to enhance the user experience when selecting quotes.
- Introduced a new `quote_notes` field to store operator-facing free-text notes, distinct from the existing `notes` field used for rejection audit logs.
- Updated the quote processing page to include a `QuoteNoteCard` for editing and displaying the operator notes, ensuring seamless integration into the PDF generation flow.
- Enhanced the PDF generation logic to render the operator notes in a blue callout below the totals, improving visibility for end-users.
- Implemented a new service function `setQuoteNotes` to handle the saving of notes, including trimming whitespace and handling non-string inputs defensively.
- Added unit tests for the `setQuoteNotes` function to ensure correct behavior and data integrity when updating notes.
- Updated relevant types and documentation to reflect the new functionality and its intended use cases.
- Introduced a new `ConfirmStockDialog` to handle stock collection confirmations, allowing operators to review per-line quantities and any short lines before finalizing the action.
- Updated the `CollectionConfirmPage` to manage the new confirmation state and trigger the dialog upon clicking the "Confirm collection" button.
- Enhanced tests to cover the new dialog functionality, ensuring correct behavior for both full and short quantity lines during the confirmation process.
- Improved documentation to clarify the flow and user interactions with the new confirmation dialog.
- Introduced a new `handleQtyChange` function to manage per-row quantity updates for both customer and stock items, ensuring correct data submission to the appropriate endpoints.
- Enhanced the `dedupeAttachments` function to handle additional document sources, including parent purchase orders, and skip empty filenames to prevent 404 errors.
- Updated the `ItemsTable` and `PoCard` components to support quantity editing, allowing users to modify collected quantities directly in the UI.
- Added tests to validate the new quantity handling logic and attachment deduplication, ensuring robust functionality across various scenarios.
- Introduced a new `DeliveryNoteRow` type to track delivery notes alongside items, improving the organization of delivery data.
- Updated the `DeliveryListResponse` type to include delivery notes, allowing for better integration with the existing delivery management system.
- Enhanced the `groupByCustomerOrder` function to accommodate delivery notes, ensuring they are grouped correctly with their respective orders.
- Implemented state management for delivery notes in the `DeliveriesPage` component, including loading and error handling.
- Added search functionality for delivery notes, enabling operators to filter by delivery note number, customer PO, and order details.
- Updated the UI to display delivery notes alongside items, improving the overall user experience in the deliveries management interface.
- Enhanced tests to cover new delivery notes functionality and ensure correct behavior across various scenarios.
- Added a new service function `markDeliveryNoteCompletedByInvoice` to auto-close delivery notes after successful QuickBooks invoice creation, updating the status to "completed" and appending an audit banner to the notes.
- Enhanced the delivery note detail page with a modal for creating invoices via QuickBooks, including customer selection and billing company options.
- Implemented state management for the invoice modal, including loading states and error handling for customer fetching.
- Updated tests to cover the new auto-close functionality and ensure correct behavior across various scenarios.
- Improved documentation for the delivery notes service to reflect the new functionality and its usage.
- Introduced a new `DuplicateKind` type to differentiate between "order_number" and "line_aggregate" duplicates.
- Updated the `DuplicateOrdersDialog` component to support tabbed navigation between order number and line aggregate views, improving user experience.
- Enhanced the data structure for duplicate records to include `totalQuantity` and `totalSellingPrice`, facilitating more accurate duplicate detection.
- Implemented logic to auto-flip to the tab with available content, ensuring operators do not encounter empty views.
- Expanded test coverage for the `findDuplicateOrders` function to validate new line-aggregate matching logic and ensure accurate detection of duplicates across different scenarios.
- Improved documentation for the duplicate order handling process, clarifying the criteria for identifying duplicates.
- Introduced structured error handling for MCP upstream fetches, distinguishing between network errors (`-32010`) and upstream errors (`-32011`). This allows the agent to provide more informative feedback based on the type of failure encountered.
- Updated the `fetchUpstreamJson` function to throw `McpUpstreamError` with detailed error data, including hints and next actions for specific tools.
- Enhanced the `TOOL_HINTS` mapping to provide tool-specific guidance for handling errors, improving the agent's ability to respond to various failure scenarios.
- Added comprehensive unit tests to validate the new error handling logic and ensure correct behavior across different error cases.
- Updated documentation in `docs/agents/hermes-skill.md` to include a new section on structured MCP errors, detailing the error codes and their meanings.
- Added a new `quotes.submit` tool that transitions a quote from `new` to `approval_pending` once all line items are priced. This tool ensures that only fully priced quotes can be submitted for approval, enhancing the integrity of the approval process.
- Updated the API documentation in `docs/api-mcp-layer.md` to include the new `quotes.submit` tool and its usage details.
- Enhanced the `quotes` status enum in `docs/agents/hermes-skill.md` to reflect the new transition and its implications.
- Regenerated documentation to ensure accuracy, increasing the total tool count from 126 to 127.
- Added comprehensive tests for the `submitQuote` function to cover various scenarios, including idempotency and error handling for unpriced items.
- update backend service to fetch stock purchase attachments
- refactor dedupe logic to avoid filename collisions between customer and stock attachments
- adjust PoCard to generate correct file URLs for stock attachments
- update test expectations for the modified service query
- Add new POST API endpoints at `/api/v1/stock-purchase-items/[id]/sku` and `/api/v1/stock-purchase-items/[id]/cost` for updating stock purchase item details
- Update ItemsTable to support cost and SKU edits for all row types, add supplier SKU column and editing for stock rows
- Extend PoCard to pass edit callbacks and add stock-specific invoice upload functionality
- Update all related test suites to cover new editing and UI features
- Adjust invoice upload messaging to differentiate between customer and stock purchase orders
Add full PIN-protected TV dashboard stack including auth, redirects, map view, APIs, and server services.
Also include these critical refinements:
- Overhaul PO number generation to use per-company monthly sequences
- Rework quote rejection to bounce quotes back to operator queues instead of marking them terminal
- Add post_processing order tab group for clearer workflow visibility
- Fix collection pool tooltip positioning, overflow clipping, and default collapsed state
- Refactor customer list API, clean up unused form props, and fix item intel popover target ID handling
- Add comprehensive tests for new components and update existing test expectations
Add inline quantity editing for delivery note items:
- New PATCH API endpoint and service function to update item quantities
- Optimistic UI updates on the detail page with error and loading states
- Input validation and status gating for draft/sent delivery notes
Add bulk delivery note combine functionality:
- New POST API endpoint and service to merge multiple draft/sent notes
- Row selection support in DataTable across pagination
- Confirmation modal with selected notes preview and optional order linking
- Automatic audit trail updates for merged and cancelled records
Extend OrderItem TypeScript interface with optional order expand field
Fix minor type casting issues in createAdHocDeliveryNote utility
This commit adds a full unauthenticated PWA for in-store retail order taking:
- Dedicated `/new-order` route with complete order form, customer/company picker
- Public `/api/counter/*` endpoints for search, order creation, and photo uploads
- Photo capture and gallery selection with 10-photo per order limit
- Offline caching via dedicated service worker and app manifest
- Shortcut redirect at `/counter` for quick installation
- Comprehensive setup and usage documentation in `docs/counter-pwa.md`
- Minor cleanup to root layout metadata and service worker registration
- Small layout fix for collection pool row text truncation
Orders submitted via the PWA appear immediately in the main CRM's New tab, and the app is designed for trusted office kiosk phones without requiring user login.
Add a complete workflow for managing duplicate intake orders:
- Add new server API endpoints for single order cancellation, duplicate detection, and bulk duplicate cancellation
- Add client UI components for bulk duplicate cleanup and single order soft cancellation
- Add a "Duplicates" button to the orders dashboard for quick access to the cleanup tool
- Refactor invoicing module: rename customerNameSimilarity to nameSimilarity, update QB customer picker to use radio buttons and remove automatic preselection
- Add comprehensive test coverage for the new order service utilities
This commit adds several key features, improvements, and cleanups across the codebase:
- Add `order_items.confirm_collected` MCP/REST endpoint to mark order items as collected without requiring driver assignment
- Add standalone order creation and order split APIs for flexible order management
- Update order list sort to oldest-first to align with user workflow of working through oldest orders first
- Improve order search to show cross-tab matches and display source tab indicators on search results
- Add customer name similarity check to warn about mismatched QuickBooks customers during invoice creation
- Remove supplier name column from delivery note PDFs and clean up related data passing across delivery note routes
- Add validation and debug logging to ItemIntelPopover for missing item ID on apply actions
- Remove unused OrderItemContextPopoverTrigger component from order detail page
- Add delivery note regeneration script and update test suites for new and existing functionality
- Update documentation and add test coverage for the new collection confirm tool
PocketBase returns 0 for unset number fields, which broke the original
null-coalescing check in effectiveValidityDays, leading to PDFs showing
"Valid until: today" for all new quotes created via email ingest.
Changes include:
- Update effectiveValidityDays to treat 0, negatives, null/undefined as falling back to 30-day default
- Add test cases for 0 and negative validity_days inputs
- Explicitly set validity_days to QUOTE_VALID_DAYS when creating new quotes
- Add one-time idempotent migration script to backfill legacy invalid validity values
- Refactor item intel popover patch logic to use PocketBase SDK for authenticated requests
- Add n8n workflow for AI-powered item insight matching
This commit replaces the legacy item history and AI insights system with a standardized, fuzzy-matched item intel workflow:
- Update AiItemHistoryTrigger component to support both quote and order items via the `collection` prop, replacing the old `insights` prop with `intel`
- Add full `ItemIntel` type definitions to `src/lib/types.ts` for the new standardized insight payload
- Create new REST API endpoints for fetching and computing item intel for `quote_items` and `order_items`
- Rewrite the MCP insight endpoint to use the new shared `item-intel-service` instead of legacy individual insight functions
- Reorder quotes page tabs to place Delivery Note quotes alongside New quotes for better daily operator workflow
- Add item intel triggers to quote process and order process pages
- Remove deprecated document-level `AiInsightsPanel` components from quote and order detail pages
- Add bulk backfill endpoint for populating intel across existing active items
Replace the cramped horizontal table layout on mobile viewports with responsive vertical cards for each quote item. The new `ProcessItemCard` component provides full-width form fields, properly sized 44px tap targets for all actions, and maintains full parity with the desktop table implementation by reusing the same state handlers and callbacks. Add comprehensive test coverage for the new component, and fix existing mobile overflow issues in the quote process page header and table layout.
Comprehensive changes including:
- Refactor picking slip line handling for multiple document types across print endpoints
- Add new `/api/v1/stock-purchases/picking-slip` API for stock booking picking slips
- Implement reusable Splitter component and useSplitRatio hook for resizable panels
- Add floating/fullscreen invoice viewer to collection confirm page
- Add real-time stock booking update event for invoicing tab
- Add full test coverage for all new components and APIs
- Include temporary chat attachment images for testing purposes
Move StockBookingSection from above the tab strip into the To Invoice tab's card list for consistent visual styling. Add expandable, lazy-loaded purchase order line items with session-based caching to reduce redundant network requests. Update component UI, styling, and documentation, and fix test mocks to prevent conflation of stock_purchases and order_items collection calls. Add ZAR currency formatting helper for displaying stock line item costs.
Repositioned booking responsibility: warehouse confirms physical
collection only (records quantity_received + flips header to
"received"); the accountant books into inventory via the new
invoicing flow once the supplier invoice is processed. This way
the payment-cycle reasoning lives at the booking step, not at
receipt.
Changes
- Migration: add booked_at + booked_by to stock_purchases
- Service: bookStockFromInvoice (accountant-side, splits from
bookStockRows)
- API route: POST /api/v1/invoicing/stock-book
- UI: stock rows surfaced on invoicing page (StockBookingSection)
- UI: stock-purchase detail page
- Driver picker on header (sentinel "delivery" user auto-collects
every line, real drivers set assigned_to_driver)
- "Confirm collection" replaces "Mark received" (warehouse-side
only — no inventory writes)
- Cancel button with confirmation dialog
- collection-confirm: confirmStockRows stripped of inventory writes;
the prior /api/v1/collection-confirm/book-stock route was renamed
to /confirm-stock and is now strictly the warehouse-side confirm
- Tests for all new flows (5/5 passing on the stock-order detail
page; 10/10 on StockBookingSection)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
At <=640px the buyer-tasks card content was crushed by the action
column sitting beside it: the PO number badge broke mid-string
('PO-2026-08-042' wrapping to 4 lines), supplier names wrapped to
6 lines, and the new 'Sent to ...' caption overlapped the PO
badge on the PO Sent tab.
Three responsive tweaks, all using Tailwind breakpoint utilities:
1. Outer card container switches from 'flex items-start' to
'flex flex-col sm:flex-row sm:items-start'. The action column
drops below the info column on mobile, giving the info all the
available width and the buttons full-width tap targets.
2. PO number badge gains 'whitespace-nowrap'. The badge stays
atomic; the parent's 'flex-wrap' carries the whole badge to a
new line when it doesn't fit.
3. PO Sent right column: buttons stack vertically on mobile
('flex-col sm:flex-row' + 'flex-1 sm:flex-none' on each
button) and the caption switches from 'text-right' to
'text-left sm:text-right' so it sits under the buttons.
Desktop layout is unchanged — verified at 1280px that the
side-by-side buttons, right-aligned caption, and inline single
button all still render exactly as before.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Adds a small muted caption under the Re-Send / Mark as ready buttons
on the PO Sent tab showing the email address the PO was sent to and
the send timestamp (en-GB short format like '5 Aug 2026, 14:23').
Data sources come from the existing BuyerTaskGroup.emailPayload
exposed in commit f61c173 — no new PB fields, no new types. The
caption only renders when sentAt is present, so legacy rows (or a
fresh draft flipped to po_sent without a successful send) stay clean.
Visible on mail_failed too: the operator can see who we tried to
reach and when, alongside the existing 'Mail failed' banner.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
This commit fixes a critical PocketBase projection bug where omitting "expand" from fields lists silently drops related data, leading to missing supplier/customer/order info across the app. It also includes usability improvements and test coverage:
1. Add "expand" to PB fields projections in invoicing page, quotes lookup API, sprint page, and quote search helper
2. Update PO Sent tab filter to exclude "collected" and "ready_invoice" statuses
3. Reorder buyer tasks list to sort by supplier first, then order number
4. Add comprehensive regression tests for all fixed issues
Included Playwright test capture artifacts from local testing.
- Add server-side `/api/v1/dashboard` endpoint to reduce client-side payload and round trips
- Implement invoice upload and order-item SKU update APIs with complete test suites
- Refactor order and order-items list endpoints (including MCP tools) to support proper PocketBase filter and sort params
- Fix pagination validation for order-items route to clamp page ≥1 and perPage 1-500
- Add redirects in next.config.ts to map underscore collection paths to hyphenated API routes
- Improve buyer tasks page to avoid redundant tab count refetches when switching tabs
- Refactor collection confirm components with editable SKUs and updated accessibility labels
- Add cached admin PocketBase auth to minimize repeated authentication calls
- Add performance testing scripts for dashboard endpoint latency
- Update quotes list route to default to excluding archived counts
- Fix photo strip component text and labels for non-invoice documents
- Refactor sprint page to lazy load supplier/customer data for pricing modal
- Clean up unused imports in test files
- Add full test coverage for all new APIs, services, and components
The Source of truth panel previously only showed the email subject and
body inside a collapsed-by-default toggle. Operators couldn't see who
sent the mail or what the subject was without expanding the body.
This change adds a persistent header above the collapsible body with
two always-visible lines:
From: jane@acme.test (AtSign icon)
Subject: RFQ for 10x bolts (Mail icon)
Sender resolution chain (in getOrderReviewData):
1. order.email_sender — n8n ingest snapshot of the actual From:
on the inbound RFQ (best signal)
2. quote.email_sender — covers pre-snapshot legacy orders
3. customer.email — last-resort fallback
4. '' — renders as '(unknown sender)' placeholder
Subject already lived on the same record; the header just promotes it
out of the collapsed body so it's visible without expanding. The
existing '(no subject)' / '(no body)' placeholder pattern is matched
with a new '(unknown sender)' placeholder.
Tests: 7 new (3 modal header, 4 service sender fallback chain). 101
passing total.
Co-authored-by: Orca <help@stably.ai>
The webview shortcut in the "Other" sidebar group now displays as
"Giellie" instead of "Hermes". Only the visible label changes — the
underlying /webview?src=hermes URL parameter and the ALLOWED-map
key in src/app/(app)/webview/page.tsx are untouched, since those
are internal identifiers (the URL key is what looks up the embeddable
iframe in the ALLOWED whitelist, not a user-facing string).
If you want the internal key renamed too, that's a follow-up that
needs to update both the sidebar link and the ALLOWED map in the
webview route in the same commit.
Co-authored-by: Orca <help@stably.ai>
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.
This commit moves both buckets to a dedicated /quotes/archive
page so:
- Main /quotes is fast (active workflow: new, claimed,
pending_approval, cancelled, delivery_note, email_failed,
problem, templates = 542 quotes vs 8,466)
- Archive can be honest about pagination
- The badge counts on the main page stop being dominated by
'Completed (7,849)'
# Service layer
- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
- view: 'completed' (default, 7,849) or 'all' (8,466)
- Strategy: one getFullList on quotes (fields: id,status) to
populate the matching set AND the header counts in a single
walk, chunk by 100 ids (PB's URL/filter cap is between
4.5-6.7 KB), fire parallel getFullList queries on quotes
with deep expand, sort + paginate in memory
- Search hits quote_number, quote_reference, rfq_number,
company, customer
- /api/v1/quotes/archive thin route: page, perPage, search,
sort, view
# Page
- /quotes/archive/page.tsx: card list with header counts
(completed / all badges), view toggle (Completed | All),
search input, pagination footer. Click a quote to open
/quotes/[id] in detail.
# Main /quotes updates
- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
/quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in quotes-service.test.ts:
- Default 'completed' view returns only completed quotes,
counts header includes both
- 'all' view returns every counted-status quote
- Search by quote_number and quote_reference
- Empty result when no quotes match
# Also: webview tweaks by another agent
- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)
Full suite: 1331/1331 pass.
Co-authored-by: Orca <help@stably.ai>
Work by another agent on the PO send flow. Adds a 'Resend' CTA
on the order detail page for POs that were already sent
(operator wants to send another copy to the supplier), plus
acceptance of the same flow from the modal. Includes:
- purchase-orders-service: accept 'sent' as a resend-from state
(in addition to 'draft' and 'mail_failed')
- send-po-modal: title flips to 'Resend Purchase Order ...' when
the PO was previously sent; 'Send' button becomes 'Resend'
- send-po-modal.test: covers the resend path
- orders/[id]/page: shows Resend button in the same sendable
state group as Send (was draft / mail_failed, now also 'sent')
- purchase-orders pdf route: updates docstring to reflect that
status is the outbound-email pipeline's responsibility
- stock-purchases pdf route: download URL uses file proxy for
admin-auth compatibility
Co-authored-by: Orca <help@stably.ai>
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
Three review-modal polish items rolled up:
1. Persisted line items now expose a description input in edit mode
(mirroring the existing qty / selling inputs). The save route +
the orders service's `updateOrderItemsPrices` accept the new
`description` field and write `description` + `po_description`
together (rejecting a blank description per row, with a
per-row failure entry so the operator can retry just those).
2. The source-of-truth panel now ALWAYS renders the email body, not
only when there are no attachments. The body is collapsed by
default and capped at `max-h-40` so the email stays a secondary
audit block instead of pushing the operator's real work
(line items + attachments) down the page. The toggle button is
unchanged.
3. The chip strip and file list inside the source-of-truth panel
were using `key={a.id}`. `orderAttachementsToQuotes` synthesizes
every attachment with `id: order.id` (PB file URLs need
`/files/{collectionId}/{recordId}/{filename}`), so a multi-file
order was throwing 'Encountered two children with the same key'.
Switched to `key={a.filename}` — unique per file in the
multi-file field, leaves the file URL untouched.
The Order type grew `email_subject`, `email_sender`,
`email_body`, and `email_message_id`. These columns exist in the
live PB (the local `pb_schema-2.json` snapshot is reference-only
per AGENTS.md, so the drift is expected). `getOrderReviewData`
prefers the order's own `email_body` and falls back to the
related `quote.email_body` for legacy orders that pre-date the
snapshot columns — quoting the operator's outbound is better than
showing '(no body)'.
Co-authored-by: Orca <help@stably.ai>
The photo-strip chips on the collection-confirm page used to call
`window.open(url, "_blank")` for the supplier invoice, kicking the
operator out to a new tab to verify the invoice against the items
table. That tab switch broke the side-by-side comparison the
operator actually wants to do — look at a line on the invoice,
look at the same line in the table, look back.
Replace that flow with a floating, draggable invoice viewer
that lives on the page:
- New `InvoiceViewer` component renders the PDF in an `<iframe>`
against the existing `/api/v1/files/order_items/<id>/` route,
which already serves PDFs with `Content-Disposition: inline`.
- Desktop: floating box anchored to the bottom-right (720×560),
header is draggable. Position is clamped to the viewport so
the close button can't be dragged off-screen. State lives in
the component; not persisted to localStorage.
- Mobile (≤ 640px): full-screen panel (`100vw × 100dvh`,
border-radius 0). Drag affordance, grip icon, and "Viewing"
badge are dropped — they're meaningless on touch or when the
panel fills the screen. Layout flips live on viewport change
via a small `useMediaQuery` hook.
- Zoom controls: + / − / Reset with discrete levels
(50 / 75 / 100 / 125 / 150 / 200 / 300%). The % display is
itself a click-to-reset button. Implemented as
`transform: scale()` on the iframe with a pre-shrunk CSS box
(`${100 / zoom}%`) and a `${zoom * 100}%` scrollable wrapper
so scrollbars appear at higher zoom levels. CSS-resize-only
does not work for PDF iframes — the browser's native PDF
viewer caches the initial render size and ignores post-load
CSS resizes, so the page content never actually re-renders.
`transform: scale()` works around that across Chrome / Safari
/ Firefox.
- "Open in new tab" header button is preserved as an opt-in
escape hatch (landscape invoices, full-page viewing).
The PhotoStrip no longer opens a new tab by default. Clicking a
chip calls a page-level `onOpen` handler that toggles the viewer
(open / swap / close); the matching chip is highlighted with
`aria-pressed="true"` + a primary variant so the operator knows
which invoice is open. Clicking the active chip closes the
viewer. The chip falls back to `window.open(...)` only when no
`onOpen` is passed, so the strip stays usable in isolation.
Tests: 126 pass across the collection-confirm suite. New tests
cover viewer open / close (X + Esc), drag math + drag-bail-out
on header buttons, the active-state highlight, swap-on-different
chip, the in-viewer new-tab escape hatch, every zoom level +
clamping + reset, the PDF wrapper / iframe transform math at
multiple zooms, and the desktop ↔ mobile layout flip on a
mocked viewport change.
Co-authored-by: Orca <help@stably.ai>
Lightweight dashboard view surfaced under the 'Other' sidebar group
for client-facing use cases that need a stripped-down WFDash
surface (e.g. embed iframe, kiosk mode).
Co-authored-by: Orca <help@stably.ai>
Blocks order-item edits when the operator saves below the 22% markup
threshold and routes them through a manager-approval gate before the
item can be processed.
- new `order-item-margin-approval-service` saves the submitted draft,
stamps `item_status='approval_needed'`, then fires the configured
n8n webhook; webhook failure leaves the item blocked and returns
`webhookFired=false`
- new POST /api/v1/order-items/[id]/request-margin-approval browser
endpoint gates the same flow with source='order_process' |
'order_sprint'
- new `MarginApprovalDialog` shared by both order-process and
order-sprint process surfaces — single source of truth for the
submit/audit-note flow
- new `approval_needed` / `approval_rejected` item statuses; the
order-process save path now returns 'blocked' when an item is in
approval_needed so the operator can't re-edit past the gate
- order-sprint-process-modal now uses the same dialog so the
below-threshold flow is consistent across both entry points
Tests: 21 new (service + route + sprint modal), 1231 → 1242 passing.
Co-authored-by: Orca <help@stably.ai>
The single office bin is the only location, so the field is
unused. Strip it from everywhere the operator sees it:
PDF (stock-item-stocktake-document.tsx):
- Drop Location column + gapMaxLocation from styles, header, rows
- Re-balance widths: Name 23% → 28%, Status 12% → 18%
(the freed space makes Name properly readable again)
Stock list page (src/app/(app)/stock/page.tsx):
- Drop Location TableHead + TableCell
- Drop location state + input + submit-handler field from the
New stock item modal
- Drop 'location' from the modal JSDoc field list
Stock detail page (src/app/(app)/stock/[id]/page.tsx):
- Drop the MapPin + item.location display in the header
- Drop the now-unused MapPin import from lucide-react
Fulfill-from-stock dialog (src/components/stock/fulfill-from-stock-dialog.tsx):
- Drop location from the search predicate
- Drop location from the placeholder text
- Drop the '· {item.location}' display in the row
Note: the location field stays on the StockItem type and PB
schema for backward compatibility — removing it from the type
would break any code that still references it, and removing it
from the PB schema is a backend migration. The field is just
no longer surfaced anywhere.
Co-authored-by: Orca <help@stably.ai>
The SendPoModal renders its 'Resend' label and inline failure
banner from purchaseOrder.status / email_error, but the buyer-tasks
page stub didn't include either field. As a result, the modal
showed 'Send' and an empty banner even when the underlying PO was
mail_failed. Now the stub includes both so the operator sees the
correct affordance.
Co-authored-by: Orca <help@stably.ai>
When a card's poStatus is 'mail_failed', the card now renders a
red warning row with the email_error reason and switches the
action button label to 'Resend' with the destructive colour. A
new data-po-status attribute on the button lets tests target the
state without depending on the human-readable label.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>