Commit graph

418 commits

Author SHA1 Message Date
2736ebd7dc feat(search): quick-overview dialog per search hit
Clicking a result now opens a summary dialog instead of navigating away,
for all eight result types.

Deliberately NOT an iframe of the detail page and NOT eight bespoke
preview components. Both were on the table:

  - Iframe: cheap, but `src/app/(app)/layout.tsx:37` renders the
    <Sidebar /> for every page in the group, so the modal would show a
    sidebar inside a sidebar.
  - Per-type components: 8 fetch + 8 field maps, drifting out of date
    as the pages they mirror change.

Instead the SERVICE returns a flat `preview: [{label, value}]` list on
each hit, built from data it already holds in the snapshot and reference
caches. One generic renderer then serves every type, opening a preview
costs no extra round-trip, and a ninth source needs no new UI.

Verified live against PB — e.g. searching "pro-tech" now returns, per
hit: order number, customer, company, rep, status mix, item count,
created date, suppliers, first line descriptions.

Two gaps found while verifying the payload rather than assuming it was
populated:
  - `item_status` was not in the item fetch, so the order's Status row
    was silently absent — the single most useful field in the preview.
    Added.
  - `customers.company` is a relation id; the preview would have printed
    a raw 15-char PB id. Now resolved to the company name.

Rows became <button>, not <a>/next/link: there is no navigation to
perform, and a link would imply a middle-click affordance that doesn't
apply. "Open full page" in the dialog is the escape hatch — a preview
must never be the only way to reach a record.

Tests: the two new-tab tests are replaced by three dialog tests (opens
instead of navigating, escape hatch present with correct attrs,
dismissing leaves query and results untouched).
2026-10-14 13:17:08 +02:00
918f73ea25 fix(search): delivered_at is never populated — 4,360 orders were invisible
Asked what else search excludes, and measured it rather than reading the
code. The terminal-state window filtered on:

    (item_status = "delivered" && delivered_at >= "<90d cutoff>")

Live PB, 2026-10-14: 12,957 delivered items, and NOT ONE carries a
`delivered_at` value. That clause matches exactly zero rows. Every
delivered order was invisible to search — 4,360 of them. `cancelled`
was unaffected because it filters on `updated`, which is always
populated, which is the entire reason the two states behaved
differently.

Fix: each terminal state gets two alternatives — the semantically
correct timestamp when it exists, and `updated` as the fallback. `updated`
is always populated and is >= the delivery moment for a delivered row,
so it can only ever be slightly INCLUSIVE, which is the safe direction
for a "must come up" guarantee. `removed` (struck-from-order lines) now
joins the terminal group too; an order whose only line is `removed` is
in effect a cancelled order, which is how /orders already groups it.

Coverage gain, measured live:
  "tiaan"  orders 96 -> 107
  "450NB"  orders  1 -> 2

COST, and it is real

Widening the window made the candidate set 5x bigger — 1,075 -> 5,361
orders, 3,5xx -> 16,5xx line items — and the snapshot rebuild is O(scope),
so cold went from ~6s to a measured 17-22s. At the old 60s TTL that was
17-22s of PB work every minute per instance, which is not shippable.

Mitigated for now by raising both the scope-id and snapshot TTLs to 5
minutes (5x less exposure) and relying on the page's mount pre-warm, now
that the pre-warm is actually authenticated and stops being a no-op.
Warm searches remain 0ms.

Stated plainly: this is a mitigation, not a fix. The architecture is
still O(scope) when it should be O(matches) — the quotes search already
does it correctly by pushing `~` filters down to PB. That rewrite is the
proper follow-up and should happen before the data grows further.

Accepted trade: a status flip or new item can take up to 5 minutes to
appear in search. Fine for "find the thing I half remember"; not fine
if this ever backs anything needing immediate consistency.
2026-10-14 12:42:46 +02:00
f1dc78c9a0 feat(search): stock orders by product/supplier; results open in a new tab
STOCK ORDERS

The Stock Orders source matched only `purchase_number` and `status`,
which made stock POs findable only by our own internal sequence number.
Live probe on 2026-10-14: searching "saw chain", "protech" or any line
description returned ZERO stock-order hits, while the literal
"PO26101404STK" matched. An operator hunting a stock order thinks in
product or supplier, not in our numbering, so the source now also
matches the resolved supplier name and every line's description, sku,
supplier_sku and product_code.

Verified live:
  "pro-tech"     -> 13 stock orders  (supplier name)
  "clamp"        -> 1                (line description)
  "chelsea boot" -> 4                (line description)
  "M36 NUT"      -> 2                (line description)

Also fixed the matched-surface label: a header match was always tagged
"PO number", so a supplier-name hit claimed to have matched the PO
number. A "why did this match" tag that's wrong teaches the operator the
tags are noise and stops them reading any of them.

Both new collections join the cache warm so the first search doesn't pay
for them.

RESULTS OPEN IN A NEW TAB

Switched from next/link to a plain `<a target="_blank" rel="noopener
noreferrer">`.

next/link does a client-side route change, which UNMOUNTS the search
page — so the query, the expanded result set and the scroll position are
all gone when you come back. A real anchor in a new tab leaves this tab
mounted and untouched, which is the behaviour that was asked for:
click a result, look at it, click back to the search tab, and you are
exactly where you were.

Verified by test: dropping `target` fails the new-tab assertion while
the rest of the suite stays green.
2026-10-14 12:25:18 +02:00
1ee3555239 feat(search): universal search across every collection
One box at /search (sidebar, under Dashboard) covering orders, quotes,
both purchase-order flavours, suppliers, companies, customers and stock
items. Replaces guessing which screen held the answer.

Window: 90 days (UNIVERSAL_SEARCH_WINDOW_DAYS) on orders and quotes —
the two that grow without bound. Suppliers, companies, customers and
stock items are searched in full because they don't expire and an
operator searching for one expects to find it at any age. The UI states
the window so a miss reads as "aged out", not "broken".

THE INVARIANT — a cap must never look like emptiness

Every group reports `total` (uncapped) separately from `hits` (capped),
plus `truncated`. The UI renders "showing 25 of 1,240 — refine". This is
the property the 500-cap bug in orders-service violated: the newest 110
orders silently vanished and it read as a missing search field. The type
makes `total` structurally independent of `hits.length`.

Symmetrically, a source that fails or times out renders as an explicit
"couldn't search quotes" row. "We couldn't search X" and "no X matched"
are different statements and must never look alike. All nine sources
fan out in parallel and settle independently.

PERFORMANCE

Profiling the first build showed 7.8s per search — ~3.5s of it pure
data-fetching (1,070 orders / 4,288 line items re-read on every
keystroke) against ~10ms of actual matching. Added:
  - order snapshot (headers + items + supplier names), 60s TTL
  - reference-collection cache, 5min TTL
  - per-query result cache, 60s TTL

