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.
`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.
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