Commit graph

419 commits

Author SHA1 Message Date
924f7b764e fix: uploads to an order line add files instead of replacing them
A PocketBase file PATCH under the bare field name replaces the whole
list. On 2026-10-19 a buyer's "Add supplier invoice" on PO202610008LIF
deleted the driver's delivery-note photo (uploaded 14:38, read by the AI
14:40, replaced 14:41). Generating or regenerating a delivery-note PDF
did the same to every line on the DN.

- New src/lib/server/order-item-files.ts: addAttachmentForm() adds with
  `order_item_attachements+` and removes (`-`) only older stored copies
  of the same document; pickStoredCopy() finds a file by its uploaded
  name (PB stores "DN26101913.pdf" as "dn26101913_<10>.pdf").
- Used by the Collection Confirm invoice upload, DN create, DN regenerate
  (replaces only its own previous PDF) and /api/invoicing/delivery-note.
- /api/invoicing/delivery-note-pdf finds the DN PDF by name instead of
  taking the line's first file (falls back to the old behaviour for
  legacy names).
- AGENTS.md: always add, never replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:46:37 +02:00
e66ea11845 feat(collection-confirm): Delete photo; drop the Run agent scan button
- Delete photo next to "Add supplier invoice" removes the photo open in
  the previewer (wrong upload, or one of several for the same PO) after an
  inline "Delete …? Yes, delete" confirm; the confirm resets when you
  switch photos. Not offered for the PO PDF.
- POST /api/v1/collection-confirm/delete-photo (browser session) →
  deleteCollectionPhoto: takes the file off every order line carrying it
  (or the stock purchase's photo slot) and deletes the AI's reads of it
  plus the checks built on them (shared forgetReads with Move photo).
- Removed the header's blue "Run agent scan" button and its results panel
  (not in use); the /api/v1/agent/scan route stays for the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:26:10 +02:00
af4cdc66da feat(collection-confirm): spot a photo on the wrong PO and move it
Drivers photograph invoices per item and can't see which order a photo
lands on. B&R invoice 00282979 (stock order PO26100702STK) ended up on
the thinners line of PO202610017BLY; the AI read "PO26100702STK" off it
but the card only said "not found".

- The card list now carries each read's po_refs and source file, and sets
  ai_doc.other_po when the document names a PO that exists and isn't the
  row's PO (none named, or this card's PO among them: no hint).
- PO card: "Photo may be for <PO>" in the header; when open, "Invoice <no>
  is for <PO>, not this PO" with a Move photo button. Nothing moves on its
  own.
- POST /api/v1/collection-confirm/move-photo (browser session) →
  movePhotoToPo: copies the photo to the target (a collected line of a
  customer PO, or the stock purchase's single photo slot — refused when
  taken), removes it from the source, deletes the AI's read of it and the
  checks built on it so it is re-read where it now belongs.
- normalizePoReference moves to the client-safe src/lib/po-reference.ts
  (re-exported from the matching service).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:08:47 +02:00
802565d2d8 feat(collection-confirm): check printed prices on delivery notes; show AI reading notes
Plumblink hands out delivery notes with prices on them. The Price tick
was always grey on a delivery note, so a card could go purple without
its prices ever being checked, and the AI's reading notes ("delivery
note, not a tax invoice", "year not printed") were never shown.

- Price tick: compare whenever the document line has a price, invoice
  or delivery note; grey only when no price is printed. The AI still
  only corrects a cost from a tax invoice.
