Commit graph

886 commits

Author SHA1 Message Date
8d83065964 feat(giellie): tighten the agent tool policy and MCP surface
Aligns the tool policy, the system prompt, the MCP route and the skill
doc so they describe the same boundary, with tests pinning the policy
and prompt text.

The docs and the executable policy had drifted apart — the skill doc
advertised capabilities the policy does not grant, which is worse than
not documenting them: an agent reading the doc will plan around a tool
it isn't allowed to call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-16 00:44:52 +02:00
09e4d8f1e1 feat(collection-pool): back-to-pending, collection date, and a safe notes feed
Gives the operator two new controls on a collection-pool stop, and fixes
the audit feed they write into.

Back-to-pending (`items/[id]/back-to-pending`)

  A stop has to come back when the driver can't take it — no-show,
  wrong date, supplier pushed the delivery — or when the supplier
  confirmation was premature. A row can be assigned to a driver and/or
  marked ready at the same time, so the route clears both in one write
  rather than making the operator unwind them in the wrong order.

Collection date (`items/[id]/collection-date`)

  `order_items.collection_date` was a PB date field that existed but
  nothing read or wrote it. On a PENDING stop it is a forecast — the
  supplier said goods land on the 20th, so the stop stays
  un-assignable because promising a driver a pickup for goods that
  don't exist yet would be a lie. On a READY stop it is the confirmed
  collection day.

