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>
- Runbook: pre-migration settings don't persist (not "saves fail"),
eligible statuses, 15 lines per quote, audit attribution, clone/template
behaviour, supplier kept, doubts dot, confidence guidance (Medium is the
default; raise to High if reps often change AI prices).
- quotes.assistant_fill added to docs/api-mcp-layer.md,
docs/audit/api-mcp-coverage.md and the Giellie skill.
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>
- 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>
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>
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>
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>
Audit of the POS catalogue against the supplier price lists: broken
supplier names, cost drift over 15%, negative stock, items not
sellable from stock, and SKUs split across two codes for the same
product. Includes the supplier-name backups taken before any mutation.
Follows the existing convention of keeping dated audit output under
reports/.
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.
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.
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>
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.