Commit graph

560 commits

Author SHA1 Message Date
4c5a312e16 feat(orders): type exclude_from_invoice field on OrderItem
Added the boolean to src/lib/types.ts per the non-invoiceable
extras design. No behavior change yet — this is just the type.
Tasks 2+ wire up the operational flow.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 14:30:17 +02:00
eb874bfd93 fix(orders): stop an order matching its own delivery note; include draft/loaded
Two coupled corrections to delivery-note repeat detection.

Self-match: delivery notes have no line-item collection -- items are
order_items rows tagged with delivery_note_number. The Path A flow
stamps that number onto rows that KEEP their `order` relation, so a
processed order and its own DN are backed by the same database rows.
isExactItemMatch compared them against themselves and returned true
unconditionally. 149 live rows are in that state. Guarded both by DN
id and, for combined DNs whose `order` is empty, by inspecting the DN
rows' own order relation.

Candidate statuses: `draft` and `loaded` are real deliveries here.
The owner confirmed drafts simply never get advanced in the system,
and `loaded` is reachable directly from draft, so goods can go out
without ever passing through `sent`. Excluding both left a candidate
pool of 3 DNs out of 116, making the feature inert. Ordering matters:
the self-match guard had to land first, because the DN that exposed
the bug is itself a draft.

Cancelled stays excluded pending confirmation -- it is 84 of 116 and
may have been used to clear superseded delivery orders rather than to
mark abandonment.

Tests: a prior review showed by mutation that the existing tests
assert output shape but pin no scoping decision -- company-vs-customer,
the status filter and the tie-break could all be inverted with the
suite green. Added filter-inspecting and self-match tests, each
verified by mutation to fail when its guard is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:09:23 +02:00
5a869c5f41 test(orders): widen makePb override param to a plain record 2026-08-30 13:57:30 +02:00
370a270b4c feat(orders): show a repeat-order pill when an order matches a delivery note
Blue Truck pill on the orders list, distinct from the amber quote
suggestion and the teal linked quote. Reads
potential_delivery_note_number (denormalized, no expand needed) —
the service writes the relation and the number in the same update
call so the list query doesn't pay for a second expanded relation
(see orders-service.ts:253 for why the codebase avoids that).

Additive — an order can show both a quote pill and a delivery-note
pill at once; they're independent facts (a repeat-order
observation, not a provenance link).

Added potential_delivery_note: string and
potential_delivery_note_number: string to the Order interface in
src/lib/types.ts so the page gets the new fields via full-record
return from getOrdersDashboardData (no fields: projection in the
list path).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:59:45 +02:00
3df1c1874b feat(orders): add on-demand delivery-note match re-check route
Thin wrapper around suggestDeliveryNoteForOrder(id). The route
mirrors the quote matcher's /quote-match endpoint for the case
where an order's items were edited after intake, or the order
predates the automatic intake check.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:58:56 +02:00
fa28aca8bc feat(orders): detect delivery-note repeats at order intake
Two independent try/catch blocks inside the existing runQuoteMatch
closure in createOrder. Each matcher (quote + delivery-note) is its
own scope, so a failure in one cannot starve the other. The
intake-path after()/E468 guard from Task 6 stays in place — this
adds the second matcher call alongside the first.