Notes feed (`src/lib/note-feed.ts`)

  `notes` on order_items / stock_purchase_items is NOT free text — it
  is a JSON array of NoteEntry objects, and every consumer parses it as
  one. The assign route's "mark ready" path wrote a plain string into
  it, which silently destroys the entire history: the next parse falls
  back to an empty feed. All writers now go through `appendNote` with
  the row's current value. Lives in `src/lib` rather than a page view
  model because both the client pages and the server routes need it,
  and an API route must never import from `app/(app)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-16 00:44:49 +02:00
3b926570c7 fix(quotes): delivery-note quotes skip approvals; company follows customer
Two quote-flow defects found while auditing Q202610315.

1. Delivery-note quotes were being dumped into the rep approval queue.

   A DN quote is never sent on its own — it goes out with the note, via
   the note's "Send the delivery note and quote to the customer" button.
   But `generateQuotePdf` auto-flipped every non-terminal quote to
   `approval_pending`, which overwrote the DN status before the
   skip-approval branch in `sendQuoteEmail` could ever run. Ten DN
   quotes were sitting in Pending Approval as a result.

   - `createQuoteFromDeliveryNote` now creates the quote `approved`
     rather than `delivery_note`.
   - The PDF render no longer flips DN quotes at all, as a backstop.
   - The quote page gets a non-sending action row pointing back at the
     note. No "send" button — a second way to email a customer the same
     invoice is a liability, not a feature.

   Also fixes a latent duplicate-invoice bug: the duplicate guard
   pinned `status = "delivery_note"`, so once the quote had been sent
   it fell through and a second quote could be raised for the same
   note. Verified by test — the old guard silently minted a duplicate.

2. `quotes.company` never followed a customer change.

   `quotes.company` is a denormalised copy of `customer.company` and
   PB does not re-cascade relations, so swapping the customer left the
   old company behind forever (Q202610315 read "Mre" while its
   customer sat at Harmony — two contradictory companies on one row).

   The customer swap ran as a raw client-side PB PATCH that wrote only
   `customer`, which also bypassed the server's placeholder guard. It
   now goes through `PATCH /api/v1/quotes/[id]/pdf`, which cascades
   both relations atomically.

   The customer-facing PDF already read `customer.expand.company`,
   which is why this never reached a customer — but every consumer of
   the quote's own `company` got the wrong answer. A read-only audit
   (`scripts/audit-quote-company-drift.mjs`) puts it at 2,591 of 9,950
   quotes. That data is NOT backfilled here.

Also drops the duplicate standalone "COMPANY" column on the quote
detail page, and the stale-company fallback in approvals, so this
class of bug can't hide in the UI again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-16 00:44:45 +02:00
248c390184 chore: update route.ts 2026-10-15 16:28:37 +02:00
d35c25a9e7 feat(collection-pool): badge suppliers with driver-reported problems
A supplier whose stops include a driver-reported problem (out of
stock, damaged, short supply, wrong item, no-show) now carries a red
badge beside its name showing the count.

The point is the COLLAPSED case. Previously an operator had to expand
every supplier group to discover which ones had problems — the signal
only existed on the expanded rows. Now it is visible at the group
level, which is where an operator starts reading.

Not colour-only: the badge carries an AlertTriangle icon plus the count
and the word "issue"/"issues", and its tooltip names the affected stops
so the operator knows what they are about to open.

New pure helpers in view-model (unit-tested, no PB needed):
  isProblemRow / getProblemLabel  — per-row flag + human label
  summarizeSupplierProblems      — count + tooltip detail lines for a
                                   supplier group

Also drops the now-unused PoolRow / getAssignmentBadge / getPoolAgeLabel
imports from page.tsx — dead imports flagged as lint warnings while
working on the search page.

Committed at the operator's request alongside the search work; the
changes were already present and verified green (tsc, lint, full suite
3967 passing) before committing.
2026-10-15 16:06:33 +02:00
1a03a1422a test(search): pin the clock in the age-chip assertion
Follow-up to d55e546, which moved the window but could not include
this fix. The chip renders an age relative to NOW, and the assertion
expected "4d" from a fixture dated 2026-10-10 against an unpinned
Date.now(). It passed only because the wall clock happened to be
2026-10-14; it expired the next day, which is how it failed on the
first run of the window change.

vi.setSystemTime() makes it deterministic.

Not committing the uncommitted src/app/(app)/collection-pool/ work in
the same tree — unrelated to this fix and not mine to commit.
2026-10-15 16:02:57 +02:00
d55e54680b perf(search): narrow the recency window 90d -> 60d
Operator request. Bigger effect than expected.

THREE constants, not one

"90" appeared in three places, and two of them are load-bearing:

  universal-search-service.ts   UNIVERSAL_SEARCH_WINDOW_DAYS  orders scope
  quotes-search-service.ts      SEARCH_RECENT_WINDOW_DAYS     quotes pushdown
  quotes-service.ts             SEARCH_RECENT_WINDOW_DAYS     exported; shared
                                with the /quotes page + archive

The last two must stay equal — the /quotes page renders "Show recent
(last N days)" from the exported constant while the server filters on
the private one, so moving them apart desyncs the label from the
behaviour with no error anywhere. All three moved together. Universal
search calls searchQuotes without `includeOlder`, so it inherits the
quotes window; changing only the universal constant would have left
global search reading 60 days of orders and 90 days of quotes behind a
single "last 60 days" label.

The UI label reads windowDays straight off the service, so it follows.

MEASURED

Order candidate set collapses — the window was carrying far more than
the linear slice it looks like:

  90d -> 5,394 orders / 16,640 items / 6,696ms scope scan
  60d ->   972 orders /  3,307 items / 1,606ms scope scan

Cold search "tiaan": 17-22s -> 4.6s measured. That is most of the
mitigation the TTL bump was papering over, so the long-deferred pushdown
rewrite is now worth less than it was this morning.

Quotes genuinely drop, and it is real rather than a regression: Tiaan's
own quotes go 22 -> 6 between a 90- and 60-day window, because that
band is where most of his quotes sit. Verified against PB directly.

ALSO

Pinned the clock in the age-chip test. It asserted "4d" against a
fixture dated four days before the wall clock, so it expired the day
the date rolled past — it failed on this run for exactly that reason.
2026-10-15 16:02:34 +02:00
c22e9d1bd6 feat(delivery-notes): lock the cost price, markup stays the only input
A delivery note's cost price is what the business paid. Editing it
while setting a margin rewrites that silently, so a stray keystroke
changes every downstream number on the note without ever looking like
a pricing decision.

The rule lives in quote-pricing.ts as a `costLocked` flag captured at
load time, and repriceLine DROPS a cost patch on a locked line. That
is the guarantee; the read-only cell in the UI is only the
convenience, so no caller can quietly rewrite the cost.

- "Edit note" and "Create quote" grids: cost is read-only with a lock
  icon once the line arrived costed; a blank cost stays fillable and
  still seeds the 25% default markup on first entry.
- readOnly, not disabled, so the number can still be selected and
  checked against a supplier invoice.
- Locked at load, not per keystroke: a cost typed in this session
  stays correctable until save (an operator choice), and locks on the
  next open because the row then carries the cost.
- The DN detail page's edit mode is untouched — it stays the
  deliberate "that cost is simply wrong" correction path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-15 15:44:24 +02:00
c5e6ce63a2 feat(pool): show out-of-stock rows in red, and let the operator clear them
A driver reports an item as out of stock; the item goes back to the
collection pool flagged. Then nothing could ever clear it — no route,
no button, in either app. The line stayed red-flagged for good even
after the supplier confirmed stock was back, and the pool had no way
to say "this one's fine now".

Adds a dedicated clear-problem endpoint rather than reusing
/assign. /assign computes item_status + drv_status + drv_id and
blanks assigned_at on every non-assign call, so routing "in stock
again" through it would drag the row's assignment state along with
it. This route writes the three problem fields and nothing else, so
it cannot clobber a driver assignment made while the supplier was
being chased.

problem_notes is cleared too, deliberately. It's the driver's own
words and the flag is what the UI keys off; leaving it would keep
the detail popover reading "No Stock" after the operator confirmed
otherwise.

The Out of stock pill sits ABOVE the Ready/Pending badge, not below.
A row can legitimately be both — this happens to be one right now:
supplier confirmed ready, driver then found none on the shelf.
"Ready · Out of stock" back to back reads as a contradiction, so
out-of-stock takes the prominent slot.

Problem fields now ride through on stock rows too, since the driver
app reports stock-side problems on stock_purchase_items and the
operator needs the same control there.
2026-10-15 08:25:19 +02:00
cf8f766d80 feat(search): newest-first within relevance tier, plus a colour-coded age chip
ORDERING — recognition first, then recency

Not recency-first, deliberately. Sorting purely by date buries an exact
order number under every newer order sharing a weaker match, which is
the opposite of what you typed the number for. `score` already encodes
the tier (identifier 100 > name 60 > body 20, with a <=25 exact/prefix
bonus), so comparing it pins precise matches above prose matches for
free; the date then breaks what's left.

That "what's left" is the case that matters day to day: searching a
company name matches every order they have, all with an identical score,
and newest-first is the only useful order for those. Verified live on
"teleg" — 28 orders, identifier-prefix matches first, everything else
descending by date; and "121026-1 ENG" still returns exactly one hit.

Quotes already sort newest-first inside quotes-search-service, so they
inherit this without change.

AGE CHIP

Every row now carries a compact age chip — today / 3d / 2w / 1mo / 2y —
in three colour bands: emerald ≤7d, sky ≤30d, slate beyond. The numeric
label is always rendered, so colour is a scanning aid and never the only
carrier of meaning; the exact date is the chip's tooltip.

No created date from a source means the chip is omitted rather than
rendered blank, matching how the timeline drops undated steps.

ageLabel/ageBand/ageClass moved to ./age because Next.js App Router
pages may only export a fixed set of names — an extra named export from
page.tsx fails the generated route-type check.

That split also surfaced a formatting bug worth recording: the original
bands capped weeks at 60 days, so 30–59 days rendered as "4w"–"8w" and
60+ as "2mo" — "1mo" was unreachable. Month band now starts at 30 days.

Adds `createdAt` to all eight hit sources and `created` to the field
lists that lacked it.
2026-10-14 14:17:01 +02:00
00736320bf fix(search): orders searchable by customer/company name; timeline in preview
Two reports, one root cause each.

1. "teleg" didn't find order 121026-1 ENG

   Same defect class as the supplier gap in efb5a53. `partyNames` was
   resolved and used to BUILD the preview, but never added to the MATCH
   set — so an order was findable by its number and displayed with its
   company name, yet not findable BY that name. `orders.customer` and
   `orders.company` are PB relation ids; without the resolved names in
   the match set, partial company searches returned nothing.

   Now matched on both, with the matched surface reported accurately
   (customer / company / contact / rep / order) rather than a blanket
   "order". Verified live: "teleg" → ENG found, via=company.

2. Preview had no lifecycle trail

   Built from timestamps already on the order and its items, so it costs
   no extra round-trip — an audit-log query per hit would undo the
   point of the preview being free. Verified live on a progressed order:

     2026-08-04  Order raised
     2026-08-12  Driver assigned
     2026-08-12  Collected
     2026-10-14  Invoiced

   Steps with no timestamp are OMITTED, not rendered blank — a rail with
   holes reads as broken data rather than unfinished work. That is why
   121026-1 ENG shows a single step: it is only at po_generated, so
   nothing after "Order raised" has happened yet.

   Column population on live PB, for anyone extending this:
     assigned_at 1371 · drv_collected_at 1358 · invoiced_date 1626
     delivered_at 2 · drv_assigned_at 0 · collected_date 0
   `delivered_at` being effectively empty is the same defect fixed in
   918f73e; here it means the Delivered step will rarely appear.

HITS_PER_GROUP 25 → 50

Searching a company name returns every order that company has, and they
all score identically (same matched surface), so the ENG order fell off
a 25-row list that was one row short of showing it. 50 covers a single
customer's order volume. Truncation is still surfaced as "showing N of
TOTAL", so this stays a UX choice rather than a hidden guarantee.

Dialog restyled: type as an eyebrow, a "matched: <surface>" pill so the
reason a hit is present is stated plainly, a bordered label/value table
instead of a loose grid, and the timeline as a vertical rail.
2026-10-14 13:54:52 +02:00
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
f355895c75 fix(search): send the PB auth token, and a second instance of the same bug
Universal search returned 401 "Not authenticated." on every query.

Cause: dashboard auth is a PocketBase token in the `Authorization`
header, NOT a cookie — `assertBrowserSession` reads the header and
refreshes it against PB. The page used a bare `fetch()`, which sends
neither. The service underneath was always correct.

Worth being explicit about what was missed: the service was verified
end-to-end against live PB, and the route compiles, but curl against
the dev server returns the same auth-gated shell as /orders does — so
the authenticated path was never actually exercised until a human
opened it in a browser. Green tests plus a working backend is not the
same as a working feature.

The cache-warm call had the same defect, which mattered more than it
looks: an unauthenticated warm is a server no-op, so the first real
search would have quietly paid the full ~4.5s cold build every session.

SECOND INSTANCE, same root cause

`customer-form.tsx` fetched `/api/v1/customers/sites` — a guarded route
— with no Authorization header. The `.catch` swallowed the 401, so the
site-location autocomplete has always silently returned an empty list,
which is indistinguishable from "no sites in use yet". Fixed in the same
pass.

Audited the rest: cross-referenced every route that calls
assertBrowserSession against every client caller, and this is the only
other real one. The `x-wfdash-auth` header installed by
`installAuditFetch` is attribution for the audit trail, not auth, which
is probably what makes this easy to get wrong.

New: 5 tests on the search page covering the auth header on both the
search and the warm call, the truncated-group "showing N of TOTAL"
rendering, the failed-source row, and session expiry. Verified the two
auth tests fail when the header is removed while the other three keep
passing.

Also completed the customer-form pocketbase test mock, which was missing
`authStore` — the real SDK always has one.
2026-10-14 11:49:30 +02:00
60ccfa8122 fix(sidebar): top-level nav rendered only dashboardItems[0]
The Search entry added in 1ee3555 was in the array, in the build, and
typed correctly — and never appeared. The top-level nav block rendered
`dashboardItems[0]` with a hardcoded index, so every entry after the
first was silently dropped. Caught when the operator asked where the
link was.

Rendered as a map so appending an entry just works.

Regression test asserts the whole top-level set (Dashboard + Search)
rather than "Search exists", so the next person to append an entry gets
a passing test instead of an invisible link. Verified it fails when the
map is limited back to a single entry while the three pre-existing
sidebar tests keep passing.
2026-10-14 11:03:04 +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
4519288073 feat(collection-pool): name the assigned driver, link the PO for download
The pool showed only "Assigned today" — no indication of WHO had the
stop — and the PO number was inert text, so an operator chasing a
supplier had to open the order to find the paperwork.

Driver name
  order_items.drv_id is a PB record id, so it is useless on screen.
  The page already loads the user list into `driversById` to pre-fill
  the assign dialog; that same map now rides onto the row, so the
  badge reads "Assigned to Ruan" and a second line spells it out with
  the scheduled date for column scanning. Filtering the pool by driver
  name now works too, since the name is on the row.

  The badge keeps its old date-only wording when no name resolves (the
  lookup is best-effort) and never renders the raw id — "assigned to
  krpyec9l" is not actionable. Unassigned rows ignore a stale drv_id.

PO download
  The PO number links to
  `/api/v1/purchase-orders/{id}/pdf?download=1`, matching the order
  detail page. `version` is deliberately omitted so the link always
  resolves to the CURRENT document — the one the supplier was sent.

  Customer rows only. `source: "stock"` rows hang off a
  `stock_purchase`, a different collection: the purchase-orders PDF
  route is keyed on `purchase_orders` records and would 404 on a stock
  id. Those POs live as PB files under `stock_purchases/{id}/` with no
  download route yet, so their number stays plain text rather than
  shipping a broken link.

Tests: 6 new cases covering driver naming, the unresolved-name
fallback, ignoring a stale driver on unassigned rows, driver search,
and refusing to match a PB id as if it were a name.
2026-10-14 09:59:25 +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
cc015d62bd merge: fix dead discount toggle + stale figure, lift dark palette 2026-10-12 23:18:00 +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
e230d17f0c merge: POS ex-VAT totals, custom lines and fixed discount tiers 2026-10-12 22:54:12 +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
841121097c fix(invoicing): handle PDF uploads and improve error messaging
This commit addresses issues with PDF handling in the invoicing process. The application now correctly uploads the PDF returned from QuickBooks to the appropriate order item, ensuring that the PDF is available for preview. If no PDF is provided, the system will return a clear message to the user instead of displaying a blank frame.

Additionally, the `readQbWebhookInvoice` function has been updated to include a `pdfBase64` field, allowing for better handling of PDF data. The `PdfPreviewDialog` component has been modified to manage the display of the PDF or an informative message when the PDF is unavailable.

Tests have been added to verify the new functionality and ensure that the application behaves as expected when handling PDF uploads and errors.
2026-10-09 13:27:17 +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
bc6c4fdb31 fix(delivery-note-editor): "Save & show document" was not saving at all
The operator's report: change the quantities, press Save & show, and
the document still carries the old numbers — it "just reverts back".

It wasn't reverting. **The button never saved anything.** Its handler
was:

    onOpenChange(false);
    onSaved?.(deliveryNoteNumber);

which is exactly what Cancel does — close the dialog, show the PDF. The
`save()` call lived only on "Save changes". So the operator changed the
quantities, pressed the button, got the OLD document, and drew the
reasonable conclusion that their edit hadn't taken.

Both buttons now go through `save()`, which takes a flag for whether
to show the document afterwards. The document opens only after the
save resolves, so what she sees is what she just saved.

## Ruled out along the way

Three plausible alternatives, each checked and cleared rather than
assumed:

  - **Browser cache.** No — the PDF route already sends
    `Cache-Control: private, no-store`.
  - **Attachment stacking.** No — `regenerateDeliveryNotePdf` replaces
    the file rather than appending; every item on a regenerated note
    carries exactly one attachment, so reading index [0] is correct.
  - **Silent regeneration failure.** No — `pdfRegenerated: true` comes
    back from the PATCH, and the file's last-modified advances on each
    save.

Verified in the browser on DN26100904: quantity 2 -> 9 via "Save & show
document" now persists to the database AND advances the PDF's
last-modified, i.e. the regenerated document carries the new number.
Data restored to 150/150/150/2 afterwards.

Full suite 3832 passing, tsc clean.
2026-10-09 12:23:33 +02:00
5b8e9e87bd chore(backfill): stamp qty_collected on 301 counter delivery-note lines
Operator-authorised repair. A counter delivery note is raised when the
customer takes goods off the shelf, so the collection has already
happened — but these lines were created with `qty_collected` at 0.

`qty_collected` is what the invoice path reads to decide how many units
to bill, so every one of these would have produced a zero-quantity
invoice line, which QuickBooks rejects (n8n 400 -> the opaque 502 the
operator reported). New and edited notes are already correct
(`848c483`); this is the backlog.

  found:   315 counter lines with qty_collected <= 0
  fixed:   301 rows across 114 delivery notes
  skipped: 14 — already invoiced, deliberately left alone

Two exclusions are load-bearing:

  - Already invoiced. Rewriting a row after the invoice settled
    against the old number would leave the note disagreeing with a
    document the customer already holds. One carried a real
    QuickBooks number (INV29681); the rest are dated "INVOICED…"
    markers. Reported by the script so the skip is visible rather
    than silent.
  - Parent order. On an order-linked note `qty_collected` means "the
    driver has this many in the van". Overwriting it with the order
    quantity would bill for goods that have not arrived — the exact
    irregularity the field exists to prevent.

`scripts/backfill-counter-collected.mts` is DRY RUN by default and
needs `--apply` to write. It is idempotent: a re-run reports 0 to fix.
Verified after the fact that no order-linked line was touched
(0 remaining, same as before).
2026-10-09 12:00:17 +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
74680e456d fix(invoicing): a zero-quantity line can never be sent — bill the ordered qty
"QuickBooks webhook returned 400" on DN26100303, surfaced as an opaque
502. n8n (and QuickBooks) reject an invoice line with qty 0, so the send
could never succeed.

## The bug

`resolveInvoiceLineDefaults` caps the invoice qty at what was actually
COLLECTED, and guards the "nothing collected yet" case with
`collected === null`. Ad-hoc counter delivery notes don't come through
with null — they come through with `qty_collected = 0`. So they fell
through to `min(ordered, collected)` = min(11, 0) = 0.

Found by capturing the exact payload the app posts, by pointing the
webhook at a local echo server instead of calling n8n. All three lines
of DN26100303 went out as `"qty": 0`.

(That echo trick is what should have been used the first time I went
to test reachability, instead of posting a live payload to the real
webhook and creating INV30011.)

## Why the old behaviour was wrong, not just different

An existing test asserted qty 0, with the intent "don't bill for goods
that haven't arrived — show the operator a zero line and make them
follow up with the driver first."

That intent is sound. The mechanism was not: a zero-quantity line is
rejected, so the operator never saw the zero line and never got to
follow up. The intent now lives in a warning instead — the invoice
route returns `uncollectedLines`, so the operator can see which lines
have no recorded pickup before anything reaches QuickBooks.

Also fixed in the same line: `ordered ?? 1` passes 0 straight through
(`??` only falls back on null/undefined), so a row with an ordered qty
of 0 still produced qty 0. An ordered qty of 0 now falls back to 1.

Three new tests, including the invariant that no line may ever be
built with qty 0, and the exact case from your data (ordered 11,
collected 0 -> 11).

Full suite 3820 passing, tsc clean.
2026-10-09 11:15:03 +02:00
22a82b337e feat(delivery-notes): open the note document to print; surface the real QB error
## Delivery note = document, Edit note = the editor

The "Delivery note" button used to open the editor, so the most common
intent — print the note, or read what it says — started in a pricing
grid. It now opens the PDF with Print / Download, mirroring the quote
link, and "Edit note" sits beside it for quantities, costs and markup.

Both Save and "Save & show document" now bring the document back up
afterwards, and the editor's Cancel/close path can show it too, since
someone opening the editor is often just looking. Six controls still
fit the row at 546px.

## The QuickBooks 502 was undiagnosable by design

The route returns n8n's own error text in an `upstream` field for a
502, and the modal threw it away. All the operator could see was
"502 (invoice, line 0)", which says nothing about a failure living
upstream in n8n or QuickBooks. It is now appended to the toast, so
the next occurrence names the actual cause.

### Not a localhost problem

Worth recording because it was the natural guess:

- the n8n webhook answers from this machine — an empty POST returns a
  validation 400, not a connection error
- a well-formed POST returns 200 and creates the invoice
- `NEXT_PUBLIC_QB_INVOICE_WEBHOOK_URL` IS present in the compiled
  server bundle, so the "not configured" 502 branch is not it either

So it is neither config nor reachability. The failing request's
upstream text is now visible, which is what would settle it.

NOTE: diagnosing that cost a real invoice. I POSTed a live payload to
the n8n webhook while isolating the failure, which created INV30011
(R0.00, order_number DN26100904) in the live QuickBooks book. It needs
voiding. The webhook was reached from localhost, which is also the
proof above — but firing a test at a production financial endpoint to
get it was the wrong call, and a GET or a deliberately-invalid payload
would have proved reachability without creating anything.

Full suite 3817 passing, tsc clean.
2026-10-09 11:00:03 +02:00
f0c8787e3f fix(delivery-note-editor): the pricing route is PATCH, not POST
"Could not save line 'Husqvarna 578BTF backpack leaf blower' (405)" —
and nothing was written.

`/api/v1/delivery-notes/items/[id]/pricing` exports PATCH only. The
editor POSTed to it, so every save 405'd. The DN detail page has been
PATCHing the same URL correctly all along; I should have matched it
rather than assuming POST.

## Why it got through my verification

My browser check opened the editor, typed a cost, watched the totals
recompute — and stopped there. The save path is entirely separate
from the render path, and I never pressed Save, so I verified the one
half that was never broken.

Redone properly: changed a quantity 150 -> 7, pressed Save, reopened
the editor and read 7 back (the other three lines untouched at
150/150/2), then put it back to 150 so the data is as found. That is
the check that matters on these screens — display and persistence are
different code paths, and a perfectly-rendering dialog proves nothing
about either.

Suite: 3817 passing, tsc clean.
2026-10-09 10:22:28 +02:00
58831683d4 feat(delivery-notes): edit the note before quoting, and fix the QuickBooks error
Four things, three of them the operator asked for and one a bug I
introduced earlier in this session.

## Delivery note editor, first in the flow

New "Delivery note" button ahead of "Create quote": quantity, cost,
markup and selling price per line, one Save for the whole note.

Sits FIRST deliberately. The quote is priced from the DN's lines, so
a wrong quantity or a stale cost here becomes a wrong number on a
customer-facing document. Fixing it at the source is the point.

It saves through the existing
`POST /api/v1/delivery-notes/items/[id]/pricing`, which takes cost +
selling + quantity in one call and re-renders the DN PDF. No new
endpoint. One Save rather than per-line saves, so a half-typed cost
can't be left behind on one line while the others commit.

Cost and markup are the inputs; selling is derived, same rule as the
quote review modal. A line that already carries a selling price KEEPS
it and shows the markup that price actually implies — verified live:
a line at R259.74 selling on R200 cost opened showing 29.87%, not
the 25% default, so a deliberate price is never overwritten.

## The QuickBooks invoice error

The modal refused to send when the order number was empty, and a
counter note has no parent order, so it opened blank and blocked. The
operator could not tell that an empty field was the only thing wrong.

The field now seeds with the DELIVERY NOTE number (which is what
`order_number` did before, so nothing changes for notes that have one).
QuickBooks needs a number and the DN number is real, unique and
customer-recognisable. Still editable. Verified live: a note with no
order now opens with DN26100814.

## The signed copy — stated, not toggled

The operator asked for an option to include the signed copy. There
isn't one to offer: when a signed copy exists it REPLACES the rendered
PDF at quote-creation time, so the attachment was already the signed
document. A checkbox there would be a control that cannot change
anything — the exact complaint that started this work.

Instead the send modal now says so plainly when the outgoing PDF is
the customer's signed copy.

## My bug

The editor's first fetch was a bare `fetch()` with no Authorization
header. `assertBrowserSession` 401s without it — the same mistake as
earlier this session, caught in the browser because the dialog hung on
"Loading items…". Fixed and re-verified.

Full suite 3817 passing, tsc clean, no lint warnings.
2026-10-09 09:27:09 +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
3bc4633587 feat(invoicing): cancel a dead order off the To Invoice list, with a reason
Lines were getting stuck on To Invoice cards because the page pulls
every sibling line of an order that has one ready line, and outstanding
is defined as "not ready_invoice and not invoiced". Nothing auto-advances
checked_in or collected to ready_invoice, so a card can sit there for
months with no way to express "this order is dead, take it off my
screen". Live data: 24 checked_in, 6 with_driver, 5 collected, 3
po_generated across the 16 orders that currently have ready lines.

Adds a destructive "Cancel order" button next to Invoice in QuickBooks
and Delivery Note. Order-level rather than per-line, because line items
aren't editable from this page — the buyer owns those.

The reason is required and stamped into order_item_details.cancellation
on every cancelled row. Lines already invoiced, on a delivery note, or
cancelled are left alone; the dialog states that split before you commit.

Also fixes the reason the button would otherwise be worthless:
buildOrderInvoiceCards treated cancelled and removed as outstanding, so
flipping item_status to cancelled would leave the line sitting right
where the operator was trying to get rid of it. Both now drop out.
2026-10-08 00:58:16 +02:00
9a72f2e285 feat(delivery-notes): link a manual invoice, and show the invoice without leaving
Two requests. The second turned out to be much smaller than it
sounded.

## 1. Link an existing invoice (the backlog)

Hundreds of delivery notes were invoiced by hand before this app
existed. The invoice is already in QuickBooks; all the app needs is
the link. New "Link invoice" button beside "Create invoice" opens a
dialog that records the number and completes the note.

`POST /api/v1/delivery-notes/[id}/link-invoice` does two writes, and
the second is the one that's easy to forget:

  1. `reconcileOrderItemInvoicedStatus` — stamps
     `customer_invoice_number` + `invoiced_date` on the note's
     order_items and moves them out of ready-to-invoice.
  2. `markDeliveryNoteCompletedByInvoice` — flips the DN to
     `completed` so it leaves the Active list.

Without (1) the note closes but its lines stay "ready to invoice"
forever, so the Invoicing page still shows the work as outstanding —
done in one place, unfinished in another. Items go FIRST: if that
fails the note is left open and retryable, whereas closing first
would leave a completed note with no obvious way back.

It reuses `buildReconcileRows`, the same quantity rule as the
create-in-QuickBooks path, so the invoice qty is still capped at
min(qty_collected, quantity) and a short pickup isn't stamped as
fully invoiced. A second copy of that rule is how the two paths
would disagree about what "invoiced" means.

Deliberately a SEPARATE button from "Create invoice", not a mode of
it: one fires a webhook and creates money, the other records a
number. Folding them together is how a customer gets invoiced twice.

## 2. The invoice comes back to the screen

Not a new integration at all. n8n already downloads the QuickBooks
invoice PDF and attaches it to the note's order_items as
`qb_invoice_pdf` — 1,209 items carry one today. The Invoicing page
has been printing them from PocketBase all along.

The create route now returns `invoice: { number, date, pdfFileName,
pdfRecordId }` alongside the updated DN (additive — existing callers
are untouched), and the list opens that PDF in the same preview
dialog the quote uses. Print and Download, no navigation.

The `pdfRecordId` matters: the file hangs off the ORDER_ITEM, not
the delivery note, and PB's file URL is
`/api/files/<collection>/<recordId>/<fileName>` — anchoring on the
delivery note would 404.

Verified in the browser as David Els: both buttons render in order
(Create quote · Send · Link invoice · Create invoice), the link
dialog opens on the list, and the URL never leaves /delivery-notes.

Full suite 3783 passing, tsc clean, no lint warnings.
2026-10-08 00:05:32 +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
970104e510 feat(delivery-notes): invoice in QuickBooks from the list, with the order number
The "Create invoice" button used to navigate to the note's own page
with ?invoice=1, which opened the invoice modal there. One click, but
it threw her out of the list she was working through. It now opens
in place, on the list.

The ~180-line QuickBooks modal moved out of the detail page into
`QbInvoiceDialog` and both screens mount it — the same pattern used
for the action buttons and the readiness rule, both of which had
drifted precisely because they existed twice.

## The order number

Duplicated from the invoicing screen, which has had it all along:
"Invoice order number", seeded from the note's order, editable, with
a hard stop if it's empty when the operator tries to send. The
backend already accepted `orderNumberOverride` — the route resolves
`override || order.order_number || fallback` — so only the UI was
missing, which is exactly what the operator asked for.

That last fallback matters more here than on the invoicing screen: a
counter delivery note usually has NO parent order, so this field is
often the only place the number on the QuickBooks invoice can come
from. A blank one would silently become `QB-<dn>-<timestamp>`, which
is not a number anyone recognises on an invoice.

## The auto-search could only ever fail

The modal auto-loaded customers when it opened. The n8n webhook
rejects an empty `customer_query` with a 400, the proxy turns that
into a 502, so opening the dialog produced a red toast before the
operator had touched anything. Verified against the live webhook:

  {"customer_query":""}        -> 400 "customer_query is required"
  {"customer_query":"Caliber"} -> 200, real customers back

There is now no auto-search: the operator types (the box is seeded
for a note with an order) and presses Enter or Search.

The loading flag also had to be rewritten. It was first derived from
`loadedFor !== requestKey`, which meant typing in the search box
flipped the spinner on while nothing re-ran the fetch, and it spun
forever until Enter. `loading` now means "a request is genuinely in
flight" and nothing else.

Verified in the browser as David Els: Create invoice opens on the
list with the order number pre-filled (Q202609244), a search for
"Caliber" returns Caliber Machine and Fabrication (Pty) Ltd., and the
URL never leaves /delivery-notes.

Full suite 3676 passing, tsc clean.
2026-10-05 22:56:22 +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