Commit graph

803 commits

Author SHA1 Message Date
d8e8714720 feat(stock): update pricing logic and transform script for fasteners
- Enhanced `nss-fasteners-pb-import.json` to compute `selling_price` as `cost_price * (1 + 80%)` and included `markup` in the records.
- Updated `nss-fasteners-pb-transform.ts` to allow configurable CSV and output file names via environment variables, improving flexibility for different product runs.
- Added support for uploading photos in the repair detail page, including validation for file types and sizes, and implemented a dialog for creating customer quotes linked to repairs.
- Introduced a new `customer_quote` field in the `Repair` type to link generated quotes back to their respective repairs.

These changes streamline the pricing process and enhance the user experience in managing repairs and quotes.
2026-09-21 12:32:44 +02:00
b99aacb020 feat(rep): add /rep/visits map with heatmap, per-customer clusters
New page lets a rep see their own rep_visits on a Leaflet map.
Defaults to last 7 days; toggleable to 14 / 30 / 90.

  /rep/visits       ── server-side fetch + re-export to client
  /rep/visits map   ── per-customer cluster, count badge, last-visit
                       label, popup with full per-visit timeline + notes
                       + 'manual' badge, side-list of customers in window
                       + search filter + heatmap toggle

Service + route (no pb-helpers):
  visits-service.getVisitsForRep(repId, {from?, to?, limit?})
    - admin PB (session-scoped at the route layer)
    - expands customer relation, filters position_source != 'tracker'
      server-side, PB date format gotcha (T → space), 5000-row cap
  /api/v1/rep-visits/list   GET, browser-session auth, scopes repId
                            from user.id (never a query param — same
                            hardening as POST /api/v1/visits)

Heatmap:
  leaflet.heat wrapper at src/components/leaflet-heatmap-layer.tsx
  - useMap() + useEffect lifecycle
  - brand gradient (deep blue → green → amber → yellow → crimson)
  - reads on both light OSM and dark CARTO tile sets
  - maxZoom default 10 — leaflet.heat scales intensity by
    1/2^(maxZoom-currentZoom); without this the OSM default maxZoom
    of 18 makes points render at ~5% opacity at our default zoom 10

Other fixes landed mid-feature:
  - KPI cards collapsed from 3 into 1 (clusters count is a
    zoom-dependent implementation detail, not a glanceable stat)
  - added 'View my visits on a map' nav link under /rep header

Dep: +leaflet.heat@^0.2.0, +@types/leaflet.heat@^0.2.5
     (react-leaflet@5, leaflet@1.9.4 already present)

Tests: 50 + 34 = 84 pass in the touched (rep) area + visits-service
       + rep-visits API. Pre-existing failures on main (sidebar,
       pocketbase-admin, quotes-service) confirmed unrelated by
       stashing this branch.
2026-09-20 23:50:03 +02:00
Dev - WFDASH
41963f8dd9 fix(rep): cap picker sheet at max-h-[90dvh] so the list stays on-screen
Without a height constraint, the base-ui bottom-sheet's
`data-[side=bottom]:h-auto` sizes the sheet to its content.
The picker renders 678 customers, so the sheet grew to
46,373px in an 809px viewport — the inner overflow-y-auto
container had nothing to scroll within, and the list rendered
above the visible area (confirmed on 2026-09-20).

Fix: add `max-h-[90dvh]` at the picker SheetContent call site.
`dvh` (dynamic viewport height) accounts for mobile browser
chrome so the sheet never gets pushed off a small screen.

`flex-1 overflow-y-auto` on the inner modal-shell__body was
already there from the picker sort/toast refactor — it just
needed a parent height to scroll within.

Regression guard: src/app/(rep)/rep/rep-picker-height.test.ts
asserts the picker SheetContent keeps the max-h cap, uses dvh
(not vh), and the inner list container still has flex-1 +
overflow-y-auto so the cap actually scrolls.
2026-09-20 21:51:35 +02:00
38c65af8e3 feat(rep-checkin): unified picker for pinned and unpinned customers
The rep check-in screen now treats pinned and unpinned customers through a
single interface instead of two stacked sections. The bottom-sheet picker
opens from a new 'Add a customer' button below the nearby list, shows every
customer with unpinned rows first, and stamps a 'no location yet — saving
will pin this spot' badge on each so the rep sees what ticking will do.

The success toast now reports the pin count when any selected customer was
unpinned (e.g. '5 visits logged · saved this spot for 2 new customers'),
giving the rep visible confirmation that coordinates were written.

Pure front-end change on top of the existing pinCustomer path — no server
or schema work. New helpers:
  - picker-sort: sortCustomersForPicker + filterPickerCustomers
  - visit-toast: formatVisitToast (plurals, pin suffix, offline hint)

19 new tests; rep + visits scope stays at 66/66 green.
2026-09-20 20:52:26 +02:00
bcd9b88dd4 fix(pos): revive the dead "Print cash-up" button, add 20c/10c denominations
The button has never worked. cashup.html wires it with an inline
onclick="printCashup()", but the page's whole script is IIFE-wrapped, so
printCashup was never a global — the click threw "ReferenceError:
printCashup is not defined" into the console and the button silently did
nothing. No alert, no dialog, no feedback.

Dead since 46d5d54 swapped the button from onclick="window.print()" (a
real global, so it worked) to the ePOS-Print path. receipt.html hit this
exact bug earlier and fixed it by exporting its handlers to window — with
a comment noting the buttons "all silently do nothing". cashup.html never
got the same line. It does now.

On the network transport the cash-up slip already mirrored the sale slip
(resolve 'cashup' blocks -> letterheadLogoRaster at 384 -> printViaNetwork),
so the export is the whole fix for a till on a network printer.

Also here:

- 20c and 10c added to the drawer count, with the total rounded to whole
  cents. Neither is exact in binary floating point, so 3x10c would
  otherwise have summed to 0.30000000000000004 and printed a variance
  that reads as a rounding bug on the slip.
- The button restored its label with textContent, which dropped its
  printer <svg> permanently on first use. Restores innerHTML instead.
- The Bluetooth branch quietly called window.print() (OS dialog, not the
  thermal printer). getSettings() DEFAULTS to bluetooth, so any till with
  cleared settings would land there and look dead all over again. It now
  fails loudly, matching EposPrint.printTest()'s stance. No ESC/POS
  cash-up renderer yet — only the sale slip speaks Bluetooth.

pos-app.js had the same defect in four more places: the edit-line modal's
Save / Cancel / Remove and the customer "Remove" chip are all inline
onclick handlers declared inside its IIFE, and only openEditLine was
exported — so the modal could be opened but not dismissed or committed.
Confirmed by parsing the file (one top-level statement, no top-level
function declarations), fixed with four additive export lines.

src/lib/pos-inline-handlers.test.ts guards the class across every POS
page: any identifier called from an inline on* attribute must be a
builtin, a namespaced call, or exported on window. It was red on exactly
these bugs and is green now.

