Commit graph

765 commits

Author SHA1 Message Date
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
dd3ab2ad9a fix(quote-assistant): clone/save-as-template don't copy unreviewed AI prices
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>
2026-10-16 23:30:22 +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
144971779f feat(quote-assistant): settings section, n8n tick, runbook
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>
2026-10-16 20:10:02 +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
c8e977c61e fix(quote-assistant): warn-coloured doubts, real button for no-match badge, more button tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:48:19 +02:00
ef6193367d feat(quote-assistant): ai colour token, marker, fill button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:45:44 +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
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