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>
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>
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>
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.
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.
- 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.
- Introduced a new `quote_notes` field to store operator-facing free-text notes, distinct from the existing `notes` field used for rejection audit logs.
- Updated the quote processing page to include a `QuoteNoteCard` for editing and displaying the operator notes, ensuring seamless integration into the PDF generation flow.
- Enhanced the PDF generation logic to render the operator notes in a blue callout below the totals, improving visibility for end-users.
- Implemented a new service function `setQuoteNotes` to handle the saving of notes, including trimming whitespace and handling non-string inputs defensively.
- Added unit tests for the `setQuoteNotes` function to ensure correct behavior and data integrity when updating notes.
- Updated relevant types and documentation to reflect the new functionality and its intended use cases.
- Updated the `quotes.submit` tool to allow submission of quotes with unpriced items by introducing a `partial: true` flag, transitioning the quote to `partial_approval_pending` instead of `approval_pending`.
- Enhanced the response structure of `submitQuote` to include `previousStatus`, `submittedAt`, and `targetStatus`, providing better context for the agent.
- Improved error handling in the `sendQuoteEmail` function to surface detailed readiness issues when a PDF cannot be generated, allowing operators to address specific gaps in quote items.
- Regenerated API documentation in `docs/api-mcp-layer.md` to reflect these changes, ensuring accuracy and clarity in tool descriptions.
- Added comprehensive tests to validate the new functionality and error handling improvements.
- Added a new `quotes.submit` tool that transitions a quote from `new` to `approval_pending` once all line items are priced. This tool ensures that only fully priced quotes can be submitted for approval, enhancing the integrity of the approval process.
- Updated the API documentation in `docs/api-mcp-layer.md` to include the new `quotes.submit` tool and its usage details.
- Enhanced the `quotes` status enum in `docs/agents/hermes-skill.md` to reflect the new transition and its implications.
- Regenerated documentation to ensure accuracy, increasing the total tool count from 126 to 127.
- Added comprehensive tests for the `submitQuote` function to cover various scenarios, including idempotency and error handling for unpriced items.
- Introduced a new `quotes.create` tool for creating quotes from scratch without an inbound email, allowing operators to generate quotes directly from the dashboard.
- Updated `customers.list` to support filtering by representative using either a 15-char PB user ID or a case-insensitive name/email match.
- Enhanced API documentation in `docs/api-mcp-layer.md` to reflect the addition of the new quote creation tool and updated customer filtering capabilities.
- Regenerated documentation to ensure accuracy, increasing the total tool count from 125 to 126.
- Add server-side `/api/v1/dashboard` endpoint to reduce client-side payload and round trips
- Implement invoice upload and order-item SKU update APIs with complete test suites
- Refactor order and order-items list endpoints (including MCP tools) to support proper PocketBase filter and sort params
- Fix pagination validation for order-items route to clamp page ≥1 and perPage 1-500
- Add redirects in next.config.ts to map underscore collection paths to hyphenated API routes
- Improve buyer tasks page to avoid redundant tab count refetches when switching tabs
- Refactor collection confirm components with editable SKUs and updated accessibility labels
- Add cached admin PocketBase auth to minimize repeated authentication calls
- Add performance testing scripts for dashboard endpoint latency
- Update quotes list route to default to excluding archived counts
- Fix photo strip component text and labels for non-invoice documents
- Refactor sprint page to lazy load supplier/customer data for pricing modal
- Clean up unused imports in test files
- Add full test coverage for all new APIs, services, and components
- quotes.update: happy path with whitelisted fields, forbidden_field
rejection for system-controlled fields (status/customer/company/
quote_number/id), empty-patch rejection, missing-id rejection
- create_from_email: unit field now preserved end-to-end, result.items
returns the actual QuoteItem array (not just a count)
Also updates the existing tools/list test (now 72 tools, was 71) and
the create_from_email mock in route.test.ts to use the new
items/items_count shape so it matches the type.
All 1379 tests pass.
Co-authored-by: Orca <help@stably.ai>
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.
This commit moves both buckets to a dedicated /quotes/archive
page so:
- Main /quotes is fast (active workflow: new, claimed,
pending_approval, cancelled, delivery_note, email_failed,
problem, templates = 542 quotes vs 8,466)
- Archive can be honest about pagination
- The badge counts on the main page stop being dominated by
'Completed (7,849)'
# Service layer
- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
- view: 'completed' (default, 7,849) or 'all' (8,466)
- Strategy: one getFullList on quotes (fields: id,status) to
populate the matching set AND the header counts in a single
walk, chunk by 100 ids (PB's URL/filter cap is between
4.5-6.7 KB), fire parallel getFullList queries on quotes
with deep expand, sort + paginate in memory
- Search hits quote_number, quote_reference, rfq_number,
company, customer
- /api/v1/quotes/archive thin route: page, perPage, search,
sort, view
# Page
- /quotes/archive/page.tsx: card list with header counts
(completed / all badges), view toggle (Completed | All),
search input, pagination footer. Click a quote to open
/quotes/[id] in detail.
# Main /quotes updates
- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
/quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in quotes-service.test.ts:
- Default 'completed' view returns only completed quotes,
counts header includes both
- 'all' view returns every counted-status quote
- Search by quote_number and quote_reference
- Empty result when no quotes match
# Also: webview tweaks by another agent
- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)
Full suite: 1331/1331 pass.
Co-authored-by: Orca <help@stably.ai>
Same shape as the orders refactor \u2014 applied to the quotes page,
with a fix for a long-standing tab-count bug along the way.
# Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items)
Before:
- 22 parallel per-status getList(1, 1) queries for the badge
counts: 2.6s wall time (no quotes.status index)
- 64 KB JSON response for 20 quotes with full deep-expand
(45+ fields per quote, including unbounded email_body)
- Page footer shows 'Page 1 of N' where N is the global count
\u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393'
After (no PB index, just code):
- 1 combined counts query: 138ms (19x faster)
- 5.5 KB JSON response (12x smaller)
- Server-side tab filter: page 1 of N reflects the tab's set
- Cancelled tab badge now correctly counts rejected+cancelled
# Server-side read model
- getQuotesDashboardData in src/lib/server/quotes-service.ts
- tab-scoped list + global counts in one round-trip
- tab param drives a server-side filter (status IN (...) for
workflow tabs, relation filter for delivery_note, is_template
for templates, excludeTemplate for 'all')
- pagination reflects the active tab's matching set, not the
global ~8.5k quote count
- getQuoteStatusTabCounts: one combined getFullList with all
17 counted statuses ORed (fields: 'status'), deduped in memory.
Replaces 22 separate per-status scans. Cached 60s on the server
with bypassCache option for future realtime refetches.
- /api/v1/quotes/list thin route: validates tab against
QUOTE_TAB_ORDER, threads tab + nocache params to the service.
# Slim wire payload
- fields param on the orders list: just the columns the page reads
+ per-expand limits (quote.quote_number doesn't need
email_body, company.company doesn't need its address field, etc.)
- Dropped the customer.company 3-level expand (the page never read
expand.customer.expand.company \u2014 only expand.company.company,
which is a direct relation from quote)
- searchQuotesWithItems: same fields added, with
fetchQuoteItemsForQuotes now accepting fields
# Bonus bug: 'Cancelled' tab badge was always empty
The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected'
to the 'completed' count, so the page's 'Cancelled' tab badge
showed 0 even when 60+ rejected quotes existed. The page's
tabFilters separately grouped them under 'cancelled', so a quote
that was rejected showed up in the list but not the count.
The new QUOTE_TAB_STATUSES in the service layer is the canonical
mapping (cancelled absorbs both statuses). Page constants and
service constants now derive from the same source \u2014 no more drift.
# Index migration
scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent.
The live PB has no quotes.status index today (verified by hitting
/api/collections/quotes). With the index, the combined counts
query drops from 138ms to sub-10ms on the live instance.
# Tests
6 new tests in quotes-service.test.ts:
- QUOTE_TAB_STATUSES contract (cancelled absorbs rejected,
no empty status lists unless special-cased)
- getQuoteStatusTabCounts buckets correctly into tabs
- Cache hit returns the same counts on second call
- bypassCache: true sees new state
- getQuotesDashboardData end-to-end: tab-scoped items +
global counts
- Regression guard for the cancelled/rejected bug
Mock infrastructure upgrade: the quotes-service mock's where
function is now a proper recursive-descent filter parser (handles
nested || inside parens, empty quoted values). getList mock now
returns totalPages.
Full suite: 1312/1312 pass.
Co-authored-by: Orca <help@stably.ai>
- orders-service.test.ts: destructure capture-group bug on regex match
(was assigning m[0] to 'field', so filter clauses never matched).
Two pre-seed tests were masked by the bug.
- quotes-service.test.ts: createQuoteFromEmail now returns 0
attachments; the n8n flow uploads them in a follow-up step. Update
the assertion that still expected the old embedded-base64 count.
- collection-pool/page.test.tsx: tests assume ONE supplier group with
multiple rows; supply that shape and scope the selectors. Also wait
for data load in the chevron test and gather list items across all
<ul> elements in the sort control helper.
- pocketbase.ts: wrap authStore.onChange registration defensively so
the test mock (no authStore) doesn't throw at startup.
- stock-orders/page.tsx: drop the re-export of
STOCK_PURCHASE_STATUS_VALUES on the page module — Next.js page
route files only allow the default export.
- collection-confirm/view-model.test.ts: buildConfirmNote input is
Pick<InvoiceDetails, 'date' | 'number'>; drop the extra setBy fields.
Co-authored-by: Orca <help@stably.ai>
- Standardize quote number format from Q-YYYY-MM-NNN to QYYYYMMNNN across all client, server, test and API code
- Add atomic backfill logic for quote counters to avoid sequence collisions with existing records
- Rearrange Delivery Notes and Repairs to the Operations sidebar section
- Add supplier count badge to pricelist catalogue and improve filter tab overflow styling
- Optimize font loading in root layout to skip preloading unused font weights
- Improve table styling for customer and company pages with better text wrapping and empty states
- Fix rep display logic in quotes page to show "Unassigned" when no rep is assigned
- Add supporting documentation, PocketBase counter schemas and setup migration scripts
- Remove embedded SupplierMap from suppliers page for cleaner layout
- Improve invoice card layout for company and customer info spacing
- Improve filter label styling and accessibility in collection map page
This commit overhauls the order management system to use item-level status tracking instead of a single order-level status:
- Remove deprecated `orders.status` field and add `invoiced` and quote match audit fields
- Add `isOrderComplete` utility with tests to validate fully delivered orders
- Add purchase order PDF rendering support and tooling
- Add geo coordinate fields for companies/customers with map link UI
- Add REST management APIs for customers, companies, and suppliers
- Update customer/company services to use relation fields instead of free text
- Add status pill and tab filter components for item status visualization
- Add quote-to-order matching logic and unit tests
- Update all pages, components, and tests to match the new schema
- Include all required PocketBase migrations and schema updates
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
This commit adds full quote PDF generation functionality with per-company letterhead branding:
- Server-side PDF rendering using @react-pdf/renderer matching the sample quote layout
- In-app modal PDF viewer with print, download, and new-tab controls
- Letterhead service to fetch and apply company-specific branding (CNL/ISHERWOOD)
- ZAR currency and date formatting utilities for South African quotes
- Updated QuoteAttachment type to handle PocketBase's array-based file tokens
- Fixed PocketBase attachment URL generation in quote email card
- Removed deprecated `included_in_quote` field from QuoteItem and quotes service
- Updated quote item creation test to validate draft item logic
- Added comprehensive unit tests for PDF rendering and formatting helpers
- Added dev scripts for debugging PDF and image rendering issues
- Added sample generated PDF files for visual verification
- Updated tsconfig to exclude development scripts directory
- Added @react-pdf/renderer production dependency
- Removed JEI as a valid letterhead option per requirements