- ai_doc now carries the read's notes; cardNotes() unions them per card
  and the AI findings box lists them under "Reading notes" (the box now
  shows when there are notes but no findings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:50:27 +02:00
a6455376e4 fix(collection-confirm): purple card only when the AI found nothing off
- The collapsed PO card showed white instead of purple: it always carried
  `bg-card`, which beat `bg-ai/10` in the CSS (but lost to the green).
  Now the card gets exactly one background.
- Purple now means "the AI checked every line and no tick is red or
  amber" (grey "couldn't check" ticks are fine). A card the AI checked
  with a line off, or with unchecked lines, goes amber and says
  "AI checked N of M lines · K need a look". Green still means "photo,
  not checked yet".
- cardAiState() in collection-ai-ticks.ts works this out from the same
  computeLineTicks the table uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:20:36 +02:00
d73fb6f0ab feat(collection-ai): read invoice discounts and set the discounted cost
HYCO (I009753) prints full prices on the lines and "Discount 25.00%" in
the totals; Swagelok (622831) has a "Disc %" column on each line. The AI
compared our cost (already the discounted price) with the printed price,
so the Price tick went red on both.

- The read now captures a line's discount_percent and a whole-document
  doc_discount_percent (prompt rule 5b) and stores net_unit_price on each
  line (both discounts stacked).
- The Price tick compares the cost with the price after discount; the
  tooltip and the document-lines panel spell it out
  ("R15.10 less 25% invoice discount = R11.33").
- Owner's call: when a "sure" tax-invoice line has a discount and our
  cost isn't the discounted price, the AI sets the cost to it (customer
  lines through updateOrderItemCost, which adds a cost note; stock lines
  through updateStockPurchaseItem) and records why in ai_check.cost_changed,
  shown in purple under the line. No discount, a delivery note or a
  "likely" match: the cost is never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:18:21 +02:00
4eb4a24b84 fix(collection-confirm): show AI ticks under the description; always fill the SKU
- The AI and POS columns sat at the far right of the items table, hidden
  behind the document preview in the split view. Ticks (now labelled SKU /
  Qty / Desc / Price) and the POS status render under each description,
  and the table is back to its original six columns and width.
- Owner's call: fill an empty SKU on a sure pairing even if the code may
  be misread; the rep edits it and a near-miss still helps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:45:34 +02:00
a4345c1f85 fix(collection-ai): day-first dates, only fill SKUs read with certainty, tolerant supplier names
- Dates: the model also returns the date as printed; code parses SA
  day-first formats (16-10-26 -> 2026-10-16) and drops dates more than a
  year old or a month ahead (the Bestobell read gave 2016-10-26).
- SKU: a new code_certain flag per line; the AI only fills an empty SKU
  when it read every character clearly (it filled 0515315 for 051315).
- Supplier: names sharing a distinctive word (Bestobell, Belting) no
  longer raise a 'supplier differs' finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:36:58 +02:00