Cold ~4.5s, warm 250ms-1.6s, repeat query 0ms. The page pre-warms on
mount via an empty-query request, so the ~4.5s cold build happens while
the operator is still reading the box rather than in front of them.

Orders match supplier NAMES, not relation ids — verified live that
"truco" returns the Truco order, the 6 matching quotes AND the supplier.

NO Reps group, deliberately

A rep is a filter, not a page: this app has no user detail view and no
page accepts ?search=. A Reps group would be rows whose every link
404s. `orders.rep` is a matched field on the Orders source, so
searching "tiaan" still returns exactly his 96 orders — which is what
was actually asked for.

PO hits link to their parent order, since there is no standalone PO
detail page; orphaned POs fall back to the PO PDF.

Tests: 15 cases over the pure layer — ranking tiers, best-of-not-sum
scoring, filter escaping against injection, the window maths, group
ordering, and the cap/failure invariants. One of them caught a real
defect: scoreField lowercased the haystack but not the query, so an
uppercase query scored 0 against every row.
2026-10-14 10:32:14 +02:00
f44258667c fix(orders): search silently dropped the newest active orders
Searching for a supplier returned nothing even though the order existed
with that supplier set and "TRUCO" in the line description.

Root cause is not search coverage — it's a stale safety cap. The search
path fetched the active candidate set with a fixed `perPage: 500`, on the
comment's reasoning: "91 active orders out of 4,782, so the cap is
unreachable in practice." Measured on live PB 2026-10-14 the active set
is 610 orders. Because /api/v1/orders/list sorts `created` ASCENDING
(oldest first, so the buyer works the queue top-down), the slice kept
the 500 oldest and silently discarded the 110 newest.

Verified against live PB before the fix:
  truco     -> 0 matches
  skirting  -> 0 matches
  tiaan     -> 57 matches
After:
  truco     -> 1 match  (ORD-26-10-13-SCALPERS, collection_pool)
  skirting  -> 1 match
  tiaan     -> 76 matches  (19 were being dropped too)

It presented as "some searches work and some don't": only matches on
OLD orders survived, so it looked like a missing supplier field rather
than truncation, and the supplier lookup added in efb5a53 inherited the
bug rather than causing it.

Fix: `perPage: activeIds.length` so the candidate set is never sliced.
The set is already bounded by COMPLETED_WINDOW_DAYS plus the non-terminal
statuses, so it grows with daily throughput, not total history.

Regression test seeds 520 active orders with the needle on the newest,
so an ascending sort puts it past the old cap. Verified it FAILS against
the old code and passes against the new.
2026-10-14 09:51:46 +02:00
efb5a5390c feat(orders): search by supplier, SKU and forgiving prices
The /orders search box only matched order number, company, customer and
line description, and only did price matching when the whole query parsed
as a clean number. Typing "afrisam" returned nothing because
order_items.supplier stores a PB record id and wasn't even fetched.

Scope is unchanged and still correct: the candidate set is the ACTIVE
order set (getActiveOrderIds) — items not completed plus anything
completed in the last 30 days. Supplier search inherits that window for
free, so "what did Afrisam supply for us?" now returns the live work plus
the last 30 days, and nothing older (that stays on /orders/archive).

Now searchable: supplier name, SKU, contact name, rep, RFQ number, order
description/notes, company address + vendor, customer email/buyer email/
phone, item notes, customer invoice number, quantity, cost_price.

Prices are matched as a substring of the plain digit string, so "12.2"
finds 12.22 and "R 1 250" finds 1250.00. Two guards keep the widened set
usable: a 1-2 char numeric query no longer probes prices ("50" is a
substring of 1250, 350 and 50, which made every price a hit), and
zero-priced placeholder lines never match a price probe. Quantity is an
exact compare for the same reason.

Supplier ids resolve to names via a targeted, chunked lookup (PB 400s on
an inline id filter past ~106 ids) rather than an expand, which would add
an IN query to every page load and realtime refetch. The supplier fetch
and the widened item field list (sku/supplier/notes) are both gated
behind an active search, so plain page loads keep the lean payload.

Tests: 9 new cases covering supplier name/partial/case, the 30-day
window, SKU, price shapes, the short-numeric and zero-price guards,
metadata fields, quantity exactness, and the no-search/no-query guard.
Full suite 3902 passing.
2026-10-14 09:15:39 +02:00
96a9c2617c feat(collection-confirm): add clearing actions and improve review UI 2026-10-13 11:54:16 +02:00
0377c51602 fix(pos): dead discount toggle + stale discount figure, lift dark palette
Two reported symptoms that turned out to be one bug, plus the dark
mode the user described as "too black".

The `hidden` attribute does nothing on the discount controls

`hidden` is implemented by the UA stylesheet as `[hidden]{display:none}`.
Any author rule that sets `display` outranks it — and `.totals` and
`.discount-body` both set `display:flex`. So `el.hidden = true` was a
no-op on both:

  - the discount tier panel could not be collapsed, making "Add
    discount" look like a dead control;
  - the Discount totals row stayed on screen after the discount was
    cleared, still showing the last figure (R 98.37) under a total
    that no longer contained it. The JS only ever set `hidden` and
    never cleared the text, so the operator had no way to tell stale
    text from a live one.

`.cat-menu` and `.cat-backdrop` already carried per-element
`[hidden]{display:none}` overrides for exactly this reason; the two
discount elements were missed. Replaced with one global rule so the
whole class is covered rather than relying on each element to
remember. The clear path now also blanks the amount.

Verified statically rather than in a browser: jsdom does not model
the UA-vs-author cascade and reported the broken and fixed cases
identically, so the cascade was checked by resolving which rules
apply. Regression test fails without the fix.

Dark palette

The POS canvas was #0f0f0f (L* 4.3 — effectively black) with
surfaces only 3-4 L* above it, mixing a warm #1c1416 against a
neutral #181818 in the same UI. That near-black base plus two
competing surface tones is what read as muddy. Replaced with a
single cool-neutral ramp with four real steps, and menus/modals now
sit on a `--raised` layer instead of reusing the surface colour.

`#717171` was the secondary text colour in 25 places and measured
3.93:1 on the old canvas — below WCAG AA. It now measures 6.49:1.
Both the text greys and the surfaces were hardcoded per component
across seven POS pages; they are now tokens, with the dark block
rebinding them.

The shared `.dark` block in themes.css (drives all 24 themes) sat at
oklch(0.15) ≈ L* 3.1. Lifted +0.04 across the ramp, preserving its
shape and separation; muted-foreground goes 6.32:1 → 5.95:1, still
above AA. Cursor/Supabase, which override it darker, were at L* 1.3
and lifted too.