SW cache bumped to pos-v26 so the tills pick the files up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:30:11 +02:00
74cde03142 fix(order-process): stamp qty_collected when marking items in stock
In-stock rows were toggled to `item_status="ready_invoice"` without
setting `qty_collected`, so the Invoicing page's `min(qty_collected,
quantity)` rule pushed a 0-qty line to QuickBooks for every in-stock
row. The operator couldn't invoice — the goods were physically on
the shelf but the system thought nothing had been received.

Single-row `toggleInStock` and bulk `markInStockReady` now both stamp
`qty_collected = quantity` on the flip. In-stock rows still don't go
through a supplier PO (`purchase_order=""`) — the fix is purely on
the "we have these on our shelves" signal that feeds invoicing.

Backfill: ran `scripts/nss-backfill-in-stock-qty.ts` across the org.
Found 316 stale `in_stock=true` rows out of 324, stamped
`qty_collected=quantity` on each. Idempotent (second run reports 0
to do). ZA10PO26011801 (Master Drilling, M16 rod) and Q202609445
(Denmar Steel — 8 lines including nuts/washers at qty 170) verified
in PB.

Companion fix to the earlier in-stock totals exclusion: the
Invoicing page now gets the full qty for each in-stock row, while the
process-page PO totals still exclude them (since no PO is sent). Both
behaviours line up.
2026-09-18 08:07:38 +02:00
dce54934f0 fix(invoicing): bill only what's collected, support partial pickups + kit splits
The invoice qty rule used to read the customer's order qty regardless
of how much the driver actually collected. QuickBooks ended up billing
8-of-10 on one line and 7-of-10 on the other for Kusasalethu PO 390588
(INV10924) — a 5-unit overbilling that the mine's receiving team flags
as suspicious.

The rule now caps the invoice qty at min(qty_collected, quantity):
  - Short pickup → bill what's collected, the row stays at
    item_status="ready_invoice" with qty_invoiced < quantity, and a
    follow-up invoice fires automatically when the rest arrives.
  - MOQ overflow → still capped at the customer's order qty; the
    surplus-book flow continues to route the overflow to stock.

The Invoicing page now shows "N short" / "N units short" badges on
short-pickup rows, a "N hidden" badge when supplier-side rows are
suppressed, and a "Show supplier-side lines" toggle (default OFF) so
the invoicing operator doesn't see kit-component remainders unless she
needs to. A split-remainder dialog on the order process page lets the
operator explicitly flag a remainder as exclude_from_invoice=true when
the original line was a kit (e.g. "M20 bolt comes with nut and washer"
split across 3 suppliers).

PB schema additions (one-time, run scripts/nss-add-invoice-columns.ts):
  - order_items.qty_invoiced            (number)
  - order_items.customer_invoice_number (text)
  - order_items.invoiced_date           (date)

These three columns were referenced throughout the codebase but never
existed on the live schema, so writes were being silently dropped. The
schema fix unblocks the reconcile flow that stamps them after every
QB webhook returns.

Backfill for the live Kusasalethu order: scripts/nss-fix-kusasalethu-inv10924.ts
updates order_items.quantity 10→8 / 10→7 to match what was collected,
stamps the new columns, and flips order.invoiced=true.

New server helper reconcileOrderItemInvoicedStatus() runs after each
QB webhook and per-row decides:
  - item_status = "invoiced"     if qty_invoiced >= quantity (fully billed)
  - item_status = "ready_invoice" otherwise (still partial — stays in
    the to-invoice tab for a follow-up invoice once the rest arrives)

Wired into both the bulk invoicing page and the delivery-note-invoice
route. markOrderInvoiced now compares against qty_invoiced vs
quantity rather than the flat item_status check, and its error
message names the outstanding shortfall per row.
2026-09-17 13:53:36 +02:00
1bc0da40e7 feat(customers): lift active/inactive toggle to top of customer form
The active/inactive customer switch was buried under the
'Details' section after the sales rep and description — easy
to miss when the only thing the operator wants to do is mark
a customer as inactive.

Moved it to the second row of the form, immediately after
the customer name field, where it's the first decision the
operator makes. Visual treatment bumped to match the new
prominence: a green-tinted container when active, muted
grey when inactive, so the state is readable from across
the room instead of requiring the operator to read the
trigger label.

  state semantics (unchanged from bbfa888):
  - 'status' is a PB bool, true = active, false = inactive
  - new customers default to true via the form's
    'status: customer?.status ?? true' initializer
  - legacy rows with status undefined render as Active on
    both the detail and list pages (same default-active
    contract on read) — no migration needed
  - the only way to render an inactive customer is to flip
    the switch and save it; PB then stores false
2026-09-16 11:04:39 +02:00
da90706ce0 feat(stock): implement auto-markup for selling price and markup on stock item creation and updates
This commit enhances the stock item management by introducing automatic calculation of selling price and markup based on the cost price. Key changes include:

- Integration of `applyDefaultMarkupToPatch` to automatically set `selling_price` and `markup` at 80% of the cost price when they are not explicitly provided during stock item creation and updates.
- Updates to the `create` and `update` functions in the stock items service to ensure that existing values are respected and not overwritten if they are already set by the operator.
- Tests added to verify the correct behavior of auto-markup during both creation and updates of stock items, ensuring that the functionality works as intended across various scenarios.

This feature aims to streamline the pricing process for stock items, reducing manual input and potential errors.
2026-09-16 09:49:52 +02:00
bbfa888cc1 feat(customers): add status toggle and site/location field
Two new fields added to the customer add/edit form, both
operator-facing for the active/inactive customer workflow:

  - customers.status (bool): flips the customer active/inactive.
    Legacy rows pre-date the field and load as undefined; both
    the form and the read side collapse that to active=true so
    the migration date doesn't silently flip the existing base.

  - customers.site_location (text): free-text site/shaft label
    keyed off the customer's company. Distinct from the existing
    'site' free-text mine tag owned by the pin tool. Storage is
    plain text on purpose, with a /api/v1/customers/sites
    endpoint returning distinct existing values for an input
    + datalist autocomplete under the company combobox.

  surfaces:
  - Active/Inactive badge on customer detail and list rows
  - site_location row in the customer Details card and under
    the company line on the list (desktop + mobile card)
  - Switch under the sales-rep section of the form to flip
    status; existing customers stay active by default

  also fixes a UX trap on edit: previously opening the edit
  form showed 'Select company...' / 'Select rep...' as placeholders
  for an instant until the lookup lists resolved, making a real,
  saved company/rep look unlinked. CompanySearch and UserSearch
  now accept a selectedLabel fallback; CustomerForm passes the
  resolved customer.expand.company / customer.expand.rep names
  through so the triggers display the linked names immediately.

  listDistinctSiteLocations helper added to customers-service.ts
  alongside the existing listDistinctSites.
2026-09-16 09:47:09 +02:00
698abe9fbc feat(stock): inline qty editing + column sort + pricing-gap operator scripts
Doubles down on the /stock list to make catalogue and inventory
edits one-click for the operator.

What's new:

- /stock: clickable column headers on Cost / Markup / Selling
  cycle none → asc → desc → none and feed through to the PB sort
  param. The apply bar now commits pricing AND qty in parallel
  (Promise.all against /set-pricing and /set-quantities) with
  per-row failure tracking and focused error toasts. In edit
  mode the row is no longer a button — inputs render in-place
  for current_qty, min_qty, and target_qty alongside the existing
  pricing inputs, and the row click is gated off so clicks on an
  input never navigate to the detail page.
- stock-quantities-service + /api/v1/stock/items/set-quantities
  (POST): bulk-update current_qty / min_qty / target_qty on a
  set of stock_items rows. Whitelist-validated patches, per-row
  results with stable failure reasons, and a stock_ledger row
  written FIRST whenever current_qty actually moves so the audit
  trail stays ahead of the cached total (same pattern as
  stock-surplus and stock-booking-service).
- scripts/apply-default-markup.ts: one-shot operator tool that
  seeds selling_price = round(cost_price * 1.80, 2) and markup = 80
  on stock_items rows whose cost_price is set but selling_price
  is zero. Bypasses STOCK_ITEM_PATCHABLE_FIELDS (operator-initiated
  bulk action); idempotent (never overwrites a non-zero shelf
  price or markup).
- scripts/list-irda-stock.ts: read-only inspector for the
  IRIDA spray-caint line with summary counts (without
  selling_price, without cost_price, zero stock) so the
  operator can see exactly which rows the backfill tools above
  will touch.

Tests: 81 new test cases across /stock page, stock-quantities
service + route, and apply-default-markup.
2026-09-14 13:04:26 +02:00
7443776a14 feat(stock): seed pricing fields on create + sync price_list to stock_items
Closes the pre-Fix-#1 stock pricing gap so the till and the POS see
prices on day one.

What's new:

- price-list-service: addPriceListItems now mirrors the upserted
  selling_price + markup onto the matching stock_items row when
  (and only when) the stock row has no price yet. Sync failures
  are returned as warnings — never as a failed price_list write,
  so a single broken stock row can't poison a 500-item batch.
- stock-quick-draft-service: addQuickDraftLine seeds cost_price +
  bootstraps a one-entry cost_history on inline-create when the
  buyer supplies newStockItem.costPrice. Existing-item path
  backfills cost_price when zero and always appends a
  cost_history entry so the line addition shows up in the audit
  log without waiting on bookStockFromInvoice.
- stock/items/create (POST): accepts optional costPrice,
  sellingPrice, and markup on the standalone create endpoint so
  the operator can stamp a price and bootstrap cost_history in
  one round-trip. Empty bodies still produce an empty
  cost_history (legacy callers unchanged).
- scripts/backfill-stock-prices.ts: one-shot operator tool that
  mirrors price_list rows onto existing stock_items rows whose
  selling_price is still zero. Pure decision function exported
  for unit tests; --commit / --limit / --sku CLI; idempotent
  (never overwrites a non-zero shelf price or cost).
- vitest.config.ts: pull scripts/**/*.test.ts into the vitest
  include list so backfill-stock-prices.test.ts runs in
  `npx vitest run` without a per-script config.

Tests: 65 new test cases across price-list-service,
stock-quick-draft-service, stock/items create, and
backfill-stock-prices.
2026-09-14 13:04:08 +02:00
58b174606b fix(deliveries): group by company and show items on delivery-note rows
Two operator-facing bugs on /deliveries:

1. Page grouped rows by customer instead of company. Added a
   CompanyGroup layer above CustomerGroup with a new
   groupByCompanyCustomerOrder() that builds
   Company → Customer → Order → Items + DNs (4-level hierarchy).
   Each level sorted stably so the most recent work sits on top.

2. The delivery-note expand panel said 'No items loaded on this
   delivery note' for every DN, even when the same DN had items
   on the full /delivery-notes/{id} page. Two root causes:

   a. Wrong join key. The page filtered by
      i.delivery_note === dn.id (the never-populated relation
      field), so the comparison was always null === id and
      never matched. The full DN page joins on the text column
      delivery_note_number — switched the page to the same key.

   b. Items missing from the response. The page-level items
      list is filtered by item_status (invoiced / with_driver /
      checked_in) per tab, so items still in the 'delivery_note'
      lifecycle state (paper sent, invoice not yet created)
      weren't in the page-wide list. Added a third PB query in
      the service that runs after the DN list lands and pulls
      every item whose delivery_note_number matches one of the
      page's DNs, regardless of item_status. Returned as a new
      dn_items field and threaded through to the expand panel
      as a 'dnItems' prop. The main items list still uses the
      status-filtered query so bulk actions stay scoped to the
      tab.
2026-09-14 12:58:43 +02:00
3d009b144f fix(quotes): persist letterhead choice on terminal-status quotes too
The first letterhead-persistence fix (1e3121d) gated the WHOLE PB
write for generateQuotePdf's operator-choice persistence behind
"quote.status is not terminal" -- because terminal statuses
(approved/emailed/complete/cancelled/sending) deliberately skip the
pdf_generated_at re-stamp.

But regenerating a PDF on an already-terminal quote (e.g. clicking
"ISherwood" on an already-`emailed` quote from the process page) is
a normal, supported operator action -- confirmed live: it renders
the right letterhead and toasts "quote kept at emailed". That regen
hit the terminal branch and skipped the PB write entirely, so
company_letterhead never updated. A later auto-regen (sendQuoteEmail's
staleness check, e.g. triggered by a markup edit) then fell back to
the stale stored value and shipped the wrong letterhead again --
reproducing the original bug through a path the first fix didn't
cover.

Verified against the live quote via a direct PocketBase read
(bypassing app/MCP caching): company_letterhead was unchanged by the
first fix's terminal-status regen, and is now correctly persisted
while pdf_generated_at/status stay untouched as intended.

Split the letterhead write out from the timestamp/status write so it
always persists on an explicit operator choice regardless of quote
status. Added a regression test for the terminal-status + explicit
letterhead case.

Also syncs the n8n "Quote Outbound Send" workflow snapshot with two
more live fixes made during testing:
- Build attachments list: this.helpers.getBinaryDataBuffer() takes
  an item INDEX (number), not an item object -- per n8n's own docs.
  Was passing the item object, which threw an opaque non-Error value
  on every real send.
- Send Email (SMTP): the html field injected the plain-text
  email_message (with literal \n breaks) straight into HTML, so
  clients collapsed every paragraph into one block. Now converts \n
  to <br> first.
Also captures that the four WhatsApp-notify nodes and the workflow
itself are currently disabled/inactive in the live instance (manual
n8n edits during testing, not part of this fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 18:09:27 +02:00
1e3121d838 fix(quotes): persist operator's letterhead choice to prevent silent letterhead-flip
This commit addresses the silent letterhead-flip bug reported on 2026-09-09, where the operator's selected letterhead was not being saved, leading to incorrect letterhead being used in subsequent PDF generations. The operator's choice is now persisted in the quote row during PDF generation, ensuring that the correct letterhead is used for future auto-regens and email sends.

Changes include:
- Updated the `generateQuotePdf` function to include the operator's letterhead choice in the database write.
- Adjusted the API response to surface the persisted letterhead for UI synchronization.
- Added tests to verify that the letterhead choice is correctly saved and not overwritten by empty or invalid inputs.

This fix enhances the reliability of the quote generation process and improves user experience by maintaining the operator's selections.
2026-09-09 16:06:40 +02:00
a19ad698cd fix(quotes): finish migrating quote detail page to split attachment fields
The 2026-09-09 attachment split (cd359b7) moved outbound/internal
attachments to `quotes.outbound_attachments` / `internal_attachments`,
migrating the approvals and process pages but missing the main quote
detail page. It was still reading/writing the legacy
`quote_attachments` collection (`direction="outbound"/"internal"`),
which nothing downstream reads anymore.

Effect: the Attachments tab and Approve & Send modal on this page
couldn't see files uploaded via the new field, so the modal's
"selected attachments" checkboxes were built from an empty list —
operator-attached outbound files silently never made it into
`email_outbound_attachment_ids`, and the customer got an email with
no attachments even when the operator had attached one.

- Outbound/internal tabs, upload, and delete now go through
  `/api/v1/quotes/[id]/outbound-attachments` and `/internal-attachments`
  instead of writing `quote_attachments` rows directly.
- Send modal's attachment list is synthesized from the quote row's
  token fields (same pattern as the approvals page), so
  `selectedAttachmentIds` actually match what the server validates.
- Attachment preview dialog fetches via the `/api/v1/files/quotes/...`
  proxy instead of PB's raw `/api/files/quote_attachments/...`, which
  pointed at the wrong collection/id after this fix.

Also fixes the n8n "WFDASH — Quote Outbound Send" workflow: three
Code nodes filtered quote_attachments rows to filenames containing
'Quote-CNL' or 'Quote-ISHERWOOD' before picking the latest PDF. Since
PDF filenames are `Quote-{letterheadCode}-{number}.pdf` and there's a
third valid letterhead (WESTSCOTT GROUP OF COMPANIES), any quote sent
under that letterhead would starve the whole send chain of an input
item and silently no-op. The upstream PB query already scopes
correctly by quote id + kind, so the filter was redundant as well as
wrong — removed it, kept the latest-PDF-per-quote dedup. Repo
snapshot in n8n_workfows/ synced to match the deployed workflow
(PIEAkPYYYZLGl6Pj).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:44:29 +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
f895b3b933 chore(sidebar): temporarily hide Quote Sprint and Order Sprint nav entries
The sprint boards are disabled pending a rebuild. Operators
process quotes through /quotes/[id]/process and orders through
/orders/[id] until they are re-enabled. The original nav
entries are commented in place so they can be restored by
uncommenting a single line each.
2026-09-08 18:55:46 +02:00
81b65a92e4 chore(quote-pdf-service): drop leftover debug console.error from PDF render catch 2026-09-08 18:55:46 +02:00
230150fc88 test(quote-pdf-service): expand PB mock to support expand + FormData uploads
- getOne: resolve singular relation fields via the standard
  customer/company/user -> collections/companies/users mappings
  and hydrate them under `expand.<relation>`, including one
  level of nested expansion.
- create: accept FormData so generateQuotePdf's PDF upload
  path is exercised end-to-end; blob entries become a
  one-element filename array under their key to mirror PB's
  file-field shape.
- create: pin the mock-generated id INTO the row so getList's
  fields projection returns a real id to callers (services
  reading the returned id, e.g. auto-claim lookup, were
  receiving `id: undefined`).
- auto-claim tests: add generic type params to getOne calls
  so .status / .claimed_by are typed against the mock.
2026-09-08 18:55:46 +02:00
213a0e5a08 feat(stock-orders): add Generate / regenerate PO PDF modal with audit-trail reason
Mirrors the customer-PO PDF regenerate flow on /orders/[id] for stock purchases. Adds a regenerate modal that captures a free-text reason (defaults to 'Initial generation' for v1, blank for v2+) and posts to /api/v1/stock-purchases/[id]/pdf, appending to purchase.documents_history with the new version + reason. The Generate PDF button replaces the existing static action when no version exists yet. Refetches via fetchAll so the documents card picks up the new entry. 215 lines of new test coverage on page.test.tsx.
2026-09-08 18:46:03 +02:00
48c57d47a7 feat(quotes): auto-claim unclaimed quotes on Generate PDF
Operators no longer need to click Claim before Generate. The PDF service stamps claimed_by = <caller> when actorEmail is provided, quote.claimed_by is empty, and the quote is in a non-terminal status. Auto-claim lands atomically with the status/timestamp flip. Skip cases (existing claim, terminal status, missing/invalid actor email) leave claimed_by alone and still render the PDF. Email resolution failures are logged and non-fatal. Surfaces the post-update claimed_by on the route outcome and process page setQuote so the Claimed-by chip refreshes without a refetch.
2026-09-08 18:45:53 +02:00
56476e3ca0 fix(quote-sprint): propagate description edits to quote_items.description
The sprint-board pricing modal was writing operator edits only to
quote_items.quote_description, but the quote detail page renders
quote_items.description. The 'process and open' flow writes to
description directly, which is why edits appeared there but not
after saving from the sprint board.

Mirror the trimmed description into both quote_description and
description on save, matching the process page's write path.

Add three regression guards (edited description, cleared
description, untouched save) to sprint-item-pricing-modal.test.tsx.
2026-09-08 18:45:13 +02:00
b9d30c8e6f fix(quotes-intake): clamp email_body to PB's 5000-char cap
PocketBase treats `max: 0` on a text field as "use the default 5000-char cap", not unlimited. A Redpath reply with a 5029-char body 400'd on every 2-minute poll for hours on 2026-09-08, wedging every RFQ behind it because the failed email stayed UNSEEN.

Added clampEmailBody (with a truncation marker) and routed email_body writes through it so oversized replies still become quotes instead of blocking the queue. The untruncated body remains in the mailbox, and attachments carry the detail. Raise this cap in lockstep with any PB admin change to the field's max.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:28:20 +02:00
9ec1b04cab fix(quotes-search): order hits by created desc so the most recent match is on top
Operators searching for a customer name (e.g. 'Martin', 'Blythwaite') or
an item term (e.g. 'generator') were seeing their latest quote buried
beneath an older quote that scored higher on the relevance ranker
(exact quote_number > customer name > item description). Pre-Sept-2026
the service sorted by score desc with id as a stable tiebreak, so a
2025 quote_number hit from 2025 outranked a 2026 customer-name match
for the same person.

Score still decides which quotes qualify as matches (the
breakdown.raw > 0 filter upstream of the sort), and the per-hit
score is still surfaced in the response for the UI's 'why this
match' breakdown. What changed: the order within the result set is
now created desc, with quote id as a deterministic tiebreaker for
batch-imported quotes that share a created timestamp.

Added tests:
  - orders hits by created desc, regardless of score (regression
    guard for the user-reported case)
  - breaks ties on identical created timestamps by quote id asc
    (stable pagination across page loads)

Renamed 'ranks exact identifier match above item-only match' to
'surfaces both identifier and item matches in the result with
their scores intact' — the assertion still holds because both
fixtures share the default created date, but the test's intent
is now 'both hits surface with their scores preserved', not
'order is determined by score'.
2026-09-08 10:24:34 +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
David
43cdf02ae5 feat(mobile-dashboard): add light/dark toggle, switch accent to red, bigger numbers
Boss phone dashboard (/) improvements:

- Add a Sun/Moon theme toggle button next to the refresh button.
  Uses the existing next-themes provider so the choice stays in
  sync with the Settings → Mode toggle.
- Replace the cyan accent (cyan-400/cyan-500/*) with red (red-400
  for chrome, red-500 on tinted backgrounds) for stronger contrast
  on both light and dark themes. The active today/week/month tab
  and per-rep/per-driver avatar circles use bg-red-500/15 +
  text-red-500 for high-contrast highlights.
- Bump OrderTotal values from text-xl/font-semibold to
  text-3xl/font-bold, and MiniMetric values from text-sm/font-
  semibold to text-lg/font-bold, so the month-of-loaded/claimed/
  delivered/invoiced counts are easier to read at a glance.
2026-09-07 15:02:10 +02:00
20304442a7 fix(delivery-notes): backfill customer/company when linking a DN's order
setDeliveryNoteOrder only ever wrote the `order` relation. Delivery
notes created while the fields+expand bug (see prior commit) was live
got saved with customer/company/order all blank — this setter is the
only existing entry point that touches the order relation, so it now
also fills customer/company from the linked order when the DN's own
values are blank (never overwrites an operator-set value). Applies on
both the write path and the idempotent-order-unchanged path, so
re-running the same call is enough to finish backfilling an affected
DN once the order is already linked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 10:39:22 +02:00
6dc7204477 fix(invoicing): restore expand data on delivery-note PDF generation
PocketBase silently drops `expand` from the response once a `fields`
projection is present, unless the expand sub-paths are explicitly
listed in that same `fields` string. The `exclude_from_invoice`
projection added on 2026-08-30 didn't list any `expand.*` paths, so
order/company/customer all rendered blank on the delivery note PDF
(e.g. DN26090702).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 10:22:17 +02:00
95d93791f9 fix(rep): refresh the customer snapshot on reopen, not just on first load
Reported live: a customer (Benjamin) was pinned this morning, but the
rep check-in screen never showed him nearby no matter how far the
search radius was widened — even though the server-side query for him
was confirmed correct and fast.

Root cause: the customer snapshot only ever fetched once, on mount
(empty-deps useEffect). A PWA is rarely actually closed — it gets
backgrounded and resumed — so reopening the app after someone else's
pin landed didn't count as "loading" it again; the phone kept working
off whatever list it cached before that write happened. Widening the
radius only changes which of the *already-loaded* customers match; it
can't find someone who was never in the list to begin with.

This was invisible because the freshness label only showed the
calendar date ("updated 9/7/2026"), so a snapshot from three hours
earlier read identically to one from thirty seconds ago for the rest
of that day.

Fixes, in snapshot-freshness.ts (pure, 9 tests) + check-in-view.tsx:
- Label now shows relative age ("5 min ago", "3h ago"), ticking forward
  every 30s while the screen sits open.
- Snapshot older than 15 min is flagged with an inline "refresh" link.
- Refetches automatically when the app regains foreground focus AND
  the cached snapshot has gone stale (visibilitychange + staleness
  check — not on every glance, to avoid needless refetching).
- Always-available manual refresh icon in the header, independent of
  staleness, for a rep who has out-of-band reason to believe the list
  changed (e.g. told a colleague just pinned someone).

Separately, on the same report: "Henny" showing as nearby instead of
Benjamin was NOT a bug — there are 6 different "Hennie"-named customers
in the database, 5 unpinned and one (HENNIE, all caps) with real
coordinates 552m from Benjamin's, genuinely in the Driefontein area,
not Fochville. The app matched correctly; the ambiguous duplicate name
is a data-quality issue for the batch pin tool to help sort out
separately, not a code fix.

27 tests pass in src/app/(rep). Verified the server-side customer query
directly against PocketBase during diagnosis — confirmed correct and
not the source of the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:10:18 +02:00
7ba0b9e71f feat(visits): batch pin tool — tag every customer on a shaft at once
The single-pin tool (pick one customer, click the map, save) doesn't
scale to how the customer base actually clusters: dozens of contacts
sit at Harmony Kusasalethu, at South Deep Shaft 3, and so on. This adds
a batch counterpart at /visits/pin-tool/batch: type a site name, tick
every customer there, click the map once, and all of them get pinned.

Site is a plain free-text field on customers (Customer.site in
lib/types.ts), not a separate sites collection — simpler to add and
maintain, at the cost of relying on the office typing the name the same
way each time. The batch tool's autocomplete (backed by
listDistinctSites) and its "select everyone already tagged X" shortcut
exist specifically to curb that drift without a second collection to
manage.

- jitterCoordinates (lib/geo.ts) scatters each customer 30-80m from the
  clicked point so forty pins on one shaft render as distinct dots on
  the office map instead of one marker stacked forty deep. Bounded by
  distanceKm in tests, which is the same great-circle maths the
  check-in radius match uses.
- assignCustomersToSite / listDistinctSites (customers-service.ts) —
  the batch write and its autocomplete source. Each customer is written
  independently, mirroring recordVisits: a stale id in a forty-customer
  batch shouldn't cost the other thirty-nine.
- use-customer-list.ts extracted from pin-tool-view.tsx (verbatim, no
  behaviour change) so the batch view doesn't duplicate the fetch.
- batch-pin-helpers.ts holds the pure decisions (save-readiness order,
  filter) the same way pin-tool-helpers.ts does for the single-pin tool
  — the Leaflet-dependent view is hard to unit test directly.

Applied the "use client" + dynamic(ssr:false) lesson from the single-pin
tool's earlier 500 up front in batch/page.tsx, with a comment explaining
why it's load-bearing rather than decorative.

99 new/updated tests. Scoped verification only (geo, customers-service,
visits/*) — the wider suite currently shows unrelated failures in
quotes-service/quote-pdf-service from a change on main I did not make
and did not investigate further; none of it touches customers, geo, or
visits.

BLOCKED until one PB admin change: customers.site does not exist yet
(text field, optional). Nothing in this commit writes to it until then.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:30:23 +02:00
85913f5869 fix(quotes): guard possibly-undefined itemAt in isQuotePdfStale
Deployment was failing at `npm run build` with:
  quote-pdf-service.ts(487,10): error TS18048: 'itemAt' is possibly 'undefined'

`itemAt` comes from `latestItem.items[0]?.updated` (row type
`{ updated?: string }`), so it's `string | undefined`, while `pdfAt`
was already narrowed to `string` by the earlier `if (!pdfAt)` guard —
comparing the two fails under strict mode.

Semantically, a quote with no active items left (all moved/removed)
has no item timestamp, and that alone shouldn't mark the PDF stale;
only the quote-level timestamp decides in that case.

Verified with a full `npm run build` (exit 0).
2026-09-07 07:54:10 +02:00
66559af422 Add quote PDF generation and stale PDF handling 2026-09-07 07:08:27 +02:00
6bedf27617 fix(pos): print the letterhead's own logo, and match delivery note to the sale slip
Logo — three separate faults, all of which had to be fixed for the
letterhead logo to print at all:

1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it
   printed the app's bundled mark (the solid black W block) and never
   looked at the letterhead at all.
2. The paths that DID use the letterhead (printTest, cashup) passed
   PocketBase's fully-qualified cross-origin URL into imageToRaster(),
   which draws to a <canvas> — getImageData() taints and throws on a
   cross-origin image, so the .catch(() => null) silently dropped the
   logo. pos-letterhead-service now returns a same-origin
   /api/v1/files/... URL (per AGENTS.md's file-URL rule).
3. letterhead_settings wasn't whitelisted in that file proxy, so the
   same-origin URL would have 403'd.

Verified against the live WESTSCOTT letterhead: its SVG rasterizes to
384x137 at ~15% ink coverage (line art), no tainted canvas.

Also fixes the company name never printing: every builder read
`letterhead.name`, but the POS letterhead record exposes
`display_name` — so the name line silently rendered empty on every
slip, on screen and on paper.

Delivery note now matches the sale slip, per request: gains the logo
and business_info blocks and the same 2x-size title, instead of being
a stripped-down variant. This also reconciles the transport
divergence flagged earlier (Bluetooth baked business info into its
header; the network builder omitted it entirely) — it is now one
reorderable business_info block on both.

resolveBlocks() now slots a newly-shipped block in at its intended
position rather than appending it to the end. Without this, any
letterhead with an already-saved layout would have printed the new
logo/business_info blocks BELOW the signature line.
2026-09-06 23:03:24 +02:00
a7341397a6 fix(pwa): restore apple-mobile-web-app-capable so iOS installs standalone
Adding /rep to an iPad home screen and tapping it opened the main app,
not the rep app — while the shortcut URL was still /rep.

Cause: Next 16 renders `appleWebApp.capable: true` as the standardised
<meta name="mobile-web-app-capable"> and no longer emits the
apple-prefixed name. iOS only reads the apple-prefixed one. Without it
the icon is a plain Safari bookmark rather than a standalone app, so
tapping it foregrounds Safari on whatever tab was already open — which
presents exactly as "it opened the main app instead".

This was not specific to /rep. Verified against the running app that
/m (Boss Phone) had the same gap, so it has never actually launched
standalone on iOS either; /counter likewise. The comment in
(counter)/layout.tsx asserting the root layout already supplied this
tag was wrong — the root also only set `appleWebApp.capable`.

Declared in the root layout so all four surfaces inherit it, and
repeated in the (rep) and (mobile) layouts because a child's
`metadata.other` replaces the parent's rather than merging into it.
Corrected the misleading counter comment.

Verified by fetching each route and asserting the rendered head:
/rep, /m, /new-order and /dashboard now all emit
apple-mobile-web-app-capable="yes" while still resolving their own
manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:56:01 +02:00
c97b2cf018 fix(visits): surface batch write failures instead of a silent 200
Found by driving /rep in a real browser session, not by a unit test:
submitting a check-in returned 200 OK with body {"created":0,"pinned":1},
and the visit vanished with no trace while the customer coordinate pin
still landed. Reproduced against the live database (confirmed via
direct PocketBase query) and again after the fix, with the response
now 502 and the entry correctly retained in the outbox.

Root cause was two-layered:

1. visits-service.ts caught every per-visit write error and continued
   silently, discarding the message. A per-item failure (one stale
   customer id in a batch) is meant to be swallowed so it doesn't cost
   the other visits — but the same catch also swallowed a systemic
   failure (every item in the batch hitting an identical PB validation
   error), which looks completely different: a bare `created: 0` gives
   the caller no way to tell "nothing to do" from "everything failed".
   recordVisits now returns an `errors: {customerId, message}[]` array
   instead of discarding the exception.

2. POST /api/v1/visits returned HTTP 200 whenever the request was
   well-formed, regardless of whether anything was actually written.
   The offline outbox treats any 2xx as delivered and discards the
   entry — so a total write failure was indistinguishable from success
   at the one layer that decides whether to keep retrying. The route
   now returns 502 when `created === 0`, which the outbox already
   retries (see the existing 5xx-keeps-the-entry test), rather than a
   4xx, which it treats as permanent and drops.

The concrete trigger was live: rep_visits.position_source doesn't yet
allow "phone" (flagged in the previous commit). That specific gap will
close once it's added to the PB select, but this fix stands on its own
— any future rejected field would have hit the identical silent-loss
path.

12 new tests (service: captured errors on per-item and total failure;
route: 502 vs 200 boundary, repId taken from the session not the body;
outbox: a 502 keeps the entry queued rather than treating it as sent).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:25:52 +02:00
c8b7e1f51b feat(rep): visit check-in PWA — phone GPS, offline outbox
The rep app: open it on site, it finds who's around you, tick who you
saw, done. Third instance of the per-route-group PWA pattern already
running here (/m boss phone, /counter), so it reuses MobileAuthGate and
gets its own manifest + service worker.

Phone GPS is the primary position source, not the vehicle tracker. The
tracker knows where the bakkie is; the phone knows where the rep is,
and those diverge by a few hundred metres once the rep walks into the
plant. GPS also needs no network, which matters because mine shafts
have poor signal. The tracker is the secondary path
(GET /api/v1/visits/position) for when location is denied or
unavailable. The independently-collected vehicle trail corroborates
check-ins afterwards, so honesty doesn't depend on the phone.

Matching runs on the phone, not the server. The app caches a compact
customer snapshot and runs rankNearbyCustomers locally, so there's one
code path that behaves identically online and off — rather than a
server path plus an offline fallback that only gets exercised in the
places it's hardest to debug.

- (rep)/rep/            layout, page, check-in view, outbox
- visits-service.ts     getCustomerSnapshot, recordVisits
- POST /api/v1/visits           record a check-in
- GET  /api/v1/visits/customers snapshot for the offline cache
- GET  /api/v1/visits/position  tracker fallback
- public/manifest-rep.json, public/sw-rep.js, pickSwPath() branch

These routes authenticate with the browser session, not AGENT_API_KEY —
the caller is a logged-in rep. The rep id comes from the validated
session rather than the request body, so a client can't log a visit as
someone else.

The outbox uses localStorage rather than IndexedDB deliberately: the
payloads are tiny and localStorage is synchronous, which matters when
the rep hits Save and immediately pockets the phone. Entries are only
removed once the server acknowledges them, so a failed flush retries
instead of dropping a visit — duplicates are preferred over loss, and
surfaced rather than merged. Permanently-rejected entries (4xx other
than 401/408/429) are dropped so one bad row can't wedge the queue.

recordVisits writes each visit independently: a stale customer id
shouldn't cost the other four visits in a queued batch.

BLOCKED until one PB admin change: rep_visits.position_source does not
yet allow "phone" — verified against the live instance, PB returns
400 "Invalid value phone". Add it to the select. Separately,
accuracy_m is silently accepted and discarded (PB ignores unknown
fields), so GPS accuracy is thrown away until that number field is
added; not blocking.

47 new tests. Verified on :4001: /rep serves 200 with the right
metadata, manifest-rep.json and sw-rep.js are served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:09:27 +02:00
a551a71277 feat(tracker): breadcrumb poller — WFDASH's own vehicle position history
FleetAI has no position-history endpoint, so to answer "where was this
rep's vehicle at 10:20 last Tuesday" we have to record it ourselves.
An n8n Schedule Trigger calls POST /api/v1/tracker/poll every 5 minutes
during working hours; a nightly run derives stops and prunes.

  poll:     */5 6-18 * * 1-5     (the working-hours window lives in the
                                  cron, not in app settings — disabling
                                  the workflow is the off switch)
  nightly:  30 2 * * *

One getTokenBaseLiveData call covers the whole fleet per cycle, and only
genuinely new GPS fixes are written: dedupe keys on (vehicle_no,
fixed_at) against the newest fix already held. A parked tracker keeps
re-reporting the same GPSActualTime, so without this a 5-minute cycle
would store the same row a dozen times an hour.

Raw breadcrumbs are pruned at 35 days; derived rep_stops are kept
indefinitely, since a stop is small and is what reports actually ask
about.

Two bugs found by running this against the real stack, neither of which
the unit tests could have caught:

- FakeTrackerAdapter derived its fix time from Date.now(), so every poll
  looked like new movement. It could never reproduce a parked vehicle —
  the exact case dedupe exists for — and made the poller look correct
  while storing a duplicate every cycle. The fake now quantises its fix
  time to a bucket, so repeated polls inside one window return an
  identical fix.
- PocketBase's filter parser silently matches NOTHING against an
  ISO-8601 `T` separator. `fixed_at >= "2026-09-06T00:00:00.000Z"`
  returned 0 rows where the space-separated form returned 2, with no
  error either way — the rollup would have derived no stops and the
  prune deleted no rows, both reporting success, forever. Fixed with a
  `pbDate()` helper matching the existing convention in
  boss-dashboard-service.ts and order-delivery-note-link-service.ts.

Verified end to end on :4001 against the live PocketBase: three
consecutive polls returned stored=0/duplicates=1, and the nightly rollup
went from vehicles=0 to vehicles=1 once the date format was fixed. Test
rows were removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:08:20 +02:00
b09a7e2e56 fix(visits): pin tool page 500s without the "use client" directive
`GET /visits/pin-tool` returned 500 on every request:

  x `ssr: false` is not allowed with `next/dynamic` in Server
    Components. Please move it into a Client Component.

The page uses `dynamic(..., { ssr: false })` to keep Leaflet off the
server (it touches `window` on import) but was missing the
`"use client"` directive that `supplier-map/page.tsx` carries for
exactly the same reason.

Worth noting how this got through: it fails at request time, not build
time, so `tsc --noEmit` was clean and all 32 unit tests passed. Nothing
renders the page, so only loading it in a browser surfaces it. Verified
by hand — 500 before, 200 after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:00:19 +02:00
61c5d8c906 feat(visits): office pin tool for seeding customer coordinates
Radius-based rep check-in has nothing to match against until customers
carry real positions: 0 of 616 had any — every `coordinates` geoPoint
was PocketBase's `{lon: 0, lat: 0}` default, and most company addresses
are P.O. Boxes, so geocoding can't seed them either. This is the tool
that seeds the ~20 main West Rand shafts by hand.

- /visits            landing page; a launcher until the visit-history
                     view has data to show
- /visits/pin-tool   pick a customer, click the map, save
- setCustomerCoordinates() in customers-service.ts, with lat/lng range
  validation so a string off a form input can't write a poisoned row
- PATCH /api/v1/customers/[id]/coordinates for agent/MCP callers
- Visits entry in the sidebar's Management group, plus MOBILE_TITLE_MAP

The browser writes through a Server Action, not the /api/v1/ route:
that route sits behind `assertAgentAuth` and a browser has no
AGENT_API_KEY. Both paths share `setCustomerCoordinates`. Mirrors the
existing saveAiSettingsAction split.

Pure view logic lives in pin-tool-helpers.ts so it can be tested away
from the Leaflet runtime.

Also refreshes the PB schema export — `rep_visits`, `rep_stops`,
`rep_vehicle_positions` and `users.tracker_vehicle_id` now exist on the
live instance.

32 tests. The suite's 9 pre-existing failing files are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:58:07 +02:00
f336404f71 fix(invoicing): refresh a cached Invoiced tab after stock is booked
Booking stock from the To Invoice tab did not show up on the Invoiced
tab when that tab had already been visited once. The new-booking event
was ignored unless the operator happened to be on Invoiced at the time,
and switching back afterwards found a non-null `stockBookings` cache and
skipped the fetch — so the freshly booked PO stayed invisible until a
full reload.

The event handler now invalidates the cache first and only then decides
whether to re-fetch, so the tab always picks up the new row. The initial
load is deferred by a zero-delay timeout to keep the fetch out of the
tab-switch render pass.

Also corrects the `loadStockBookings` comment, which referred to a
`recentStockBookings` binding that no longer exists, and notes that rows
sort by `booked_at` rather than the stock PO's own dates.

Covered by a new test that visits Invoiced, switches to To Invoice,
fires the booking event, and asserts the second fetch happens.

This change was already present in the working tree at the start of the
session that committed it; committed here on request. The one unrelated
failure in this file ("shows a Printed badge in the Invoiced tab") is
pre-existing and was verified identical on a stashed tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:30:50 +02:00
6fd11f4af0 feat(rep-visits): FleetAI tracker adapter, nearby-customer matching, stop detection
Groundwork for the rep visit check-in feature: a rep taps "I'm on site",
the vehicle's tracker position surfaces which customers are there, and
the visit is logged against them.

Pure/unit-testable layers only — no PocketBase writes and no UI yet,
both of which are blocked on schema that has to be added in the PB
admin first. Design doc: docs/superpowers/specs/2026-09-05-rep-visit-check-in-design.md

- lib/visit-matching.ts   rankNearbyCustomers: radius match around a
                          point, rep's own customers first
- lib/visit-stops.ts      detectStops: breadcrumb trail -> stops.
                          Anchor-based clustering rather than a running
                          centroid, which would let a slow crawl chain
                          into one bogus stationary stop
- lib/tracker-parsing.ts  Netstar FleetAI response decoding
- lib/server/tracker-service.ts
                          FleetAiAdapter + FakeTrackerAdapter behind a
                          TrackerAdapter seam. The fake is auto-selected
                          when no credentials are configured, so the rest
                          of the feature builds and tests without vendor
                          access.
- lib/geo.ts              isRealGeoPoint, lifting the Null-Island guard
                          that was inlined identically in customers/[id]
                          and companies/[id]

Three FleetAI conventions drive real code here and are each pinned by a
test: errors arrive as HTTP 200 with a failure envelope; "--" means
"unavailable" and is not null (and for IGN, not false); timestamps are
platform-local UTC+2 with no offset marker, so parsing them naively puts
every fresh fix two hours in the past.

FleetAI has no position-history endpoint, so the design polls
getTokenBaseLiveData (whole fleet in one call) and keeps its own trail.
scripts/fleetai-probe.mjs is a throwaway read-only probe to confirm the
three account-shaped assumptions before more is built on top.

102 tests pass. The 9 pre-existing failing test files elsewhere in the
suite were verified identical on a stashed tree and are unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:22:28 +02:00
102673d00b feat(pos): add GET/PUT /api/v1/pos/slip-layout/[letterheadId] 2026-09-04 13:24:00 +02:00
3a29ad6a6a feat(pos): add pos-slip-layout-service for reading/writing per-letterhead slip layouts 2026-09-04 13:23:09 +02:00
7d2132e000 fix(pos): fix uncaught 500 on checkout, allow overselling into negative stock
- checkoutSale's stock-availability loop called pb.getOne() with no
  try/catch, so a stale/deleted stock_item_id in a persisted cart
  threw all the way up to a bare 500 with no useful message. Now
  caught and returned as a clear pb_error the till can display.
- Removed the insufficient-stock block entirely: the till is used
  ahead of a physical stock take, so a sale must never be blocked
  for lack of stock — current_qty is now allowed to go negative and
  gets corrected at the next stock take. Drops the now-dead
  insufficient_stock/shortages branches from the route, service
  types, and client error handling.
2026-09-04 13:00:12 +02:00
85761a8698 fix(orders): improve error handling for order creation and enforce email body length limit
Enhanced error messages to include specific field validation details from PocketBase, allowing callers to identify issues more easily. Additionally, implemented a safeguard to truncate the email body to a maximum of 5000 characters to prevent validation errors from surfacing as generic messages.
2026-09-04 12:55:53 +02:00
46d5d540a0 feat(pos): letterhead service + 2 API endpoints + vat_percent on Letterhead type 2026-09-04 09:13:53 +02:00
aed3f5c861 feat: implement discount functionality in POS system
Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
2026-09-03 12:19:21 +02:00
c64e5dc2ab feat: expand customer relation in PDF generation
Enhanced the PDF generation for quotes by expanding the customer relation to include the associated company information. This allows the company name to be displayed prominently in the "TO:" block of the PDF, improving clarity for users. Updated test data to reflect the new structure and ensure proper rendering of the company name when available.
2026-09-02 11:40:50 +02:00
ccb27db82f feat: add phone view link to dashboard
Introduced a new link in the dashboard that allows users to access the mobile view of the application. The link features a smartphone icon and is styled for better visibility, enhancing user navigation options.
2026-09-02 11:06:15 +02:00
bdd0ac46e3 feat: implement per-route service worker selection
Updated the service worker registration logic to allow for specific service workers based on the route. Added support for a mobile service worker at `/m`, while maintaining existing functionality for `/counter` and `/new-order` routes. This enhances the PWA experience by serving optimized service workers for different app sections.
2026-09-02 07:49:38 +02:00
23d4e8dfe1 chore: update page.tsx 2026-09-02 01:38:33 +02:00
058803d90f chore: update AGENTS.md 2026-09-02 01:17:05 +02:00
e3b1c09704 chore: update package.json 2026-09-02 00:41:31 +02:00
7c2322b22a fix: use operator's quote_description on the quote PDF
The pricing modal saves quote_description correctly, but the PDF
template rendered item.description unconditionally, so the
operator's edit never showed up on the customer-facing quote.
2026-09-01 18:09:34 +02:00
ac201cb0a1 fix: preserve destination through the login redirect
Auth gates redirected to a bare /login with no memory of where the
user was headed, and the login page always sent them to /dashboard
afterward — so opening the /m PWA while signed out landed you on the
main dashboard instead of back on /m. Both gates now pass ?next=,
and login honors it (falling back to /dashboard if missing/unsafe).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:47:27 +02:00
2014814c84 feat(deliveries): click-to-expand DN items panel
Adds a chevron button at the left edge of every delivery-note row on
/deliveries. Clicking reveals an inline items panel (description +
qty) so the operator can match against the printed DN paper in their
hand before loading.

Items come from a client-side filter of the order group's items array
(items.filter(i => i.delivery_note === dn.id)). No new server endpoint.
DNs whose items aren't in the order group's slice show a 'See full DN →'
link to /delivery-notes/[id].

Expand state is held at the OrderGroupView level as a Set<string> of
expanded DN ids — independent per DN, resets on refresh. (State lifted
to OrderGroupView rather than DeliveriesPage because order.items is
already in scope there; avoids prop-drilling through CustomerGroupView.)

Service change: deliveries-service.listDeliveryItems now fetches the
delivery_note (relation id) + unit fields on each item, so the page
can filter and render units without an extra round-trip.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 12:15:35 +02:00
742010a9a9 chore(login): simplify to login-only, remove register/reset tabs
The /login page previously had three tabs (Login / Register / Reset
Password) wired through useAuth(). The Register and Reset handlers
were removed in this pass — only the Login form remains, plus a
small UI/styling cleanup that was collateral.

- Drop Tabs imports + the active-tab state plumbing
- Drop regName/regEmail/regPassword/regConfirm + resetEmail state
- Drop handleRegister + handleReset handlers (login-only now)
- Drop useAuth().register / useAuth().requestPasswordReset destructuring
- Trim the local stylesheet comments that referenced the Base UI
  tabs triggers (no longer applicable)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 02:11:28 +02:00
bd328d40dc feat(deliveries): enrich DN row subline with customer/order/paperclip
The /deliveries To Load + Back from Driver + Checked In tabs each
render delivery-note rows showing only 'DN {number}' + a one-line
status subline. Operators couldn't identify the customer or tell
whether the signed copy was back without scrolling up to the
customer/order group header.

Replace the existing subline with a compact pieces list:
customer name (truncated, with title tooltip) · company name ·
parent order number · signed-copy indicator (paperclip icon +
filenames in title=) · delivery date (if set). Counter-pickup DNs
(no parent order) collapse to 'Counter pickup' in italic.

All data is already on the row payload from /api/v1/deliveries —
no server or schema changes.

Also removed the now-unused 'subline' const that the prior
implementation used for the old single-line status subline.

Verified visually via dev server: counter-pickup fallback renders
correctly ('Counter pickup' italic). Order-attached and signed-copy
code paths are structurally correct per the spec — would need a
DN with order + signed files in the live data to exercise them,
but no such DN was visible in the current dev data.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 02:09:49 +02:00
1fc3580aed perf(dashboard2): consolidate 14 PB count queries into 1 fetch
Single getList pulls every quote this rep created in the last 62 days,
then buckets client-side into the 6 day/week/month windows plus the
8-week trend. Also fans out the 4 remaining queries (outstandingQuotes,
outstandingOrderItems probe, monthRevenue, the consolidated quotes
fetch) in parallel via Promise.all.

Query count: 18 → 5. Page load bounded by the slowest query instead of
the sum. The widest quote window is "last month" (~62 days), so that
single bound covers every other day/week/month window for bucketing.
Cap at 2000 rows to defend against runaway responses for very active
reps.
2026-09-01 02:08:52 +02:00
dc8436e832 feat(orders): surface order age as colour-coded badge
Replaces the bare formatDate(order.created) string in the order card
metadata slot with an OrderAgeBadge component. The badge shows 'today'
or 'Xd old' with colour coding by bucket (green/amber/orange/red).
The absolute date is preserved in the badge's title= tooltip.

Bucket math lives in src/lib/order-age.ts (covered by 14 unit tests).
This change is rendering-only — no server or schema impact.

Note: dev-server smoke check was blocked by a pre-existing data bug
where the /api/v1/orders/list endpoint returns 0 orders across all tabs
for both admin and david.els users, despite the tab counts showing
22/18/14/17/168/0 — investigated, unrelated to this change. The
OrderAgeBadge will render correctly once that bug is fixed.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 02:05:07 +02:00
27efc8628c feat(dashboard2): show last month's selling value alongside the delta
Selling card now reads "+12% vs previous · R 200,000" instead of just
"+12% vs previous". Optional previousValue prop on RepKpiCard; the other
3 cards don't pass it, so their format is unchanged.
2026-09-01 02:04:25 +02:00
42916f2948 fix(dashboard2): surface PocketBase error details in /api/v1/rep-dashboard
PB SDK rejects filters with a generic "Failed to fetch." wrapper, but the
real reason lives in error.response.data.message (or .data.message).
Walk the chain so the page's inline error UI shows the actual PB message
instead of a useless generic toast. Also log the full error server-side.
2026-09-01 02:00:55 +02:00
6fcef50b3e feat(orders): add order-age util with bucket thresholds
Pure function: getOrderAge(created, now?) returns {daysOld, bucket, label}
in calendar-day delta for Africa/Johannesburg. bucketToBadgeClass maps
to tailwind classes (fresh/recent/slipping/stale). No React, no DB.

Vitest unit tests cover all 5 bucket thresholds, future-date clamp, and
the JNB timezone edge case (same calendar day in JNB = daysOld 0).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 01:58:46 +02:00
f84d14fe4d fix(dashboard2): show error inline instead of getting stuck on Loading
- Track explicit `error` state; render an inline error panel with a Try-again button when the fetch fails and we have no data to display (previously the toast fired but the page sat on the Loading… screen forever)
- Add `content-type: application/json` header (matches /dashboard convention)
- Switch gate from `loading || !data` to `loading && !data` so we don't hide data once it's loaded
- Defer the initial fetch via setTimeout(0) and only start the realtime subscriptions AFTER the first successful load, so a slow first fetch doesn't race with realtime events
2026-09-01 01:58:38 +02:00
c9096dfd84 feat(dashboard2): trim Row 1 to Selling-only with last-month delta
- Drop Cost and Markup gauge — Selling is the headline the rep cares about
- monthRevenue is now {sellingThisMonth, sellingLastMonth}
- One combined PB query covers both months; classify each row by date in code so the delta costs zero extra round-trips
- RepKpiCard gains a format="currency" prop so Selling renders ZAR-formatted with a delta arrow (mirrors the quote-count cards)
- Row 1 grid adjusts from 6-col to 4-col to match the new card count
2026-09-01 01:55:52 +02:00
f7938375f6 feat(dashboard2): move Selling/Cost/Markup up to Row 1 alongside quote KPIs
All 6 headline numbers now sit on a single row at the top (grid-cols-1 / sm:grid-cols-3 / lg:grid-cols-6). Gauge is shrunk to size=140 so it fits a 6-col grid; sublabel trimmed to single-line currency pair so it doesn't wrap inside the narrow card.
2026-09-01 01:52:22 +02:00
9abc7d4d03 feat(dashboard2): split Selling/Cost into KPI cards, parallelize data fetches
- Row 3 now shows separate Selling + Cost KPI cards (ZAR-formatted) plus the Markup gauge
- Outstanding-quotes, outstanding-order-items probe, and month-revenue queries now run in parallel via Promise.all (was sequential, costing ~300ms)
- Outstanding-order-items probe capped at 200 rows so a single rep's data stays in a single PB round-trip instead of paginating
2026-09-01 01:49:57 +02:00
23050e3dc6 feat: add /dashboard2 rep-scoped dashboard page 2026-09-01 01:36:53 +02:00
afb396b03b feat: add /api/v1/rep-dashboard route 2026-09-01 01:36:35 +02:00
3637eef83a feat: add rep-scoped dashboard data aggregation service 2026-09-01 01:36:31 +02:00
f7b5de4c85 feat(charts): allow RadialGauge to override its center label 2026-09-01 01:33:16 +02:00
652b60706e feat: add getBrowserSessionUser for routes that need the caller's identity 2026-09-01 01:33:10 +02:00
9cf8e39b65 test(invoicing): mock useAuth to keep rep-filter a no-op in tests 2026-08-31 23:38:06 +02:00
ed07617827 feat(delivery-notes): filter list to the logged-in rep's own notes 2026-08-31 23:28:02 +02:00
29eb003cd6 feat(invoicing): filter rows to the logged-in rep's own orders 2026-08-31 23:27:23 +02:00
8faa4437b6 feat(quotes): filter list to the logged-in rep's own quotes 2026-08-31 23:26:42 +02:00
defa481661 feat(orders): filter list to the logged-in rep's own orders 2026-08-31 23:25:44 +02:00
d23c811b91 feat: add isRepUser helper for rep-scoped list filtering 2026-08-31 23:24:25 +02:00
392a76fef7 fix(boss-dashboard): show raw claim count + fix stale window on tab switch
"Claimed" was gated to emailed/approved/sent quotes, undercounting reps
who claim a lot but haven't pushed everything out yet (e.g. Karin showed
1 instead of her actual 4 claimed today). Swap in quotesLoaded so it
reflects total quotes processed, any status.

Also fixes tab switching (Today/Week/Month) needing a manual refresh to
show the right numbers: selectWindow() called fetchData() before the
activeWindowRef mirroring effect had run, so the fetch used the
previous tab's window. Now set synchronously in the click handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:38:41 +02:00
e1f14d9c48 fix(boss-dashboard): make /m installable as a PWA
manifest-mobile.json had an empty icons array, which blocks Chrome's
install prompt entirely. Point it at the existing android-chrome
192/512 icons and override appleWebApp.title to "Boss" so iOS
Add-to-Home-Screen matches the manifest's short_name instead of
inheriting the root layout's "WFDASH".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:14:48 +02:00
e722788ab0 refactor(boss-dashboard): simplify rep card to loaded/claimed/orders
Drop the separate quotesLoaded metric and relabel quotesDoneFor as
"loaded" and quotesDoneBy as "claimed" — two near-duplicate numbers
per rep card was more confusing than useful.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:14:43 +02:00
f05fc5d9cf fix(boss-dashboard): filter to active reps only + fix PB date-filter bug
The reps list seeded a row for every `users` record (admin, test
accounts, drivers, duplicate entries), not just the 6 real sales reps.
Also, window filters built with toISOString() used a "T"-separated
date PB's filter engine doesn't match against `created`, so every
window silently returned zero rows — the whole dashboard read 0
regardless of real activity. Same fix pattern already used in
order-delivery-note-link-service.ts / order-order-duplicate-link-service.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:14:40 +02:00
40993ce8f8 refactor: remove check-progress and run-backfill scripts; add DELETE endpoint for quote_items
This commit deletes the check-progress.mjs and run-backfill.mjs files, which were previously used for order processing and backfilling tasks. Additionally, it introduces a DELETE endpoint for quote_items, allowing for the deletion of quote items by ID, along with corresponding tests to ensure proper functionality and error handling.
2026-08-31 18:36:15 +02:00
9f27e9d4f1 feat(stock): beforeunload warning when pricing draft is dirty 2026-08-31 17:15:42 +02:00
7aaed60860 feat(stock): persist pricing draft to localStorage 2026-08-31 17:14:34 +02:00
759e05048c feat(stock): edit-pricing mode + dirty rows + apply bar 2026-08-31 17:12:28 +02:00
104cf4cc6c feat(stock): missing-pricing-only filter on /stock 2026-08-31 17:08:26 +02:00
f63fb7d8fc feat(stock): show pricing columns toggle on /stock 2026-08-31 16:57:25 +02:00
Claude
a4bfef123b fix(stock): return 400 (not 500) for malformed PB id in set-pricing route
Code-review feedback from Task 4: the verbatim plan source had a
`throw new Error(...)` inside the per-row .map() that escaped
synchronously as an HTTP 500 instead of the structured 400 envelope
the rest of the route uses for validation errors. Swap .map() for a
for-of loop that returns the structured 400.

Verified:
- Bad id → 400 {code: "validation_error", ...}
- Happy path → 200 unchanged
2026-08-31 16:56:03 +02:00
6f8c6da0fe feat(stock): add POST /api/v1/stock/items/set-pricing 2026-08-31 16:54:03 +02:00
ab639eea6d test(stock): cover validation + not_found + per-row independence 2026-08-31 16:48:40 +02:00
Claude
86eba97d33 fix(stock): skip ledger row on first-time pricing + include cost delta in notes
Code-review feedback from Task 2:
- Code-review Issue #1 (Important): the strict-equality guard fired on
  'before.cost_price = undefined' vs 'update.costPrice = 0' — exactly
  the population this feature targets (fresh catalogue rows the
  operator is pricing for the first time). No real cost movement was
  happening, but a spurious ledger row was getting written. Gate the
  ledger write on Number.isFinite(before.cost_price) so first-time
  pricing doesn't pollute audit history with a 0 -> 0 'movement'.
- Code-review Issue #4 (Minor, audit-readability): add the actual
  before -> after delta to the ledger notes so operators scrolling
  the ledger history see 'Gary edited cost 2.50 -> 3.20 from /stock'
  rather than the prior opaque 'Gary edited cost from /stock'.
- Added two regression tests: one for the first-time-pricing case,
  one pinning the notes delta format.
2026-08-31 16:46:42 +02:00
202f5e73e2 feat(stock): write stock_ledger audit row when cost_price changes 2026-08-31 16:43:13 +02:00
Claude
0cab704840 refactor(stock): tighten pricing-service reason type + drop dead FORBIDDEN_KEYS
Code-review feedback from Task 1:
- Drop the unused FORBIDDEN_KEYS constant + its export. The whitelist
  PATCHABLE_KEYS already covers what was meant; nothing else reads this.
- Replace UpdateStockItemsPricingResultRow's reason: string with a tight
  UpdateStockItemsPricingFailureReason union so the route can map to HTTP
  status without parsing the message string. Matches UpdateStockItemResult
  in stock-items-service.ts:113.
- Replace the array-tail-fragile 'last push matches current id' guard
  with an explicit validationFailed boolean per row, decoupling from
  push order in the inner loop.
2026-08-31 16:41:34 +02:00
076e80ecb4 feat(pos): add FA icons to catalog, matched by category
Each stock item now renders a Font Awesome icon in the catalog grid
in place of the generic '?' initials. The icon comes from the item's
category, set on the categories collection's new 'icon' field (e.g.
'fa-solid fa-bolt' for Fasteners). All 10 active categories seeded
with sensible defaults; can be edited per-row in PB admin.

Match logic lives in pos-service.listActiveStockCatalog: it pulls the
categories matcher table, then assigns each item by:
  1. category.match_brands (against item text or supplier's supply_tags)
  2. category.match_supplier_tags
  3. category.match_keywords (longest first so 'hydraulic hose' beats 'hose')
  4. the 'Unknown / Other' catch-all is excluded from matching — anything
     with no match simply gets the initials fallback

The matcher table is in PB (the existing match_keywords/match_supplier_tags
/match_brands fields on categories), so admins can tune coverage without
a deploy. Two keyword additions to Fasteners (allen cap, set screw) and
Cutting Tools (tap, drill, chuck) lifted the live match rate from 67% to
71% (298/418 items across 8 categories).

FA 6.5.2 Free, solid only, bundled under public/pos/fa/ (256KB total:
100KB CSS + 156KB solid woff2). Both the CSS and the woff2 are in the
service worker APP_SHELL so icons render offline. No CDN dependency.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 16:40:02 +02:00
85f238d3e0 feat(stock): add stock-pricing-service scaffold with happy-path 2026-08-31 16:37:02 +02:00
e66437322f Revert "feat(orders): add Duplicates tab surfacing flagged duplicate orders"
This reverts commit b4855a9c9f.
2026-08-31 16:34:37 +02:00
8ae4592108 feat(orders): add since=YYYY-MM-DD to backfill endpoint for scoped re-scans
Lets the operator scope a backfill to recent orders so off-peak
sanity passes don't disturb the active workflow. e.g.:

  POST /api/v1/orders/backfill-duplicate-checks?limit=200&since=2026-08-24

rescans the last 7 days up to 200 rows. Combines with limit/skip;
rejects malformed dates with 400 instead of silently no-op'ing.
2026-08-31 11:34:46 +02:00
b4855a9c9f feat(orders): add Duplicates tab surfacing flagged duplicate orders
Cross-cutting review tab at the end of the strip — lists every order
with any of the three duplicate-detection flags set:
  - order_number_duplicate
  - potential_duplicate_order
  - potential_delivery_note

Implementation:
  - src/lib/item-status-ui.ts: add 'duplicates' to TabGroup + visual +
    TAB_GROUP_ORDER (placed last so it doesn't compete with
    active-workflow tabs). Empty array in TAB_GROUP_ITEM_STATUSES
    (it's not an item-status group — the duplicates tab is
    cross-cutting on order-header fields).
  - src/lib/server/orders-service.ts: add duplicateOrderFilter() +
    orderHasAnyDuplicateFlag() helpers. listOrdersWithItems routes
    tabGroup='duplicates' to a PB filter on the orders collection
    (separate from the item-level tabGroupFilter the other tabs use).
    getOrderItemStatusTabCounts counts duplicates independently so
    the badge stays accurate even when the active-workflow counts
    are filtered out.
  - src/app/(app)/orders/page.tsx: add the tab to the strip + an
    empty state with the purple #a855f7 visual.
2026-08-31 11:30:09 +02:00
ff150bdf6e feat(orders): include selling_price in duplicate-detection line match 2026-08-31 09:03:00 +02:00
a956b70137 feat(pos): default catalog to recently added, render in chunks of 50
The till was painting all 373 active stock items at once, which is slow
and buries the stock staff actually reach for. The catalog now comes back
newest-first (PosCatalogItem carries created) and renders 50 at a time,
with a Show-more button that also auto-loads as it scrolls into view.

Header reads 'Recently added' when browsing and 'Results' once a search
or category is applied; changing either resets paging to the first chunk.
Scroll position is preserved across re-renders so adding to the cart no
longer throws the operator back to the top of the list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:25:05 +02:00
4d58bde6ff fix(pos): type checkout test stub as PosPb to satisfy tsc
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:41:26 +02:00
0282d43109 feat(pos): add customer search behind /api/v1/pos/customers
Keeps the till free of PocketBase credentials — the mockup queried the
customers collection directly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:36:37 +02:00
b60cdd8547 feat(pos): add catalog, checkout and sales API routes
All three gated on POS_API_KEY. Checkout accepts all four tender types
and forwards client_ref for idempotent offline-queue replays.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:35:51 +02:00
739ef8f846 feat(pos): add listSalesInRange for history/cashup
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:34:27 +02:00
b177aca35c feat(pos): add checkoutSale with stock validation and ledger writes
Records all four tender types. Only cash sets cash_given/change_given;
card, EFT and account settle outside the till, so cashup can reconcile
the physical drawer. Stock decrements for every tender including
account, since the goods leave on a delivery note.

client_ref makes checkout idempotent: an offline-queued sale that was
already committed server-side replays to the original record instead of
decrementing stock twice.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:33:31 +02:00
a228b02156 feat(pos): add listActiveStockCatalog
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:31:53 +02:00
af7a74b4fa feat(pos): add assertPosAuth for POS_API_KEY-gated routes
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:30:52 +02:00
f6bfb6f1eb feat(pos): add PosCatalogItem/PosSale/PosSaleItem types
Supports all four POS tender types (cash/card/eft/account) rather than
just cash/card: only cash puts money in the drawer, so cashup needs the
distinction. PosSale.client_ref carries the till's local sale id as an
idempotency key for the offline queue.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:30:15 +02:00
a73f3b3f97 fix(orders): update order_number collision-checking logic
Refine the handling of order_number values in the MCP API. Custom free-text order_numbers are now allowed to repeat across orders without triggering collision checks, while only values formatted as ORD-YYYY-MM-NNN will be subject to collision verification. Update tests to reflect this new behavior and ensure proper functionality.
2026-08-31 01:24:56 +02:00
2dbe96da23 fix(orders): correct page math in backfill endpoint (perPage must equal limit) 2026-08-31 01:05:54 +02:00
463265fe36 fix(orders): honor skip param in backfill endpoint (was hardcoded to page 1)
The route accepted ?skip=N but always called getList(1, ...) — every
chunk re-processed the FIRST limit orders, leaving offsets > limit
unprocessed. Convert skip to page (floor(skip/limit) + 1) and add a
regression test asserting getList is called with the right page.
2026-08-31 00:55:56 +02:00
ee2dcdecb7 fix(orders): lower bulk filter chunk size 200 -> 100 to stay under PB limit 2026-08-31 00:06:47 +02:00
2b0dd57af0 fix(orders): chunk bulk filter to stay under PB's filter-expression length limit 2026-08-31 00:03:29 +02:00
410785c886 perf(orders): bulk-fetch candidate DN items in suggestDeliveryNoteForOrder (kill N+1) 2026-08-30 23:59:49 +02:00
507531bf15 perf(orders): bulk-fetch candidate items in suggestOrderDuplicate (kill N+1) 2026-08-30 23:57:20 +02:00
8888a4722c feat(orders): paginate backfill endpoint (?limit&?skip) for chunked operator runs
The backfill is N+1-bounded (each order = 1 + N candidate queries),
so a 5,300-order table takes ~3 hours at ~25 orders/min on a busy
PB instance. Splitting into 200-order chunks keeps each call under
Next.js's maxDuration (10 min) and gives the operator a clear
progress signal: every chunk returns nextSkip + done.
2026-08-30 23:30:24 +02:00
687c82a4b8 polish(orders): add no-company test for order-order matcher + backfill route prereq note 2026-08-30 22:34:12 +02:00
14347c4b4b test(orders): cover auth + matcher-isolation for backfill endpoint, document response shape 2026-08-30 22:30:52 +02:00
f0c5daa99e feat(orders): add backfill endpoint for duplicate-detection fields 2026-08-30 22:28:00 +02:00
51a53851de feat(orders): render order-number and item-duplicate pills 2026-08-30 22:26:50 +02:00
d9f0925d83 refactor(orders): rename runQuoteMatch to runPostCreateMatchers, update stale comments 2026-08-30 22:26:05 +02:00
70932eb967 feat(orders): run order-number and item-duplicate checks on every new order 2026-08-30 22:23:54 +02:00
d0f24dd299 feat(orders): add order/item duplicate-detection fields to Order type 2026-08-30 22:22:34 +02:00
d32d71f55b refactor(orders): hoist WINDOW_DAYS, add 60-day boundary tests, clarify comment 2026-08-30 22:20:29 +02:00
a6d88cef1c feat(orders): widen DN repeat-match to any status, add 60-day window 2026-08-30 22:18:02 +02:00
75b1e524ad test(orders): pin no-match _attempted flag + 60-day boundary for order duplicate matcher 2026-08-30 22:15:36 +02:00
f981cf96c3 refactor(orders): drop no-op .replace('Z','Z') from pbDate helper 2026-08-30 22:12:31 +02:00
dae184ef57 feat(orders): flag exact item-match duplicates, same company, last 60 days 2026-08-30 22:10:39 +02:00
1a824d3477 fix(orders): document PB schema prerequisite + strengthen duplicate-matcher tests 2026-08-30 22:08:24 +02:00
9ec4cf2421 feat(orders): flag exact order_number duplicates, any status, no time limit 2026-08-30 22:04:57 +02:00
d59a2a1825 feat(orders): add cancelOrderItem service for soft-deleting lines
Operator-driven soft delete at item scope — flips item_status to
'cancelled' rather than removing the PB row, so any PO / DN /
invoice history the line carries is preserved for audit.

Mirrors cancelOrder's blocked-status list (delivered / invoiced /
cancelled): a line that's already reached the customer can't be
soft-deleted without orphaning the customer-facing record — a UI
gate should prevent the call from reaching the service, but the
service gate is the last line of defence.

The exclude_from_invoice flag doesn't affect eligibility — a normal
line and a non-invoiceable extra cancel the same way. The flag
only governs what reaches the customer invoice / delivery note.

When an actor is supplied, stamps [ISO timestamp] cancelled by
{name|id} on the row's notes field (appended, not replaced — preserves
any operator note that was already on the line). Soft delete leaves
a full audit trail in PB.

5 tests + 1 mutation-test prove:
- pending → cancelled (happy path)
- 404 when order doesn't exist
- 404 when item belongs to a different order
- 409 when item is already terminal (invoiced)
- actor stamp lands on notes
- mutation (don't flip the status): the happy-path test fails

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 20:06:26 +02:00
9996923bf3 fix(invoicing): guard the QuickBooks send against an all-excluded group
Review follow-up to the non-invoiceable-extras work. Task 6 of the plan
called for the billable-subset guard on `sendToQuickBooks` as well as
in the modals; it landed in the modals only.

`rows.length === 0` counts excluded rows, which ride along for the n8n
status flip but never bill. A group where every row is excluded passes
that check and ships an empty `items` array, so QuickBooks would create
a zero-line invoice while n8n still flipped every row to `invoiced`.

Not reachable through the current UI — both live modals disable submit
on this case. This is defence-in-depth for the callers that bypass
them: the (currently unrendered) bulk modal and any legacy call passing
`overrides: undefined`, which falls back to every row in the group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:28:44 +02:00
7a1239a187 fix(orders): bill the customer quantity, not the supplier order_qty
`order_items.order_qty` is the SUPPLIER-facing quantity — what prints
on the PO. Five call sites read it as the customer quantity, which is a
money bug in both directions:

  - supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6
  - shorted line (customer 100, supplier covers 40) → invoiced 40,
    under-billing 60 until the operator splits the remainder

The invoicing screen never fetched `quantity` at all (zero occurrences
in page.tsx), so it could not bill the customer quantity even in
principle. Added to the PB field projection, which is load-bearing:
drop it and PB returns undefined and every line silently falls back to
order_qty. A page-level test now fails if the projection drifts.

`maxSurplusFor` measured the surplus as `collected - order_qty`, which
on an MOQ line is always 0 — the overflow was mathematically unbookable
and vanished from inventory by construction, not by operator error.

Four call sites built delivery-note lines by hand; two passed
`quantity` and two forgot it, so the same DN printed the customer's 4
via the service layer and the supplier's 10 via the API routes. All
four now share `buildDeliveryNoteLine`, so they cannot diverge again.

`order_qty` had no doc comment at all next to an 11-line one on
`quantity` — that absence is why five subsystems guessed differently.
Now documented, with a new `src/lib/order-item-qty.ts` holding
`customerQty` / `supplierQty` as the single definition.

driver-stops.ts and collection-confirm/po-card.tsx read `order_qty`
CORRECTLY (they check what the supplier delivered against what we
ordered from them) and carry comments saying so, so the next cleanup
doesn't "fix" them into breaking.

23 new tests. Full suite: no regressions — the 15 failures are
identical before and after (verified by stashing and re-running).

Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:28:33 +02:00
12657d126b feat(mcp): order_items.create accepts exclude_from_invoice + doc updates
Task 9 of the non-invoiceable-order-extras plan.

order_items.create MCP tool:
- Input schema gains an optional boolean 'exclude_from_invoice'.
- Tool description rewritten to explain the supplier-side component
  use case, the PO + driver + collection flow, the silent drop from
  the customer invoice and delivery note, and the force-zero selling
  price.
- operationData hint / nextActions block updated — the prior text
  documented a '409 order_locked' refusal that no longer fires
  (Task 2 removed the pending gate).
- Handler forwards the boolean to createOrderItemForOrder (which
  itself forces selling_price=0 when the flag is set — Task 2).

Also fixed a TypeScript strict-mode complaint in the T7 test that
the previous commit left behind (vi.fn mock-call tuple type
needed an explicit parameter so mock.calls[0] is typed).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 17:46:06 +02:00
f7a79e671a test(invoicing): lock in picking-slip inclusion of excluded rows
Task 8 of the non-invoiceable-order-extras plan.

Two regression tests, no production code change. The picking-slip
feature relies on a property of the existing print pipeline:
excluded rows DO receive item_status = 'invoiced' (Task 7 / n8n
batch flip) and DO carry the invoice number / DN number, so the
print queries pick them up automatically. The picker must still
pull the washers even though they never reach the customer.

- print-bundle: explicitly verifies the picking slip renderer is
  called with both billable and excluded rows (mocked
  PickingSlipPdf → assert lines array has both descriptions).
- delivery-note-print: explicitly verifies the PB filter does NOT
  carry an exclude_from_invoice clause — adding one would
  silently filter the picker slip and break Task 7's design.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 17:44:12 +02:00
cb3f1618c4 feat(invoicing): split delivery-note PDF lines for non-invoiceable rows
Task 7 of the non-invoiceable-order-extras plan.

The status flip + audit stamp + delivery_note header create all
run on EVERY row so the order closes normally — excluded rows are
real operational lines from the supplier's POV. The PDF line
list is built from billable.items only, so the customer never
sees excluded lines.

Added exclude_from_invoice to the getFullList projection (load-
bearing — omit it and the billable filter silently passes
undefined, every extra lands on the customer delivery note).

New 400 guard 'No billable lines for a delivery note' when every
row in the request is excluded. UI guard in Task 6 prevents the
operator from reaching this path, but the route has its own
defence so the picker doesn't get a guaranteed-to-fail submit
silently.

2 tests + 1 mutation-test prove the split:
- excluded rows still get status flip + DN number + PDF upload,
  but the renderer is called with ONLY billable items
- all-excluded returns 400 before any PATCH fires
- mutation (PDF built from items): the mixed test fails because
  the renderer sees 3 lines instead of 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 17:35:15 +02:00
08f6005fd5 feat(invoicing): muted rendering + billable-submit guard for non-invoiceable rows
Task 6 of the non-invoiceable-order-extras plan.

Both modals (QB submit + delivery note submit) — same shape change:

- Excluded rows render muted with a 'Not invoiced' chip in the
  row metadata. The muted state overrides 'bg-card' even when
  included, so the operator doesn't mistake them for billable.
- Their include-checkbox is checked and disabled — the row must
  ride along for the n8n status flip, and the operator must not
  be able to remove it or add it to the bill. onChange defensively
  bails when the flag is set, in addition to the disabled prop.
- New 'billableCount' guard: when every billable row has been
  unchecked, submit is replaced with 'No billable lines' label
  and the click handler toasts 'This order has no billable lines.'
  The existing 'noneIncluded' check still catches the operator
  unchecking everything (including the excluded rows the disabled
  checkbox blocks them from unchecking in practice); the new guard
  is the per-mode subset check.

The existing 'noneIncluded' check isn't sufficient because excluded
rows are always in 'includedRowIds' (they have to ride along), so
includedRowIds.size === 0 is unreachable in practice — the billable
subset can still be empty.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 17:30:19 +02:00
7320bfdce6 feat(invoicing): split order_item_ids from items for non-invoiceable rows
Task 5 of the non-invoiceable-order-extras plan.

buildQbInvoicePayload now keeps order_item_ids built from ALL
rows (so n8n's 'Build PocketBase Invoice Update' node flips
excluded rows to invoiced alongside their siblings — the order
closes normally), and builds items from BILLABLE rows only (so
QuickBooks never learns about the excluded lines).

billableRows helper exported for sharing with the invoice UI
(Task 6) and the delivery-note route (Task 7) — three callers,
one definition of 'billable'.

Added exclude_from_invoice to RawInvoicingRow AND the
INVOICING_FIELDS projection. The projection is load-bearing:
omit it and PB returns undefined, every extra silently bills
the customer. Confirmed by inspection and by the failing
'mixed set' test if the projection drifts.

4 tests + 1 mutation-test prove the split:
- mixed: excluded ids in order_item_ids, absent from items
- all-billable: identical output to the no-flag case
- all-excluded: empty items, full order_item_ids
- billableRows helper unit tests
- mutation (items built from input.rows): mixed and all-excluded
  tests fail

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 17:26:46 +02:00
8b23cc1b6a feat(orders): bypass margin gate for non-invoiceable extras
Task 4 of the non-invoiceable-order-extras plan.

The page-level margin gate (process/page.tsx) short-circuits when
the item has exclude_from_invoice. An excluded line has
selling_price forced to 0 (Task 2), so the computed markup is
always -100 and the gate would wedge every extra into
approval_needed and block the order. Bypassing the gate keeps the
operational flow unblocked.

Suppressed low-margin colouring for excluded rows — a red
'-100%' chip on every extra is noise. The 'Not invoiced' badge
(T3) replaces the price chip visually.

Server-side guard in requestOrderItemMarginApproval refuses to gate
an excluded item with code 'excluded_from_invoice' (status 422).
Belt-and-braces: the MCP path can't weaponise the approval queue
to push an excluded row through manager review. Checked BEFORE
the missing_prices guard so an excluded item with selling=0 throws
the descriptive error rather than the misleading missing_prices
one.

2 tests + 1 mutation-test prove both sides of the bypass:
- excluded: throws 'excluded_from_invoice', webhook NOT fired
- non-excluded: proceeds to approval_needed, webhook DOES fire
- mutation (guard disabled): the excluded test fails

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 17:21:28 +02:00
50e75e6955 feat(orders): add-item affordance + sheet on the process page
Task 3 of the non-invoiceable-order-extras plan.

- New POST /api/v1/orders/{id}/items route wraps
  createOrderItemForOrder. Same-origin browser auth (PB session
  cookie), mirrors the posture of /quote after the order-quote
  migration.
- AddItemSheet component on the process page: right-side Sheet
  (mirrors PriceEditSheet's layout) with description / quantity /
  cost / selling / supplier / notes + the load-bearing
  'exclude_from_invoice' checkbox. When ticked, the selling-price
  input is forced to 0 and disabled, a 'Not invoiced' badge replaces
  the margin chip so a -100% red chip on every extra is noise.
- Add Item button below the item card stack — always available,
  not gated on item_status. The empty-state copy above ('No line
  items on this order. Add items before processing.') now points at
  a real action.
- Existing excluded rows render with a muted 'Not invoiced' badge
  in the card stack so the operator can spot them at a glance
  without opening the sheet.
- The server (Task 2) forces selling_price=0 from the flag
  regardless of caller input — the client forcing here is for UX
  consistency.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 17:18:30 +02:00
234ba6d3cd feat(orders): allow adding lines mid-flight, accept exclude_from_invoice
Dropped the pending gate in createOrderItemForOrder. The original
gate's reasoning was that adding a row alongside an in-flight PO
would orphan that PO's pricing/supplier reference — wrong model
of what adding a row does. A new row is independent: it gets its
own PO when the operator processes it, never mutates an existing
one.

The unrelated nonPending gate in setOrderQuote (preventing a
quote link change after items are past pending) stays — different
invariant.

exclude_from_invoice flag: when set, selling_price is forced to
0 regardless of what the caller passes — the flag is the source of
truth downstream, the price column is not consulted by the
invoice/delivery-note payload builders. The flag itself is
persisted in the secondary-field patch alongside sku/unit/
supplier/notes.

orderLocked return field kept for API compatibility — now always
false. Pre-existing callers reading the shape compile and read a
constant.

3 tests + 2 mutations prove the assertions guard real behavior:
deleting the flag-persistence line and the force-zero line each
break their target test.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 14:34:37 +02:00
4c5a312e16 feat(orders): type exclude_from_invoice field on OrderItem
Added the boolean to src/lib/types.ts per the non-invoiceable
extras design. No behavior change yet — this is just the type.
Tasks 2+ wire up the operational flow.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 14:30:17 +02:00
eb874bfd93 fix(orders): stop an order matching its own delivery note; include draft/loaded
Two coupled corrections to delivery-note repeat detection.

Self-match: delivery notes have no line-item collection -- items are
order_items rows tagged with delivery_note_number. The Path A flow
stamps that number onto rows that KEEP their `order` relation, so a
processed order and its own DN are backed by the same database rows.
isExactItemMatch compared them against themselves and returned true
unconditionally. 149 live rows are in that state. Guarded both by DN
id and, for combined DNs whose `order` is empty, by inspecting the DN
rows' own order relation.

Candidate statuses: `draft` and `loaded` are real deliveries here.
The owner confirmed drafts simply never get advanced in the system,
and `loaded` is reachable directly from draft, so goods can go out
without ever passing through `sent`. Excluding both left a candidate
pool of 3 DNs out of 116, making the feature inert. Ordering matters:
the self-match guard had to land first, because the DN that exposed
the bug is itself a draft.

Cancelled stays excluded pending confirmation -- it is 84 of 116 and
may have been used to clear superseded delivery orders rather than to
mark abandonment.

Tests: a prior review showed by mutation that the existing tests
assert output shape but pin no scoping decision -- company-vs-customer,
the status filter and the tie-break could all be inverted with the
suite green. Added filter-inspecting and self-match tests, each
verified by mutation to fail when its guard is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:09:23 +02:00
5a869c5f41 test(orders): widen makePb override param to a plain record 2026-08-30 13:57:30 +02:00
370a270b4c feat(orders): show a repeat-order pill when an order matches a delivery note
Blue Truck pill on the orders list, distinct from the amber quote
suggestion and the teal linked quote. Reads
potential_delivery_note_number (denormalized, no expand needed) —
the service writes the relation and the number in the same update
call so the list query doesn't pay for a second expanded relation
(see orders-service.ts:253 for why the codebase avoids that).

Additive — an order can show both a quote pill and a delivery-note
pill at once; they're independent facts (a repeat-order
observation, not a provenance link).

Added potential_delivery_note: string and
potential_delivery_note_number: string to the Order interface in
src/lib/types.ts so the page gets the new fields via full-record
return from getOrdersDashboardData (no fields: projection in the
list path).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:59:45 +02:00
3df1c1874b feat(orders): add on-demand delivery-note match re-check route
Thin wrapper around suggestDeliveryNoteForOrder(id). The route
mirrors the quote matcher's /quote-match endpoint for the case
where an order's items were edited after intake, or the order
predates the automatic intake check.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:58:56 +02:00
fa28aca8bc feat(orders): detect delivery-note repeats at order intake
Two independent try/catch blocks inside the existing runQuoteMatch
closure in createOrder. Each matcher (quote + delivery-note) is its
own scope, so a failure in one cannot starve the other. The
intake-path after()/E468 guard from Task 6 stays in place — this
adds the second matcher call alongside the first.

The 3-test block in createOrder delivery-note auto-detection proves
the independence in both directions: a quote matcher throw doesn't
prevent the delivery-note match, and vice versa. Mutation-tested by
merging the two try/catches — guards the design itself, not just
the wiring.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:58:36 +02:00
3cf2837c3f feat(orders): add delivery-note repeat-order detection service
Wraps the pure comparator with PB reads/writes. Same shape as
suggestQuoteForOrder:
- skip when order has no company (don't stamp attempted)
- write potential_delivery_note + number + confidence + method +
  attempted:true on completion
- empty write when no candidate matched

Tie-break order:
1. priced quote match (status=delivery_note, selling_price == order)
2. most recent (created desc)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:46:13 +02:00
e09de3cc80 feat(orders): add exact-match comparator for delivery-note detection
Pure module — no I/O, mirrors the separation established by
order-quote-match.ts.

isExactItemMatch is multiset equality on (normalizeLine(description),
quantity). Empty sets return false (would otherwise vacuously flag
every zero-line order against every zero-line DN).

14 tests cover: exact match, qty diff, extra line on either side,
multiplicity enforcement, the empty-set case, normalization
collapsing whitespace + case, and punctuation-sensitive 'exact'.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:45:34 +02:00
623cfefc0d feat(orders): show quote line notes and attachments on the order detail page
Closes the same vanishing-buyer-notes bug B12 fixed on the process
page, this time on the order detail page that operators see first.

- Inline row indicators (MessageSquare icon for a buyer note,
  Paperclip + count for per-line attachments) inside the Description
  TableCell. Hidden when the line has no signal. Click toggles a
  QuoteLineContext panel below the row.
- QuoteLineContext panel wrapped in a Fragment-keyed extra row,
  colSpan=8 (matches the eight TableHead columns: Description, Qty,
  Selling, Supplier, Cost, Total, Status, Edit). The plan called
  for colSpan=9, but the Driver column was removed 2026-08-24 and
  the live count is 8.
- quoteAttachments intentionally empty in the detail-page panel —
  order-level attachments have their own card below the table.

Migration off pb-helpers (AGENTS.md §'data access layer discipline'):
- /api/v1/orders/{id}/quote route now treats empty quote_id as the
  unlink path (clears quote + potential_quote + matching fields),
  and dropped assertAgentAuth so the page can call it from a
  browser session (admin auth happens via the PB session cookie at
  the PocketBase layer; the MCP forwarder still works without that
  gate because the MCP route gates at its own boundary).
- /api/v1/orders/{id}/quote-match is a thin wrapper around
  suggestQuoteForOrder (returns the new SuggestQuoteResult shape —
  best.quoteId / best.quoteNumber, not the old best.quote.id /
  best.quote.quote_number).
- Page migrated four pb-helpers calls: findMatchingQuotesForOrder →
  POST quote-match, applyOrderQuoteMatch → merge into quote-match +
  QuoteMatchCard accepts the result via SuggestionCard from the
  fetched order, clearOrderQuoteMatch → POST quote with empty
  quote_id, setOrderQuoteManually → POST quote with the picked id.
- runMatch signature simplified — accept-vs-suggest was the old
  split; the new endpoint only ever suggests. 'Accept' now goes
  through linkQuoteManually via POST /quote.
- Stale matcher block (4 functions + 3 helpers + re-exports +
  imports) deleted from pb-helpers.ts. The pure scoring helpers now
  import directly from @/lib/server/order-quote-match.

Known follow-up: QuoteMatchCard still reads from matchResult (a
QuoteMatchResult shape). Clearing matchResult after runMatch means
the SuggestionCard renders empty until the next fetchOrder lands.
Reads from order.expand.potential_quote after that. A future task
can rewrite the card to drop the in-memory result entirely.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:45:02 +02:00
dda1999fb6 fix(orders): resolve quote line pairing server-side on the process page
This is the actual fix for the vanishing-buyer-notes bug the matcher
service exists to prevent. Before this commit, the process page did
an exact/substring description match on every render — and silently
hid the buyer note whenever the PO's wording differed from the
quote's, even after the matcher had resolved a confident pair.

- Server-resolved linePairs now drives the per-item lookup in three
  places (inline quote preview row, the price-edit sheet's matched
  prop, and copyCostFromMatch).
- findMatchedQuoteItem — the local exact/substring guesser that was
  the root cause — is deleted. No other callers exist.
- QuoteLineContext renders description (when different), buyer note,
  per-line and quote-level attachments, plus the 'possible match'
  banner + confirm button. Replaces a hand-rolled block that had
  drifted from the orders detail page.
- QuoteLineNoMatch renders when the order has a quote relation but
  this specific order line didn't pair to any quote line —
  previously this case rendered nothing, which was indistinguishable
  from 'the quote line had no note'.
- onConfirmPair POSTs to /api/v1/orders/{id}/quote-line (Task 10)
  and re-runs fetchAll so the next render sees the pair as
  'confident'. Only exposed for tier === 'possible'.
- The Pricing source grid stays inline — it's order-side data
  (SKU / pricing_source / matched_price_list_id / pricing_notes
  round-trip from quote_items onto order_items), not part of the
  quote context, and is editable on the order.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:31:24 +02:00
33fc549e10 feat(orders): add shared quote-line context component
Shared presentation of a paired quote line — description (when it
differs from the order), buyer note, line attachments, quote
attachments, plus a 'possible match' warning banner for the
operator's confirm button.

Used by both the order detail table (Task 13) and the process page
(Task 12) so the two never drift. The process page used to own the
only copy; the detail page showed nothing.

tier='possible' framing is the load-bearing part: a buyer is never
shown a note from the wrong line without being told it might be
wrong.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:20:39 +02:00
23b50a32a9 feat(orders): serve resolved quote-line pairing to the order UI
The order detail page and process page need the pairing, not the
matcher — until now the process page was doing an exact/substring
guess at render time and silently hiding buyer notes when the PO's
wording differed from the quote's.

- matched-attachments response gains linePairs (keyed by order_item_id,
  carrying quote_item_id, confidence, tier). pairOrderLines is called
  defensively — a pairing failure cannot kill the attachments
  response (catch returns null → empty linePairs object, the rest of
  the payload still serves).
- POST /api/v1/orders/[id]/quote-line writes the operator's confirm
  decision (or unlink with empty quote_item_id) onto
  order_items.quote_item. Guards against patching a line that
  belongs to a different order (404 otherwise).

Both routes are additive — the existing matched-attachments fields are
unchanged, the MCP orders.list_attachments consumer keeps working.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:20:16 +02:00
63e4a654bf refactor(orders): extract AttachmentList into a shared component
Pure move — bodies copied verbatim from process/page.tsx into
src/components/orders/attachment-list.tsx. The order detail page
(Task 13) will now import the same component instead of growing a
second copy.

The 4 icon imports (FileText, Image as ImageIcon, FileQuestion,
ExternalLink) became unused after the move and were removed from
process/page.tsx. Re-exported from the new file alongside the
MatchedAttachment type, attachmentHref helper, and inferKind helper.

No behaviour change. tsc clean. No new lint errors in the touched
files.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:16:31 +02:00
e374513fba feat(mcp): expose orders.quote_match_sweep
Hermes (n8n) drops the AGENT_API_KEY on direct REST calls, so anything
n8n needs to run must exist as an MCP tool. This forwards the agent's
JSON-RPC call to POST /api/v1/orders/quote-match/sweep, mirroring the
existing orders.set_quote forwarding pattern.

Updated the tools/list expected-list test to include the new tool so
the catalog stays self-documenting.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 08:46:28 +02:00
4e54fd6218 feat(orders): add quote-match backlog sweep with age-gap reporting
The matcher only runs on orders created from now on. This sweeps the
historical backlog plus any order whose lines changed after creation.

- sweepUnmatchedOrders in order-quote-link-service.ts walks
  quote_matching_attempted = false && quote = '' (the codebase's
  pattern for an empty relation, verified against orders-service.ts
  usage) in batches, projecting only id (the wider order fields are
  re-fetched inside suggestQuoteForOrder).
- bucketAgeGaps exposes the histogram that lets FUZZY_WINDOW_DAYS be
  re-tuned from real data instead of the 90-day estimate.
- POST /api/v1/orders/quote-match/sweep is the agent-auth surface
  with maxDuration=300 for the 500-order worst case.

Tests cover the filter shape, the field projection (load-bearing —
over-projection wastes wire bytes), the no-op on no-match path, the
fault tolerance (one row throwing doesn't kill the batch), and all
six histogram bucket boundaries including the ≤ N vs < N edges.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 08:45:23 +02:00
093bbafe76 fix(orders): cover the after() fallback path and log when it engages
The mock for next/server's after() detaches the callback regardless of
what production code does, which left the outer catch's fire-and-forget
fallback untested and the runQuoteMatch inner catch tested for nothing.

- New test 'falls back to an inline match when after() is unavailable'
  forces after() to throw and asserts suggestQuoteForOrder was called
  anyway, plus that the fallback log fired. Mutation: deleting the
  'void runQuoteMatch()' line breaks it.
- 'never fails order creation when the matcher throws' now spies on
  console.error and asserts the inner catch's swallow actually logged.
  Mutation: deleting the inner console.error breaks it.
- The outer catch now logs [createOrder] after() unavailable... so a
  silent switch to fire-and-forget (after upgrade, waitUntil missing on
  deploy target) is impossible to miss in production logs.
- Comment names error code E468, Next 16.2.6, and
  node_modules/next/dist/server/after/after.js, and corrects the
  justification: createOrder is an exported service function reachable
  from multiple layers, not 'a smoke script or another background job'.

Verified against the documented baseline — same single pre-existing
failure (splitOrderForContinuation > moves pending items...), no new
failures introduced.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 08:40:17 +02:00
21bc57797b feat(orders): auto-detect the source quote at order intake
createOrder previously hard-coded quote_matching_attempted: false and
never scheduled a match, so ~85% of orders that correspond to an
existing quote arrived unmatched and stayed that way until an
operator opened the order and clicked a button. Fire
suggestQuoteForOrder(order.id) via next/server's after() once the
response has been sent, so intake latency is unaffected, and guard
both the matcher call and the after() call itself: after() throws
synchronously outside a request scope (confirmed by reading
node_modules/next/dist/server/after/after.js), so a caller without
one (e.g. a background script invoking createOrder directly) falls
back to firing the match inline instead of crashing order creation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:22:21 +02:00
1453f14419 fix(orders): guard the pairing call site properly, correct comments
The previous commit's two "happy path" pairing tests never reset
pairOrderLines' mock call history — vitest.config.ts doesn't set
clearMocks, and the file's global beforeEach only clears the PB
store. A prior passing setOrderQuote test left toHaveBeenCalledWith
already satisfied on entry, so those two tests passed even with the
production call site deleted. Add mockClear() after the dynamic
import in both so they actually exercise the call they claim to.

Also: reworded the idempotent-path comment to note the strict-pending
gate runs first, so pairing only re-runs there while every line is
still pending (not a general refresh mechanism); fixed the
"don't write, don't stamp" line, which the pairing call now
contradicts (order_items.quote_item is a write); and replaced a
comment pointer to a nonexistent plan section with a standalone
statement of the deferred-signal gap.
2026-08-29 14:17:26 +02:00
a0e7e152f7 feat(orders): pair lines when a quote is linked to an order
setOrderQuote is the single choke point for creating orders.quote —
used by the UI, REST API, and Hermes/n8n. Call pairOrderLines(orderId,
quoteId) right after the hard link lands so confident order-line to
quote-line pairs persist automatically instead of needing a separate
manual step.

Pairing also runs on the idempotent no-op path (order already points
at the requested quote) — it's safe to repeat and lets an operator
refresh pairing by re-running the same call after editing order lines.

A pairing failure never fails the link itself (console.error only,
by design) — the hard link is the operator's important intent and
must land even if pairing can't complete.
2026-08-29 14:06:10 +02:00
0a5f352784 fix(orders): close cross-line quote_item collision, pin pairing's field projections
pairOrderLines' no-overwrite guard only checked whether an order
line's own row already carried a quote_item, not whether the quote
line it was about to claim was already held by a different order
line. A description drift (or a hand-confirmed link) on one line let
a fresh confident match on another line silently double-assign the
same quote_item, so two parts would render the same estimator's note
and attachments — the exact failure this feature exists to prevent,
reached from the other direction. Fixed by seeding a claimed-quote-item
set from the fetched order rows before the write loop, and skipping
any confident pair that targets an already-claimed quote line.
unmatchedOrderItemIds now also excludes any line that already carries
a link, even when this run's pairing pass didn't re-propose it.

Also names the two field projections (PAIR_ORDER_ITEM_FIELDS,
PAIR_QUOTE_ITEM_FIELDS) next to the existing MATCHER_QUOTE_ITEM_FIELDS
constant, and adds a test that asserts on the actual `fields` string
passed to each getFullList call. Previously nothing distinguished
`fields: "id,description"` from `fields: "id,description,quote_item"`
at the order-items query — a one-token trim silently disabled the
no-overwrite guard (every row read as unlinked) with the full suite
staying green, since the existing behavioral tests supply `quote_item`
values directly in their fixtures rather than through the query. Verified
by re-running that exact mutation against the new test: it fails, and
neither pre-existing behavioral test catches it on its own.
2026-08-29 14:01:13 +02:00
b4ad67680a feat(orders): persist confident order-line to quote-line pairs
Adds pairOrderLines to order-quote-link-service.ts: pairs an order's
lines against a quote's lines via pairQuoteLines, writes quote_item
only for confident (>=0.5 Jaccard) pairs, and never overwrites a
line's existing quote_item. Possible-tier pairs are returned but left
unwritten so an operator has to confirm them before a buyer sees a
note attached on a guess.

Reuses fetchQuoteItemsForQuotesAdmin (already used by the fuzzy pass
in this file) to fetch quote_items rather than reintroducing a raw
QUOTE_ITEMS_COLLECTION query. Writes stay sequential, matching the
existing bulk-update convention in orders-service.ts.
2026-08-29 13:49:39 +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
699512e216 feat(orders): add server-side quote suggestion service
Ports the PocketBase half of the order<->quote matcher off the
browser's PB singleton and onto getAdminPocketBase(), so matching can
run at order intake and from a backlog sweep instead of only on a
button click. Reuses the pure scoring in order-quote-match.ts
untouched (scoreOrderVsQuote, quoteWindowForOrder,
quoteToOrderAgeDays).

suggestQuoteForOrder tries the four exact-identifier fields first
(never date-limited), then falls back to a company/customer/
description/price fuzzy scan windowed to 90 days before the order
date, widening to 365 days only if that comes up empty.

Writes only the soft suggestion (potential_quote,
potential_quote_confidence, quote_matching_attempted,
quote_match_method) - never orders.quote, the hard link. Orders that
already carry a hard link are skipped untouched.

pb-helpers.ts's findMatchingQuotesForOrder is left in place; a later
task migrates its last UI caller and removes it.
2026-08-29 13:22:52 +02:00
aba3bdca5d fix(orders): parse PocketBase date format explicitly in the matcher
Two findings from review of the quote-line-pairing task:

- quoteToOrderAgeDays: Math.round ties toward +Infinity, so a negative
  half-day gap rounds to -0. Normalize to a clean 0.
- quoteWindowForOrder / quoteToOrderAgeDays relied on `new Date(...)`
  parsing PocketBase's space-separated datetime form
  ("2026-08-29 10:00:00.000Z") the same as ISO 8601. That happens to
  work on V8 but is implementation-defined per spec. Both now go
  through a shared parseFlexibleDate helper that normalizes the
  separator explicitly.

Adds tests pinning both behaviours; the existing 40 tests stay green.
2026-08-29 13:20:40 +02:00
a4bc52b2ca feat(orders): add order-anchored fuzzy search window
Adds quoteWindowForOrder (pure) to constrain the fuzzy-match candidate
scan to a date window anchored on the order's own created date, plus
the forward grace period for quotes raised a few days after the PO.
Also adds quoteToOrderAgeDays, used by a later task's age-gap
histogram, with test coverage the plan omitted (normal, inverted, and
unparseable-input cases).
2026-08-29 11:28:10 +02:00
99050dcac5 fix(orders): wire LINE_PAIR_POSSIBLE into the pairing floor, add boundary test
Code review found LINE_PAIR_POSSIBLE was decorative -- pairLinesByDescription
still filtered on a hardcoded 0.3 literal, so editing the constant wouldn't
change behaviour. Also adds a test pinned to an exact 0.5 Jaccard pair to
catch off-by-one mutations on the confident/possible tier boundary.
2026-08-29 11:23:38 +02:00
f2a1cbf586 feat(orders): expose tiered quote line pairing 2026-08-29 11:13:59 +02:00
fd5c3c8510 fix(quotes): stop the detail page's tab strips colliding
Moving the admin panels below the items table left them at a third of
the row each (~186px of tab strip), which is too narrow for the labels
they carry. "Outbound" plus its count needs ~71px against the ~58px it
was given, so the three Attachments labels overlapped each other; the
same squeeze hit "Email Status" on the Activity card.

Lays the panels out two-up instead: Attachments spans both columns on
its own row (its three tabs, dropzone and file list want the space),
with Linked Orders and Activity sharing the row beneath at half width.
Both strips now have several times the headroom they need, measured at
a 1044px viewport, and mobile is unchanged (single column, no
page-level horizontal scroll).

Also drops the `ml-1.5` from the attachment count spans — TabsTrigger
already applies `gap-1.5` between its children, so the margin was
double-spacing and contributed to the overflow — and adds
`tabular-nums` so the tab widths stay steady as counts tick past 9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:25:45 +02:00
53f9aaaee4 refactor(quotes): give the detail page's items table the full width
The detail page was a 3-column grid with the Items card in a
lg:col-span-2 and the admin panels beside it. That left the items
table ~430px for a table whose natural width is ~790px, so pricing a
quote meant scrolling the table sideways.

Stacks the page instead: the items table (the actual work surface)
takes the full width, and Attachments / Linked Orders / Activity sit
3-up underneath, where they read fine at a third of the width because
they're reference panels rather than things you act on while pricing.

Two width trims make the table fit outright rather than merely fit
better:
  - Description's min-width 360px → 280px. It's a floor, not a target;
    the table is table-layout:auto so Description still absorbs any
    width the fixed columns leave. 360 was forcing overflow on its own.
  - The "Processed" header is whitespace-nowrap, so the word set a
    ~78px floor on a column whose content is a 2-3 character badge.
    Renamed to "Done" (title="Processed") at w-[64px].

Measured at a 1044px viewport: the table went from 110px of horizontal
overflow to 0. Mobile is unaffected — it uses the stacked card list,
and the admin grid collapses to one column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:17:07 +02:00
42179229aa fix(quotes): repair misaligned items table on the quote detail page
The table had 9 header cells against 8 body cells: a stray duplicate
"Description" header sat above the one carrying min-w-[360px]. Every
column therefore rendered one position off its label (the "Qty" header
sat over the supplier cell, "Supplier" over cost, and so on), and the
360px reserved for Description landed on Qty instead.

The visible symptom was a long description crushed into ~99px and
wrapped one word per line down a tall thin column, while the Qty cell
sat in 360px of empty space.

Removing the stray header restores the header/cell alignment and gives
Description its intended 360px. Also switches the row action from a
"Update price" text button to an icon-only button with the same title
and an aria-label, which trims the table's natural width from 911px to
789px — the card it lives in is only ~430px wide, so every pixel is
horizontal scrolling the operator has to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:00:35 +02:00
7c3083c017 feat(quotes): add quote-level "Quoted before?" panel to the process page
When a mine puts a spot out to tender, several engineering companies
each ask us to price the same item list. Before pricing a new one the
operator wants to see what the earlier quote went out at.

The per-line answer already existed: populateItemIntel powers the ⓘ
popover on each row and returns recent_quote (prior quote number,
company, selling price, days ago). This adds no new matching logic —
it runs that same lookup across every line of a quote and rolls the
results up, so the operator gets one panel instead of clicking ⓘ on
each row.

What the roll-up adds over the per-line popover:
  - "N of M lines have been quoted before" at a glance
  - relatedQuotes ranked by HOW MANY of this quote's lines they also
    carried; a prior quote covering 5 of 5 is very likely the same
    tender from a different bidder, one covering 1 of 5 is a shared
    commodity line

Two honesty details, both driven by what the first live run showed:
  - match confidence is surfaced as close/likely/loose. The underlying
    matcher accepts anything above 0.5, and a live check returned a
    R150 hit against a magnetic base drill — a loose row must not look
    as trustworthy as a close one.
  - an unpriced prior quote renders "unpriced", never R0.00, which
    would read as "we quoted it at zero".

Read-only: passes write:false so viewing the panel doesn't persist
intel onto rows the operator only looked at. On-demand rather than
auto-loading, since it costs one intel computation per line
(~400-650ms each, batched 5 at a time, capped at 40 lines).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:52:19 +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
0efbae8dc5 feat(quotes): enhance search functionality and user experience
- Updated the search input handling to improve responsiveness by integrating a debounced search mechanism, reducing latency from 550 ms to approximately 300 ms.
- Added keyboard shortcut support for focusing the search box, enhancing accessibility and usability for frequent users.
- Improved the search placeholder to reflect all searchable fields, providing clearer guidance to operators.
- Introduced a clear-search button to streamline user interactions, allowing quick resets of the search input.
- Enhanced the display of search results with a live result count badge, providing immediate feedback on search outcomes.

These changes collectively enhance the search experience for operators, making it more intuitive and efficient.
2026-08-28 20:13:44 +02:00
5eed862007 feat(api): enhance order creation with optional email metadata fields
- Added `email_sender`, `email_subject`, and `email_body` fields to the order creation input schema for improved traceability of inbound emails.
- Updated logic to include these fields in the order creation process, allowing operators to track the source of orders without needing follow-up updates.
- Enhanced documentation to reflect the new optional fields and their purpose in the order management workflow.

This update improves the audit trail for orders and enhances user experience by linking orders directly to their source emails.
2026-08-28 13:00:31 +02:00
dc0139a62c feat(api): add support for optional custom order_number in order creation
- Introduced `order_number` field in the order creation input schema, allowing users to provide a custom identifier (e.g., PO/reference).
- Implemented logic to handle empty or whitespace values, defaulting to auto-generated order numbers.
- Added error handling for order_number collisions, returning structured error responses for better client-side handling.
- Updated documentation to reflect the new order_number functionality and its implications.

This enhancement improves order management flexibility and user experience.
2026-08-28 12:30:38 +02:00
11415d4784 refactor(boss-dashboard): drive tab change from click handler, stabilize fetchData via window ref
Fixes react-hooks/set-state-in-effect lint error in the tab-change useEffect.

- Tab buttons now call selectWindow(w) directly instead of relying on a
  useEffect that synchronously calls setState. selectWindow flips the
  state, closes the sheet, and triggers the refetch from one event handler.
- fetchData now reads activeWindow via a ref synced by a tiny useEffect
  (the canonical React pattern — eslint flagged the inline ref-write as
  'Cannot update ref during render'). With no useCallback deps, fetchData
  identity is stable, so the realtime subscription no longer churns on
  every tab switch (a perf concern flagged in Task 15).
- The mount-only initial fetch useEffect is preserved with a justified
  eslint-disable on the set-state-in-effect rule (no SSR data pass-through
  on this client-only page, so the initial fetch has no user event to
  hang off).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 00:33:14 +02:00
a74cb17940 feat(boss-dashboard): add PB realtime subscription with 1s debounce 2026-08-28 00:27:19 +02:00
ab0aa8ec8d fix(boss-dashboard): close sheet on backdrop click + tab switch 2026-08-28 00:26:24 +02:00
9101871fef feat(boss-dashboard): add rep-activity bottom sheet 2026-08-28 00:24:46 +02:00
44f93feb13 feat(boss-dashboard): add drivers section 2026-08-28 00:23:32 +02:00
eb3f801167 feat(boss-dashboard): add reps list section 2026-08-28 00:22:30 +02:00
ca6d43e0e8 feat(boss-dashboard): add order totals strip (Total / Delivered / Invoiced) 2026-08-28 00:21:36 +02:00
b07327eae5 feat(boss-dashboard): add time tab bar (Today / Week / Month) 2026-08-28 00:20:18 +02:00
16efafc111 fix(boss-dashboard): guard fetch races, surface errors, tighten response type 2026-08-28 00:19:12 +02:00
f09f9163c0 feat(boss-dashboard): add /m page + dashboard-view skeleton 2026-08-28 00:16:33 +02:00
fcbc085723 feat(boss-dashboard): add /(mobile)/m layout with viewport + manifest link 2026-08-28 00:15:36 +02:00
15206eaf49 test(boss-dashboard): add 500/null-fallback/edge-case coverage + tighten mock hygiene 2026-08-28 00:14:06 +02:00
e9ce5f4fc4 test(boss-dashboard): add route handler tests for dashboard and rep-activity 2026-08-28 00:10:00 +02:00
f243132dc0 refactor(boss-dashboard): use shared assertBrowserSession + cap page 2026-08-28 00:07:57 +02:00
70807e34fe feat(boss-dashboard): add /api/v1/boss-dashboard/rep-activity route handler 2026-08-28 00:05:20 +02:00
8ecacf78fe feat(boss-dashboard): add /api/v1/boss-dashboard route handler 2026-08-28 00:02:51 +02:00
5a944bc1f9 test(boss-dashboard): honour expand in mock + assert customer name in rep activity 2026-08-28 00:02:02 +02:00
da1ccc48fd feat(boss-dashboard): paginated rep activity feed for drill-down sheet 2026-08-27 23:58:19 +02:00
9aaeee25ec fix(boss-dashboard): route unresolved orders.rep to (unknown) + parallelise 6th fetch 2026-08-27 23:55:59 +02:00
ebbcc73020 fix(boss-dashboard): exclude cancelled orders via order_items.item_status
The cancelled check was reading orders.item_status, which doesn't exist on
the orders collection in PB. Status lives on order_items, so the check
never fired and cancelled orders were counted in orderTotals.total and
each rep's ordersPlaced.

Mirror the delivered fix: derive a cancelled-order-id set from
order_items.item_status in the active window. An order is excluded from
total if ANY of its lines carries item_status="cancelled".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 23:52:59 +02:00