Commit graph

43 commits

Author SHA1 Message Date
89e52bbd9d feat(orders): AI fills order lines from the matched quote
The assistant now prices the order's lines from the quote they are
paired with, as purple drafts a person then reviews.

- order-assistant-core.ts: isOrderLineWritable + buildOrderLineFillPatch.
  Only a pending line with no cost of its own, no person-set pricing
  source and no reviewed fill may be written. Supplier / SKU /
  matched_price_list_id only when the line has none; selling_price only
  when the line has none; markup + markup_percent written together.
  pricing_source becomes "ai_quote". A customer price that differs
  from the quoted price by more than 5% becomes a doubt.
- order-assistant-lines.ts (new): the shared, cycle-free helpers —
  idFilter, aiQuotePairs, writeAiPairs, clearAiPairs and
  fillOrderFromQuote. It lives in its own module so orders-service.ts
  can use it without an import cycle, since order-assistant-service.ts
  already imports setOrderQuote.
- The fill re-reads every line right before writing it, so a cost a
  person typed in the meantime wins. It stays quiet until the migration
  has added order_items.ai_fill — PB rejects a projection that names a
  column the collection does not have.
- setOrderQuote writes the AI's line pairs before pairOrderLines runs,
  so the AI's pairing wins over word matching, then fills the lines.
  A failure there is logged and never fails the link.
- The tick gains a second pass that fills the lines of orders already
  linked to a quote. Only orders the fill actually wrote to count
  towards the budget, so a skipped order cannot block the run.
- The run route fills after the match and reports the counts.

PocketBase filters have no IN operator, so every id list is an
OR-chain. fetchQuoteItemsForQuotesAdmin no longer tries an IN filter
first — that was a guaranteed 400 on every call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:24:56 +02:00
a3a831d183 fix(quotes): list renders the customer's company, not the stale copy
Third and last place that read `quotes.company` directly. The list page
showed "Mre" next to Pierre Visser, who works for Harmony — the same
drift already reported on the detail page.

This one was a deliberate perf shortcut rather than an oversight.
`QUOTE_LIST_EXPAND` dropped `customer.company` to "save one IN query per
page load" and the page read the quote's own `company` relation
instead. That relation is a denormalised copy that PocketBase never
re-cascades, so the shortcut read a field nothing keeps correct — and
the column it fed is what a rep reads to decide whether to approve, so
a stale company there is worse than a slightly larger query.

The 3-level expand is back, on both the list and the search path (the
search results feed the same columns, so they needed it too). Search
now also RANKS on the customer's company rather than the stale copy —
otherwise a query for "Mre" would surface quotes whose results then
render "Harmony", contradicting themselves on screen. It falls back to
the quote's own copy for the ~4% of customers with no company relation,
where the two are equal by construction.

Applies to both the desktop table column and the mobile card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-16 01:07:50 +02:00
d55e54680b perf(search): narrow the recency window 90d -> 60d
Operator request. Bigger effect than expected.

THREE constants, not one

"90" appeared in three places, and two of them are load-bearing:

  universal-search-service.ts   UNIVERSAL_SEARCH_WINDOW_DAYS  orders scope
  quotes-search-service.ts      SEARCH_RECENT_WINDOW_DAYS     quotes pushdown
  quotes-service.ts             SEARCH_RECENT_WINDOW_DAYS     exported; shared
                                with the /quotes page + archive

The last two must stay equal — the /quotes page renders "Show recent
(last N days)" from the exported constant while the server filters on
the private one, so moving them apart desyncs the label from the
behaviour with no error anywhere. All three moved together. Universal
search calls searchQuotes without `includeOlder`, so it inherits the
quotes window; changing only the universal constant would have left
global search reading 60 days of orders and 90 days of quotes behind a
single "last 60 days" label.

The UI label reads windowDays straight off the service, so it follows.

MEASURED

Order candidate set collapses — the window was carrying far more than
the linear slice it looks like:

  90d -> 5,394 orders / 16,640 items / 6,696ms scope scan
  60d ->   972 orders /  3,307 items / 1,606ms scope scan

Cold search "tiaan": 17-22s -> 4.6s measured. That is most of the
mitigation the TTL bump was papering over, so the long-deferred pushdown
rewrite is now worth less than it was this morning.

Quotes genuinely drop, and it is real rather than a regression: Tiaan's
own quotes go 22 -> 6 between a 90- and 60-day window, because that
band is where most of his quotes sit. Verified against PB directly.

ALSO

Pinned the clock in the age-chip test. It asserted "4d" against a
fixture dated four days before the wall clock, so it expired the day
the date rolled past — it failed on this run for exactly that reason.
2026-10-15 16:02:34 +02:00
d983eccd6c fix(delivery-notes): return to the list after quoting; kill the phantom supplier
Two things the operator hit straight away.

