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>
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>
.claude/launch.json lets the Browser pane start the app on :4001 by
name instead of needing a manually-run dev server. The .tmp screenshots
are visual verification captures from mobile dashboard debugging.
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.
Companion to the design spec at docs/superpowers/specs/. Covers all 15
implementation tasks (windowRange helper, aggregations, rep-activity
pagination, route handlers, tests, PWA manifest, mobile layout,
dashboard view with realtime + bottom sheet) plus the final
verification step.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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>
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>
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>
Hermes filed WFDASH-dev-ask-quotes-create.md on 2026-08-26 claiming
`quotes.create` doesn't exist. It does — registered at
src/app/api/mcp/route.ts:1131, REST at /api/v1/quotes/create, added
2026-08-15 in Phase G gap-fill, documented as Recipe 7 in
docs/agents/workflows.md. The agent's actual failure was discovery,
not a real gap.
Two doc-only changes to keep this from recurring:
1. Tool count 125 → 141 (live count), recipe count 7 → 9 (live count)
2. New prominent "Three quote-creation tools — don't confuse them"
section right under the intro listing all three paths
(quotes.create / quotes.ingest_from_email /
quotes.create_from_email) with the common failure mode explicitly
called out. Recipe 7 already covers the walk-in workflow;
this surfaces the contrast up front so the agent sees it during
session-startup instead of after it has already picked the wrong
tool.
No code change. No FEATURE-REQUESTS.md entry — the tool isn't a gap.
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>
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>
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>
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>
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>
Implementation plan for the spec committed in d87a007. Four tasks:
1. Scaffold 7 component tests (all fail initially)
2. Replace handleApprove + handleApprovePartial with lifted
SendQuoteModal + sendApprove; new 'Approve & send' button
3. Verify partial-row test passes from task 2 wiring
4. Manual smoke test against running PB
Lifts the detail-screen "Approve & send email" button behaviour onto
the pending-approvals list rows. Replaces the list's existing
two-step approve + drill-in flow with the existing combined
review-approve endpoint behind the same SendQuoteModal the detail
page already uses.
Out of scope: "approve without send" toggle in the modal, inline
markup-edit on a row.
Co-Authored-By: Claude <noreply@anthropic.com>