Service worker cache bumped to pos-v36 — pos.html and pos-app.js are
served cache-first, so without the bump the tills keep serving the
broken files.

3863 tests pass (3 new); build clean.
2026-10-12 23:17:57 +02:00
8232a87f99 feat(pos): ex-VAT totals, ad-hoc custom lines, fixed discount tiers
Three changes to the POS sale pipeline, all in one commit because
pos-service.ts / types.ts / pos-app.js are each touched by all three and
splitting them would only produce intermediate broken states.

VAT is now charged on top, not backed out

Catalogue prices are ex-VAT: `selling_price` is derived as
`cost_price x markup` from the supplier's ex-VAT cost, with no VAT
uplift anywhere (verified — median selling/cost across the catalogue is
exactly 1.8). The till nevertheless treated them as VAT-inclusive and
recovered the net with `total / 1.15`, so every sale was recorded with
an understated net and an understated VAT line.

An R100 ex-VAT line now rings up as R115. The rate comes from the till's
letterhead, read server-side rather than accepted off the wire, and is
stored per sale so a later letterhead rate change cannot rewrite what
was charged. All 3,623 catalogue prices are untouched.

Ad-hoc "custom" lines

A cashier can now ring up something that was never a catalogue item —
"bolt cutting" at R20 agreed at the counter — via a new
"+ Add a custom item" composer. The line prints on the slip and lands in
pos_sale_items, but carries no stock_item relation and takes no stock
movement: no ledger row, no current_qty decrement, no catalogue entry.

pos_sale_items.stock_item is no longer required at the schema level and
an explicit `is_custom` flag marks the line, rather than inferring it
from a blank relation — so "how much did we make on bolt cutting this
month" is a plain query, and a future stock-less line type cannot be
confused with a corrupt one.

Custom lines are refused on account sales: those raise a delivery note
the customer signs for and feed their statement, so an ad-hoc charge
must not slip onto one. Enforced server-side, not just in the UI.

Discounts are now 20% or 40%, nothing else

The R-off / %-off pills and the free-typed number box are gone. The
server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses
anything outside it rather than coercing it, so a stale till cannot
invent a discount. The discount lands on the ex-VAT subtotal, making
"20% off" a true 20% off both the net and the VAT on it.

Two defects found while doing this

pos_sales was missing six fields that checkoutSale has been writing all
along (gross_subtotal, gross_vat, gross_total, discount_amount,
discount_kind, discount_input). PocketBase accepts unknown fields
without error and silently drops them, so all 192 historical sales lack
them and a discounted sale reprinted as though no discount had been
applied. Added, plus vat_percent.

History also passed gross_total (VAT-inclusive) where the receipt
derives the gross VAT from an ex-VAT base — a reprint double-counted
VAT. Corrected.

Verification: 3860 tests pass (14 new); typecheck, lint and production
build clean; live PocketBase probe confirms a pos_sale_items row with
stock_item="" and is_custom=true round-trips, with the probe removed
afterwards.
2026-10-12 22:54:09 +02:00
ad4ef77bfd fix(quotes): delivery-note quotes saved markup as 0 on every line
The DN->quote batch copied cost and selling but never wrote markup, so
the approval screen showed 0% beside differing cost/selling. Write it at
creation, and derive it on the approval page for older lines.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:29:00 +02:00
6cfb413508 fix(pricing): the 18% floor was applied on every keystroke
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.
2026-10-09 12:49:13 +02:00
fd3a9296c6 feat(delivery-notes): the note decides the quantity; the quote follows it
A counter note is raised when the customer takes goods off the shelf.
"They said make it five more" is edited on the NOTE. Previously that
edit changed the note and left the quote showing the old number, so
the same physical goods carried two different quantities across two
screens and the invoice was whichever screen the operator opened last.

`updateDeliveryNoteItemPricing` now pushes the note's quantity onto
the quote lines that came from it. A sync failure is logged, not
fatal — the edit already succeeded and the quote can be re-synced by
re-saving.

## Matching without a back-link

`quote_items` has no field pointing at its source `order_item`, so
the operator's call was to make the note authoritative and work around
it. Lines are paired by `sku + description`, and when a key repeats,
by ORDER OF OCCURRENCE.

That is sound because both sides derive from the same ordered list
of note lines: `createQuoteFromDeliveryNote` iterates the note's
items in query order and emits one quote line each, so the Nth of a
repeated key on the note is the Nth on the quote. On the live data
across 500 note lines there are ZERO groups sharing
(note, sku, description) — the key is unique in practice. The
occurrence counter exists so that if two identical lines are ever
added, the second still lands on the right row instead of both
writing to the first.

That is the failure the tests exist for: matching on the key alone
silently writes the wrong number onto a customer invoice. Verified by
removing the occurrence counter — exactly that test fails.

## Also carried through

- The note's COLLECTED qty is the billing qty, not the ordered qty, so
  a short pickup (10 ordered, 8 collected) puts 8 on the quote.
- A cancelled, rejected or declined quote keeps its own numbers.
  Rewriting a quote the customer has already seen is worse than a
  stale number.
- A line that cannot be matched is left alone, never guessed at.
- A note line with neither a collected nor an ordered qty is skipped,
  so this can never reintroduce the zero-quantity invoice line.

8 new tests. Full suite 3832 passing, tsc clean, no new lint errors.
2026-10-09 11:56:48 +02:00
848c483929 fix(delivery-notes): a counter note's quantity IS its collected quantity
`qty_collected` is read by the invoice path to decide how many units
to bill. A counter delivery note has no parent order: the customer is
standing at the counter taking goods off the shelf, so the collection
has ALREADY happened — there is no driver pickup to record, and it can
even have happened before the system existed. Those notes were being
created with `qty_collected` left at 0, so every one of them invoiced
as a zero-quantity line, which QuickBooks rejects (n8n 400 -> the
opaque 502 the operator saw).

Two writes, both scoped to parentless notes:

1. `createAdHocDeliveryNote` stamps `qty_collected = quantity` on each
   new item. The goods have been handed over; that IS the collection.

2. Editing a counter note's quantity keeps them in step, so when the
   customer says "actually make it five more" the DN editor, the quote
   and the invoice all agree. This is the loop the operator described:
   edit -> quote -> invoice, with the quantity carried through.

   Order-linked notes are deliberately left alone. There
   `qty_collected` means "the driver has this many in the van", and
   overwriting it with the order qty would bill for goods that have
   not arrived. `orders/process` already stamps collected=quantity for
   the in-stock flip; this is the same rule applied to the counter
   path that had been missed.

Four tests, including that an order-linked note's collected qty is
NOT touched. Verified they bite by removing both guards: 2 fail.

Not included: 315 existing parentless items still have
qty_collected <= 0. A backfill is a production data change, so it
waits for the operator's go-ahead.

