The assistant now prices the order's lines from the quote they are
paired with, as purple drafts a person then reviews.
- order-assistant-core.ts: isOrderLineWritable + buildOrderLineFillPatch.
Only a pending line with no cost of its own, no person-set pricing
source and no reviewed fill may be written. Supplier / SKU /
matched_price_list_id only when the line has none; selling_price only
when the line has none; markup + markup_percent written together.
pricing_source becomes "ai_quote". A customer price that differs
from the quoted price by more than 5% becomes a doubt.
- order-assistant-lines.ts (new): the shared, cycle-free helpers —
idFilter, aiQuotePairs, writeAiPairs, clearAiPairs and
fillOrderFromQuote. It lives in its own module so orders-service.ts
can use it without an import cycle, since order-assistant-service.ts
already imports setOrderQuote.
- The fill re-reads every line right before writing it, so a cost a
person typed in the meantime wins. It stays quiet until the migration
has added order_items.ai_fill — PB rejects a projection that names a
column the collection does not have.
- setOrderQuote writes the AI's line pairs before pairOrderLines runs,
so the AI's pairing wins over word matching, then fills the lines.
A failure there is logged and never fails the link.
- The tick gains a second pass that fills the lines of orders already
linked to a quote. Only orders the fill actually wrote to count
towards the budget, so a skipped order cannot block the run.
- The run route fills after the match and reports the counts.
PocketBase filters have no IN operator, so every id list is an
OR-chain. fetchQuoteItemsForQuotesAdmin no longer tries an IN filter
first — that was a guaranteed 400 on every call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task 9 of the AI order assistant plan.
- new "Order assistant" card in Settings → AI: enabled switch, run
every (minutes, 5-1440), auto-link when sure, fill lines from the
quote, the prompt with show/reset default, and the last-run summary
- parseSettingsForm reads oa_* and validates the interval; the last-run
fields are never written by the form
- the page passes the new settings through as the initial state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task 8 of the AI order assistant plan.
- new AiQuoteMatchPanel: "Linked by AI" / "AI suggests Q…", the
reason, the doubts and the line-pair table (order line -> quote line
with same_item)
- the quote match card gains a "Match with AI" button in every state,
posts to /api/v1/orders/{id}/assistant/run with the PB token, toasts
the 409 message verbatim, then reloads the order
- new card state: the AI suggested a quote -> the AI panel + Accept
(links the AI's quote) / Match with AI / Dismiss
- the AI suggestion marks the order as quote_matching_attempted so the
deterministic sweep cannot overwrite it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task 7 of the AI order assistant plan.
- POST /api/v1/orders/assistant/tick (n8n, assertAgentAuth)
- POST /api/v1/orders/{id}/assistant/run (browser session; 409 for
"AI is disabled" and "not set up yet")
- MCP orders.assistant_match { orderId } -> matchOrder(force) with a
TOOL_HINTS entry; deliberately NOT in READ_TOOLS
- docs/order-assistant.md runbook and the AGENTS.md section
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shows "Short delivered · on <PO>" next to the description on /orders and
the order page so the buyer doesn't re-order units already on back order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collection Confirm cuts a short line to what was collected (that part
invoices and closes) and creates the rest as a new pending line on the
same order, with Back order (same PO / straight to pool) on the process
page. Invoicing subtracts qty_invoiced so a part-invoiced line can't bill
twice. Supplier-side lines close at confirm when their order is billed,
and stragglers get a close button on Invoicing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/v1/driver/invoice-match returns exact matches (same product,
invoice qty = ordered qty) and lines to review. Read-only for the lines.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Confirm stock now sends the collected lines to stock booking and moves
lines not yet collected to a follow-on order (<PO>-2, -3, ...). The
invoice matcher follows a closed PO to its open follow-on.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
POST /api/v1/driver/collection-check runs the collection AI check for a PO
and returns a per-line verdict (right item, right quantity) for drivers.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
next/font/google downloads every font from fonts.googleapis.com during
`next build`, and the Coolify build kept failing on it (Nunito/Outfit on
2026-10-17, Lato and JetBrains Mono on 2026-10-20).
The ten families (Geist, Geist Mono, Inter, Space Grotesk, DM Sans,
Playfair Display, Lato, Nunito, Outfit, JetBrains Mono) are now Latin
.woff2 files in src/app/fonts (344 KB, downloaded from Google Fonts),
loaded with next/font/local under the same CSS variable names and
weights. Verified: `npm run build` succeeds with no network access, and
the login page renders in the local Inter with no font errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The driver app records the full quantity as received when it collects a
stock pickup, and Collection Confirm only listed stock lines that were
not fully received — so collected stock orders skipped it and sat at
"sent", outside the accountant's booking queue, until someone clicked
Mark received (29 stuck on 2026-10-19).
- STOCK_ON_PAGE_FILTER / stockLineOnPage: a collected stock line stays on
Collection Confirm until its stock order is received; cancelled orders
never show. Shared by the list and the AI check (so those lines get
photos read and ticks too).
- Confirm stock: markStockOrdersReceived flips each stock order to
"received" once every line is collected/booked (→ booking queue);
orders with lines still out stay open, reported as stillOpen. The
route returns { updatedIds, received, stillOpen }; the toast says so.
- Stock photos: `npm run pb:migrate:stock-photos` makes
stock_purchases.additional_attachments multi-file;
addStockPurchaseAttachment, Move photo and Delete photo add/remove with
`+`/`-` instead of replacing the single slot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PO numbers were "read the month's highest, add one", so POs for several
suppliers on one order created together got the same number — 34 numbers
by 2026-10-19 (e.g. PO202610008LIF for BarloWorld CAT and John Ratcliffe,
created 71 ms apart).
- createPurchaseOrderWithNumber (po-number.ts): numbers and creates POs
one at a time in the server, retries (up to 5) when the unique index
rejects a clash; ignores any po_number passed in.
- buyer-tasks / MCP generate_po use it.
- The Process page's Send PO and in-stock PO buttons now call
POST /api/v1/orders/[id]/purchase-orders (browser session) instead of
numbering in the browser; the client generatePONumber is removed from
pb-helpers.
- npm run pb:migrate:po-number-unique adds a partial unique index on
purchase_orders.po_number for POs created from then on; the 34 existing
duplicates are left as they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- 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>
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>
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>
The n8n tick was calling in every 5 minutes but WFDASH answered
"disabled" because Collection checks was off in Settings → AI, and
nothing on Collection Confirm said so.
- New "AI checks" switch next to Refresh, the same setting as Settings →
AI → Collection checks. Purple and "On" when running, with the last
run in the tooltip; a gear links to /settings/ai.
- GET/PUT /api/v1/collection-confirm/ai/enabled (browser session). Only
reads/writes collection_ai_enabled and the last run — no key, model or
prompt goes through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
- 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>
A source line whose AI fill is still pending review (isAiFillPending) is
copied without cost, selling price, markup or supplier, so the purple
draft price can't resurface as an ordinary price on the copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- 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>
Quote assistant card in Settings, AI (on/off, interval, min confidence,
editable prompt with reset, last-run line); parseSettingsForm handles the
qa_* fields. docs/quote-assistant.md has the runbook and the n8n tick
workflow spec; AGENTS.md gets a short section.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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).
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.
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.
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.