Commit graph

37 commits

Author SHA1 Message Date
cd359b75a9 feat(quotes): split attachments into internal vs outbound with operator upload UI
Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).

Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.

API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`

MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.

Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.

Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
  (`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
  `email_outbound_attachment_ids`, downloads each from
  `/api/files/quotes/<id>/<token>`, and emits
  `options.attachments: [{name, content, type}]` consumed by
  `Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
  `retryQuoteEmail` can find failed sends (it only accepts the
  latter). Previously failed sends were stranded.

Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
  pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
  (/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
  and current `Q202609246` formats; canonicalised digits-only for
  PB `~` substring match.

Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.

Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.

Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes

Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
2026-09-09 13:43:56 +02:00
3de1d622b8 feat(quotes): allow resubmit from any post-submit status, not just rejected
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.

Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.

Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
2026-09-07 16:51:55 +02:00
66559af422 Add quote PDF generation and stale PDF handling 2026-09-07 07:08:27 +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
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
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
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
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
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
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
50944a3526 feat(quotes): add operator-facing notes functionality to quotes
- 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.
2026-08-17 15:22:16 +02:00
Dave
cf87a370cf feat(quotes): enhance quotes.submit functionality and error handling
- 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.
2026-08-15 23:57:51 +02:00
Dave
151ae8d67f feat(quotes): introduce quotes.submit tool for approval transition
- 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.
2026-08-15 19:01:52 +02:00
Dave
c4b53bcc4b feat(mcp): add walk-in quote creation and enhance customer filtering
- 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.
2026-08-15 11:50:48 +02:00
Dave
397df8e2b2 fix: correct quote expiry handling for PocketBase's 0 default
PocketBase returns 0 for unset number fields, which broke the original
null-coalescing check in effectiveValidityDays, leading to PDFs showing
"Valid until: today" for all new quotes created via email ingest.

Changes include:
- Update effectiveValidityDays to treat 0, negatives, null/undefined as falling back to 30-day default
- Add test cases for 0 and negative validity_days inputs
- Explicitly set validity_days to QUOTE_VALID_DAYS when creating new quotes
- Add one-time idempotent migration script to backfill legacy invalid validity values
- Refactor item intel popover patch logic to use PocketBase SDK for authenticated requests
- Add n8n workflow for AI-powered item insight matching
2026-08-11 08:32:17 +02:00
SJCoetz
63048e51c9 feat: add AI quote processing pipeline and admin settings UI
Add complete end-to-end AI-powered quote extraction workflow:
- Disable PB SDK auto-cancellation to support parallel batch requests
- Add required runtime dependencies for AI processing and document parsing
- Implement AI settings backend, operator UI, and REST API routes
- Add quote ingestion, insight population, and manual re-run AI endpoints
- Build service layers for quote items, AI settings, and structured item insights
- Fix attachment upload handling and quote item history blob parsing
- Add supporting migrations, smoke tests, and shared utility functions
- Export previously private helper functions for cross-service reuse
2026-08-09 16:17:29 +02:00
SJCoetz
12fa65a481 feat: add new order, invoice, and dashboard management tools
- 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
2026-08-05 23:00:54 +02:00
SJCoetz
d79c433704 fix(quotes): import QuoteItem + return real array from dedup branch
Production deploy failed the TypeScript type-check (next build exited 1)
because commit 2acfd2d changed CreateQuoteFromEmailResult.items from
'number' to 'QuoteItem[]' without updating:

  1. The type import — QuoteItem was never added to the import from
     @/lib/types (line 6 only brought in Quote, Customer, Company).

  2. The dedup branch (line 1060-1076) — still passed itemsCount: number
     where the new shape requires QuoteItem[]. Now fetches the real rows
     via getFullList and sets items_count = items.length for back-compat.

The build log shows the error was visible right at 'Running TypeScript ...'
but the deployment platform's log scraper cut off the actual stderr,
making it look like an opaque DeploymentException. Reproduced locally
with 'npm run build' to confirm both errors.

Build now passes (no next build / tsc failures). All 1392 tests pass.

Co-authored-by: Orca <help@stably.ai>
2026-08-05 16:18:31 +02:00
SJCoetz
2acfd2d346 fix(mcp): add quotes.update + preserve unit in create_from_email
Closes the two real gaps from the agent's gap analysis (2026-08-05):

1. **quotes.update MCP tool** — was missing entirely. The quote header
   has fields (company_letterhead, quote_date, quote_reference, etc.)
   that couldn't be patched via MCP, so the agent had to pass
   letterhead at PDF-render time and couldn't persist it on the record.
   Now exposed with a strict field whitelist mirroring the
   quote_items whitelist pattern: any field outside the allowed set
   (status, customer, company, quote_number, id, created, updated, …)
   errors with forbidden_field rather than silently writing to PB.

   New catalog entry + dispatch handler in src/app/api/mcp/route.ts.
   Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing
   QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability.

2. **create_from_email now preserves the unit field** — extracted_items
   from the AI's RFQ parsing carries {description, quantity, unit,
   notes} but the create loop was silently dropping unit. Also the
   service returned items: <count> which made the agent believe no
   items were created when in fact stub rows existed. Now returns
   the actual QuoteItem array (with a back-compat items_count field)
   and the unit value lands on each row.

The agent's third claim (PB admin auth unavailable) is a misdiagnosis
— WFDASH uses its own AGENT_API_KEY and all writes go through the
Next.js admin client. No code change needed for that.

All 1379 tests pass. quote_items.update path unaffected. The
quotes.update path mirrors the same pattern: whitelist + reject empty
patch + call pb.collection.update.

Co-authored-by: Orca <help@stably.ai>
2026-08-05 11:39:16 +02:00
SJCoetz
e132319910 fix(orders,quotes): null-safe sort comparator in archive functions
The archive sort comparator in both getOrdersArchiveData and
getQuotesArchiveData was:

  if (av === undefined) return 1;
  if (bv === undefined) return -1;
  return (av > bv ? 1 : -1) * sortDir;

PocketBase returns `null` (not `undefined`) for unset fields
like `updated`, `email_body`, etc. The strict equality check
missed nulls, and the `av > bv` comparison where one side is
null fails the production tsc check (TS strict mode rejects
`null > string`).

Production build failed with:

  ./src/lib/server/orders-service.ts:687:13
  Type error: 'av' is possibly 'null'.

Fix: use `== null` (which matches both null and undefined),
and use explicit > / < comparisons that return 0 on equality
rather than a fragile ternary that falls through.

Also fixed in the matching function in quotes-service.ts — the
same sort comparator was used in getQuotesArchiveData and would
fail the same way.

All 1331 tests pass; npm run build completes cleanly.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 20:36:50 +02:00
SJCoetz
3ea7c9c189 feat(quotes): move completed+all to dedicated /quotes/archive page
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>
2026-08-04 20:25:13 +02:00
SJCoetz
292cf2468e fix(orders): remove broken fields param + show contact_name + REP label
# Regression: orders/quotes list lost customer/company/rep data

Last commit added a `fields` parameter to the orders and quotes
list queries to trim the wire payload. Measured on a stale test
result, looked like a 16x win. Actual: PB 0.26's `fields`
parameter is a HARD ALLOWLIST of MAIN record columns only \u2014
specifying it makes PB drop the entire `expand` payload from
the response. Result: customer, company, rep, quote names
disappeared from every order/quote card.

Verified against the live PB:
  - With `fields`: expand is dropped, just relation IDs returned
  - Without `fields`: expand works, full records (55-57 KB for
    20 rows with deep expand)

Fix: remove the `fields` parameter from both list queries. The
slim-payload intent has to be paid at the schema level (cap the
unbounded text columns) rather than via PB query params.

The earlier '12x smaller' / '16x smaller' benchmarks were
measurements of the BROKEN response, not the working one. Real
wire sizes are unchanged from before the perf work \u2014 but the
other wins (server-side tab filter, combined status counts,
targeted users fetch, 60s counts cache) all still apply.

# Bonus: show contact_name on the order card

~76 of ~4.5k orders have `contact_name` set (the free-text
person the order is for, captured during email-intake when the
customer record is just the company). Added to the OrderCard
header between customer and rep, with a docstring explaining
the distinction from the customer relation and email_sender.

Added contact_name to the Order type and pinned the regression
with a test that asserts the orders list query does NOT pass
`fields` (so a future 'let's slim the payload' attempt
doesn't reintroduce the bug).

# REP label

The rep column on the order card now reads 'REP' (uppercase)
instead of 'rep' (lowercase). All 1312 tests pass.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 19:27:02 +02:00
SJCoetz
db1e7d6f76 perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix
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>
2026-08-04 19:01:31 +02:00
SJCoetz
4444842ef5 feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components

Co-authored-by: Orca <help@stably.ai>
2026-08-03 09:33:45 +02:00
SJCoetz
7a68cafe35 chore: add logos, opencode config and build assets
This commit includes the temporary Next.js build cache files, public brand
logos, and project configuration added in this change.
2026-07-27 08:21:20 +02:00
SJCoetz
cdaa2e2b25 fix, feat, style: standardize quote numbers, polish UI
- 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
2026-07-10 22:21:50 +02:00
SJCoetz
7f924ccc45 refactor: switch hardcoded UI colors to semantic theme tokens
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.

Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
2026-07-07 17:22:03 +02:00
SJCoetz
6c07a91693 refactor: drop order-level status, shift to item-driven workflow
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
2026-06-29 13:06:01 +02:00
SJCoetz
588e9762c2 feat: add quote templates, improved email, new statuses
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
2026-06-24 22:57:01 +02:00
SJCoetz
714e910949 feat: add branded quote PDF generation and in-app viewer
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
2026-06-23 08:30:38 +02:00
SJCoetz
41b5fc4cbf feat(quotes): add email-to-quote pipeline with attachments and replies 2026-06-16 20:46:28 +02:00