Full suite 3824 passing, tsc clean.
2026-10-09 11:35:01 +02:00
2387a71cfe fix(quotes): stop quote_items markup going negative; round prices up
Quoteers reported markup changing to a random number after saving or
clicking away, and lines carrying markup = -100 that nobody had
edited. 51 quote_items rows were affected (40 of them exactly -100),
all with a real cost and selling_price = 0. All 51 reproduced exactly
from ((selling - cost) / cost * 100) — none were typed by a human.

Two independent defects, both price-rounding:

1. The approvals screen priced with Math.round(cost * (1 + m/100)) and
   no precision argument — round to WHOLE RAND. A 30% markup on a
   R0.07 washer became R0.00, and a selling price of 0 re-derives as
   exactly -100. Rounding is now a 2dp ceiling: R0.07 @ 30% -> R0.10.
   A ceiling can never collapse a positive price to zero, so that
   failure mode is structurally unreachable rather than merely patched.

2. Round-to-nearest also undercut cheap lines generally: 30% on R0.07
   rounded to R0.09, which re-derives as 28.6% — the "random number".
   Rounding up means the price can only drift in the customer's favour.

Markup now floors at MIN_MARKUP_PERCENT (18) once a line has a cost,
applied in one place so every screen inherits it. Scoped to priced
lines: an unpriced draft (no cost) is undecided, not 0% margin, and
stays writable — 1,181 live rows depend on that. Applied at derivation
(not just on save) so -100 can no longer be computed, plus
server-side in review-approve as the authoritative gate.

order_items already carried markup min=0 plus a Math.max(0, …) clamp,
which is why no order row was ever affected. quote_items had neither.

Verified: 3,817 tests pass, typecheck clean, lint delta zero, and all
16 of the real broken rows re-priced through the new logic resolve to a
valid markup and a non-zero selling price (was 16/16 broken).

Existing negative rows are left untouched — re-pricing live quotes is a
separate, deliberate decision.
2026-10-08 07:13:07 +02:00
7256de8859 feat(pos): refund a whole sale, put the goods back, print a credit slip
A customer who brings goods back needs the sale reversed: the items go
back into stock and the money comes back off the till. There was no way
to mark a POS sale as refunded, so nothing stopped the same sale being
refunded twice — which credits the customer twice and puts the same
stock back on the shelf twice.

Refunds are WHOLE-SALE only by operator decision; no partial refunds.
That is what lets `pos_sales.refunded` be a boolean instead of a
per-line refund table. Three columns: `refunded`, `refunded_at`,
`refund_amount`, stamped from the sale's post-discount total, which is
what the customer actually paid.

DOUBLE-REFUND GUARD, twice over, because this is the one failure that
costs real money:

  - `refunded` is read before anything else, and a flagged sale returns
    409 with no stock writes at all
  - the flag is stamped BEFORE any stock write, so a second concurrent
    tap sees it and bails. Writing stock first and flagging last would
    let both taps through.

A stock write that fails part-way does NOT roll the flag back. The
customer has their money either way, so re-running would double-credit
them; the failed lines come back in `stockErrors` for a manual count
correction, which is strictly better than a second refund.

Stock needs no schema: `return` was already in STOCK_REASON_VALUES,
just never used. Rows are written with POSITIVE change_qty and
reference_type "pos_sale_refund", leaving the original
customer_fulfillment rows untouched so the sale's own audit trail still
shows what was originally rung up.

cashup.html had expected drawer = sum of cash sales, so a cash refund
would have shown as a false variance on every cash-up after it. It now
subtracts cash refunds, and refunded sales contribute nothing to
takings for any tender.

The tender is read off the sale, never off the request — an earlier
version accepted it in the body, which would have let a caller assert
that a cash sale was really an EFT and talk the drawer out of balance.

