Review follow-up to the non-invoiceable-extras work. Task 6 of the plan
called for the billable-subset guard on `sendToQuickBooks` as well as
in the modals; it landed in the modals only.
`rows.length === 0` counts excluded rows, which ride along for the n8n
status flip but never bill. A group where every row is excluded passes
that check and ships an empty `items` array, so QuickBooks would create
a zero-line invoice while n8n still flipped every row to `invoiced`.
Not reachable through the current UI — both live modals disable submit
on this case. This is defence-in-depth for the callers that bypass
them: the (currently unrendered) bulk modal and any legacy call passing
`overrides: undefined`, which falls back to every row in the group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`order_items.order_qty` is the SUPPLIER-facing quantity — what prints
on the PO. Five call sites read it as the customer quantity, which is a
money bug in both directions:
- supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6
- shorted line (customer 100, supplier covers 40) → invoiced 40,
under-billing 60 until the operator splits the remainder
The invoicing screen never fetched `quantity` at all (zero occurrences
in page.tsx), so it could not bill the customer quantity even in
principle. Added to the PB field projection, which is load-bearing:
drop it and PB returns undefined and every line silently falls back to
order_qty. A page-level test now fails if the projection drifts.
`maxSurplusFor` measured the surplus as `collected - order_qty`, which
on an MOQ line is always 0 — the overflow was mathematically unbookable
and vanished from inventory by construction, not by operator error.
Four call sites built delivery-note lines by hand; two passed
`quantity` and two forgot it, so the same DN printed the customer's 4
via the service layer and the supplier's 10 via the API routes. All
four now share `buildDeliveryNoteLine`, so they cannot diverge again.
`order_qty` had no doc comment at all next to an 11-line one on
`quantity` — that absence is why five subsystems guessed differently.
Now documented, with a new `src/lib/order-item-qty.ts` holding
`customerQty` / `supplierQty` as the single definition.
driver-stops.ts and collection-confirm/po-card.tsx read `order_qty`
CORRECTLY (they check what the supplier delivered against what we
ordered from them) and carry comments saying so, so the next cleanup
doesn't "fix" them into breaking.
23 new tests. Full suite: no regressions — the 15 failures are
identical before and after (verified by stashing and re-running).
Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 9 of the non-invoiceable-order-extras plan.
order_items.create MCP tool:
- Input schema gains an optional boolean 'exclude_from_invoice'.
- Tool description rewritten to explain the supplier-side component
use case, the PO + driver + collection flow, the silent drop from
the customer invoice and delivery note, and the force-zero selling
price.
- operationData hint / nextActions block updated — the prior text
documented a '409 order_locked' refusal that no longer fires
(Task 2 removed the pending gate).
- Handler forwards the boolean to createOrderItemForOrder (which
itself forces selling_price=0 when the flag is set — Task 2).
Also fixed a TypeScript strict-mode complaint in the T7 test that
the previous commit left behind (vi.fn mock-call tuple type
needed an explicit parameter so mock.calls[0] is typed).
Co-Authored-By: Claude <noreply@anthropic.com>
Task 8 of the non-invoiceable-order-extras plan.
Two regression tests, no production code change. The picking-slip
feature relies on a property of the existing print pipeline:
excluded rows DO receive item_status = 'invoiced' (Task 7 / n8n
batch flip) and DO carry the invoice number / DN number, so the
print queries pick them up automatically. The picker must still
pull the washers even though they never reach the customer.
- print-bundle: explicitly verifies the picking slip renderer is
called with both billable and excluded rows (mocked
PickingSlipPdf → assert lines array has both descriptions).
- delivery-note-print: explicitly verifies the PB filter does NOT
carry an exclude_from_invoice clause — adding one would
silently filter the picker slip and break Task 7's design.
Co-Authored-By: Claude <noreply@anthropic.com>
Task 7 of the non-invoiceable-order-extras plan.
The status flip + audit stamp + delivery_note header create all
run on EVERY row so the order closes normally — excluded rows are
real operational lines from the supplier's POV. The PDF line
list is built from billable.items only, so the customer never
sees excluded lines.
Added exclude_from_invoice to the getFullList projection (load-
bearing — omit it and the billable filter silently passes
undefined, every extra lands on the customer delivery note).
New 400 guard 'No billable lines for a delivery note' when every
row in the request is excluded. UI guard in Task 6 prevents the
operator from reaching this path, but the route has its own
defence so the picker doesn't get a guaranteed-to-fail submit
silently.
2 tests + 1 mutation-test prove the split:
- excluded rows still get status flip + DN number + PDF upload,
but the renderer is called with ONLY billable items
- all-excluded returns 400 before any PATCH fires
- mutation (PDF built from items): the mixed test fails because
the renderer sees 3 lines instead of 2
Co-Authored-By: Claude <noreply@anthropic.com>
Task 6 of the non-invoiceable-order-extras plan.
Both modals (QB submit + delivery note submit) — same shape change:
- Excluded rows render muted with a 'Not invoiced' chip in the
row metadata. The muted state overrides 'bg-card' even when
included, so the operator doesn't mistake them for billable.
- Their include-checkbox is checked and disabled — the row must
ride along for the n8n status flip, and the operator must not
be able to remove it or add it to the bill. onChange defensively
bails when the flag is set, in addition to the disabled prop.
- New 'billableCount' guard: when every billable row has been
unchecked, submit is replaced with 'No billable lines' label
and the click handler toasts 'This order has no billable lines.'
The existing 'noneIncluded' check still catches the operator
unchecking everything (including the excluded rows the disabled
checkbox blocks them from unchecking in practice); the new guard
is the per-mode subset check.
The existing 'noneIncluded' check isn't sufficient because excluded
rows are always in 'includedRowIds' (they have to ride along), so
includedRowIds.size === 0 is unreachable in practice — the billable
subset can still be empty.
Co-Authored-By: Claude <noreply@anthropic.com>
Task 5 of the non-invoiceable-order-extras plan.
buildQbInvoicePayload now keeps order_item_ids built from ALL
rows (so n8n's 'Build PocketBase Invoice Update' node flips
excluded rows to invoiced alongside their siblings — the order
closes normally), and builds items from BILLABLE rows only (so
QuickBooks never learns about the excluded lines).
billableRows helper exported for sharing with the invoice UI
(Task 6) and the delivery-note route (Task 7) — three callers,
one definition of 'billable'.
Added exclude_from_invoice to RawInvoicingRow AND the
INVOICING_FIELDS projection. The projection is load-bearing:
omit it and PB returns undefined, every extra silently bills
the customer. Confirmed by inspection and by the failing
'mixed set' test if the projection drifts.
4 tests + 1 mutation-test prove the split:
- mixed: excluded ids in order_item_ids, absent from items
- all-billable: identical output to the no-flag case
- all-excluded: empty items, full order_item_ids
- billableRows helper unit tests
- mutation (items built from input.rows): mixed and all-excluded
tests fail
Co-Authored-By: Claude <noreply@anthropic.com>
Task 4 of the non-invoiceable-order-extras plan.
The page-level margin gate (process/page.tsx) short-circuits when
the item has exclude_from_invoice. An excluded line has
selling_price forced to 0 (Task 2), so the computed markup is
always -100 and the gate would wedge every extra into
approval_needed and block the order. Bypassing the gate keeps the
operational flow unblocked.
Suppressed low-margin colouring for excluded rows — a red
'-100%' chip on every extra is noise. The 'Not invoiced' badge
(T3) replaces the price chip visually.
Server-side guard in requestOrderItemMarginApproval refuses to gate
an excluded item with code 'excluded_from_invoice' (status 422).
Belt-and-braces: the MCP path can't weaponise the approval queue
to push an excluded row through manager review. Checked BEFORE
the missing_prices guard so an excluded item with selling=0 throws
the descriptive error rather than the misleading missing_prices
one.
2 tests + 1 mutation-test prove both sides of the bypass:
- excluded: throws 'excluded_from_invoice', webhook NOT fired
- non-excluded: proceeds to approval_needed, webhook DOES fire
- mutation (guard disabled): the excluded test fails
Co-Authored-By: Claude <noreply@anthropic.com>
Task 3 of the non-invoiceable-order-extras plan.
- New POST /api/v1/orders/{id}/items route wraps
createOrderItemForOrder. Same-origin browser auth (PB session
cookie), mirrors the posture of /quote after the order-quote
migration.
- AddItemSheet component on the process page: right-side Sheet
(mirrors PriceEditSheet's layout) with description / quantity /
cost / selling / supplier / notes + the load-bearing
'exclude_from_invoice' checkbox. When ticked, the selling-price
input is forced to 0 and disabled, a 'Not invoiced' badge replaces
the margin chip so a -100% red chip on every extra is noise.
- Add Item button below the item card stack — always available,
not gated on item_status. The empty-state copy above ('No line
items on this order. Add items before processing.') now points at
a real action.
- Existing excluded rows render with a muted 'Not invoiced' badge
in the card stack so the operator can spot them at a glance
without opening the sheet.
- The server (Task 2) forces selling_price=0 from the flag
regardless of caller input — the client forcing here is for UX
consistency.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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.
- 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.
- 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.
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>
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>
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>
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>
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>
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>
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>
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>