1. Creating a quote parked her on the quote detail page.
   She is working through notes one after another, so that pulled her
   out of the list for a document she had just reviewed in the modal.
   `afterCreate` now defaults to "list": the modal closes, the toast
   confirms, and she is back where she was with the row's first slot
   now showing the quote number.

2. "Couldn't load this quote's PDF" — blaming a supplier.
   The message was right about the failure and wrong about the cause.
   The readiness rule existed in TWO places: `quotes-service.ts` for
   the send path, `quote-pdf-service.ts` for the PDF route. When the
   supplier requirement was dropped I changed one of them and left the
   other enforcing it. So a counter note whose lines never had a
   supplier could be quoted and sent but never rendered.

   Both copies now call one function, `quoteItemReadinessGaps`, so a
   third copy cannot appear. Verified in the browser: Q202609631 now
   fails with "Every item still needs: cost price, not processed,
   selling price" — no supplier — and that quote's line genuinely has
   cost 0, selling 0 and is_processed false, so refusing it is correct.
   The phantom requirement is gone; the real blockers are named.

   `is_processed` is deliberately KEPT. It is the flag that says a
   person reviewed the line, and nothing else can set it on a
   delivery-note quote — the quote_items update whitelists exclude it.
   Dropping it would let a quote go out with lines nobody looked at.

The quotes-service test mock now spreads the real module, so these
tests run against the real rule rather than a stub that could agree
with the implementation while production disagreed.

Full suite 3676 passing, tsc clean, no new lint errors.
2026-10-05 19:49:20 +02:00
c2eabe1cfc fix(delivery-notes): unblock quoting, and one action set for both screens
Two things stopped the operator quoting a delivery note. Neither was
friction; both were bugs.

1. "The status is not right"
   `createQuoteFromDeliveryNote` opened with `if (dn.status !== "draft")`
   and refused everything else. A note the office had already sent, or
   that the customer had put a PO against, could not be quoted at all,
   and the whole error message was that condition.

   Now any status except `cancelled` can be quoted — cancelled is the
   one genuinely unquotable state, because the work was abandoned and
   pricing it would put dead goods on a customer-facing document.

   Critically, the status write is now conditional. It used to force
   `sent` on every create, which with a relaxed gate would have dragged
   a po_received note BACKWARDS to sent and erased the fact that the
   customer had sent their PO. Pricing a note is not a reason to move
   it. The detail page's Create quote button was also `draft`-only and
   now matches the server.

2. "It needs a supplier and this and that"
   The quote was being created fine — the PDF was refusing to render.
   The readiness check required four things per line, and the two the
   modal already supplied were the only two that were satisfiable:

   - `is_processed` was never set on a delivery-note quote, and
     NOTHING could set it: the quote_items update whitelists exclude
     it, and the only writer was /quotes/[id]/process. So a DN-created
     quote could not produce a PDF without leaving the flow entirely.
     It now derives from the confirmed pricing — cost AND selling
     both present — the same rule the process page uses. A line the
     operator left unpriced still blocks, so the check keeps its teeth.
   - The supplier block is gone. A counter note has no parent order,
     so its lines frequently carry no supplier; demanding one refused
     the PDF for a field the counter form never asked for. The
     supplier is still printed when present.

Signed copy is never a completion signal. The upload route only ever
wrote `signed_copy` — nothing was writing `completed` — and that is now
pinned by a test asserting create-quote never sets `completed` for any
input status, so the relaxed gate cannot introduce it.

One action set, not two
The list and the detail page each hand-wrote the same action set and
had drifted twice: the list got a pricing-review modal the detail page
never received; the detail page got Mark complete, Cancel, Download /
Regenerate PDF and Upload signed that the list never received. The
status gates drifted with them.

`DeliveryNoteActions` now owns the set, the order and the gating. Both
pages render it — icons on the list, labelled buttons on the detail —
and each supplies its own handlers, so behaviour stays where the state
lives but "which buttons, and when" is answered in one place. An
action with no handler is not rendered, so neither presentation can
invent or hide one.

Tests: 27 new. 19 pin the action set and gating, 8 cover the relaxed
gate / no-backwards / is_processed / no-supplier rules. Verified they
bite by regressing create-quote to draft-only and by restoring the
old supplier block. Full suite 3670 passing, tsc clean, no new lint
warnings.
2026-10-05 15:27:39 +02:00
7555196166 feat(audit): implement audit trail for delivery notes and orders
- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field.
- Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability.
- Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications.
- Enhanced the `package.json` with a new migration script for setting up the audit log functionality.

This feature improves accountability and transparency in the management of delivery notes and orders.
2026-09-30 01:48:44 +02:00
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