The 3-test block in createOrder delivery-note auto-detection proves
the independence in both directions: a quote matcher throw doesn't
prevent the delivery-note match, and vice versa. Mutation-tested by
merging the two try/catches — guards the design itself, not just
the wiring.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:58:36 +02:00
3cf2837c3f feat(orders): add delivery-note repeat-order detection service
Wraps the pure comparator with PB reads/writes. Same shape as
suggestQuoteForOrder:
- skip when order has no company (don't stamp attempted)
- write potential_delivery_note + number + confidence + method +
  attempted:true on completion
- empty write when no candidate matched

Tie-break order:
1. priced quote match (status=delivery_note, selling_price == order)
2. most recent (created desc)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:46:13 +02:00
e09de3cc80 feat(orders): add exact-match comparator for delivery-note detection
Pure module — no I/O, mirrors the separation established by
order-quote-match.ts.

isExactItemMatch is multiset equality on (normalizeLine(description),
quantity). Empty sets return false (would otherwise vacuously flag
every zero-line order against every zero-line DN).

14 tests cover: exact match, qty diff, extra line on either side,
multiplicity enforcement, the empty-set case, normalization
collapsing whitespace + case, and punctuation-sensitive 'exact'.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:45:34 +02:00
623cfefc0d feat(orders): show quote line notes and attachments on the order detail page
Closes the same vanishing-buyer-notes bug B12 fixed on the process
page, this time on the order detail page that operators see first.

- Inline row indicators (MessageSquare icon for a buyer note,
  Paperclip + count for per-line attachments) inside the Description
  TableCell. Hidden when the line has no signal. Click toggles a
  QuoteLineContext panel below the row.
- QuoteLineContext panel wrapped in a Fragment-keyed extra row,
  colSpan=8 (matches the eight TableHead columns: Description, Qty,
  Selling, Supplier, Cost, Total, Status, Edit). The plan called
  for colSpan=9, but the Driver column was removed 2026-08-24 and
  the live count is 8.
- quoteAttachments intentionally empty in the detail-page panel —
  order-level attachments have their own card below the table.

Migration off pb-helpers (AGENTS.md §'data access layer discipline'):
- /api/v1/orders/{id}/quote route now treats empty quote_id as the
  unlink path (clears quote + potential_quote + matching fields),
  and dropped assertAgentAuth so the page can call it from a
  browser session (admin auth happens via the PB session cookie at
  the PocketBase layer; the MCP forwarder still works without that
  gate because the MCP route gates at its own boundary).
- /api/v1/orders/{id}/quote-match is a thin wrapper around
  suggestQuoteForOrder (returns the new SuggestQuoteResult shape —
  best.quoteId / best.quoteNumber, not the old best.quote.id /
  best.quote.quote_number).
- Page migrated four pb-helpers calls: findMatchingQuotesForOrder →
  POST quote-match, applyOrderQuoteMatch → merge into quote-match +
  QuoteMatchCard accepts the result via SuggestionCard from the
  fetched order, clearOrderQuoteMatch → POST quote with empty
  quote_id, setOrderQuoteManually → POST quote with the picked id.
- runMatch signature simplified — accept-vs-suggest was the old
  split; the new endpoint only ever suggests. 'Accept' now goes
  through linkQuoteManually via POST /quote.
- Stale matcher block (4 functions + 3 helpers + re-exports +
  imports) deleted from pb-helpers.ts. The pure scoring helpers now
  import directly from @/lib/server/order-quote-match.

Known follow-up: QuoteMatchCard still reads from matchResult (a
QuoteMatchResult shape). Clearing matchResult after runMatch means
the SuggestionCard renders empty until the next fetchOrder lands.
Reads from order.expand.potential_quote after that. A future task
can rewrite the card to drop the in-memory result entirely.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:45:02 +02:00
dda1999fb6 fix(orders): resolve quote line pairing server-side on the process page
This is the actual fix for the vanishing-buyer-notes bug the matcher
service exists to prevent. Before this commit, the process page did
an exact/substring description match on every render — and silently
hid the buyer note whenever the PO's wording differed from the
quote's, even after the matcher had resolved a confident pair.

- Server-resolved linePairs now drives the per-item lookup in three
  places (inline quote preview row, the price-edit sheet's matched
  prop, and copyCostFromMatch).
- findMatchedQuoteItem — the local exact/substring guesser that was
  the root cause — is deleted. No other callers exist.
- QuoteLineContext renders description (when different), buyer note,
  per-line and quote-level attachments, plus the 'possible match'
  banner + confirm button. Replaces a hand-rolled block that had
  drifted from the orders detail page.
- QuoteLineNoMatch renders when the order has a quote relation but
  this specific order line didn't pair to any quote line —
  previously this case rendered nothing, which was indistinguishable
  from 'the quote line had no note'.
- onConfirmPair POSTs to /api/v1/orders/{id}/quote-line (Task 10)
  and re-runs fetchAll so the next render sees the pair as
  'confident'. Only exposed for tier === 'possible'.
- The Pricing source grid stays inline — it's order-side data
  (SKU / pricing_source / matched_price_list_id / pricing_notes
  round-trip from quote_items onto order_items), not part of the
  quote context, and is editable on the order.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:31:24 +02:00
33fc549e10 feat(orders): add shared quote-line context component
Shared presentation of a paired quote line — description (when it
differs from the order), buyer note, line attachments, quote
attachments, plus a 'possible match' warning banner for the
operator's confirm button.

Used by both the order detail table (Task 13) and the process page
(Task 12) so the two never drift. The process page used to own the
only copy; the detail page showed nothing.

tier='possible' framing is the load-bearing part: a buyer is never
shown a note from the wrong line without being told it might be
wrong.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:20:39 +02:00
23b50a32a9 feat(orders): serve resolved quote-line pairing to the order UI
The order detail page and process page need the pairing, not the
matcher — until now the process page was doing an exact/substring
guess at render time and silently hiding buyer notes when the PO's
wording differed from the quote's.

- matched-attachments response gains linePairs (keyed by order_item_id,
  carrying quote_item_id, confidence, tier). pairOrderLines is called
  defensively — a pairing failure cannot kill the attachments
  response (catch returns null → empty linePairs object, the rest of
  the payload still serves).
- POST /api/v1/orders/[id]/quote-line writes the operator's confirm
  decision (or unlink with empty quote_item_id) onto
  order_items.quote_item. Guards against patching a line that
  belongs to a different order (404 otherwise).

Both routes are additive — the existing matched-attachments fields are
unchanged, the MCP orders.list_attachments consumer keeps working.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:20:16 +02:00
63e4a654bf refactor(orders): extract AttachmentList into a shared component
Pure move — bodies copied verbatim from process/page.tsx into
src/components/orders/attachment-list.tsx. The order detail page
(Task 13) will now import the same component instead of growing a
second copy.

The 4 icon imports (FileText, Image as ImageIcon, FileQuestion,
ExternalLink) became unused after the move and were removed from
process/page.tsx. Re-exported from the new file alongside the
MatchedAttachment type, attachmentHref helper, and inferKind helper.

No behaviour change. tsc clean. No new lint errors in the touched
files.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:16:31 +02:00
e374513fba feat(mcp): expose orders.quote_match_sweep
Hermes (n8n) drops the AGENT_API_KEY on direct REST calls, so anything
n8n needs to run must exist as an MCP tool. This forwards the agent's
JSON-RPC call to POST /api/v1/orders/quote-match/sweep, mirroring the
existing orders.set_quote forwarding pattern.

Updated the tools/list expected-list test to include the new tool so
the catalog stays self-documenting.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 08:46:28 +02:00
4e54fd6218 feat(orders): add quote-match backlog sweep with age-gap reporting
The matcher only runs on orders created from now on. This sweeps the
historical backlog plus any order whose lines changed after creation.

- sweepUnmatchedOrders in order-quote-link-service.ts walks
  quote_matching_attempted = false && quote = '' (the codebase's
  pattern for an empty relation, verified against orders-service.ts
  usage) in batches, projecting only id (the wider order fields are
  re-fetched inside suggestQuoteForOrder).
- bucketAgeGaps exposes the histogram that lets FUZZY_WINDOW_DAYS be
  re-tuned from real data instead of the 90-day estimate.
- POST /api/v1/orders/quote-match/sweep is the agent-auth surface
  with maxDuration=300 for the 500-order worst case.

Tests cover the filter shape, the field projection (load-bearing —
over-projection wastes wire bytes), the no-op on no-match path, the
fault tolerance (one row throwing doesn't kill the batch), and all
six histogram bucket boundaries including the ≤ N vs < N edges.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 08:45:23 +02:00
093bbafe76 fix(orders): cover the after() fallback path and log when it engages
The mock for next/server's after() detaches the callback regardless of
what production code does, which left the outer catch's fire-and-forget
fallback untested and the runQuoteMatch inner catch tested for nothing.

- New test 'falls back to an inline match when after() is unavailable'
  forces after() to throw and asserts suggestQuoteForOrder was called
  anyway, plus that the fallback log fired. Mutation: deleting the
  'void runQuoteMatch()' line breaks it.
- 'never fails order creation when the matcher throws' now spies on
  console.error and asserts the inner catch's swallow actually logged.
  Mutation: deleting the inner console.error breaks it.
- The outer catch now logs [createOrder] after() unavailable... so a
  silent switch to fire-and-forget (after upgrade, waitUntil missing on
  deploy target) is impossible to miss in production logs.
- Comment names error code E468, Next 16.2.6, and
  node_modules/next/dist/server/after/after.js, and corrects the
  justification: createOrder is an exported service function reachable
  from multiple layers, not 'a smoke script or another background job'.

Verified against the documented baseline — same single pre-existing
failure (splitOrderForContinuation > moves pending items...), no new
failures introduced.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 08:40:17 +02:00
21bc57797b feat(orders): auto-detect the source quote at order intake
createOrder previously hard-coded quote_matching_attempted: false and
never scheduled a match, so ~85% of orders that correspond to an
existing quote arrived unmatched and stayed that way until an
operator opened the order and clicked a button. Fire
suggestQuoteForOrder(order.id) via next/server's after() once the
response has been sent, so intake latency is unaffected, and guard
both the matcher call and the after() call itself: after() throws
synchronously outside a request scope (confirmed by reading
node_modules/next/dist/server/after/after.js), so a caller without
one (e.g. a background script invoking createOrder directly) falls
back to firing the match inline instead of crashing order creation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:22:21 +02:00
1453f14419 fix(orders): guard the pairing call site properly, correct comments
The previous commit's two "happy path" pairing tests never reset
pairOrderLines' mock call history — vitest.config.ts doesn't set
clearMocks, and the file's global beforeEach only clears the PB
store. A prior passing setOrderQuote test left toHaveBeenCalledWith
already satisfied on entry, so those two tests passed even with the
production call site deleted. Add mockClear() after the dynamic
import in both so they actually exercise the call they claim to.

Also: reworded the idempotent-path comment to note the strict-pending
gate runs first, so pairing only re-runs there while every line is
still pending (not a general refresh mechanism); fixed the
"don't write, don't stamp" line, which the pairing call now
contradicts (order_items.quote_item is a write); and replaced a
comment pointer to a nonexistent plan section with a standalone
statement of the deferred-signal gap.
2026-08-29 14:17:26 +02:00
a0e7e152f7 feat(orders): pair lines when a quote is linked to an order
setOrderQuote is the single choke point for creating orders.quote —
used by the UI, REST API, and Hermes/n8n. Call pairOrderLines(orderId,
quoteId) right after the hard link lands so confident order-line to
quote-line pairs persist automatically instead of needing a separate
manual step.

Pairing also runs on the idempotent no-op path (order already points
at the requested quote) — it's safe to repeat and lets an operator
refresh pairing by re-running the same call after editing order lines.

A pairing failure never fails the link itself (console.error only,
by design) — the hard link is the operator's important intent and
must land even if pairing can't complete.
2026-08-29 14:06:10 +02:00
0a5f352784 fix(orders): close cross-line quote_item collision, pin pairing's field projections
pairOrderLines' no-overwrite guard only checked whether an order
line's own row already carried a quote_item, not whether the quote
line it was about to claim was already held by a different order
line. A description drift (or a hand-confirmed link) on one line let
a fresh confident match on another line silently double-assign the
same quote_item, so two parts would render the same estimator's note
and attachments — the exact failure this feature exists to prevent,
reached from the other direction. Fixed by seeding a claimed-quote-item
set from the fetched order rows before the write loop, and skipping
any confident pair that targets an already-claimed quote line.
unmatchedOrderItemIds now also excludes any line that already carries
a link, even when this run's pairing pass didn't re-propose it.

Also names the two field projections (PAIR_ORDER_ITEM_FIELDS,
PAIR_QUOTE_ITEM_FIELDS) next to the existing MATCHER_QUOTE_ITEM_FIELDS
constant, and adds a test that asserts on the actual `fields` string
passed to each getFullList call. Previously nothing distinguished
`fields: "id,description"` from `fields: "id,description,quote_item"`
at the order-items query — a one-token trim silently disabled the
no-overwrite guard (every row read as unlinked) with the full suite
staying green, since the existing behavioral tests supply `quote_item`
values directly in their fixtures rather than through the query. Verified
by re-running that exact mutation against the new test: it fails, and
neither pre-existing behavioral test catches it on its own.
2026-08-29 14:01:13 +02:00
b4ad67680a feat(orders): persist confident order-line to quote-line pairs
Adds pairOrderLines to order-quote-link-service.ts: pairs an order's
lines against a quote's lines via pairQuoteLines, writes quote_item
only for confident (>=0.5 Jaccard) pairs, and never overwrites a
line's existing quote_item. Possible-tier pairs are returned but left
unwritten so an operator has to confirm them before a buyer sees a
note attached on a guess.

Reuses fetchQuoteItemsForQuotesAdmin (already used by the fuzzy pass
in this file) to fetch quote_items rather than reintroducing a raw
QUOTE_ITEMS_COLLECTION query. Writes stay sequential, matching the
existing bulk-update convention in orders-service.ts.
2026-08-29 13:49:39 +02:00
8e990e8a57 fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:

1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
   constant owned by the duplicate-quote scan. The matcher now passes
   its own `fields` (an added, defaulted parameter) instead of
   depending on that string never being trimmed out from under it —
   losing `description`/`selling_price` would have collapsed every
   confidence score toward zero with no exception and no failing
   test.

2. The candidate-quote query in fuzzyPass had no `fields`, so it
   pulled full quote records (email_body, ai_insights,
   migration_meta) for up to 200 candidates, twice per order when the
   window widens, to read five scalars. Added
   `fields: "id,quote_number,company,customer,created"`.

3. findExactMatch's bare `catch {}` swallowed everything, not just
   the "field doesn't exist on an older PB schema" case it claimed to
   guard (all four fields exist per pb_schema-2.json). An expired
   admin token or a PB blip during the sweep would silently fall
   through to the fuzzy path, and writeSuggestion would then persist
   quote_matching_attempted: true / quote_match_method: "no_match" -
   permanently hiding an order that had a valid citation from the
   sweep's retry filter. Narrowed to rethrow anything that isn't a
   ClientResponseError with status 400.

4. Exact-identifier lookups (quote_reference_text, rfq_number are
   customer free text) weren't scoped to the order's company, so a
   reference-number collision across two customers could return a
   confidence-1.0 suggestion pointing at the wrong company's quote.
   Added `company = "..."` to the filter when the order has one.

5. SuggestQuoteResult.window used the literal "90d", hardcoding
   FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
   can't make the sweep's reporting lie.

6. order-quote-link-service.test.ts's makePb forced every test to
   redeclare the full collection map. Gave it a default map merged
   with a per-test override and renamed useP to mockAdminPb, since
   two more tasks add tests to this file.

Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 13:44:56 +02:00
cefa52c3e3 fix(orders): chunk the fuzzy matcher's quote_items fetch, don't blow the filter length
fuzzyPass built a single `quote = "x" || quote = "y" || ...` OR-chain
across up to FUZZY_CANDIDATE_CAP (200) candidate quotes to fetch their
items. The predecessor in pb-helpers.ts documents exactly why that
doesn't scale: PB's filter expression has a max length, and a naive
OR-chain 400s past roughly 50 ids. There was no try/catch, so
getFullList's rejection propagated straight out of
suggestQuoteForOrder - the matcher failed outright for precisely the
high-volume companies where auto-matching matters most.

Reuse fetchQuoteItemsForQuotesAdmin (now exported from
quotes-service.ts) instead of a third copy of the same chunking logic.
It already does this for the duplicate-quote scan: try PB's `IN (...)`
syntax first, fall back to 80-id chunked OR-chains on rejection.

Adds a regression test that simulates PB rejecting an oversized filter
across 100 candidate quotes and asserts the fetch recovers via
multiple chunked calls rather than rejecting outright. Confirmed it
fails against the pre-fix code (uncaught rejection propagates out of
suggestQuoteForOrder) before verifying it passes against the fix.
2026-08-29 13:32:23 +02:00
699512e216 feat(orders): add server-side quote suggestion service
Ports the PocketBase half of the order<->quote matcher off the
browser's PB singleton and onto getAdminPocketBase(), so matching can
run at order intake and from a backlog sweep instead of only on a
button click. Reuses the pure scoring in order-quote-match.ts
untouched (scoreOrderVsQuote, quoteWindowForOrder,
quoteToOrderAgeDays).

suggestQuoteForOrder tries the four exact-identifier fields first
(never date-limited), then falls back to a company/customer/
description/price fuzzy scan windowed to 90 days before the order
date, widening to 365 days only if that comes up empty.

Writes only the soft suggestion (potential_quote,
potential_quote_confidence, quote_matching_attempted,
quote_match_method) - never orders.quote, the hard link. Orders that
already carry a hard link are skipped untouched.

pb-helpers.ts's findMatchingQuotesForOrder is left in place; a later
task migrates its last UI caller and removes it.
2026-08-29 13:22:52 +02:00
aba3bdca5d fix(orders): parse PocketBase date format explicitly in the matcher
Two findings from review of the quote-line-pairing task:

- quoteToOrderAgeDays: Math.round ties toward +Infinity, so a negative
  half-day gap rounds to -0. Normalize to a clean 0.
- quoteWindowForOrder / quoteToOrderAgeDays relied on `new Date(...)`
  parsing PocketBase's space-separated datetime form
  ("2026-08-29 10:00:00.000Z") the same as ISO 8601. That happens to
  work on V8 but is implementation-defined per spec. Both now go
  through a shared parseFlexibleDate helper that normalizes the
  separator explicitly.

Adds tests pinning both behaviours; the existing 40 tests stay green.
2026-08-29 13:20:40 +02:00
a4bc52b2ca feat(orders): add order-anchored fuzzy search window
Adds quoteWindowForOrder (pure) to constrain the fuzzy-match candidate
scan to a date window anchored on the order's own created date, plus
the forward grace period for quotes raised a few days after the PO.
Also adds quoteToOrderAgeDays, used by a later task's age-gap
histogram, with test coverage the plan omitted (normal, inverted, and
unparseable-input cases).
2026-08-29 11:28:10 +02:00
99050dcac5 fix(orders): wire LINE_PAIR_POSSIBLE into the pairing floor, add boundary test
Code review found LINE_PAIR_POSSIBLE was decorative -- pairLinesByDescription
still filtered on a hardcoded 0.3 literal, so editing the constant wouldn't
change behaviour. Also adds a test pinned to an exact 0.5 Jaccard pair to
catch off-by-one mutations on the confident/possible tier boundary.
2026-08-29 11:23:38 +02:00
f2a1cbf586 feat(orders): expose tiered quote line pairing 2026-08-29 11:13:59 +02:00
fd5c3c8510 fix(quotes): stop the detail page's tab strips colliding
Moving the admin panels below the items table left them at a third of
the row each (~186px of tab strip), which is too narrow for the labels
they carry. "Outbound" plus its count needs ~71px against the ~58px it
was given, so the three Attachments labels overlapped each other; the
same squeeze hit "Email Status" on the Activity card.

Lays the panels out two-up instead: Attachments spans both columns on
its own row (its three tabs, dropzone and file list want the space),
with Linked Orders and Activity sharing the row beneath at half width.
Both strips now have several times the headroom they need, measured at
a 1044px viewport, and mobile is unchanged (single column, no
page-level horizontal scroll).

Also drops the `ml-1.5` from the attachment count spans — TabsTrigger
already applies `gap-1.5` between its children, so the margin was
double-spacing and contributed to the overflow — and adds
`tabular-nums` so the tab widths stay steady as counts tick past 9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:25:45 +02:00
53f9aaaee4 refactor(quotes): give the detail page's items table the full width
The detail page was a 3-column grid with the Items card in a
lg:col-span-2 and the admin panels beside it. That left the items
table ~430px for a table whose natural width is ~790px, so pricing a
quote meant scrolling the table sideways.

Stacks the page instead: the items table (the actual work surface)
takes the full width, and Attachments / Linked Orders / Activity sit
3-up underneath, where they read fine at a third of the width because
they're reference panels rather than things you act on while pricing.

Two width trims make the table fit outright rather than merely fit
better:
  - Description's min-width 360px → 280px. It's a floor, not a target;
    the table is table-layout:auto so Description still absorbs any
    width the fixed columns leave. 360 was forcing overflow on its own.
  - The "Processed" header is whitespace-nowrap, so the word set a
    ~78px floor on a column whose content is a 2-3 character badge.
    Renamed to "Done" (title="Processed") at w-[64px].

Measured at a 1044px viewport: the table went from 110px of horizontal
overflow to 0. Mobile is unaffected — it uses the stacked card list,
and the admin grid collapses to one column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:17:07 +02:00
42179229aa fix(quotes): repair misaligned items table on the quote detail page
The table had 9 header cells against 8 body cells: a stray duplicate
"Description" header sat above the one carrying min-w-[360px]. Every
column therefore rendered one position off its label (the "Qty" header
sat over the supplier cell, "Supplier" over cost, and so on), and the
360px reserved for Description landed on Qty instead.

The visible symptom was a long description crushed into ~99px and
wrapped one word per line down a tall thin column, while the Qty cell
sat in 360px of empty space.

Removing the stray header restores the header/cell alignment and gives
Description its intended 360px. Also switches the row action from a
"Update price" text button to an icon-only button with the same title
and an aria-label, which trims the table's natural width from 911px to
789px — the card it lives in is only ~430px wide, so every pixel is
horizontal scrolling the operator has to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:00:35 +02:00
7c3083c017 feat(quotes): add quote-level "Quoted before?" panel to the process page
When a mine puts a spot out to tender, several engineering companies
each ask us to price the same item list. Before pricing a new one the
operator wants to see what the earlier quote went out at.

The per-line answer already existed: populateItemIntel powers the ⓘ
popover on each row and returns recent_quote (prior quote number,
company, selling price, days ago). This adds no new matching logic —
it runs that same lookup across every line of a quote and rolls the
results up, so the operator gets one panel instead of clicking ⓘ on
each row.

What the roll-up adds over the per-line popover:
  - "N of M lines have been quoted before" at a glance
  - relatedQuotes ranked by HOW MANY of this quote's lines they also
    carried; a prior quote covering 5 of 5 is very likely the same
    tender from a different bidder, one covering 1 of 5 is a shared
    commodity line

Two honesty details, both driven by what the first live run showed:
  - match confidence is surfaced as close/likely/loose. The underlying
    matcher accepts anything above 0.5, and a live check returned a
    R150 hit against a magnetic base drill — a loose row must not look
    as trustworthy as a close one.
  - an unpriced prior quote renders "unpriced", never R0.00, which
    would read as "we quoted it at zero".

Read-only: passes write:false so viewing the panel doesn't persist
intel onto rows the operator only looked at. On-demand rather than
auto-loading, since it costs one intel computation per line
(~400-650ms each, batched 5 at a time, capped at 40 lines).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:52:19 +02:00
93cb898f1e feat(quotes): add repeat-request detection service
Adds findDuplicateQuotes(), which groups quotes representing the same
underlying request. The business case is mining tendering: several
engineering companies each ask us to price the SAME item list because
they're all bidding for one spot on a mine, and the office needs to
find what the earlier quote went out at.

That inverts the usual dedupe rules, so both are deliberate:
  - matching is CROSS-COMPANY (two customers sending the same list is
    the signal, not a false positive)
  - the email-body pass is CROSS-SENDER (bidders forward the same
    tender document from different addresses)

Three passes: item_content (normalised line fingerprint, order
independent), email_body, and email_message_id.

Noise filtering is tuned against live data. The n8n intake writes an
"items from email request <name>" placeholder when it can't extract
line items; without excluding those, every unextracted email matches
every other one. A minimum line length drops junk like a bare "quote"
while keeping real one-liners the office genuinely re-quotes.

The completed-quote window measures `created`, not `updated`: a bulk
backfill rewrote `updated` on all 8,964 rows to a single timestamp, so
an `updated >=` cutoff matches the whole collection.

Not wired to the UI. A live run showed the quotes collection has no
trustworthy per-quote date (94% of `created` and 95% of `updated` are
migration stamps; quote_date and email_received_at are ~empty), so the
recency window can't currently bound the scan, and full-history content
matching returns groups of up to 53 for commonly-quoted commodities.
Landing the service on its own so the matching logic is available for
the on-demand "have we quoted this before?" lookup.

Also fixes a latent bug in the test PB mock's filter parser, which
stripped outer parens even when the leading "(" didn't match the
trailing ")", corrupting `(a) || (b)` into `a) || (b`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:42:37 +02:00
113735d0ba fix(quotes): speed up archive search and add item preview to archive rows
The archive's search fetched the entire recency-windowed matching set
(thousands of rows once "Include older" is on) and substring-filtered
it in memory, taking several seconds per keystroke; it now delegates
to the same ranked pushdown search service the main /quotes list uses.
Also fixes pagination being hidden while searching (results past page
1 were unreachable) and the match count showing the current page's
size instead of the true total.

Adds the chevron item-preview toggle to archive rows (extracted into
a shared QuoteItemsExpanded component, backed by a new GET
/api/v1/quotes/[id]/items route), removes a duplicate "Customer or
Company" label in the new-quote form, and drops the stray
warning-colored search box styling that leaked from DataTable onto
every list page that uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 02:30:14 +02:00
0efbae8dc5 feat(quotes): enhance search functionality and user experience
- Updated the search input handling to improve responsiveness by integrating a debounced search mechanism, reducing latency from 550 ms to approximately 300 ms.
- Added keyboard shortcut support for focusing the search box, enhancing accessibility and usability for frequent users.
- Improved the search placeholder to reflect all searchable fields, providing clearer guidance to operators.
- Introduced a clear-search button to streamline user interactions, allowing quick resets of the search input.
- Enhanced the display of search results with a live result count badge, providing immediate feedback on search outcomes.

These changes collectively enhance the search experience for operators, making it more intuitive and efficient.
2026-08-28 20:13:44 +02:00
5eed862007 feat(api): enhance order creation with optional email metadata fields
- Added `email_sender`, `email_subject`, and `email_body` fields to the order creation input schema for improved traceability of inbound emails.
- Updated logic to include these fields in the order creation process, allowing operators to track the source of orders without needing follow-up updates.
- Enhanced documentation to reflect the new optional fields and their purpose in the order management workflow.

This update improves the audit trail for orders and enhances user experience by linking orders directly to their source emails.
2026-08-28 13:00:31 +02:00
dc0139a62c feat(api): add support for optional custom order_number in order creation
- Introduced `order_number` field in the order creation input schema, allowing users to provide a custom identifier (e.g., PO/reference).
- Implemented logic to handle empty or whitespace values, defaulting to auto-generated order numbers.
- Added error handling for order_number collisions, returning structured error responses for better client-side handling.
- Updated documentation to reflect the new order_number functionality and its implications.

This enhancement improves order management flexibility and user experience.
2026-08-28 12:30:38 +02:00
11415d4784 refactor(boss-dashboard): drive tab change from click handler, stabilize fetchData via window ref
Fixes react-hooks/set-state-in-effect lint error in the tab-change useEffect.

- Tab buttons now call selectWindow(w) directly instead of relying on a
  useEffect that synchronously calls setState. selectWindow flips the
  state, closes the sheet, and triggers the refetch from one event handler.
- fetchData now reads activeWindow via a ref synced by a tiny useEffect
  (the canonical React pattern — eslint flagged the inline ref-write as
  'Cannot update ref during render'). With no useCallback deps, fetchData
  identity is stable, so the realtime subscription no longer churns on
  every tab switch (a perf concern flagged in Task 15).
- The mount-only initial fetch useEffect is preserved with a justified
  eslint-disable on the set-state-in-effect rule (no SSR data pass-through
  on this client-only page, so the initial fetch has no user event to
  hang off).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 00:33:14 +02:00
a74cb17940 feat(boss-dashboard): add PB realtime subscription with 1s debounce 2026-08-28 00:27:19 +02:00
ab0aa8ec8d fix(boss-dashboard): close sheet on backdrop click + tab switch 2026-08-28 00:26:24 +02:00
9101871fef feat(boss-dashboard): add rep-activity bottom sheet 2026-08-28 00:24:46 +02:00
44f93feb13 feat(boss-dashboard): add drivers section 2026-08-28 00:23:32 +02:00
eb3f801167 feat(boss-dashboard): add reps list section 2026-08-28 00:22:30 +02:00
ca6d43e0e8 feat(boss-dashboard): add order totals strip (Total / Delivered / Invoiced) 2026-08-28 00:21:36 +02:00
b07327eae5 feat(boss-dashboard): add time tab bar (Today / Week / Month) 2026-08-28 00:20:18 +02:00
16efafc111 fix(boss-dashboard): guard fetch races, surface errors, tighten response type 2026-08-28 00:19:12 +02:00
f09f9163c0 feat(boss-dashboard): add /m page + dashboard-view skeleton 2026-08-28 00:16:33 +02:00
fcbc085723 feat(boss-dashboard): add /(mobile)/m layout with viewport + manifest link 2026-08-28 00:15:36 +02:00
15206eaf49 test(boss-dashboard): add 500/null-fallback/edge-case coverage + tighten mock hygiene 2026-08-28 00:14:06 +02:00
e9ce5f4fc4 test(boss-dashboard): add route handler tests for dashboard and rep-activity 2026-08-28 00:10:00 +02:00
f243132dc0 refactor(boss-dashboard): use shared assertBrowserSession + cap page 2026-08-28 00:07:57 +02:00
70807e34fe feat(boss-dashboard): add /api/v1/boss-dashboard/rep-activity route handler 2026-08-28 00:05:20 +02:00
8ecacf78fe feat(boss-dashboard): add /api/v1/boss-dashboard route handler 2026-08-28 00:02:51 +02:00
5a944bc1f9 test(boss-dashboard): honour expand in mock + assert customer name in rep activity 2026-08-28 00:02:02 +02:00
da1ccc48fd feat(boss-dashboard): paginated rep activity feed for drill-down sheet 2026-08-27 23:58:19 +02:00
9aaeee25ec fix(boss-dashboard): route unresolved orders.rep to (unknown) + parallelise 6th fetch 2026-08-27 23:55:59 +02:00
ebbcc73020 fix(boss-dashboard): exclude cancelled orders via order_items.item_status
The cancelled check was reading orders.item_status, which doesn't exist on
the orders collection in PB. Status lives on order_items, so the check
never fired and cancelled orders were counted in orderTotals.total and
each rep's ordersPlaced.

Mirror the delivered fix: derive a cancelled-order-id set from
order_items.item_status in the active window. An order is excluded from
total if ANY of its lines carries item_status="cancelled".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 23:52:59 +02:00
9ec621e9f8 fix(boss-dashboard): pin drivers to today window + count delivered orders via order_items 2026-08-27 23:50:54 +02:00
721b310f4d feat(boss-dashboard): aggregate per-rep stats and driver stops per window
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 23:46:25 +02:00
52b4b1f053 feat(boss-dashboard): add windowRange helper with UTC boundaries 2026-08-27 23:42:16 +02:00
Dave
cfd3e3e6be feat(quotes): add person_hint for customer resolution in forwarded emails
Introduces the `person_hint` field to the `EmailIntakePayload`, allowing the system to resolve the actual customer when a rep forwards an email. The `findOrCreateCustomer` function now prioritizes `person_hint` over the sender's email, enabling a more accurate attribution of quotes. This change includes a case-insensitive substring match with a token-drop fallback for multi-token names, ensuring better matching against existing customer records.

Additionally, new unit tests have been added to validate this functionality, covering various scenarios including token-drop behavior and ensuring the correct customer is linked when a rep forwards an RFQ.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 11:11:44 +02:00
Dave
dc84ce0f30 feat(quotes): resolve customer from person_hint when rep forwards email
Gary (and any future rep) forwards inbound RFQs to a shared mailbox.
Today the customer linked to the resulting quote is the rep (or the
Unknown Sender sentinel), because the only resolution path is exact
email match — the from_email resolves to Gary, not the actual customer.

Add person_hint to EmailIntakePayload. The AI extraction layer reads
the subject / body / signature for the customer being quoted FOR and
fills person_hint. findOrCreateCustomer now walks three resolution
passes in order:

  1. Exact email match (existing behaviour — direct RFQs unchanged)
  2. person_hint resolution: case-insensitive substring on
     customer.customer, with a companyHint tie-break when multiple
     candidates exist
  3. Existing sender-based create-new path (Gary wins, quoter
     reassigns in the WFDASH UI)

MCP schema for quotes.create_from_email surfaces the new field with
doc explaining the rep-forwarded case. /api/v1/quotes/from-email
doc updated. AI Agent system prompt in the n8n workflow doc now
extracts person_hint with worked examples.

Also extends the mock filter parser to accept PB's bare ~ operator
(production code uses ~; the mock previously only accepted =~).

5 new unit tests + 2 integration tests for the new path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 10:45:51 +02:00
Dave
4d8382c87f fix(quotes): wire no-quote toggle into process-page UI gates
The server-side PDF route was already no-quote-aware — when
`quote.is_no_quote === true` it bypasses the pricing gate
(`pdf/route.ts:156, 238, 240`) and the PDF component renders "Not
quoted" cells + "—" totals (`quote-document.tsx:239, 242, 276/280/285`).
But the client side was ignoring the flag, so flipping the toggle
looked like a no-op. Five one-file fixes to close the gap:

1. `itemsReady` now short-circuits to true when no-quote is on.
   Before: `itemsReady = items.length > 0 && missingSupplier === 0 &&
   missingPricing === 0;` (always evaluated pricing). After: prepend
   `isNoQuote ||` so the Generate Quote button enables regardless of
   cost/selling prices. (Bug A)

2. `applyPricing` markup>0 guard now bypassed when no-quote is on.
   Before: toast "Markup must be greater than 0" + refuse save when
   markup ≤ 0 — blocked the operator from saving a 0/0/0 row. After:
   `if (!isNoQuote && ...)` so the operator can save the row. (Bug B)

3. `handlePricingChange` auto-derive now skipped when no-quote is on.
   The cost↔selling recompute on every keystroke (`value * (1 + markup
   / 100)` and inverse) was firing even when no-quote was set, which
   is what produced the "I set 0/0/1% markup and it shows 1 cent"
   artefact. Three branches (markup/cost/selling) now short-circuit
   to pass-through under no-quote. (Bug C)

4. Helper text under the Generate Quote PDF button now explains
   "No-quote letter — prices ignored on PDF (renders 'Not quoted'
   cells)" when the toggle is on, instead of the misleading
   "Price every item + assign a supplier before generating".

5. Second helper line under the toggle's label explaining the full
   UI flow when on: "the pricing form stays editable but isn't
   required, and the PDF renders 'Not quoted' cells with no totals."

Operator workflow now: flip toggle ON → enter description on a row
at 0/0/0 → save → click Generate Quote → PDF renders decline letter
with "Not quoted" cells and no totals. No PB migration needed
(`is_no_quote` boolean column already present per `pb-exports/pb_schema-2.json:2178`).

Build: clean. Typecheck: 0 errors. No tests added (manual flow; no
existing test exercises this toggle path).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 08:00:17 +02:00
Dave
7986784eaf chore(tests): fix ~60 pre-existing TS errors across 11 test files
Typecheck cleanup after the deploy blocker push — `tsc --noEmit` was
silent on `d11a184` baseline but ~60 errors hid across test files
that only the compiler surfaces (the build was clean because test
files are excluded from the prod bundle). Touched:

- src/app/api/mcp/route.test.ts (16) — generic on `vi.fn()` for
  tuple inference; `as unknown as` two-step cast on record literals
- src/app/api/mcp/po-agent-workflow.test.ts (1) — spread instead of
  duplicate key
- src/components/buyer-tasks/buyer-task-group-card.test.tsx (5) —
  `PurchaseOrderEmailPayload` now requires `subject`/`body`
- src/components/collection-confirm/confirm-stock-dialog.test.tsx (1)
  — removed shadowed `id` from `makeRow` overrides
- src/components/item-intel-popover.test.tsx (2) — missing row
  fields + boolean→object for `verified`
- src/components/quotes/process-item-card.test.tsx (2) — full
  QuoteItem stub; generic on `vi.fn()`
- src/components/sprint-item-pricing-modal.test.tsx (3) — full
  SprintItem/QuoteItemAttachment/QuoteAttachment stubs
- src/lib/server/orders-service-review.test.ts (~60) — annotated
  `getOne` return type to break union widening on the expand option
- src/lib/server/orders-service.test.ts (2) — removed bogus
  `includeArchivedCounts` param; `rfq_number` now whitelisted on Order
- src/lib/server/quotes-service.test.ts (3) — two-step cast on
  Quote header; `unit?` now on QuoteItem
- src/lib/server/stock-purchase-items-service.test.ts (~10) — typed
  `pbMock` via `StockPurchaseItemsPb` so `*WithPb(pb,...)` checks

Also added two optional fields to `src/lib/types.ts`:
- `QuoteItem.unit?` — pre-2026 rows have no unit, so it's optional
- `Order.rfq_number?` — populated by the n8n email agent, inherited
  verbatim by `splitOrder`

Build: clean. Typecheck: 0 errors. Tests: 14 pre-existing runtime
failures + 1 pre-existing unhandled error in `supplier-form.test.tsx`
remain — confirmed they exist on `d11a184` baseline, so they are
outside the cleanup scope and unchanged by this commit.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 04:43:40 +02:00
Dave
d11a1848e8 feat(quotes): finish createQuote refactor for operator-supplied quote_number
The /api/v1/quotes/create route was wired to accept operator-supplied
quote_number + rfq_number + quote_notes + notes + status, but the
service-layer side never matched: QuoteNumberExistsError didn't exist
and CreateQuoteInput ignored the new fields. Production deploy was
failing on `Module has no exported member 'QuoteNumberExistsError'`.

Service changes:
- New QuoteNumberExistsError class (mirrors OrderNumberCollisionError):
  carries existingId + attemptedNumber, mapped to 409 by the route
- CreateQuoteInput extended with quote_number, rfq_number, quote_notes,
  notes, status; imported QuoteStatus from @/lib/types
- createQuote now uses input.quote_number verbatim when supplied, else
  falls back to the existing atomic-counter generator
- createQuote's pb.create call is wrapped in try/catch: PB's UNIQUE
  constraint violation on quote_number is re-thrown as the typed error
  with the colliding row's id resolved via a follow-up getList

New route test file (9 cases) covers happy path with supplied
quote_number, the 409 collision branch, notes/quote_notes fallback
merge, auth-gate failures, missing customer/items validation, and
generic 500 fallback. Uses importOriginal so the real
QuoteNumberExistsError class survives the service-layer mock.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 04:19:51 +02:00
Dave
1717d62980 fix(approvals): 404 test coverage, exclude PDFs from outbound, drop dead pushMock
Three final-review fixes for the Approve & send from list feature:

1. The 404 branch added in 5760ef9 had no test guarding it. Add
   a test mirroring the invalid_status pattern: mock a 404 from
   review-approve, assert router.refresh() was called and
   toast.info fired with the 'no longer exists' message.

2. The list page's ensureOutboundAttachments fetched all attachments
   where direction='outbound' — including PDFs — and passed them
   straight to SendQuoteModal. The modal renders its own pdfAttachment
   above the outbound list, so an operator who re-uploaded a PDF as
   a proof would see it twice (once as always-attached, once as a
   selectable outbound). Mirror the detail page's filter
   (line 1217 of approvals/[id]/page.tsx) by adding 'kind != pdf' to
   the list page's outbound-attachments query.

3. The list page never calls router.push (it uses router.refresh
   for all branches), so the pushMock in the test scaffold was
   dead. Drop it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 01:04:02 +02:00
Dave
5760ef9966 fix(approvals): address Task 2 code quality findings + test mock update
Four code-quality issues from review + one test-mock follow-up:

1. CRITICAL: isPlaceholderCustomer was called with a string arg but
   expects { id, customer, email } - TS error, and at runtime the
   function would always return true. Variable was dead (computed but
   never read) so remove the call, the explanatory comment, and the
   import entirely. The Option A behaviour (server handles placeholder
   via its 422) is preserved by simply not having the dead variable.

2. CRITICAL: toast(..., { type: 'info' }) doesn't compile under
   ExternalToast and silently drops the type option at runtime, so
   the invalid_status race toast renders with default styling instead
   of info (blue). Switch to toast.info(...). Updates the test
   assertion to check toast.info directly (the test mock defines
   toast.info as an independent vi.fn(), not a wrapper around toast,
   so the prior expect(toast).toHaveBeenCalledWith assertion would
   no longer match).

3. IMPORTANT: Spec asks for a dedicated 404 branch ('This quote no
   longer exists. Refreshing.') but the implementation fell through
   to the generic catch-all. Add the 404 branch between the mail-fail
   branch and the generic fallback.

4. NICE-TO-HAVE: formatCurrency was imported but never used. Drop.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 01:00:43 +02:00
Dave
911c6f7496 chore(approvals): drop dead handleApprovePartial
Task 2 collapsed the list's per-row "Approve partial" button into
the new "Approve & send" button, which routes through review-approve
on the server. The list no longer calls the legacy partial-approve
endpoint, but handleApprovePartial was left in the page file because
the plan's Step 3 only covered handleApprove replacement. Remove it.

The /api/v1/quotes/[id]/partial-approve endpoint itself is unchanged
(it's still used by other parts of the app).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 00:55:00 +02:00
Dave
a499329a4e feat(approvals): Approve & send from list rows
The pending-approvals list now offers the same one-step
approve-and-dispatch affordance as the detail page, instead of
forcing the rep through a two-step approve-then-drill-in flow.

- Replace the list's standalone 'Approve' button (direct PB write)
  and 'Approve partial' button (POST /partial-approve) with a single
  'Approve & send' button that opens SendQuoteModal.
- Lift the modal and sendApprove callback to page level, keyed by
  row id, callable per row.
- Submit posts to POST /api/v1/quotes/[id]/review-approve with
  action: 'approve' and the modal's email payload. The server
  atomically approves + dispatches.
- Same handler works for partial_approval_pending rows — server
  flips partial_approved -> sending -> emailed natively.
- Toast wording matches the detail page: 'Approved - email sent...'
  for full, 'Partial approved - email sent...' for partial, with
  invalid_status and mail-failed branches surfacing their own toasts.
- Reject path untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 00:53:45 +02:00
Dave
c0eca10b51 fix(test): drop stale triggerSend comment in approvals page test
The fix commit (27f60e1) removed the triggerSend hoisted spy and
wired the modal stub's button to call props.onSend directly, but
left a two-line comment in test 3 that still referenced the deleted
symbol. Replace the comment with one that describes the actual
mechanism so future readers don't go hunting for a triggerSend
identifier that no longer exists.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 00:48:14 +02:00
Dave
27f60e14ec fix(test): wire SendQuoteModal stub to props.onSend
The SendQuoteModal test stub had a noop triggerSend placeholder that
never called props.onSend, so the click on the modal's "Submit modal"
button would silently fail. Tests 3-7 (which assert on fetch /
refresh / toast effects from sendApprove) would all hang even after
Task 2 implements the page correctly.

Fix: call props.onSend directly from the button's onClick. Drop the
unused triggerSend hoisted spy. Tighten the stub's props type to
SendQuoteModalProps so Task 2's prop-name typos surface as TS errors.

Also widen the PB mock fallback's getList to return the full envelope
(items + totalItems + totalPages) so Task 2 reading quote_attachments
or any other collection doesn't crash on undefined totalItems.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 00:46:34 +02:00
Dave
097ada09f8 test(approvals): scaffold Approve & send from list tests
Component tests cover the row-button -> SendQuoteModal ->
review-approve submit flow including success, invalid_status,
and mail-failed error branches. All 7 fail; implementation in
follow-up commits.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 00:39:16 +02:00
Dave
e0d214c94f test(quotes): cover spec test cases #3 and #5 for no-quote
Closes the two IMPORTANT test-coverage gaps from the final
implementation review. Pure additions; no production code changes.

  - Spec test #3: submitQuote({partial:true}) accepts zero-priced
    items when is_no_quote=true (status flips to
    partial_approval_pending)
  - Spec test #5: strengthen the no-supplier + no-quote assertion
    to check the missing array directly, not just the failure.code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 00:02:25 +02:00
Dave
87eacdc307 fix(quotes): persist is_no_quote through quotes.create path
Closes the gap that the MCP quotes.create tool advertised is_no_quote
but the upstream POST /api/v1/quotes/create chain silently dropped the
flag. Three additions:

  - CreateQuoteInput interface gains the field
  - createQuote() forwards it to pb.collection.create(...)
  - POST handler forwards body.is_no_quote to createQuote()

Spec test case 10 ("quotes.create with is_no_quote: true persists
flag and returns quote") now passes end-to-end.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 23:58:43 +02:00
Dave
e51a405414 feat(quotes): enhance quote creation with new fields and error handling
Added support for additional fields in the quote creation process, including quote_number, rfq_number, quote_notes, and status. Updated the error handling to return a specific 409 response when a quote_number conflict occurs, improving the API's feedback for clients. Enhanced documentation to reflect these changes.
2026-08-26 23:55:30 +02:00
Dave
d4ff62c7b2 feat(mcp): expose is_no_quote on quotes.create and quotes.update
Lets Hermes and other external agents drive the no-quote flow without
a UI step. Hints document the gate-bypass and reversibility.

Five touch points in src/app/api/mcp/route.ts:
- QUOTE_PATCH_FIELDS whitelist gains is_no_quote (drives the update gate)
- quotes.create input shape declares the boolean
- quotes.update input shape declares the boolean
- quotes.update description text lists is_no_quote in the whitelist
- quotes.create handler forwards args.is_no_quote to the upstream body

The quotes.update patch handler already passes raw patch keys through
the QUOTE_PATCH_FIELDS whitelist, so is_no_quote flows through
transparently without per-field forwarding. quotes.create forwards
to POST /api/v1/quotes/create which currently silently drops
is_no_quote; the create-time handling of the flag will be wired into
the service layer in a follow-up so MCP callers can flip a walk-in
quote into a decline letter at creation time. The schema hint is
live today.

Smoke verification deferred to Task 11 (no-quote E2E). Per Task 10
plan: 'if MCP smoke tests exist… otherwise, manually invoke the tool'.
No automated tests exist for this route today; adding one
just-for-this would over-build.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 23:50:37 +02:00
Dave
6d9b6a8274 fix(quotes): address UI toggle code review
Addresses the 2 IMPORTANT and 4 NIT findings from the Task 9
code-quality review:

- Initial state bug: useState read 'quote' on first render when it
  was null, sealing isNoQuote as false. A previously-persisted
  is_no_quote: true quote would show the toggle as off. Replace
  with a useEffect that syncs once per loaded quote id.

- Dark mode: add dark: variants to the amber card border/background
  to match the project precedent (collection-pool/components.tsx:175).

- Label size: drop from text-sm to text-xs for consistency with
  every other <Label> in the file.

- Error surface: parse the PATCH 400 body and surface data.error
  rather than a generic toast, matching the lookupItem/generatePdf
  pattern in the same file.

- useCallback deps: stop recreating the callback on every toggle
  by stashing the current value in a ref.

- quote-notes-card id: add a comment so the wrapper div's purpose
  is clear.
2026-08-26 23:47:27 +02:00
Dave
0b6226cdf1 feat(quotes): add no-quote toggle to Quote Details card
Switch + Label inside the Quote Details card on the process page.
Optimistic update with rollback on error. When flipped on, scrolls
the quote_notes editor into view so the operator can type the
reason in the same flow.
2026-08-26 23:43:15 +02:00
Dave
6796dbea06 feat(pdf): add noQuote prop to QuoteDocument
Renders 'Not quoted' in item price cells, '—' in totals, and
changes the quote_notes callout header to 'Why we cannot quote'
when the flag is set. Visual identity stays the same (still a
QUOTATION document on letterhead).
2026-08-26 23:40:47 +02:00
Dave
a2e10652a4 fix(quotes): no-quote letters always flip to approval_pending
The downstream status-flip logic uses ready.length === allItems.length
to choose between approval_pending and partial_approval_pending. For
an is_no_quote quote with all-unpriced items, the unfiltered bypass
in Task 7 made ready.length === 0 while allItems.length > 0, flipping
to partial_approval_pending instead of approval_pending — contradicting
the spec ('new → approval_pending → sending → emailed' for no-quote
letters).

Add a guard so is_no_quote quotes always flip to approval_pending.
The partial-flip path remains unchanged for priced quotes.
2026-08-26 23:39:15 +02:00
Dave
cfcbbc5f10 feat(quotes): bypass PDF ready filter when is_no_quote
Lets the PDF render with all active items (regardless of price)
when the operator has marked the quote as a decline letter. The
existing PDF diagnostic still flags data issues for priced quotes.

Also adds the (unused) noQuote prop to QuoteDocumentProps so the
route compiles. Task 8 will wire the rendering.
2026-08-26 23:37:48 +02:00
Dave
1d6f7538d8 feat(quotes): skip pricing checks in diagnosePdfReadiness when is_no_quote
Keeps the 'no supplier' and 'not processed' checks active so the
function still flags legitimate data issues, but lets zero-priced
items through when the operator has marked the quote as a decline.
2026-08-26 23:31:41 +02:00
Dave
e74ef505e7 feat(quotes): skip submitQuote unpriced gate when is_no_quote
Lets a no-quote flag transition new → approval_pending even when
items have selling_price = 0. The PDF render path and email send
will relax the same way in subsequent commits.
2026-08-26 23:27:47 +02:00
Dave
bf91b601aa feat(quotes): expose is_no_quote on PATCH /api/v1/quotes/[id]
Adds the new field to the PATCHABLE_FIELDS whitelist, validates
type as boolean, and dispatches to a new setIsNoQuote service helper.
Tested via mocked route handler.
2026-08-26 23:24:21 +02:00
Dave
1ef24ffe2f docs(types): correct is_no_quote JSDoc to describe decline letter
Previous wording conflated is_no_quote with the unrelated delivery-note
flow. is_no_quote is the official decline-letter flag — items render
with 'Not quoted' cells and the quote_notes callout becomes 'Why we
cannot quote'. Optional because existing rows pre-date this column.
2026-08-26 23:21:57 +02:00
Dave
403ac152e0 feat(quotes): add is_no_quote?: boolean to Quote interface
Mirrors the is_template boolean. Wires the new field through the
type system before any service-layer code touches it.
2026-08-26 23:19:45 +02:00
Mika
e900772faa fix(delivery-notes): move 'Add item' to per-card button after each row
Step A — move the 'Add item' button from the top of the items
section to the bottom of the items list.

Step B — match the quote-form.tsx pattern: every item card is
followed by its own ghost + dashed-border 'Add another item'
button, so the operator never has to scroll back to the top
to add the next line. The dashed-border styling keeps it from
being mistaken for a submit/finish affordance.

The data-testid="dn-adhoc-add-item" selector is preserved on
the per-card buttons so any future test selectors continue to
work.

Fixes PINW-18
2026-08-26 01:08:52 +02:00
Dave
720531bc0d refactor(orders, quotes): improve table layout and column widths for better readability
- Updated the Orders and Quotes detail pages to enhance table layouts, ensuring that the Description column has adequate space for multi-line text.
- Removed the Driver column from the Orders table to streamline the layout and prevent content squeezing.
- Adjusted fixed widths for various columns to fit within the viewport without horizontal scrolling, improving overall user experience.
- Ensured consistent styling and layout adjustments across both pages for a unified look and feel.
2026-08-24 07:57:15 +02:00
Dave
a0afa51600 refactor(orders): replace OrdersReviewModal with OrdersReviewModalMount and streamline attachment handling
- Updated the orders page to use `OrdersReviewModalMount` for improved order review functionality.
- Removed the inline `orderAttachementsToQuotes` function to simplify the code structure and enhance maintainability.
- Introduced a review modal in the order detail page, allowing users to trigger the review process with a dedicated button.
- Ensured consistent behavior between the orders list and detail pages regarding order review interactions.
2026-08-24 07:26:18 +02:00
Dave
139a3f3bfe feat(quotes): enhance scoring logic to include price matching
- Updated the `scoreQuote` function to incorporate price scoring, allowing quotes to be evaluated based on selling and cost prices when the query is numeric.
- Introduced new weights for item selling and cost prices, ensuring they contribute to the overall score only when relevant.
- Enhanced the test suite to cover new price matching scenarios, ensuring accurate scoring behavior for quotes based on price inputs.
- Refactored related functions to support the new price scoring logic, maintaining consistency across the scoring process.
2026-08-24 07:09:39 +02:00
Dave
07a5e810e5 feat(attachments): enhance order attachment handling and retrieval
- Updated the `orders.add_attachment` and `orders.list_attachments` tools to utilize the order's own `order_attachements` field, allowing immediate visibility of attachments without requiring a matched quote.
- Enhanced the `POST` route for uploading attachments to support multiple files and improved response structure to include `collectionId` and `collectionName`.
- Merged attachment sources in the `GET` route for matched attachments, providing a unified view of order-level and quote-level files, with a new `source` field to indicate the origin of each attachment.
- Added utility functions for inferring MIME types and attachment kinds based on file extensions, ensuring consistent handling across the application.
- Improved documentation for clarity on the attachment handling process and updated response formats to reflect the new structure.
2026-08-21 20:47:00 +02:00
Dave
c3b452dbc4 feat(orders): enhance order handling with email_message_id deduplication and displayContactName utility
- Added support for deduplication of orders based on the `email_message_id`, preventing duplicate entries during email intake.
- Introduced the `displayContactName` utility to sanitize and format contact names, improving the display of order details by removing extraneous whitespace.
- Updated relevant order-related components to utilize the new utility for consistent contact name rendering.
- Enhanced tests to cover the new deduplication logic and the functionality of the `displayContactName` utility, ensuring robust behavior in various scenarios.
2026-08-21 19:18:02 +02:00
Dave
fb283765fb fix(repair-details): update file handling and type definitions
- Modified the file handling in the repair details page to use the correct repair ID and simplified the key assignment for photo files.
- Updated the API route to cast the status parameter to the new RepairStatus type for improved type safety.
- Expanded the RepairFileField type to include "delivery_note" for better file management.
- Removed unused helper functions to clean up the codebase and improve maintainability.
2026-08-21 01:13:44 +02:00
Dave
663c018a70 feat(quotes): add "Include older" toggle for searching full quote history
- Introduced a new toggle in the quotes and archive pages to allow users to include older quotes beyond the default 90-day cutoff.
- Updated the search parameters in the quotes and archive APIs to handle the new `includeOlder` flag, enabling full history searches.
- Enhanced the user interface to reflect the current search scope, providing clear feedback on whether the search is limited to recent quotes or includes the full archive.
- Adjusted the quotes search service to support the new functionality, ensuring efficient retrieval of older quotes when requested.
- Added relevant tests to verify the correct behavior of the new search feature and its integration with existing functionality.
2026-08-20 10:18:44 +02:00
Dave
88047eee33 feat(quotes): add "Completed (7 days)" tab for recent quotes
- Introduced a new tab for quotes completed within the last 7 days, enhancing the user experience by allowing quick access to recent activity.
- Updated the quotes search service to filter results based on the new "completed_recent" status, ensuring efficient retrieval of relevant quotes.
- Enhanced the quotes service to compute the cutoff date for the recent quotes dynamically, maintaining consistency across the application.
- Added tests to verify the functionality of the new filter, ensuring accurate behavior in the quotes search pipeline.
2026-08-19 13:36:41 +02:00
Dave
d194603dce feat(pdf): include VAT calculation in stock purchase document
- Added VAT calculation to the stock purchase document PDF, displaying VAT (15%) alongside the subtotal and total.
- Updated the layout to reflect the new totals structure, ensuring consistency with customer PO documents.
- Revised comments for clarity on the handling of total amounts and VAT reconciliation.
2026-08-19 11:45:10 +02:00
Dave
43bbdfc458 feat(tv-dashboard): enhance TV dashboard with driver stops and performance metrics
- Updated the TV dashboard to include a new section for driver stops, displaying active deliveries and pickups with GPS coordinates.
- Refactored the dashboard data structure to incorporate year-over-year performance metrics alongside existing monthly and weekly stats.
- Replaced the supplier map with a driver stops map for improved clarity on driver activities.
- Enhanced the user performance metrics to include detailed counts for quotes and orders processed by each representative.
- Removed the obsolete supplier map component to streamline the dashboard's functionality.
2026-08-19 10:16:49 +02:00
Dave
2f0e911563 feat(delivery-notes): standardize delivery-note letterhead across PDFs
- Introduced a centralized `deliveryNoteLetterheadCode` function to ensure all delivery-note PDFs consistently use the Westcott Group letterhead, regardless of the subsidiary.
- Updated relevant API routes and services to utilize the new function, removing previous hardcoded fallbacks.
- Enhanced the `DriverStop` interface to include timestamps for better reporting capabilities.
- Added new fields to track creation timestamps for driver stops, improving data accuracy for outstanding reporting.
2026-08-18 11:53:25 +02:00
Dave
bfcddb9d82 feat(quote-form): allow empty quantity input for operator convenience
- Updated the `DraftQuoteItem` interface to allow `quantity` to be an empty string, enabling operators to type counts directly without needing to delete a default value.
- Modified the `QuoteFormItemCard` to handle empty input gracefully, falling back to 1 on submission if the field is left blank.
- Adjusted the `addItem` function in `QuoteForm` to initialize `quantity` as an empty string, enhancing user experience during item addition.
2026-08-18 07:28:25 +02:00
Dave
8ed0952364 refactor(forms): update layout and structure of form components
- Adjusted the `SheetContent` width in multiple form components to improve responsiveness.
- Enhanced the `CompanyForm` layout by reorganizing fields for better clarity and usability, including the addition of coordinates input.
- Updated the `CustomerForm` and `SupplierForm` to maintain consistency in design and structure.
- Improved the `DataTable` component's card styling for better visual appeal and user interaction.
- Refined the `QuotesPage` card styling to enhance the user experience when selecting quotes.
2026-08-17 23:34:30 +02:00