79d54ce3fd fix(collection-ai): re-read sideways invoice photos turned upright
A sideways Bestobell invoice was read as doc 10045064 / a bank-details
line; turned 270 deg the same photo reads correctly (10245084,
PO202610010MRE, rubber insertion @ R365). When no printed PO number
resolves, retry the photo at 270 and 90 deg and keep the first read whose
PO resolves (within the tick's time budget).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:07:26 +02:00
8efee9db42 fix(collection-ai): pre-fill only from the chip's tax invoice, AI column only with ai_check
M2: the confirm dialog pre-fill uses the card's newest document (as the
chip) and only when it is a tax invoice.
M3: the AI ticks column (and its min-width) shows only when a row has
ai_check; the POS column stays whenever in_pos is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:20:46 +02:00
f60b1d893c fix(collection-ai): round quantities before comparing in the findings
F1: outstanding = round(ordered - collected, 3) > 0 and the order-line
mismatch compares round(order_qty, 3) with round(quantity, 3), so float
noise never produces "0 are still outstanding" or a false mismatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:19:34 +02:00
515c9d4cd2 fix(collection-ai): one AI call per failed photo, card PO always paired, findings write is soft, guard both collections
I7: a hash whose read failed this run is recorded as a failed copy with no
second AI call.
M4: the card's own PO lines are always offered for pairing alongside the
POs the document names; the fallback finding still applies when none resolve.
M5: a failed findings write no longer leaves the lines unchecked.
M6: the schema guard requires ai_check on order_items and stock_purchase_items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:18:47 +02:00
d4cdde7384 fix(collection-ai): Add to POS never duplicates SKU-less rows, failures are per row
I5: a customer row without a SKU returns needs_sku and creates nothing; the
Add to POS button is disabled for it ("Set a SKU first").
I6: a createStockItem failure is caught per row (status failed + error), the
route still 200s, and the page toasts "N added, M failed, K need a SKU".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:16:21 +02:00
457ad30f12 fix(collection-ai): stock rows tick and show their own line SKU
I4: ConfirmRow.line_sku carries stock_purchase_items.sku; the SKU tick,
the AI robot and (for stock rows) the SKU column use line_sku before the
catalogue sku via effectiveSku. The SKU pencil keeps line_sku in step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:14:17 +02:00
b49911601d fix(collection-ai): grey ticks when pairing failed, show line findings on the card
I3: ai_check.pair_failed marks lines whose covering document couldn't be
paired; computeLineTicks returns all grey for them. The card findings box
now also lists each row's ai_check.findings after the document findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:12:42 +02:00
d1956bc1cf fix(collection-ai): only sure/likely pairings count, renumber doc lines, drop filled-SKU tick shortcut
I1: a 'different'/unrated answer never claims a PO line in parsePairs, is
stored as doc_line null (verdict + reason kept), loses to a real pairing,
is not a pairing for the findings, and gives all-red ticks.
I2: document lines are always numbered 1..n.
M1: the SKU tick goes green only through the normalised code match.
Also adds effectiveSku (line_sku before sku) for I4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:10:59 +02:00
137b458a06 fix(collection-ai): page handler tests, full document lines, snapshot dialog defaults, tick aria detail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:45:12 +02:00
c1656f5ccd feat(collection-ai): ticks, findings and POS buttons on Collection Confirm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:41:52 +02:00
cc3e1d040b feat(collection-ai): add to POS, AI routes and MCP tool
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:33:56 +02:00
b60f7fd3ce fix(collection-ai): keep reads on errors, record pairing failures, budget reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:24:04 +02:00
16cf5e2746 feat(collection-ai): card check, tick and list passthrough
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:14:53 +02:00
2af5d089f4 feat(collection-ai): pair document lines with PO lines
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:54:42 +02:00
8fc4930de7 feat(collection-ai): vision read of driver documents
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:40:33 +02:00
7bcd92f999 fix(collection-ai): findings fallback tests, rounding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:37:29 +02:00
122eb9862e feat(collection-ai): document findings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:35:45 +02:00
084e0951c7 test(collection-ai): sharpen tick qty/price cases
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:26:18 +02:00
5262de97fd feat(collection-ai): line tick rules
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:24:51 +02:00
e6cac6e447 feat(collection-ai): data model, settings, migration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:18:10 +02:00
78ba560096 fix(quote-assistant): refuse to fill until the ai_fill field exists
PocketBase drops unknown fields on update, so before the migration runs
the AI's prices would land with no purple marker and no notes. The
manual button / MCP now return 409 'not set up yet' instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:26:13 +02:00
6604e8b837 fix(quote-assistant): review minors (templates, forced order, bounds, UI)
- M1: fillQuote and the tick refuse templates ("quote is not open
  (template)"); both quote pages gate the fill button with
  canAiFillQuote (new/follow_up/claimed, not a template).
- M4: run summary keeps <= 20 errors of <= 300 chars (boundRunErrors).
- M5: forced runs offer never-tried lines before tried ones.
- M6: counts route 400s on ids that aren't PB record ids.
- M7: a fill never replaces a supplier already on the line.
- M8: warn dot on the AI marker when a pending fill has doubts.
- M9: MCP hint states the real eligibility rule.
- M10: detail-page price dialog re-reads the line before recording an
  AI review.
- M11: exported QUOTE_NOT_OPEN_ERROR_PREFIX / AI_DISABLED_ERROR used by
  the run route.
- M14: prompt textarea maxLength 50000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:41:28 +02:00
ee5c3bcbb3 fix(quote-assistant): exact-SKU rows always shortlisted; per-line shortlist errors
- With a SKU on the line, shortlistStockItems runs a second narrow query
  (sku = raw || sku ~ normalised) and merges by id, so the capped broad
  pull can't crowd out the exact SKU row. Quotes and backslashes are
  stripped from the raw SKU before any filter is built.
- fillQuote catches a shortlist failure per line and marks only that line
  error instead of abandoning the quote.
- Supplier names load once per fillQuote and are passed to each
  shortlist call (new optional parameter; default unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:32:26 +02:00
4e0f7a5c5c fix(quote-assistant): AI fill on Process page keeps unsaved typing
After "Let AI fill this quote" the page re-pulls the quote's lines and
merges them with mergeItemsAfterAiRun: rows unchanged since the last load
take the server copy (so fills show), rows with unsaved changes stay as
typed, with a toast saying how many were kept. Previously onDone ran
fetchData, which replaced every row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:29:04 +02:00
a9160de1d6 fix(quote-assistant): retry unanswered lines, bound and serialise ticks
- decideWithAi throws on stop_reason max_tokens instead of returning a
  partial answer; fillQuote marks a requested line the AI did not answer
  as error (retried after 1 h), never a permanent no_match.
- QUOTE_MAX_LINES 30 -> 15; the tick also caps lines per quote so the
  60-line budget reaches other quotes.
- runAssistantTick stops starting quotes after a 90 s time budget and
  returns reason running while a newer start than the last summary is
  under 10 minutes old (lease).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:26:28 +02:00
51bd4ec32c fix(quote-assistant): credit AI writes to the assistant, not the rep who clicked
AuditActor gains ignoreRequestUser: buildAuditHeaders then omits the
forwarded PB token (X-Audit-Auth) and request-derived email hint, so the
PB audit hook falls back to X-Audit-Actor-Name. fillQuote sets it for
all its writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:22:59 +02:00
fa854060c4 feat(quote-assistant): AI x/y badge on quotes board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:01:43 +02:00
40094be3f3 feat(quote-assistant): purple AI lines on process and quote pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:56:02 +02:00
d34be52a66 feat(quote-assistant): tick/run/counts routes, MCP tool, re-price guard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:38:38 +02:00
bb06165318 feat(quote-assistant): fill service, tick, counts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:29:16 +02:00
bd78f0b3c1 feat(quote-assistant): MiniMax match decision call
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:22:18 +02:00
29144943d0 feat(quote-assistant): eligibility, patch building, review helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:17:47 +02:00
0adf23ef4e feat(quote-assistant): POS shortlist from stock_items
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:12:48 +02:00
f0e7848eed feat(quote-assistant): data model, settings fields, migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:07:52 +02:00
a1a2cc73e2 feat(search): invoice + delivery-note search, richer colour-coded preview
- Orders now match on customer invoice numbers and delivery-note
  numbers carried on their lines (tagged "invoice" / "delivery note").
- New Delivery Notes source: DN number, customer PO, customer and
  company names; preview pulls lines and invoices for visible hits only.
- Quick-overview dialog moved to search-preview.tsx and redesigned:
  type-tinted header, status pills, stat tiles, invoice chips with
  QuickBooks links, line list, icon timeline with dates and gaps.
  Fixes the dialog being pinned at 24rem by the primitive's sm:max-w-sm.
- Result list: per-type colour (icon tile, accent border, badge, row
  hover), status pills per row, and a jump bar; mobile-tightened.
- Stock-order preview now fetches ordered_value/created (Value row and
  age chip were always empty); supplier names keep their original case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:45:44 +02:00
a3a831d183 fix(quotes): list renders the customer's company, not the stale copy
Third and last place that read `quotes.company` directly. The list page
showed "Mre" next to Pierre Visser, who works for Harmony — the same
drift already reported on the detail page.

This one was a deliberate perf shortcut rather than an oversight.
`QUOTE_LIST_EXPAND` dropped `customer.company` to "save one IN query per
page load" and the page read the quote's own `company` relation
instead. That relation is a denormalised copy that PocketBase never
re-cascades, so the shortcut read a field nothing keeps correct — and
the column it fed is what a rep reads to decide whether to approve, so
a stale company there is worse than a slightly larger query.

The 3-level expand is back, on both the list and the search path (the
search results feed the same columns, so they needed it too). Search
now also RANKS on the customer's company rather than the stale copy —
otherwise a query for "Mre" would surface quotes whose results then
render "Harmony", contradicting themselves on screen. It falls back to
the quote's own copy for the ~4% of customers with no company relation,
where the two are equal by construction.

Applies to both the desktop table column and the mobile card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-16 01:07:50 +02:00
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
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
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