Ringing up 800 of something means typing 800, and the only route to the
quantity field was a 600ms long-press with no affordance anywhere on
screen. The quantity itself — the thing a cashier reaches for — was a
plain <div class="v">, so it rendered inert.
- the quantity is now a real button that opens the Edit line sheet,
with an aria-label and a 44px touch target (it was 30px, too narrow
to even show "800")
- holding +/- no longer pops the sheet open mid-tap: the pointerdown
handler bails for .qty/.rm before starting the timer
- the sheet is quantity-only. The Unit price field is gone on request;
a mis-keyed price is expensive and easy to miss. The per-line
override it fed is removed with it — pos-service.ts only ever read
unit_price, the "price edited" label and the sale record's `edited`
flag were unreachable, and nothing else could set an override,
including a cart reload (persistCart saves only {id, qty, p})
The discount trigger moved out of its own 54px footer row to ride
inside the Subtotal row, so it costs no vertical height at all while
staying visible — a discount control that appears from nowhere is one
nobody finds under pressure. The applied-discount red totals row
remains the always-on indicator.
Fixes a pre-existing bug found while testing: long-pressing a cart row
showed a grey screen with no dialog. #editSheet is a SIBLING of
#editBackdrop, but the CSS revealed it with
`.edit-backdrop.show .edit-sheet` — a descendant selector that can
never match a sibling — so the sheet sat at opacity:0 while the
backdrop went opaque. Nothing threw and the build was green, which is
why it survived; it is also why manual quantity entry looked missing.
The sheet now has its own z-index above the backdrop and its own .show
rule. They stay siblings on purpose: nesting the sheet inside the
backdrop would make every tap on Save bubble to the backdrop's
onclick and close the dialog.
Verified in jsdom against the real markup: old CSS reproduces the grey
screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix
clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS
tests pass, including 23 new ones asserting the tap target exists, the
long-press guard fires before the timer, the sheet stacks above its
backdrop, and the popup stays quantity-only.
sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out
of the shell, so without the bump tills keep the old ones.
The till filtered the catalog with one case-insensitive includes() over
sku + description, so word order decided whether you got anything:
"nuts m20" returned 0 hits while "m20 nuts" returned 1. The filter also
checked sku_normalised, brand and search_text — fields that exist on
price_list but not on a stock_items row — so three of its five clauses
could never fire, and name was never searched at all.
Search moves into public/pos/pos-search.js, unit-tested from
src/lib/pos-search.test.ts. Every tier is a strict superset of the old
filter, so nothing that used to match stops matching.
exact whole query as a run
squish punctuation/spacing removed, so M20NUT finds M20 Hex Nut
tokens every word present in any order
plural "nuts" also tries "nut"
glued "M20NUT" -> M20 AND NUT on letter/digit boundaries
Measured on the live 3,591-row catalog: "bolts" 0 -> 884 (884 real bolt
rows, and no row literally says "bolts"), "nuts m20" 0 -> 12, "nut hex"
0 -> 13, "washers flat" 0 -> 14, M20NUT 1 -> 12, zero lost matches.
A plural widens to exactly the union with its singular, asserted per pair.
Widening is safe because both step-2 features are shaped as supersets:
plural forms are alternatives inside one clause, while split parts are
separate required clauses. Making split parts alternatives instead
reduces "M20NUT" to "has M20" — 242 rows. Edit-distance fuzzy matching
was measured and rejected: "nuts" 15 -> 172, "m-20-n-u-t" -> 2,481.
Also fixes three defects the live data caught, all reachable from a code
review alone:
- split kept the generic fragment, promoting "20" from "m20nut" to a
required clause; a leading 1-2 letter run now merges into its digits
- the split group dropped the other tokens, so "M20NUT WASHER" matched
rows containing no washer; other tokens are now carried along
- a punctuation-only query ("-", "---") tokenised to nothing and threw,
which would have blanked the catalog at the till
The index is built once per catalog load on both paths (IndexedDB and
network), keeping derived strings off the items so the cache stays lean.
0.21-2.98 ms per keystroke, so no debounce is needed. filteredProducts()
falls back to the old substring filter if PosSearch is missing, since a
skewed service-worker cache would otherwise blank a shop's catalog.
sw.js v28 -> v30: the new script must be in the app shell, and the second
bump is required because pos-search.js is served cache-first and step 2
edited it in place.
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 running the real flow end-to-end in a browser as David Els.
The list page opened the pricing review modal; the detail page did
not — its "Review pricing" button still POSTed the create directly.
So the same button did two different things on two screens, and on
the detail page it created the quote blind, which is exactly what the
review modal exists to prevent.
It now opens the same `QuoteReviewDialog`, keyed on the note id so
its grid state can't leak between notes.
Also fixed a message that outlived the check it described. Removing
the supplier requirement left the refusal still reading "Every item
needs: processed ✓, supplier ✓, cost price, and selling price" — so
an operator was told to set a supplier on every line, which changes
nothing now, while the three real blockers went unnamed. The message
is now derived from the same `issues[].missing` array that drives the
check, so the two cannot drift again.
E2E evidence (live PocketBase, David Els / CNL Industrial Mining
Supplies):
DN26100599 -> pricing review -> Q202610102
cost R120 -> markup 25 -> selling R150, qty 10
is_processed = true in the database
PDF: 200 ok, 82,184 bytes, %PDF-1.3, 2 pages, valid
DN26100508 -> blind create -> Q202610101
PDF: 422 items_not_ready
missing: ["not processed", "cost price", "selling price"]
That is the whole bug reproduced on both sides: reviewed quotes render,
blind ones are refused, and the refusal now names what is actually
missing.
Also verified in the browser: the stock typeahead returns name, SKU,
unit, qty and supplier from the new route; picking a stock item
auto-fills the supplier (the field that used to block the PDF); and
the create-delivery-note dialog is unchanged.
Full suite 3676 passing, tsc clean.
Found by driving the real app in a browser as David Els, not by
reading the code. Two of these were invisible to typecheck, build and
3670 unit tests.
1. The action icons were clipped off the right edge
`preview-dn` and `download-pdf` were the SAME action — on the list
both opened the DN PDF preview, on the detail both called
`setPreviewOpen(true)`. A duplicate I had introduced in the
canonical set. Even after removing it the row still overflowed:
the Company column was ~294px, the widest thing in the table, and
every row already sits inside a company group header spelling the
same name out. Removing the column removed the redundancy AND the
overflow. Table is now 1140 in a 1140 container, 0px clipped.
2. Entering a first cost produced a ZERO-MARGIN line
A counter note opens with no cost on file, so the line starts at
cost 0 / markup 0 / selling 0. Typing a cost left the markup at 0,
which derived a selling price exactly equal to the cost — a line
that looks priced and earns nothing, on the commonest path in this
workflow. Entering a first cost now seeds the default markup, so
the operator gets R125 selling on R100 cost and a visible 20%
margin. Seeding only happens when the line had no cost AND no
markup, so it can never overwrite a margin someone chose.
Verified in the browser: cost 100 -> markup 25 -> selling R125,00
-> line total R1 250,00 -> margin R250,00 (20.0%).
The 20% on a 25% markup is the distinction the whole pricing
module exists for, and it renders correctly.
3. The actions column also gets `w-px whitespace-nowrap` so the cell
sizes to its icons instead of being squeezed by its neighbours.
Also pinned with 6 tests (repriceWithCostEntry): seeds on first cost,
yields a margin, does NOT reseed on an already-costed line, does not
override an explicit 0, collapses when the cost is cleared, and is a
no-op without a cost. Verified they bite: removing the seed fails 2.
Known limitation, reported rather than hidden: item DESCRIPTIONS are
not searchable. The list endpoint returns headers only and never
loads items, so "brazing" finds nothing while "kobus" (the customer
on the same row) finds 9. Making descriptions searchable means
loading items for every row.
Full suite 3676 passing, tsc clean, build compiles.
Two things stopped the operator quoting a delivery note. Neither was
friction; both were bugs.
1. "The status is not right"
`createQuoteFromDeliveryNote` opened with `if (dn.status !== "draft")`
and refused everything else. A note the office had already sent, or
that the customer had put a PO against, could not be quoted at all,
and the whole error message was that condition.
Now any status except `cancelled` can be quoted — cancelled is the
one genuinely unquotable state, because the work was abandoned and
pricing it would put dead goods on a customer-facing document.
Critically, the status write is now conditional. It used to force
`sent` on every create, which with a relaxed gate would have dragged
a po_received note BACKWARDS to sent and erased the fact that the
customer had sent their PO. Pricing a note is not a reason to move
it. The detail page's Create quote button was also `draft`-only and
now matches the server.
2. "It needs a supplier and this and that"
The quote was being created fine — the PDF was refusing to render.
The readiness check required four things per line, and the two the
modal already supplied were the only two that were satisfiable:
- `is_processed` was never set on a delivery-note quote, and
NOTHING could set it: the quote_items update whitelists exclude
it, and the only writer was /quotes/[id]/process. So a DN-created
quote could not produce a PDF without leaving the flow entirely.
It now derives from the confirmed pricing — cost AND selling
both present — the same rule the process page uses. A line the
operator left unpriced still blocks, so the check keeps its teeth.
- The supplier block is gone. A counter note has no parent order,
so its lines frequently carry no supplier; demanding one refused
the PDF for a field the counter form never asked for. The
supplier is still printed when present.
Signed copy is never a completion signal. The upload route only ever
wrote `signed_copy` — nothing was writing `completed` — and that is now
pinned by a test asserting create-quote never sets `completed` for any
input status, so the relaxed gate cannot introduce it.
One action set, not two
The list and the detail page each hand-wrote the same action set and
had drifted twice: the list got a pricing-review modal the detail page
never received; the detail page got Mark complete, Cancel, Download /
Regenerate PDF and Upload signed that the list never received. The
status gates drifted with them.
`DeliveryNoteActions` now owns the set, the order and the gating. Both
pages render it — icons on the list, labelled buttons on the detail —
and each supplies its own handlers, so behaviour stays where the state
lives but "which buttons, and when" is answered in one place. An
action with no handler is not rendered, so neither presentation can
invent or hide one.
Tests: 27 new. 19 pin the action set and gating, 8 cover the relaxed
gate / no-backwards / is_processed / no-supplier rules. Verified they
bite by regressing create-quote to draft-only and by restoring the
old supplier block. Full suite 3670 passing, tsc clean, no new lint
warnings.
Tabs
- Cancelled no longer has its own tab. The operator asked for it to
sit under the done heading with a filter between the two, rather
than spending a tab on a small cold set nobody visits. Tabs are
now Active | Completed | All.
- Inside Completed, an always-visible Completed / Cancelled / Both
sub-filter, each option carrying its own count. It is rendered in
the open, not behind a click — an invisible control on this screen
was the original "what does that do?" complaint, so a hidden
filter would have reintroduced it. Defaults to "both" so the tab's
count and its rows always reconcile.
- Cancelled keeps a red badge, and no row tint. It now shares a tab
with Completed, so the two must be distinguishable at a glance
without opening each row.
Search (was quietly broken)
- It only covered `delivery_note_number` and `notes`. Customer name,
company and order number were NOT searched — so "what's
outstanding for Acme", the first thing anyone types on a list
grouped by company, returned nothing.
- The search term was never escaped into the PocketBase filter,
while the `repId` clause directly below it escapes its quotes. A
customer called `Acme "Ltd"`, or any stray double quote, produced
a malformed filter and a list that failed to load.
- Search is now client-side over the already-fetched notes, which
carry customer / company / order expanded. Covers DN number, order
number, customer, company, the customer's own PO and notes. Every
word in a multi-word query must match somewhere on the row, so
"acme bolt" narrows rather than returning nothing. No round trip
per keystroke.
Also removed
- Dead pagination state. The page fetched a single wide page and
handed the DataTable page={1} totalPages={1} with a no-op
onPageChange, so page/setPage/totalPages could never do anything.
That also retires the `setPage(1)` the search handler was calling.
Empty state now distinguishes "nothing matches your search" (and
names what is searchable) from "nothing on this tab".
Tests
- 30 in delivery-note-stage.test.ts, covering the search matcher and
the tab/sub-filter bucketing. Verified they bite by reverting the
search to its old two-field coverage: 5 fail.
- matchesDoneFilter refuses an in-flight status even under "both".
It didn't need to — the filter only runs inside the Completed tab —
but a function that would return a draft for "both" relies on its
call site staying correct, so it now guards itself.
Full suite: 3630 passing. tsc clean. No new lint warnings.
The Create quote button fired blind: the quote appeared with whatever
pricing happened to be on the order items, and a wrong price was only
discoverable by finding the quote afterwards. A new review modal puts
the check in front of the create.
Modal (components/delivery-notes/quote-review-dialog.tsx)
- Letterhead picker. DN-backed quotes never set company_letterhead, so
the renderer defaulted every one of them to CNL.
- Cost and markup per line; selling price is DERIVED, never typed, so
the two cannot disagree. See src/lib/quote-pricing.ts.
- A no-cost line shows "No cost on file" and stays at R0.00 rather
than deriving a fake price off a zero cost, which reads as a
deliberate free-of-charge item.
- Bulk "set all markups" field — typing 25% into eight lines one at a
time is the tedium the modal exists to remove.
- Totals, with margin as a share of REVENUE not cost: 25% markup is a
20% margin, and conflating them quietly under-prices a job.
- Nothing persists until Generate. Cancel discards the whole grid.
Backend
- create-quote now accepts { letterhead, items: [{ orderItemId,
costPrice, markupPercent }] } and derives selling server-side.
- Overrides are keyed by order_item id and validated against THIS
note's lines. An unknown id aborts the create rather than moving
another order's goods into this customer's quote.
- Quote items now go out as one PocketBase batch instead of a loop of
individual creates. The batch is transactional: the old loop could
fail on line 5 of 8 and leave a real quote header attached to half
its lines. The header still has to be created first (the batch API
can't reference a record created in the same batch), so a failed
batch deletes the orphaned header rather than leaving it visible.
- Malformed bodies are 400s the operator can read, not 500s. "A quote
already exists" and "not in draft" are now 409s.
Tests
- 33 new: pricing maths, and the route's validation guards. Verified
the guards bite by removing them (2 fail) and restored.
- The existing service test's PB mock gained createBatch, modelled as
genuinely all-or-nothing — the compensation path depends on that
guarantee being real.
Full suite: 3615 passing. tsc clean.
The delivery-notes list was close to unusable: company groups initialised
to an empty Set, so `isOpen` was false for every group and the page landed
on a stack of company headers with nothing under them. The code comment
claimed the opposite of what the code did.
Screen
- Track *collapsed* group names instead of open ones. Open is now the
default with no initialisation, and it survives a list re-fetch.
- Rows expand to show the real line items (description, qty, supplier,
SKU, price, subtotal) via the existing /detail read model, cached after
first open. Ported from /quotes, which already had this affordance.
- Whole-row click removed so expanding can't be mistaken for opening; the
DN number and "Open full delivery note ->" navigate instead.
- Row tint as a derived progress ladder — no new stored statuses:
none = draft · yellow = signed copy · blue = quoted · green = po_received
`invoiced` leaves the Active tab, so Active is now genuinely what is
outstanding. Hovering the status badge explains the colour.
- DataTable gained an optional `selectionHeaderLabel`; the checkboxes
feed one action (Combine) and now say so.
Stock search
- The create-delivery-note dialog was `getFullList`-ing every stock item
with expand=supplier on each open, before a character was typed. Now a
debounced typeahead against /api/v1/stock-items/search.
- Deliberately NOT the existing quick-draft search route: that one is
served with an admin client and no session check, so its result isn't
gated on the listRule. The new route uses assertBrowserSession.
Fixes
- The PDF preview modal was clamped to 384px wide and 900px tall despite
passing max-w-[1100px]. DialogContent's default `sm:max-w-sm` is a
*variant*, so twMerge kept both and the variant won on cascade order
(verified against the compiled stylesheet: 88,536 vs 258,517). Both
preview variants now repeat the `sm:` prefix so the conflict resolves.
- Expand-panel fetch was missing the Authorization header. Dashboard auth
lives in localStorage, so route handlers cannot read it from cookies and
the request 401'd. A 401 now reports an expired session instead of a
bare status code.
Notes
- 25 new unit tests. The stage ladder and the PB filter construction are
the two places where a silent wrong answer looks like a UI bug.
- Unauthenticated write routes on delivery-notes (create, combine,
create-quote, customer-po, mark-invoiced) have no session check. Left
as-is and reported separately — locking them is an auth-behaviour change.
TWO bugs, one of which was hiding the other.
1. "No signed copy on file" for a note that had just been signed,
scanned and uploaded. The upload was never the problem — the READ
path was. `delivery_notes.signed_copy` is a PB file field with
`maxSelect: 1`: we WRITE it as an array (`signed_copy: [file]`),
but PocketBase RETURNS A BARE STRING when exactly one file is
attached. Verified on the live record after a real upload:
typeof : string
Array.isArray : false
value : "image_ar52u2hjfq.jpg"
value[0] : "i" <-- indexing yields a CHARACTER
Every read site assumed `string[]`, and not one of them threw:
- signed-copy GET: `Array.isArray(x) ? x[0] : null` returned null
for notes that demonstrably had a copy — the reported error
- delivery-note detail tooltip: `signed_copy[0]` rendered the
first LETTER of the filename
- delivery-notes-service: treated every signed copy as absent, so
the quote went out with the UNSIGNED pdf attached
- deliveries-service: reported an empty filename
- deliveries page: `.join(", ")` on a string — this one THREW,
on a page people open daily
Adds `src/lib/file-fields.ts` (firstFileName / fileNames / hasFile)
and routes all of them through it. The type was the root of the
wrong assumptions, so it is now `string | string[]` with a comment
pointing at the helper.
2. QR 28pt -> 52pt, moved to the top-right corner. Measured by
rasterising the real PDF at 200 dpi and decoding: it now reads at
1.24 px/module, where the old size needed ~2.5 — roughly double the
distance and angle tolerance.
It could not simply go at top:40: the logo is an absolute 160x100pt
block in the same corner, so the QR landed on top of it. That is
not only ugly, it breaks the QR's quiet zone — logo ink inside the
light margin makes the code refuse to decode. The logo now steps
left (right: 112 -> 106, width 160 -> 132) to give the QR the true
corner, and the company name is width-bounded ONLY when a logo is
present. The Westscott letterhead has no matching .svg in
public/letterhead/, so in practice the name still gets the full
width and stays on one line.
Nine tests pin the file-field shapes, including one that asserts the
old `.join(", ")` call really does throw on a string, so the string
case cannot be quietly dropped again.
Scanning felt like it never resolved. It was not slow — it was being
downscaled into unreadability.
The printed QR is 28pt on a 210mm page: 4.7% of the page width. The loop
down-scaled the camera frame to 640px wide before decoding, so a phone
pointed at a full A4 sheet rendered that QR at ~30px total, about 1 pixel
per module. Measured against the real rendered page, jsQR cannot read it
at any crop size:
cap px/module whole frame bottom-45% ROI
640 1.04 MISS MISS
1280 2.08 miss miss
1600 2.60 HIT 69ms HIT 28ms
1920 3.12 HIT 98ms HIT 39ms
2560 4.16 HIT 171ms HIT 39ms
Above ~2.5px per module it is reliable, so the cap is now 1920. No amount
of speeding up the loop could have fixed this; the pixels were not there.
- requestAnimationFrame instead of a 120ms setInterval. rAF fires
straight after a painted frame, so the decoder gets a fresh one
rather than whichever frame happened to be current at the tick; it
also removes the 120ms floor on every detection and lets a
readyState stall recover on the next frame instead of a timer tick.
- Two regions instead of one. The delivery-note QR sits in the bottom
band of the page, so that band is scanned every frame at roughly a
third of the cost of the full frame (39ms vs 98ms). The full frame
runs every 6th as a safety net for a tilted page. It is an
accelerator, not a restriction.
- Native BarcodeDetector is now EXCLUSIVE when present. It used to
fall through to jsQR on a miss "because jsQR sometimes succeeds
where native blurs", which doubled the cost on every miss — the
common case. The native detector is Google's own ML stack; when the
browser ships it, use it alone.
- Screen wake lock while scanning, re-taken on visibilitychange
because the browser drops it whenever the tab hides. Otherwise the
phone locks mid-queue.
- 3s debounce per code, and the camera keeps running after a hit, so a
stack of notes can be worked through without a tap per sheet.
The viewfinder is now ONE <video> mounted for the life of the page,
resized when a note loads. A second copy in the note branch would have
unmounted the first — and since a MediaStream is attached to the
ELEMENT, the replacement video would have come up blank while the stream
kept running into nothing.
Four new tests pin the resolution floor, including one that documents
that a full A4 page framed at 640px is UNREADABLE, so nobody lowers
MAX_SCAN_EDGE back to 640 thinking it is just a performance knob.
Scan the QR printed on a delivery note, see what it is, and act on it:
reprint, photograph the signed copy, or walk it toward invoicing. It is its
own route group outside `(app)` so it gets a clean full-bleed layout and its
own manifest — "add to home screen" should give the operator a scanner, not
the CRM. Mirrors the pattern `(counter)` already uses.
Two detectors, not one. `BarcodeDetector` is fast and allocation-free but is
Chrome-only: Safari/iOS still has it "Under Consideration" (WebKit bug
#254573) and Firefox does not ship it. A scanner that only used it would
simply not work on an iPhone. So native where it exists, jsQR everywhere
else — pure JS, so no 2 MB WASM payload on a weak warehouse signal, and the
delivery-note QR is always a QR code. Both run against the same canvas frame
so the caller never branches. Also requires HTTPS, which the page says
plainly when it isn't.
The QR carries the human number, so the first call is a new resolve endpoint
mapping number -> PB id. Everything after that speaks ids, which is what every
existing route already takes. The number is shape-validated before any network
call, so a supplier's packing label can't fire a lookup.
STATUS LADDER. mark-invoiced hard-refuses anything that isn't po_received, and
customer-po refuses a note that isn't sent/loaded — so rather than offering a
button that 500s, each action only appears when the server would accept it, and
the card shows which step comes next. It routes draft -> loaded -> po_received
-> invoiced via the existing load-dn, NOT draft -> sent, because draft -> sent
means "create a quote and email it" and a warehouse scan must never trigger
that silently.
The signed-copy upload deliberately uses the upload-only endpoint, not
/api/v1/deliveries/mark-signed-copy — that one flips the note to `completed`
in the same write, which would jump a note straight past invoicing.
Manual entry is always available, so the page works on a device with no camera
or a browser that won't scan.
Tests decode the REAL delivery-note QR through the production fallback path at
print size, and assert the shape check rejects a supplier QR, a random string
and an over-long number.
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
An account sale at the till used to produce a delivery-note-shaped slip that
was rendered entirely on the device and printed on the 80mm thermal printer.
Nothing reached the server, so the paperwork the client signed had no record
behind it. Cash / card / EFT are untouched and still get the thermal slip.
An account sale now creates a real `delivery_notes` record. The DN is raised
server-side INSIDE `checkoutSale` rather than by a follow-up call from the
till, because the offline queue (`flushQueue`) re-posts `/checkout` and
nothing else — a client-side second call would be silently dropped for every
sale taken while the network was down, which is exactly when a till is
flakiest.
`pos_sales.delivery_note` is the idempotency anchor that makes it
exactly-once: on a replay it hands back the existing DN instead of making a
second one, and a sale whose first attempt left the field blank retries. It
self-heals.
Deliberately non-fatal: if the DN can't be created the sale still succeeds.
The money and the stock movement are real by then; failing the checkout would
leave the drawer disagreeing with the stock ledger. The till is told instead.
Also in here:
- DN number collision retry. The generator reads the highest existing number
and adds one, which is not atomic, while PB holds a unique index on it. The
POS adds a second concurrent writer, so two creates could collide and 500
on a benign condition.
- GET /api/v1/pos/sales now expands customer, customer.company and
delivery_note so history.html can rebuild the DN card on a reprint. The
nested customer.company matters — customer.company is itself a relation,
so without it the card printed a raw PB id where the name belongs.
- "Print slip" is hidden for account sales; that tender produces a delivery
note, not a slip.
New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind
assertPosAuth, so the till's bearer key stays its only credential. It does not
point at the CRM's unauthenticated invoicing route.
Verified end to end against a live note: DN26100401, correct company, both
lines with pricing, PDF attached, stock decremented once, and a replay of the
same client_ref returned the same DN without a duplicate.
Every renderer funneled the PDF through a JS string and re-encoded it as
latin1. Three separate problems, only the first of which was ever real:
1. `pdf(el).toBuffer()` is NOT the buffer API — it resolves to the PDFKit
*stream* (react-pdf carries a TODO to rename it `toStream`), and under
jsdom it resolves to a bare PDFDocument handle. That is the jsdom
breakage an old comment described, but it applies to that method, not
to the module-level `renderToBuffer()` helper.
2. `toString()` renders with `compress: false`. react-pdf's own source
says why: "when rendering to string if compress=true the document is
blank". The method is flagged upstream as buggy (#2112, #2095).
3. The string round-trip mangles binary streams. Text is ASCII in the
content stream so it survives; a raster image is a compressed blob
that does not — the image object lands in the file but is never
painted. Poppler was already reporting "Bad FCHECK in flate stream".
It stayed latent because the only image in these documents is the letterhead
logo, and that logo is an SVG. SVG is text, so it round-tripped perfectly and
hid the whole thing. `0xFD` in the old comments is the fingerprint: latin1's
truncation of U+FFFD, the Unicode replacement char.
`renderToBuffer()` fixes all three in a single pass and works under jsdom, so
no fallback is needed. Note you cannot "try toBuffer, fall back to toString" —
calling `toBuffer()` first consumes the render and a later `toString()` on the
same instance returns a document with the content missing.
Three more instances of the same pattern lived outside render.tsx and are fixed
here too: the print-bundle, delivery-note-print and picking-slip routes.
`render-image-pipeline.test.ts` is the regression guard. Its size assertion is
expressed against the old uncompressed baseline rather than a round number
picked when the document was smaller — an absolute byte count is a tripwire
that fires on every feature addition, which is how a real regression gets
tuned away. Guard verified by reintroducing the old path: 2 tests fail.
actor.id must be a 15-char user id (stock_ledger.created_by is a relation to
users; a non-user id left the cost patched but unaudited). Each updates[i].id
is validated, actor.name defaults to the actor id, and the Giellie prompt now
says to pass both selling_price and markup on create and to read the item
before a partial price change. Adds tests for null / numeric-string prices
and the explicit markup:0 default-markup behaviour.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Adds the MCP tool stock_items.set_pricing (batched, max 100, snake_case in,
wraps updateStockItemsPricing, requires actor.id, audits cost changes in
stock_ledger). stock_items.create now accepts cost_price / selling_price /
markup (validated >= 0) and applies the default-markup helper like bulk
import. Giellie's prompt gets a pricing playbook (POS reads selling_price,
ask for missing cost/markup, never apply an unconfirmed markup); the tool is
classified as an approval-gated write. Tests cover route, policy, real
catalogue, bridge id guard and prompt.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An array under an id key stringifies unencoded into same-origin URL paths
(`${["../x"]}`), bypassing the string-only guard. Id-keyed values must now be
plain safe id strings; plural id arrays (item_ids, delivery_note_ids, ...) are
validated element by element. RFC 5322 message-id keys and attachment tokens
are left alone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- decision endpoint requires pendingKey; mismatch with the stored pending is a 409
- refuse to continue chats over 1.5M chars; compact pending.completed on save
- SSE ": ping" heartbeat every 15s, cleared on finish/cancel
- in-flight set lives on globalThis (survives HMR, shared across bundles)
- resolveOrigin ignores non-http(s) env values; log sanitised cause on tool-load 503
- approval card shows the confirm reason; trust caption updated
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- deny quotes.re_run_ai (wipes every quote item); orders.cancel and
delivery_notes.cancel/combine always ask; orders.archive is a read tool
- classifyToolCall escalates writes that touch email/contact fields to confirm,
with a reason stored on the pending tool use
- system prompt: real cancel tools, SAST date
- bridge rejects @collection./@request. filter references and path-shaped ids
- agent: blank-text responses become the placeholder, duplicate pending ids
answered once, clearer "result unavailable" wording
- catalogue test checks the policy against the real MCP tool list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field.
- Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability.
- Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications.
- Enhanced the `package.json` with a new migration script for setting up the audit log functionality.
This feature improves accountability and transparency in the management of delivery notes and orders.
- Enhanced `nss-fasteners-pb-import.json` to compute `selling_price` as `cost_price * (1 + 80%)` and included `markup` in the records.
- Updated `nss-fasteners-pb-transform.ts` to allow configurable CSV and output file names via environment variables, improving flexibility for different product runs.
- Added support for uploading photos in the repair detail page, including validation for file types and sizes, and implemented a dialog for creating customer quotes linked to repairs.
- Introduced a new `customer_quote` field in the `Repair` type to link generated quotes back to their respective repairs.
These changes streamline the pricing process and enhance the user experience in managing repairs and quotes.
New page lets a rep see their own rep_visits on a Leaflet map.
Defaults to last 7 days; toggleable to 14 / 30 / 90.
/rep/visits ── server-side fetch + re-export to client
/rep/visits map ── per-customer cluster, count badge, last-visit
label, popup with full per-visit timeline + notes
+ 'manual' badge, side-list of customers in window
+ search filter + heatmap toggle
Service + route (no pb-helpers):
visits-service.getVisitsForRep(repId, {from?, to?, limit?})
- admin PB (session-scoped at the route layer)
- expands customer relation, filters position_source != 'tracker'
server-side, PB date format gotcha (T → space), 5000-row cap
/api/v1/rep-visits/list GET, browser-session auth, scopes repId
from user.id (never a query param — same
hardening as POST /api/v1/visits)
Heatmap:
leaflet.heat wrapper at src/components/leaflet-heatmap-layer.tsx
- useMap() + useEffect lifecycle
- brand gradient (deep blue → green → amber → yellow → crimson)
- reads on both light OSM and dark CARTO tile sets
- maxZoom default 10 — leaflet.heat scales intensity by
1/2^(maxZoom-currentZoom); without this the OSM default maxZoom
of 18 makes points render at ~5% opacity at our default zoom 10
Other fixes landed mid-feature:
- KPI cards collapsed from 3 into 1 (clusters count is a
zoom-dependent implementation detail, not a glanceable stat)
- added 'View my visits on a map' nav link under /rep header
Dep: +leaflet.heat@^0.2.0, +@types/leaflet.heat@^0.2.5
(react-leaflet@5, leaflet@1.9.4 already present)
Tests: 50 + 34 = 84 pass in the touched (rep) area + visits-service
+ rep-visits API. Pre-existing failures on main (sidebar,
pocketbase-admin, quotes-service) confirmed unrelated by
stashing this branch.
The button has never worked. cashup.html wires it with an inline
onclick="printCashup()", but the page's whole script is IIFE-wrapped, so
printCashup was never a global — the click threw "ReferenceError:
printCashup is not defined" into the console and the button silently did
nothing. No alert, no dialog, no feedback.
Dead since 46d5d54 swapped the button from onclick="window.print()" (a
real global, so it worked) to the ePOS-Print path. receipt.html hit this
exact bug earlier and fixed it by exporting its handlers to window — with
a comment noting the buttons "all silently do nothing". cashup.html never
got the same line. It does now.
On the network transport the cash-up slip already mirrored the sale slip
(resolve 'cashup' blocks -> letterheadLogoRaster at 384 -> printViaNetwork),
so the export is the whole fix for a till on a network printer.
Also here:
- 20c and 10c added to the drawer count, with the total rounded to whole
cents. Neither is exact in binary floating point, so 3x10c would
otherwise have summed to 0.30000000000000004 and printed a variance
that reads as a rounding bug on the slip.
- The button restored its label with textContent, which dropped its
printer <svg> permanently on first use. Restores innerHTML instead.
- The Bluetooth branch quietly called window.print() (OS dialog, not the
thermal printer). getSettings() DEFAULTS to bluetooth, so any till with
cleared settings would land there and look dead all over again. It now
fails loudly, matching EposPrint.printTest()'s stance. No ESC/POS
cash-up renderer yet — only the sale slip speaks Bluetooth.
pos-app.js had the same defect in four more places: the edit-line modal's
Save / Cancel / Remove and the customer "Remove" chip are all inline
onclick handlers declared inside its IIFE, and only openEditLine was
exported — so the modal could be opened but not dismissed or committed.
Confirmed by parsing the file (one top-level statement, no top-level
function declarations), fixed with four additive export lines.
src/lib/pos-inline-handlers.test.ts guards the class across every POS
page: any identifier called from an inline on* attribute must be a
builtin, a namespaced call, or exported on window. It was red on exactly
these bugs and is green now.
SW cache bumped to pos-v26 so the tills pick the files up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
This commit enhances the stock item management by introducing automatic calculation of selling price and markup based on the cost price. Key changes include:
- Integration of `applyDefaultMarkupToPatch` to automatically set `selling_price` and `markup` at 80% of the cost price when they are not explicitly provided during stock item creation and updates.
- Updates to the `create` and `update` functions in the stock items service to ensure that existing values are respected and not overwritten if they are already set by the operator.
- Tests added to verify the correct behavior of auto-markup during both creation and updates of stock items, ensuring that the functionality works as intended across various scenarios.
This feature aims to streamline the pricing process for stock items, reducing manual input and potential errors.
Two new fields added to the customer add/edit form, both
operator-facing for the active/inactive customer workflow:
- customers.status (bool): flips the customer active/inactive.
Legacy rows pre-date the field and load as undefined; both
the form and the read side collapse that to active=true so
the migration date doesn't silently flip the existing base.
- customers.site_location (text): free-text site/shaft label
keyed off the customer's company. Distinct from the existing
'site' free-text mine tag owned by the pin tool. Storage is
plain text on purpose, with a /api/v1/customers/sites
endpoint returning distinct existing values for an input
+ datalist autocomplete under the company combobox.
surfaces:
- Active/Inactive badge on customer detail and list rows
- site_location row in the customer Details card and under
the company line on the list (desktop + mobile card)
- Switch under the sales-rep section of the form to flip
status; existing customers stay active by default
also fixes a UX trap on edit: previously opening the edit
form showed 'Select company...' / 'Select rep...' as placeholders
for an instant until the lookup lists resolved, making a real,
saved company/rep look unlinked. CompanySearch and UserSearch
now accept a selectedLabel fallback; CustomerForm passes the
resolved customer.expand.company / customer.expand.rep names
through so the triggers display the linked names immediately.
listDistinctSiteLocations helper added to customers-service.ts
alongside the existing listDistinctSites.
Doubles down on the /stock list to make catalogue and inventory
edits one-click for the operator.
What's new:
- /stock: clickable column headers on Cost / Markup / Selling
cycle none → asc → desc → none and feed through to the PB sort
param. The apply bar now commits pricing AND qty in parallel
(Promise.all against /set-pricing and /set-quantities) with
per-row failure tracking and focused error toasts. In edit
mode the row is no longer a button — inputs render in-place
for current_qty, min_qty, and target_qty alongside the existing
pricing inputs, and the row click is gated off so clicks on an
input never navigate to the detail page.
- stock-quantities-service + /api/v1/stock/items/set-quantities
(POST): bulk-update current_qty / min_qty / target_qty on a
set of stock_items rows. Whitelist-validated patches, per-row
results with stable failure reasons, and a stock_ledger row
written FIRST whenever current_qty actually moves so the audit
trail stays ahead of the cached total (same pattern as
stock-surplus and stock-booking-service).
- scripts/apply-default-markup.ts: one-shot operator tool that
seeds selling_price = round(cost_price * 1.80, 2) and markup = 80
on stock_items rows whose cost_price is set but selling_price
is zero. Bypasses STOCK_ITEM_PATCHABLE_FIELDS (operator-initiated
bulk action); idempotent (never overwrites a non-zero shelf
price or markup).
- scripts/list-irda-stock.ts: read-only inspector for the
IRIDA spray-caint line with summary counts (without
selling_price, without cost_price, zero stock) so the
operator can see exactly which rows the backfill tools above
will touch.
Tests: 81 new test cases across /stock page, stock-quantities
service + route, and apply-default-markup.
Closes the pre-Fix-#1 stock pricing gap so the till and the POS see
prices on day one.
What's new:
- price-list-service: addPriceListItems now mirrors the upserted
selling_price + markup onto the matching stock_items row when
(and only when) the stock row has no price yet. Sync failures
are returned as warnings — never as a failed price_list write,
so a single broken stock row can't poison a 500-item batch.
- stock-quick-draft-service: addQuickDraftLine seeds cost_price +
bootstraps a one-entry cost_history on inline-create when the
buyer supplies newStockItem.costPrice. Existing-item path
backfills cost_price when zero and always appends a
cost_history entry so the line addition shows up in the audit
log without waiting on bookStockFromInvoice.
- stock/items/create (POST): accepts optional costPrice,
sellingPrice, and markup on the standalone create endpoint so
the operator can stamp a price and bootstrap cost_history in
one round-trip. Empty bodies still produce an empty
cost_history (legacy callers unchanged).
- scripts/backfill-stock-prices.ts: one-shot operator tool that
mirrors price_list rows onto existing stock_items rows whose
selling_price is still zero. Pure decision function exported
for unit tests; --commit / --limit / --sku CLI; idempotent
(never overwrites a non-zero shelf price or cost).
- vitest.config.ts: pull scripts/**/*.test.ts into the vitest
include list so backfill-stock-prices.test.ts runs in
`npx vitest run` without a per-script config.
Tests: 65 new test cases across price-list-service,
stock-quick-draft-service, stock/items create, and
backfill-stock-prices.
Two operator-facing bugs on /deliveries:
1. Page grouped rows by customer instead of company. Added a
CompanyGroup layer above CustomerGroup with a new
groupByCompanyCustomerOrder() that builds
Company → Customer → Order → Items + DNs (4-level hierarchy).
Each level sorted stably so the most recent work sits on top.
2. The delivery-note expand panel said 'No items loaded on this
delivery note' for every DN, even when the same DN had items
on the full /delivery-notes/{id} page. Two root causes:
a. Wrong join key. The page filtered by
i.delivery_note === dn.id (the never-populated relation
field), so the comparison was always null === id and
never matched. The full DN page joins on the text column
delivery_note_number — switched the page to the same key.
b. Items missing from the response. The page-level items
list is filtered by item_status (invoiced / with_driver /
checked_in) per tab, so items still in the 'delivery_note'
lifecycle state (paper sent, invoice not yet created)
weren't in the page-wide list. Added a third PB query in
the service that runs after the DN list lands and pulls
every item whose delivery_note_number matches one of the
page's DNs, regardless of item_status. Returned as a new
dn_items field and threaded through to the expand panel
as a 'dnItems' prop. The main items list still uses the
status-filtered query so bulk actions stay scoped to the
tab.
The first letterhead-persistence fix (1e3121d) gated the WHOLE PB
write for generateQuotePdf's operator-choice persistence behind
"quote.status is not terminal" -- because terminal statuses
(approved/emailed/complete/cancelled/sending) deliberately skip the
pdf_generated_at re-stamp.
But regenerating a PDF on an already-terminal quote (e.g. clicking
"ISherwood" on an already-`emailed` quote from the process page) is
a normal, supported operator action -- confirmed live: it renders
the right letterhead and toasts "quote kept at emailed". That regen
hit the terminal branch and skipped the PB write entirely, so
company_letterhead never updated. A later auto-regen (sendQuoteEmail's
staleness check, e.g. triggered by a markup edit) then fell back to
the stale stored value and shipped the wrong letterhead again --
reproducing the original bug through a path the first fix didn't
cover.
Verified against the live quote via a direct PocketBase read
(bypassing app/MCP caching): company_letterhead was unchanged by the
first fix's terminal-status regen, and is now correctly persisted
while pdf_generated_at/status stay untouched as intended.
Split the letterhead write out from the timestamp/status write so it
always persists on an explicit operator choice regardless of quote
status. Added a regression test for the terminal-status + explicit
letterhead case.
Also syncs the n8n "Quote Outbound Send" workflow snapshot with two
more live fixes made during testing:
- Build attachments list: this.helpers.getBinaryDataBuffer() takes
an item INDEX (number), not an item object -- per n8n's own docs.
Was passing the item object, which threw an opaque non-Error value
on every real send.
- Send Email (SMTP): the html field injected the plain-text
email_message (with literal \n breaks) straight into HTML, so
clients collapsed every paragraph into one block. Now converts \n
to <br> first.
Also captures that the four WhatsApp-notify nodes and the workflow
itself are currently disabled/inactive in the live instance (manual
n8n edits during testing, not part of this fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit addresses the silent letterhead-flip bug reported on 2026-09-09, where the operator's selected letterhead was not being saved, leading to incorrect letterhead being used in subsequent PDF generations. The operator's choice is now persisted in the quote row during PDF generation, ensuring that the correct letterhead is used for future auto-regens and email sends.
Changes include:
- Updated the `generateQuotePdf` function to include the operator's letterhead choice in the database write.
- Adjusted the API response to surface the persisted letterhead for UI synchronization.
- Added tests to verify that the letterhead choice is correctly saved and not overwritten by empty or invalid inputs.
This fix enhances the reliability of the quote generation process and improves user experience by maintaining the operator's selections.
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.
- getOne: resolve singular relation fields via the standard
customer/company/user -> collections/companies/users mappings
and hydrate them under `expand.<relation>`, including one
level of nested expansion.
- create: accept FormData so generateQuotePdf's PDF upload
path is exercised end-to-end; blob entries become a
one-element filename array under their key to mirror PB's
file-field shape.
- create: pin the mock-generated id INTO the row so getList's
fields projection returns a real id to callers (services
reading the returned id, e.g. auto-claim lookup, were
receiving `id: undefined`).
- auto-claim tests: add generic type params to getOne calls
so .status / .claimed_by are typed against the mock.
Operators no longer need to click Claim before Generate. The PDF service stamps claimed_by = <caller> when actorEmail is provided, quote.claimed_by is empty, and the quote is in a non-terminal status. Auto-claim lands atomically with the status/timestamp flip. Skip cases (existing claim, terminal status, missing/invalid actor email) leave claimed_by alone and still render the PDF. Email resolution failures are logged and non-fatal. Surfaces the post-update claimed_by on the route outcome and process page setQuote so the Claimed-by chip refreshes without a refetch.
PocketBase treats `max: 0` on a text field as "use the default 5000-char cap", not unlimited. A Redpath reply with a 5029-char body 400'd on every 2-minute poll for hours on 2026-09-08, wedging every RFQ behind it because the failed email stayed UNSEEN.
Added clampEmailBody (with a truncation marker) and routed email_body writes through it so oversized replies still become quotes instead of blocking the queue. The untruncated body remains in the mailbox, and attachments carry the detail. Raise this cap in lockstep with any PB admin change to the field's max.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>