At the till: tap still reprints, hold a sale row for 600ms to refund —
moving money sits behind a gesture you cannot do by accident. Refunded
sales stay in the list, dimmed and badged, because deleting them would
destroy the audit trail. The sheet names the items and says plainly what
happens next ("Hand back R10.57 in cash. The drawer count for this day
will drop by that amount.").

The credit slip reuses the sale renderer and the whole printer path with
`refund: true`, so there is no second printer to configure — but note
there were THREE render paths to fix (on-screen, Bluetooth text, ePOS
XML), and missing the third would have printed "TOTAL" on a credit note.

Migration applied to live PB and verified idempotent; field shapes
round-tripped on a real sale and restored. 10 new tests, 148 POS tests
pass, tsc clean, build passes. sw.js v34 -> v35 (history, cashup,
receipt and epos-print are all cache-first in the shell).
2026-10-07 23:41:09 +02:00
4447c4e773 feat(pos): customer picker matches the company name, and stops hiding truncation
The account-sale picker filtered on three customer columns — name,
number, email. `company` on a customer row is a RELATION id, not a
name, so it could never be text-matched; the company name was only ever
resolved afterwards by `expand` for display. Typing "Sibanye" returned
nothing even though 52 people work there.

  - the filter now traverses the relation: `company.company ~ q`.
    Verified live: "Sibanye - Rustenburg" 0 → 52, "Adal" 0 → 2,
    "Alano" 0 → 1, while "Botha" and "Smit" are unchanged at 6 and 4.
  - each row carries `viaCompany`, and name matches sort above
    company-only matches. PB's own sort is by name across the whole
    result set, which would interleave the two bands.
  - /customers returns { items, total, shown } and the till renders a
    count header: "52 matches", or "Showing 100 of 122 — keep typing to
    narrow". The old `slice(0, 8)` showed 8 of 52 people with nothing
    to suggest 44 were missing; 15 companies have more staff than that.

The picker click handler bound to every `div` in the results box, so
adding a count header would have made it selectable and picked a
customer with an empty id. Now scoped to `.cust-row`.

Two bugs the tests caught rather than the code:

  - the display cap was first set to 50 while its own comment claimed
    it sat above the largest company — which has 52 staff. That
    silently re-created the truncation this change removes. Now 100,
    with a test asserting a 52-person company comes back whole.
  - PocketBase has no escape for a literal backslash: `\'` is
    accepted, but one, two or three backslashes all fail to parse and
    500 the request, so typing `\` in the search box errored. No
    escaping survives, so backslashes are stripped before the filter
    (`M20\Nut` searches `M20Nut`) and a backslash-only query returns
    nothing rather than matching every customer.

13 new tests cover the company clause, the 52-staff case, the cap,
ranking order, backslash stripping and the empty-after-strip case.
Verified live against PocketBase across 10 queries — the ranking
guarantee held on every one.

sw.js v33 -> v34: pos-app.js and pos.html are cache-first in the shell.
2026-10-07 15:53:23 +02:00
8c59f594f8 fix(pos): make cart quantity editable, and fix the sheet that hid it
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.
2026-10-07 12:54:47 +02:00
bcb0e1a7d0 feat(pos): tiered catalog search — word order, plurals, glued SKUs
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.
2026-10-06 23:36:12 +02:00
d983eccd6c fix(delivery-notes): return to the list after quoting; kill the phantom supplier
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.
2026-10-05 19:49:20 +02:00
afae9cdd99 feat(delivery-notes): one linear flow control, and fix the quote lookup
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.
2026-10-05 18:21:19 +02:00
7ce23e04f3 fix(delivery-notes): the detail page's Review pricing bypassed the review
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.
2026-10-05 16:31:04 +02:00
55be94b08f fix(delivery-notes): what the browser found — clipped actions, zero-margin lines
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.
2026-10-05 16:01:55 +02:00
c2eabe1cfc fix(delivery-notes): unblock quoting, and one action set for both screens
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.
2026-10-05 15:27:39 +02:00
c8da9daf94 fix(scan): speed up QR recognition and keep scanning 2026-10-05 09:43:53 +02:00
c0e6ad2099 fix(delivery-notes): merge cancelled into completed, and fix the search
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.
2026-10-04 23:29:48 +02:00
97c3cd4d7e feat(delivery-notes): review pricing before a quote is created
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.
2026-10-04 21:17:31 +02:00
980f40aa79 feat(delivery-notes): see what's on a note, colour-coded progress, POS-aligned stock search
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.
2026-10-04 20:40:13 +02:00
4e4e7328b3 fix(delivery-note): signed copy invisible, and a bigger QR in the corner
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.
2026-10-04 17:00:30 +02:00
80608602ad fix(scan): the QR was being downscaled below its own resolution
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.
2026-10-04 13:51:44 +02:00
d98c263f45 feat(scan): standalone delivery-note scanner PWA at /scan
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.
2026-10-04 13:15:56 +02:00
f64311b08c feat(delivery-note): print a scannable QR of the note 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.
2026-10-04 13:15:41 +02:00
a499265b26 feat(pos): raise a system delivery note on an account sale
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.
2026-10-04 13:15:29 +02:00
ff58af7586 fix(pdf): render through renderToBuffer, not pdf().toString()
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.
2026-10-04 13:14:59 +02:00
6c8f63661a fix(mcp): validate set_pricing actor and row ids; tighten pricing prompt
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>
2026-10-01 01:09:43 +02:00
031666946f feat(mcp): stock_items.set_pricing tool and pricing fields on stock_items.create
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>
2026-10-01 01:02:44 +02:00
5becd0c58f fix(giellie): reject non-string and path-shaped ids (incl. arrays) in the MCP bridge
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>
2026-10-01 00:44:20 +02:00
97fc698383 fix(giellie): stale-tab approval guard, size guard, SSE heartbeat, shared in-flight set
- 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>
2026-10-01 00:36:55 +02:00
7562a8be27 fix(giellie): args-aware tool policy, deny quotes.re_run_ai, harden bridge and agent
- 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>
2026-10-01 00:36:54 +02:00
b28d57bfc8 feat(giellie): chat UI, approval card, photo/file composer, sidebar rename
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 00:06:50 +02:00
cce2570dcd fix(giellie): in-flight guard, upload caps, orphan repair and error hardening
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 00:02:00 +02:00
080a83859c feat(giellie): service layer and authenticated SSE API routes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:54:52 +02:00
5e3d9b5b02 fix(giellie): harden agent loop approval resolution and checkpoints
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:51:15 +02:00
14ab023d71 feat(giellie): agent loop with approval pauses
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:47:30 +02:00
b35159d859 feat(giellie): chat store and giellie_chats migration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:44:55 +02:00
8500a08482 feat(giellie): system prompt and attachment ingestion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:42:35 +02:00
e7281c30f4 fix(giellie): guard non-JSON MCP responses and surface error.data
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:40:41 +02:00
133fa626fa feat(giellie): in-process MCP bridge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:39:00 +02:00
5b0a572e5c feat(giellie): tool policy (deny/read/write/confirm classification)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:36:39 +02:00
d611efe77a feat(giellie): shared types and SSE codec
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 23:34:53 +02:00
7555196166 feat(audit): implement audit trail for delivery notes and orders
- 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.
2026-09-30 01:48:44 +02:00
d8e8714720 feat(stock): update pricing logic and transform script for fasteners
- 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.
2026-09-21 12:32:44 +02:00
b99aacb020 feat(rep): add /rep/visits map with heatmap, per-customer clusters
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.
2026-09-20 23:50:03 +02:00
bcd9b88dd4 fix(pos): revive the dead "Print cash-up" button, add 20c/10c denominations
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>
2026-09-19 07:30:11 +02:00
dce54934f0 fix(invoicing): bill only what's collected, support partial pickups + kit splits
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.
2026-09-17 13:53:36 +02:00
da90706ce0 feat(stock): implement auto-markup for selling price and markup on stock item creation and updates
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.
2026-09-16 09:49:52 +02:00
bbfa888cc1 feat(customers): add status toggle and site/location field
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.
2026-09-16 09:47:09 +02:00
698abe9fbc feat(stock): inline qty editing + column sort + pricing-gap operator scripts
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.
2026-09-14 13:04:26 +02:00
7443776a14 feat(stock): seed pricing fields on create + sync price_list to stock_items
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.
2026-09-14 13:04:08 +02:00
58b174606b fix(deliveries): group by company and show items on delivery-note rows
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.
2026-09-14 12:58:43 +02:00
3d009b144f fix(quotes): persist letterhead choice on terminal-status quotes too
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>
2026-09-09 18:09:27 +02:00
1e3121d838 fix(quotes): persist operator's letterhead choice to prevent silent letterhead-flip
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.
2026-09-09 16:06:40 +02:00
cd359b75a9 feat(quotes): split attachments into internal vs outbound with operator upload UI
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.
2026-09-09 13:43:56 +02:00
81b65a92e4 chore(quote-pdf-service): drop leftover debug console.error from PDF render catch 2026-09-08 18:55:46 +02:00
230150fc88 test(quote-pdf-service): expand PB mock to support expand + FormData uploads
- 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.
2026-09-08 18:55:46 +02:00
48c57d47a7 feat(quotes): auto-claim unclaimed quotes on Generate PDF
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.
2026-09-08 18:45:53 +02:00
b9d30c8e6f fix(quotes-intake): clamp email_body to PB's 5000-char cap
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>
2026-09-08 10:28:20 +02:00
9ec1b04cab fix(quotes-search): order hits by created desc so the most recent match is on top
Operators searching for a customer name (e.g. 'Martin', 'Blythwaite') or
an item term (e.g. 'generator') were seeing their latest quote buried
beneath an older quote that scored higher on the relevance ranker
(exact quote_number > customer name > item description). Pre-Sept-2026
the service sorted by score desc with id as a stable tiebreak, so a
2025 quote_number hit from 2025 outranked a 2026 customer-name match
for the same person.

Score still decides which quotes qualify as matches (the
breakdown.raw > 0 filter upstream of the sort), and the per-hit
score is still surfaced in the response for the UI's 'why this
match' breakdown. What changed: the order within the result set is
now created desc, with quote id as a deterministic tiebreaker for
batch-imported quotes that share a created timestamp.

Added tests:
  - orders hits by created desc, regardless of score (regression
    guard for the user-reported case)
  - breaks ties on identical created timestamps by quote id asc
    (stable pagination across page loads)

Renamed 'ranks exact identifier match above item-only match' to
'surfaces both identifier and item matches in the result with
their scores intact' — the assertion still holds because both
fixtures share the default created date, but the test's intent
is now 'both hits surface with their scores preserved', not
'order is determined by score'.
2026-09-08 10:24:34 +02:00
3de1d622b8 feat(quotes): allow resubmit from any post-submit status, not just rejected
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.

Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.

Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
2026-09-07 16:51:55 +02:00
20304442a7 fix(delivery-notes): backfill customer/company when linking a DN's order
setDeliveryNoteOrder only ever wrote the `order` relation. Delivery
notes created while the fields+expand bug (see prior commit) was live
got saved with customer/company/order all blank — this setter is the
only existing entry point that touches the order relation, so it now
also fills customer/company from the linked order when the DN's own
values are blank (never overwrites an operator-set value). Applies on
both the write path and the idempotent-order-unchanged path, so
re-running the same call is enough to finish backfilling an affected
DN once the order is already linked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 10:39:22 +02:00
7ba0b9e71f feat(visits): batch pin tool — tag every customer on a shaft at once
The single-pin tool (pick one customer, click the map, save) doesn't
scale to how the customer base actually clusters: dozens of contacts
sit at Harmony Kusasalethu, at South Deep Shaft 3, and so on. This adds
a batch counterpart at /visits/pin-tool/batch: type a site name, tick
every customer there, click the map once, and all of them get pinned.

Site is a plain free-text field on customers (Customer.site in
lib/types.ts), not a separate sites collection — simpler to add and
maintain, at the cost of relying on the office typing the name the same
way each time. The batch tool's autocomplete (backed by
listDistinctSites) and its "select everyone already tagged X" shortcut
exist specifically to curb that drift without a second collection to
manage.

- jitterCoordinates (lib/geo.ts) scatters each customer 30-80m from the
  clicked point so forty pins on one shaft render as distinct dots on
  the office map instead of one marker stacked forty deep. Bounded by
  distanceKm in tests, which is the same great-circle maths the
  check-in radius match uses.
- assignCustomersToSite / listDistinctSites (customers-service.ts) —
  the batch write and its autocomplete source. Each customer is written
  independently, mirroring recordVisits: a stale id in a forty-customer
  batch shouldn't cost the other thirty-nine.
- use-customer-list.ts extracted from pin-tool-view.tsx (verbatim, no
  behaviour change) so the batch view doesn't duplicate the fetch.
- batch-pin-helpers.ts holds the pure decisions (save-readiness order,
  filter) the same way pin-tool-helpers.ts does for the single-pin tool
  — the Leaflet-dependent view is hard to unit test directly.

Applied the "use client" + dynamic(ssr:false) lesson from the single-pin
tool's earlier 500 up front in batch/page.tsx, with a comment explaining
why it's load-bearing rather than decorative.

99 new/updated tests. Scoped verification only (geo, customers-service,
visits/*) — the wider suite currently shows unrelated failures in
quotes-service/quote-pdf-service from a change on main I did not make
and did not investigate further; none of it touches customers, geo, or
visits.

BLOCKED until one PB admin change: customers.site does not exist yet
(text field, optional). Nothing in this commit writes to it until then.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:30:23 +02:00
85913f5869 fix(quotes): guard possibly-undefined itemAt in isQuotePdfStale
Deployment was failing at `npm run build` with:
  quote-pdf-service.ts(487,10): error TS18048: 'itemAt' is possibly 'undefined'

`itemAt` comes from `latestItem.items[0]?.updated` (row type
`{ updated?: string }`), so it's `string | undefined`, while `pdfAt`
was already narrowed to `string` by the earlier `if (!pdfAt)` guard —
comparing the two fails under strict mode.

Semantically, a quote with no active items left (all moved/removed)
has no item timestamp, and that alone shouldn't mark the PDF stale;
only the quote-level timestamp decides in that case.

Verified with a full `npm run build` (exit 0).
2026-09-07 07:54:10 +02:00
66559af422 Add quote PDF generation and stale PDF handling 2026-09-07 07:08:27 +02:00
6bedf27617 fix(pos): print the letterhead's own logo, and match delivery note to the sale slip
Logo — three separate faults, all of which had to be fixed for the
letterhead logo to print at all:

1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it
   printed the app's bundled mark (the solid black W block) and never
   looked at the letterhead at all.
2. The paths that DID use the letterhead (printTest, cashup) passed
   PocketBase's fully-qualified cross-origin URL into imageToRaster(),
   which draws to a <canvas> — getImageData() taints and throws on a
   cross-origin image, so the .catch(() => null) silently dropped the
   logo. pos-letterhead-service now returns a same-origin
   /api/v1/files/... URL (per AGENTS.md's file-URL rule).
3. letterhead_settings wasn't whitelisted in that file proxy, so the
   same-origin URL would have 403'd.

Verified against the live WESTSCOTT letterhead: its SVG rasterizes to
384x137 at ~15% ink coverage (line art), no tainted canvas.

Also fixes the company name never printing: every builder read
`letterhead.name`, but the POS letterhead record exposes
`display_name` — so the name line silently rendered empty on every
slip, on screen and on paper.

Delivery note now matches the sale slip, per request: gains the logo
and business_info blocks and the same 2x-size title, instead of being
a stripped-down variant. This also reconciles the transport
divergence flagged earlier (Bluetooth baked business info into its
header; the network builder omitted it entirely) — it is now one
reorderable business_info block on both.

resolveBlocks() now slots a newly-shipped block in at its intended
position rather than appending it to the end. Without this, any
letterhead with an already-saved layout would have printed the new
logo/business_info blocks BELOW the signature line.
2026-09-06 23:03:24 +02:00
c97b2cf018 fix(visits): surface batch write failures instead of a silent 200
Found by driving /rep in a real browser session, not by a unit test:
submitting a check-in returned 200 OK with body {"created":0,"pinned":1},
and the visit vanished with no trace while the customer coordinate pin
still landed. Reproduced against the live database (confirmed via
direct PocketBase query) and again after the fix, with the response
now 502 and the entry correctly retained in the outbox.

Root cause was two-layered:

1. visits-service.ts caught every per-visit write error and continued
   silently, discarding the message. A per-item failure (one stale
   customer id in a batch) is meant to be swallowed so it doesn't cost
   the other visits — but the same catch also swallowed a systemic
   failure (every item in the batch hitting an identical PB validation
   error), which looks completely different: a bare `created: 0` gives
   the caller no way to tell "nothing to do" from "everything failed".
   recordVisits now returns an `errors: {customerId, message}[]` array
   instead of discarding the exception.

2. POST /api/v1/visits returned HTTP 200 whenever the request was
   well-formed, regardless of whether anything was actually written.
   The offline outbox treats any 2xx as delivered and discards the
   entry — so a total write failure was indistinguishable from success
   at the one layer that decides whether to keep retrying. The route
   now returns 502 when `created === 0`, which the outbox already
   retries (see the existing 5xx-keeps-the-entry test), rather than a
   4xx, which it treats as permanent and drops.

The concrete trigger was live: rep_visits.position_source doesn't yet
allow "phone" (flagged in the previous commit). That specific gap will
close once it's added to the PB select, but this fix stands on its own
— any future rejected field would have hit the identical silent-loss
path.

12 new tests (service: captured errors on per-item and total failure;
route: 502 vs 200 boundary, repId taken from the session not the body;
outbox: a 502 keeps the entry queued rather than treating it as sent).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:25:52 +02:00
c8b7e1f51b feat(rep): visit check-in PWA — phone GPS, offline outbox
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>
2026-09-06 21:09:27 +02:00
a551a71277 feat(tracker): breadcrumb poller — WFDASH's own vehicle position history
FleetAI has no position-history endpoint, so to answer "where was this
rep's vehicle at 10:20 last Tuesday" we have to record it ourselves.
An n8n Schedule Trigger calls POST /api/v1/tracker/poll every 5 minutes
during working hours; a nightly run derives stops and prunes.

  poll:     */5 6-18 * * 1-5     (the working-hours window lives in the
                                  cron, not in app settings — disabling
                                  the workflow is the off switch)
  nightly:  30 2 * * *

One getTokenBaseLiveData call covers the whole fleet per cycle, and only
genuinely new GPS fixes are written: dedupe keys on (vehicle_no,
fixed_at) against the newest fix already held. A parked tracker keeps
re-reporting the same GPSActualTime, so without this a 5-minute cycle
would store the same row a dozen times an hour.

Raw breadcrumbs are pruned at 35 days; derived rep_stops are kept
indefinitely, since a stop is small and is what reports actually ask
about.

Two bugs found by running this against the real stack, neither of which
the unit tests could have caught:

- FakeTrackerAdapter derived its fix time from Date.now(), so every poll
  looked like new movement. It could never reproduce a parked vehicle —
  the exact case dedupe exists for — and made the poller look correct
  while storing a duplicate every cycle. The fake now quantises its fix
  time to a bucket, so repeated polls inside one window return an
  identical fix.
- PocketBase's filter parser silently matches NOTHING against an
  ISO-8601 `T` separator. `fixed_at >= "2026-09-06T00:00:00.000Z"`
  returned 0 rows where the space-separated form returned 2, with no
  error either way — the rollup would have derived no stops and the
  prune deleted no rows, both reporting success, forever. Fixed with a
  `pbDate()` helper matching the existing convention in
  boss-dashboard-service.ts and order-delivery-note-link-service.ts.

Verified end to end on :4001 against the live PocketBase: three
consecutive polls returned stored=0/duplicates=1, and the nightly rollup
went from vehicles=0 to vehicles=1 once the date format was fixed. Test
rows were removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:08:20 +02:00
61c5d8c906 feat(visits): office pin tool for seeding customer coordinates
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>
2026-09-06 16:58:07 +02:00
6fd11f4af0 feat(rep-visits): FleetAI tracker adapter, nearby-customer matching, stop detection
Groundwork for the rep visit check-in feature: a rep taps "I'm on site",
the vehicle's tracker position surfaces which customers are there, and
the visit is logged against them.

Pure/unit-testable layers only — no PocketBase writes and no UI yet,
both of which are blocked on schema that has to be added in the PB
admin first. Design doc: docs/superpowers/specs/2026-09-05-rep-visit-check-in-design.md

- lib/visit-matching.ts   rankNearbyCustomers: radius match around a
                          point, rep's own customers first
- lib/visit-stops.ts      detectStops: breadcrumb trail -> stops.
                          Anchor-based clustering rather than a running
                          centroid, which would let a slow crawl chain
                          into one bogus stationary stop
- lib/tracker-parsing.ts  Netstar FleetAI response decoding
- lib/server/tracker-service.ts
                          FleetAiAdapter + FakeTrackerAdapter behind a
                          TrackerAdapter seam. The fake is auto-selected
                          when no credentials are configured, so the rest
                          of the feature builds and tests without vendor
                          access.
- lib/geo.ts              isRealGeoPoint, lifting the Null-Island guard
                          that was inlined identically in customers/[id]
                          and companies/[id]

Three FleetAI conventions drive real code here and are each pinned by a
test: errors arrive as HTTP 200 with a failure envelope; "--" means
"unavailable" and is not null (and for IGN, not false); timestamps are
platform-local UTC+2 with no offset marker, so parsing them naively puts
every fresh fix two hours in the past.

FleetAI has no position-history endpoint, so the design polls
getTokenBaseLiveData (whole fleet in one call) and keeps its own trail.
scripts/fleetai-probe.mjs is a throwaway read-only probe to confirm the
three account-shaped assumptions before more is built on top.

102 tests pass. The 9 pre-existing failing test files elsewhere in the
suite were verified identical on a stashed tree and are unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:22:28 +02:00
3a29ad6a6a feat(pos): add pos-slip-layout-service for reading/writing per-letterhead slip layouts 2026-09-04 13:23:09 +02:00
7d2132e000 fix(pos): fix uncaught 500 on checkout, allow overselling into negative stock
- checkoutSale's stock-availability loop called pb.getOne() with no
  try/catch, so a stale/deleted stock_item_id in a persisted cart
  threw all the way up to a bare 500 with no useful message. Now
  caught and returned as a clear pb_error the till can display.
- Removed the insufficient-stock block entirely: the till is used
  ahead of a physical stock take, so a sale must never be blocked
  for lack of stock — current_qty is now allowed to go negative and
  gets corrected at the next stock take. Drops the now-dead
  insufficient_stock/shortages branches from the route, service
  types, and client error handling.
2026-09-04 13:00:12 +02:00
85761a8698 fix(orders): improve error handling for order creation and enforce email body length limit
Enhanced error messages to include specific field validation details from PocketBase, allowing callers to identify issues more easily. Additionally, implemented a safeguard to truncate the email body to a maximum of 5000 characters to prevent validation errors from surfacing as generic messages.
2026-09-04 12:55:53 +02:00
46d5d540a0 feat(pos): letterhead service + 2 API endpoints + vat_percent on Letterhead type 2026-09-04 09:13:53 +02:00
aed3f5c861 feat: implement discount functionality in POS system
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.
2026-09-03 12:19:21 +02:00
c64e5dc2ab feat: expand customer relation in PDF generation
Enhanced the PDF generation for quotes by expanding the customer relation to include the associated company information. This allows the company name to be displayed prominently in the "TO:" block of the PDF, improving clarity for users. Updated test data to reflect the new structure and ensure proper rendering of the company name when available.
2026-09-02 11:40:50 +02:00
23d4e8dfe1 chore: update page.tsx 2026-09-02 01:38:33 +02:00
058803d90f chore: update AGENTS.md 2026-09-02 01:17:05 +02:00
7c2322b22a fix: use operator's quote_description on the quote PDF
The pricing modal saves quote_description correctly, but the PDF
template rendered item.description unconditionally, so the
operator's edit never showed up on the customer-facing quote.
2026-09-01 18:09:34 +02:00
ac201cb0a1 fix: preserve destination through the login redirect
Auth gates redirected to a bare /login with no memory of where the
user was headed, and the login page always sent them to /dashboard
afterward — so opening the /m PWA while signed out landed you on the
main dashboard instead of back on /m. Both gates now pass ?next=,
and login honors it (falling back to /dashboard if missing/unsafe).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:47:27 +02:00
2014814c84 feat(deliveries): click-to-expand DN items panel
Adds a chevron button at the left edge of every delivery-note row on
/deliveries. Clicking reveals an inline items panel (description +
qty) so the operator can match against the printed DN paper in their
hand before loading.

Items come from a client-side filter of the order group's items array
(items.filter(i => i.delivery_note === dn.id)). No new server endpoint.
DNs whose items aren't in the order group's slice show a 'See full DN →'
link to /delivery-notes/[id].

Expand state is held at the OrderGroupView level as a Set<string> of
expanded DN ids — independent per DN, resets on refresh. (State lifted
to OrderGroupView rather than DeliveriesPage because order.items is
already in scope there; avoids prop-drilling through CustomerGroupView.)

Service change: deliveries-service.listDeliveryItems now fetches the
delivery_note (relation id) + unit fields on each item, so the page
can filter and render units without an extra round-trip.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 12:15:35 +02:00
1fc3580aed perf(dashboard2): consolidate 14 PB count queries into 1 fetch
Single getList pulls every quote this rep created in the last 62 days,
then buckets client-side into the 6 day/week/month windows plus the
8-week trend. Also fans out the 4 remaining queries (outstandingQuotes,
outstandingOrderItems probe, monthRevenue, the consolidated quotes
fetch) in parallel via Promise.all.

Query count: 18 → 5. Page load bounded by the slowest query instead of
the sum. The widest quote window is "last month" (~62 days), so that
single bound covers every other day/week/month window for bucketing.
Cap at 2000 rows to defend against runaway responses for very active
reps.
2026-09-01 02:08:52 +02:00
6fcef50b3e feat(orders): add order-age util with bucket thresholds
Pure function: getOrderAge(created, now?) returns {daysOld, bucket, label}
in calendar-day delta for Africa/Johannesburg. bucketToBadgeClass maps
to tailwind classes (fresh/recent/slipping/stale). No React, no DB.

Vitest unit tests cover all 5 bucket thresholds, future-date clamp, and
the JNB timezone edge case (same calendar day in JNB = daysOld 0).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 01:58:46 +02:00
c9096dfd84 feat(dashboard2): trim Row 1 to Selling-only with last-month delta
- Drop Cost and Markup gauge — Selling is the headline the rep cares about
- monthRevenue is now {sellingThisMonth, sellingLastMonth}
- One combined PB query covers both months; classify each row by date in code so the delta costs zero extra round-trips
- RepKpiCard gains a format="currency" prop so Selling renders ZAR-formatted with a delta arrow (mirrors the quote-count cards)
- Row 1 grid adjusts from 6-col to 4-col to match the new card count
2026-09-01 01:55:52 +02:00
9abc7d4d03 feat(dashboard2): split Selling/Cost into KPI cards, parallelize data fetches
- Row 3 now shows separate Selling + Cost KPI cards (ZAR-formatted) plus the Markup gauge
- Outstanding-quotes, outstanding-order-items probe, and month-revenue queries now run in parallel via Promise.all (was sequential, costing ~300ms)
- Outstanding-order-items probe capped at 200 rows so a single rep's data stays in a single PB round-trip instead of paginating
2026-09-01 01:49:57 +02:00
3637eef83a feat: add rep-scoped dashboard data aggregation service 2026-09-01 01:36:31 +02:00
652b60706e feat: add getBrowserSessionUser for routes that need the caller's identity 2026-09-01 01:33:10 +02:00
ed07617827 feat(delivery-notes): filter list to the logged-in rep's own notes 2026-08-31 23:28:02 +02:00
d23c811b91 feat: add isRepUser helper for rep-scoped list filtering 2026-08-31 23:24:25 +02:00
f05fc5d9cf fix(boss-dashboard): filter to active reps only + fix PB date-filter bug
The reps list seeded a row for every `users` record (admin, test
accounts, drivers, duplicate entries), not just the 6 real sales reps.
Also, window filters built with toISOString() used a "T"-separated
date PB's filter engine doesn't match against `created`, so every
window silently returned zero rows — the whole dashboard read 0
regardless of real activity. Same fix pattern already used in
order-delivery-note-link-service.ts / order-order-duplicate-link-service.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:14:40 +02:00
ab639eea6d test(stock): cover validation + not_found + per-row independence 2026-08-31 16:48:40 +02:00
Claude
86eba97d33 fix(stock): skip ledger row on first-time pricing + include cost delta in notes
Code-review feedback from Task 2:
- Code-review Issue #1 (Important): the strict-equality guard fired on
  'before.cost_price = undefined' vs 'update.costPrice = 0' — exactly
  the population this feature targets (fresh catalogue rows the
  operator is pricing for the first time). No real cost movement was
  happening, but a spurious ledger row was getting written. Gate the
  ledger write on Number.isFinite(before.cost_price) so first-time
  pricing doesn't pollute audit history with a 0 -> 0 'movement'.
- Code-review Issue #4 (Minor, audit-readability): add the actual
  before -> after delta to the ledger notes so operators scrolling
  the ledger history see 'Gary edited cost 2.50 -> 3.20 from /stock'
  rather than the prior opaque 'Gary edited cost from /stock'.
- Added two regression tests: one for the first-time-pricing case,
  one pinning the notes delta format.
2026-08-31 16:46:42 +02:00