This commit fixes a critical PocketBase projection bug where omitting "expand" from fields lists silently drops related data, leading to missing supplier/customer/order info across the app. It also includes usability improvements and test coverage:
1. Add "expand" to PB fields projections in invoicing page, quotes lookup API, sprint page, and quote search helper
2. Update PO Sent tab filter to exclude "collected" and "ready_invoice" statuses
3. Reorder buyer tasks list to sort by supplier first, then order number
4. Add comprehensive regression tests for all fixed issues
Included Playwright test capture artifacts from local testing.
- Add server-side `/api/v1/dashboard` endpoint to reduce client-side payload and round trips
- Implement invoice upload and order-item SKU update APIs with complete test suites
- Refactor order and order-items list endpoints (including MCP tools) to support proper PocketBase filter and sort params
- Fix pagination validation for order-items route to clamp page ≥1 and perPage 1-500
- Add redirects in next.config.ts to map underscore collection paths to hyphenated API routes
- Improve buyer tasks page to avoid redundant tab count refetches when switching tabs
- Refactor collection confirm components with editable SKUs and updated accessibility labels
- Add cached admin PocketBase auth to minimize repeated authentication calls
- Add performance testing scripts for dashboard endpoint latency
- Update quotes list route to default to excluding archived counts
- Fix photo strip component text and labels for non-invoice documents
- Refactor sprint page to lazy load supplier/customer data for pricing modal
- Clean up unused imports in test files
- Add full test coverage for all new APIs, services, and components
Production deploy failed the TypeScript type-check (next build exited 1)
because commit 2acfd2d changed CreateQuoteFromEmailResult.items from
'number' to 'QuoteItem[]' without updating:
1. The type import — QuoteItem was never added to the import from
@/lib/types (line 6 only brought in Quote, Customer, Company).
2. The dedup branch (line 1060-1076) — still passed itemsCount: number
where the new shape requires QuoteItem[]. Now fetches the real rows
via getFullList and sets items_count = items.length for back-compat.
The build log shows the error was visible right at 'Running TypeScript ...'
but the deployment platform's log scraper cut off the actual stderr,
making it look like an opaque DeploymentException. Reproduced locally
with 'npm run build' to confirm both errors.
Build now passes (no next build / tsc failures). All 1392 tests pass.
Co-authored-by: Orca <help@stably.ai>
The Source of truth panel previously only showed the email subject and
body inside a collapsed-by-default toggle. Operators couldn't see who
sent the mail or what the subject was without expanding the body.
This change adds a persistent header above the collapsible body with
two always-visible lines:
From: jane@acme.test (AtSign icon)
Subject: RFQ for 10x bolts (Mail icon)
Sender resolution chain (in getOrderReviewData):
1. order.email_sender — n8n ingest snapshot of the actual From:
on the inbound RFQ (best signal)
2. quote.email_sender — covers pre-snapshot legacy orders
3. customer.email — last-resort fallback
4. '' — renders as '(unknown sender)' placeholder
Subject already lived on the same record; the header just promotes it
out of the collapsed body so it's visible without expanding. The
existing '(no subject)' / '(no body)' placeholder pattern is matched
with a new '(unknown sender)' placeholder.
Tests: 7 new (3 modal header, 4 service sender fallback chain). 101
passing total.
Co-authored-by: Orca <help@stably.ai>
- quotes.update: happy path with whitelisted fields, forbidden_field
rejection for system-controlled fields (status/customer/company/
quote_number/id), empty-patch rejection, missing-id rejection
- create_from_email: unit field now preserved end-to-end, result.items
returns the actual QuoteItem array (not just a count)
Also updates the existing tools/list test (now 72 tools, was 71) and
the create_from_email mock in route.test.ts to use the new
items/items_count shape so it matches the type.
All 1379 tests pass.
Co-authored-by: Orca <help@stably.ai>
Closes the two real gaps from the agent's gap analysis (2026-08-05):
1. **quotes.update MCP tool** — was missing entirely. The quote header
has fields (company_letterhead, quote_date, quote_reference, etc.)
that couldn't be patched via MCP, so the agent had to pass
letterhead at PDF-render time and couldn't persist it on the record.
Now exposed with a strict field whitelist mirroring the
quote_items whitelist pattern: any field outside the allowed set
(status, customer, company, quote_number, id, created, updated, …)
errors with forbidden_field rather than silently writing to PB.
New catalog entry + dispatch handler in src/app/api/mcp/route.ts.
Whitelist constant QUOTE_PATCH_FIELDS colocated with the existing
QUOTE_ITEM_PATCH_FIELDS for reviewer discoverability.
2. **create_from_email now preserves the unit field** — extracted_items
from the AI's RFQ parsing carries {description, quantity, unit,
notes} but the create loop was silently dropping unit. Also the
service returned items: <count> which made the agent believe no
items were created when in fact stub rows existed. Now returns
the actual QuoteItem array (with a back-compat items_count field)
and the unit value lands on each row.
The agent's third claim (PB admin auth unavailable) is a misdiagnosis
— WFDASH uses its own AGENT_API_KEY and all writes go through the
Next.js admin client. No code change needed for that.
All 1379 tests pass. quote_items.update path unaffected. The
quotes.update path mirrors the same pattern: whitelist + reject empty
patch + call pb.collection.update.
Co-authored-by: Orca <help@stably.ai>
The archive sort comparator in both getOrdersArchiveData and
getQuotesArchiveData was:
if (av === undefined) return 1;
if (bv === undefined) return -1;
return (av > bv ? 1 : -1) * sortDir;
PocketBase returns `null` (not `undefined`) for unset fields
like `updated`, `email_body`, etc. The strict equality check
missed nulls, and the `av > bv` comparison where one side is
null fails the production tsc check (TS strict mode rejects
`null > string`).
Production build failed with:
./src/lib/server/orders-service.ts:687:13
Type error: 'av' is possibly 'null'.
Fix: use `== null` (which matches both null and undefined),
and use explicit > / < comparisons that return 0 on equality
rather than a fragile ternary that falls through.
Also fixed in the matching function in quotes-service.ts — the
same sort comparator was used in getQuotesArchiveData and would
fail the same way.
All 1331 tests pass; npm run build completes cleanly.
Co-authored-by: Orca <help@stably.ai>
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.
This commit moves both buckets to a dedicated /quotes/archive
page so:
- Main /quotes is fast (active workflow: new, claimed,
pending_approval, cancelled, delivery_note, email_failed,
problem, templates = 542 quotes vs 8,466)
- Archive can be honest about pagination
- The badge counts on the main page stop being dominated by
'Completed (7,849)'
# Service layer
- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
- view: 'completed' (default, 7,849) or 'all' (8,466)
- Strategy: one getFullList on quotes (fields: id,status) to
populate the matching set AND the header counts in a single
walk, chunk by 100 ids (PB's URL/filter cap is between
4.5-6.7 KB), fire parallel getFullList queries on quotes
with deep expand, sort + paginate in memory
- Search hits quote_number, quote_reference, rfq_number,
company, customer
- /api/v1/quotes/archive thin route: page, perPage, search,
sort, view
# Page
- /quotes/archive/page.tsx: card list with header counts
(completed / all badges), view toggle (Completed | All),
search input, pagination footer. Click a quote to open
/quotes/[id] in detail.
# Main /quotes updates
- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
/quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in quotes-service.test.ts:
- Default 'completed' view returns only completed quotes,
counts header includes both
- 'all' view returns every counted-status quote
- Search by quote_number and quote_reference
- Empty result when no quotes match
# Also: webview tweaks by another agent
- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)
Full suite: 1331/1331 pass.
Co-authored-by: Orca <help@stably.ai>
Work by another agent on the PO send flow. Adds a 'Resend' CTA
on the order detail page for POs that were already sent
(operator wants to send another copy to the supplier), plus
acceptance of the same flow from the modal. Includes:
- purchase-orders-service: accept 'sent' as a resend-from state
(in addition to 'draft' and 'mail_failed')
- send-po-modal: title flips to 'Resend Purchase Order ...' when
the PO was previously sent; 'Send' button becomes 'Resend'
- send-po-modal.test: covers the resend path
- orders/[id]/page: shows Resend button in the same sendable
state group as Send (was draft / mail_failed, now also 'sent')
- purchase-orders pdf route: updates docstring to reflect that
status is the outbound-email pipeline's responsibility
- stock-purchases pdf route: download URL uses file proxy for
admin-auth compatibility
Co-authored-by: Orca <help@stably.ai>
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
# Regression: orders/quotes list lost customer/company/rep data
Last commit added a `fields` parameter to the orders and quotes
list queries to trim the wire payload. Measured on a stale test
result, looked like a 16x win. Actual: PB 0.26's `fields`
parameter is a HARD ALLOWLIST of MAIN record columns only \u2014
specifying it makes PB drop the entire `expand` payload from
the response. Result: customer, company, rep, quote names
disappeared from every order/quote card.
Verified against the live PB:
- With `fields`: expand is dropped, just relation IDs returned
- Without `fields`: expand works, full records (55-57 KB for
20 rows with deep expand)
Fix: remove the `fields` parameter from both list queries. The
slim-payload intent has to be paid at the schema level (cap the
unbounded text columns) rather than via PB query params.
The earlier '12x smaller' / '16x smaller' benchmarks were
measurements of the BROKEN response, not the working one. Real
wire sizes are unchanged from before the perf work \u2014 but the
other wins (server-side tab filter, combined status counts,
targeted users fetch, 60s counts cache) all still apply.
# Bonus: show contact_name on the order card
~76 of ~4.5k orders have `contact_name` set (the free-text
person the order is for, captured during email-intake when the
customer record is just the company). Added to the OrderCard
header between customer and rep, with a docstring explaining
the distinction from the customer relation and email_sender.
Added contact_name to the Order type and pinned the regression
with a test that asserts the orders list query does NOT pass
`fields` (so a future 'let's slim the payload' attempt
doesn't reintroduce the bug).
# REP label
The rep column on the order card now reads 'REP' (uppercase)
instead of 'rep' (lowercase). All 1312 tests pass.
Co-authored-by: Orca <help@stably.ai>
Same shape as the orders refactor \u2014 applied to the quotes page,
with a fix for a long-standing tab-count bug along the way.
# Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items)
Before:
- 22 parallel per-status getList(1, 1) queries for the badge
counts: 2.6s wall time (no quotes.status index)
- 64 KB JSON response for 20 quotes with full deep-expand
(45+ fields per quote, including unbounded email_body)
- Page footer shows 'Page 1 of N' where N is the global count
\u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393'
After (no PB index, just code):
- 1 combined counts query: 138ms (19x faster)
- 5.5 KB JSON response (12x smaller)
- Server-side tab filter: page 1 of N reflects the tab's set
- Cancelled tab badge now correctly counts rejected+cancelled
# Server-side read model
- getQuotesDashboardData in src/lib/server/quotes-service.ts
- tab-scoped list + global counts in one round-trip
- tab param drives a server-side filter (status IN (...) for
workflow tabs, relation filter for delivery_note, is_template
for templates, excludeTemplate for 'all')
- pagination reflects the active tab's matching set, not the
global ~8.5k quote count
- getQuoteStatusTabCounts: one combined getFullList with all
17 counted statuses ORed (fields: 'status'), deduped in memory.
Replaces 22 separate per-status scans. Cached 60s on the server
with bypassCache option for future realtime refetches.
- /api/v1/quotes/list thin route: validates tab against
QUOTE_TAB_ORDER, threads tab + nocache params to the service.
# Slim wire payload
- fields param on the orders list: just the columns the page reads
+ per-expand limits (quote.quote_number doesn't need
email_body, company.company doesn't need its address field, etc.)
- Dropped the customer.company 3-level expand (the page never read
expand.customer.expand.company \u2014 only expand.company.company,
which is a direct relation from quote)
- searchQuotesWithItems: same fields added, with
fetchQuoteItemsForQuotes now accepting fields
# Bonus bug: 'Cancelled' tab badge was always empty
The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected'
to the 'completed' count, so the page's 'Cancelled' tab badge
showed 0 even when 60+ rejected quotes existed. The page's
tabFilters separately grouped them under 'cancelled', so a quote
that was rejected showed up in the list but not the count.
The new QUOTE_TAB_STATUSES in the service layer is the canonical
mapping (cancelled absorbs both statuses). Page constants and
service constants now derive from the same source \u2014 no more drift.
# Index migration
scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent.
The live PB has no quotes.status index today (verified by hitting
/api/collections/quotes). With the index, the combined counts
query drops from 138ms to sub-10ms on the live instance.
# Tests
6 new tests in quotes-service.test.ts:
- QUOTE_TAB_STATUSES contract (cancelled absorbs rejected,
no empty status lists unless special-cased)
- getQuoteStatusTabCounts buckets correctly into tabs
- Cache hit returns the same counts on second call
- bypassCache: true sees new state
- getQuotesDashboardData end-to-end: tab-scoped items +
global counts
- Regression guard for the cancelled/rejected bug
Mock infrastructure upgrade: the quotes-service mock's where
function is now a proper recursive-descent filter parser (handles
nested || inside parens, empty quoted values). getList mock now
returns totalPages.
Full suite: 1312/1312 pass.
Co-authored-by: Orca <help@stably.ai>
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
Three review-modal polish items rolled up:
1. Persisted line items now expose a description input in edit mode
(mirroring the existing qty / selling inputs). The save route +
the orders service's `updateOrderItemsPrices` accept the new
`description` field and write `description` + `po_description`
together (rejecting a blank description per row, with a
per-row failure entry so the operator can retry just those).
2. The source-of-truth panel now ALWAYS renders the email body, not
only when there are no attachments. The body is collapsed by
default and capped at `max-h-40` so the email stays a secondary
audit block instead of pushing the operator's real work
(line items + attachments) down the page. The toggle button is
unchanged.
3. The chip strip and file list inside the source-of-truth panel
were using `key={a.id}`. `orderAttachementsToQuotes` synthesizes
every attachment with `id: order.id` (PB file URLs need
`/files/{collectionId}/{recordId}/{filename}`), so a multi-file
order was throwing 'Encountered two children with the same key'.
Switched to `key={a.filename}` — unique per file in the
multi-file field, leaves the file URL untouched.
The Order type grew `email_subject`, `email_sender`,
`email_body`, and `email_message_id`. These columns exist in the
live PB (the local `pb_schema-2.json` snapshot is reference-only
per AGENTS.md, so the drift is expected). `getOrderReviewData`
prefers the order's own `email_body` and falls back to the
related `quote.email_body` for legacy orders that pre-date the
snapshot columns — quoting the operator's outbound is better than
showing '(no body)'.
Co-authored-by: Orca <help@stably.ai>
The order list and order sprint pages were rendering the rep column
with a raw PocketBase record id, which the operator could not
recognize. The data layer now resolves the rep through the user's
name:
- New `resolveRepDisplayName` helper (`src/lib/order-rep.ts`) takes
the customer record's `expand.rep`, the `customer.rep` relation
id, the legacy `orders.rep` text snapshot, and an admin-resolved
user map, and returns a name (or empty when only an unresolved PB
id is present).
- The orders service now fetches the user list alongside the order
page/counts query and resolves the rep on the way out, so the
client receives a `repName` per row. New endpoints
`/api/v1/orders/list` and `/api/v1/orders/[id]/review` expose
the read model; the page and sprint calls go through them.
- The list card and the sprint table + mobile card render the
resolved name. An opaque PB id is never displayed.
Migrates `/orders` off `pb-helpers.ts` per the data-layer
discipline, and adds a shared browser-session helper for
`Authorization: Bearer <PB token>` validation in route handlers
that serve the dashboard UI (vs. the agent API).
Co-authored-by: Orca <help@stably.ai>
Blocks order-item edits when the operator saves below the 22% markup
threshold and routes them through a manager-approval gate before the
item can be processed.
- new `order-item-margin-approval-service` saves the submitted draft,
stamps `item_status='approval_needed'`, then fires the configured
n8n webhook; webhook failure leaves the item blocked and returns
`webhookFired=false`
- new POST /api/v1/order-items/[id]/request-margin-approval browser
endpoint gates the same flow with source='order_process' |
'order_sprint'
- new `MarginApprovalDialog` shared by both order-process and
order-sprint process surfaces — single source of truth for the
submit/audit-note flow
- new `approval_needed` / `approval_rejected` item statuses; the
order-process save path now returns 'blocked' when an item is in
approval_needed so the operator can't re-edit past the gate
- order-sprint-process-modal now uses the same dialog so the
below-threshold flow is consistent across both entry points
Tests: 21 new (service + route + sprint modal), 1231 → 1242 passing.
Co-authored-by: Orca <help@stably.ai>
The single office bin is the only location, so the field is
unused. Strip it from everywhere the operator sees it:
PDF (stock-item-stocktake-document.tsx):
- Drop Location column + gapMaxLocation from styles, header, rows
- Re-balance widths: Name 23% → 28%, Status 12% → 18%
(the freed space makes Name properly readable again)
Stock list page (src/app/(app)/stock/page.tsx):
- Drop Location TableHead + TableCell
- Drop location state + input + submit-handler field from the
New stock item modal
- Drop 'location' from the modal JSDoc field list
Stock detail page (src/app/(app)/stock/[id]/page.tsx):
- Drop the MapPin + item.location display in the header
- Drop the now-unused MapPin import from lucide-react
Fulfill-from-stock dialog (src/components/stock/fulfill-from-stock-dialog.tsx):
- Drop location from the search predicate
- Drop location from the placeholder text
- Drop the '· {item.location}' display in the row
Note: the location field stays on the StockItem type and PB
schema for backward compatibility — removing it from the type
would break any code that still references it, and removing it
from the PB schema is a backend migration. The field is just
no longer surfaced anywhere.
Co-authored-by: Orca <help@stably.ai>
PDF:
- Add Location column between Max and Status (4% gap on each side)
- Re-balance widths: Name 28% → 23%, Status 18% → 12% to make room
- Total still 100% (with consistent 4% gaps between data columns)
- Update JSDoc to reflect new column + sort
Service:
- Sort by expanded supplier name (with item name as secondary sort)
- Items without a supplier sort to the end
- PB-side sort becomes 'supplier,name' so the JS comparator does
fewer swaps on large lists
- PB can't sort by an expanded relation's field, so the JS
re-sort is what actually produces the printable grouping
Tests:
- Update mock to handle 'expand: supplier' and populate
expand.supplier.suppliername on each row
- Update mock to handle comma-separated sort lists (PB accepts
'supplier,name')
- Add a 'sorts items with no supplier to the end' test
- Update existing sort test to cover multi-supplier grouping
Co-authored-by: Orca <help@stably.ai>
The previous 2% gapSkUName was too subtle to read as a column
separator — it was being absorbed by the empty space at the end
of the SKU column. Bumping to 4% (and trimming Name from 30%
to 28% to keep the total at 100%) makes the gap clearly visible
regardless of how long the SKU text is.
Co-authored-by: Orca <help@stably.ai>
- Add width: 100% to table/thead/tr so the row actually fills
the landscape A4 page width (was collapsing to ~70% of page)
- Move Status to the rightmost column with right-aligned bold
'Out of stock' as the visual anchor operator scans for
- Drop the trailing spacer column
- Re-balance widths: Name gets the lion's share (30%), Supplier
shrinks (11%), Current/Min/Max narrower (6/5/6%) and pushed
further right with explicit gap columns
- Small 2% gap between SKU and Name, 4% gaps between the
other pairs so the eye groups {SKU, Name, Supplier} as 'what'
and {Current, Min, Max, Status} as 'how much / how urgent'
Co-authored-by: Orca <help@stably.ai>
The previous layout had numeric columns (Current, Min, Max —
right-aligned) flush against the Status column (left-aligned).
A row like '100In stock' read as a single mashed string
because there was no whitespace between the right edge of the
Max column and the left edge of the Status column.
Fix: add paddingRight: 8 to the three numeric columns and
paddingLeft: 8 to the Status column. Right-aligned numbers
now end 8pt before the column boundary, and the Status text
starts 8pt inside its column — visible gap between columns,
even with the Status sitting right next to Max.
Co-authored-by: Orca <help@stably.ai>
Visual layout cleanup on the Stocktake PDF:
- Zebra-stripe alternating rows (subtle slate-50 on even rows,
white on odd) so adjacent rows scan easily.
- Drop the Location column — one less thing to fit on the page,
and the operator has the /stock detail page for that.
- Rename the Target column header to 'Max' (user-facing naming).
- Move the Status column to the right edge and make it bold +
colour-coded using STOCK_STATUS_STYLES[status].hex, matching
the on-screen badge palette.
- Widen the Name column (18%→24%) so full stock-item names read
in one line for most rows.
- Redistribute remaining widths so the table still totals 100%
of the landscape A4 page width.
Column widths now: SKU 10 / Name 24 / Supplier 18 / Current 9 /
Min 8 / Max 9 / Status 16 / spacer 6 (= 100%).
Co-authored-by: Orca <help@stably.ai>
The original Stocktake PDF columns (Name, SKU, Location, Unit,
Current qty, blank Actual, Supplier) didn't match what the
operator sees on /stock. They pointed at the wrong list — they
worked for a blank-column walk-through stocktake form, but the
user wants to print the /stock list as it appears on screen.
This rewrite makes the Stocktake PDF mirror the /stock table
column-for-column:
- SKU, Name, Supplier, Current, Min, Target, Location, Status
- Status derived via getStockStatus + STOCK_STATUS_STYLES so
it matches the on-screen badge label
- Current qty now renders with the unit suffix (matching the
on-screen cell)
- Target renders '—' when null/undefined/0, matching /stock
Also:
- Switched to landscape A4 to give 8 columns room (was
portrait).
- Added a copy of the 'Download Stocktake PDF' button on the
/stock page next to 'New stock item' — both /stock and
/stock-orders now expose the same endpoint. Button uses the
same plain <a target='_blank' rel='noopener'> pattern as
the existing stock-orders buttons, with aria-hidden on the
Download icon.
- Added a test on /stock for the new link (href, target,
rel). 33/33 tests pass.
Co-authored-by: Orca <help@stably.ai>
The send-email route's auto-PDF block did an internal
\`fetch(/api/v1/stock-purchases/[id]/pdf)\` to render a PDF when
the operator hit Send on a stock PO without one. Cloudflare's
loopback filter rejects that self-call with a network-level
error before Next.js sees it, surfacing as 'fetch failed' in the
modal (status rolled to draft, route returned 500 with code
'pdf_failed').
Extract the PDF generation into \`generateStockPurchasePdf\` on
the service layer so the send-email route can render the PDF
in-process. Same pattern as the customer-PO flow's
\`generatePurchaseOrderPdf\`. The route stays a thin shim that
maps \`StockPurchasePdfValidationError\` to 422.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Move the thead <View> out of the table and onto the page itself
with the @react-pdf/renderer 'fixed' prop, so reports that span
multiple A4 pages still label their columns. Without this, page 2+
of a long stock-orders report or stocktake would show data without
column headers — making the right-hand 'Actual' column on the
stocktake and the value columns on the orders list unreadable.
Co-authored-by: Orca <help@stably.ai>
Two new @react-pdf/renderer documents backed by renderStockPurchaseListPdf
and renderStockItemStocktakePdf in render.tsx. The Orders PDF mirrors
the on-screen table; the Stocktake PDF adds a blank 'Actual' column
for the operator to fill in during the walk-through. Both inherit
the toString() → Buffer pattern so they run under the same jsdom
test setup as the other PDF renderers.
Co-authored-by: Orca <help@stably.ai>
The mock's getFullList is now a vi.fn so the call args can be inspected. Each test asserts expand: 'supplier' was passed, so a regression that drops the expand (e.g. a copy-paste from the dashboard read) fails fast instead of silently shipping rows without supplier names.
Co-authored-by: Orca <help@stably.ai>
Returns active stock items sorted by name with supplier expanded,
for the new Stocktake PDF download endpoint. Distinct from the
dashboard's low-stock query: stocktake wants every SKU
alphabetically, not the under-stocked ones first.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Same operator-flow fix we made for customer POs in 79b9309: sending a
stock PO no longer auto-flips the linked stock_purchase_items from
`collection_pool` to `assigned_to_driver`. Items now stay in
`collection_pool` after the supplier email goes out; the operator
marks them ready-to-collect from the stock-order page only after the
supplier confirms, and that's the action that promotes them to
`assigned_to_driver` (so the driver-app sees them as ready).
Without this, the stock-order "PO Sent" state was invisible — the
items jumped straight to driver-ready the moment the email left,
before any supplier confirmation. Now the PO Sent → Ready to Collect
→ Assign Driver flow is observable end-to-end.
3 new tests, full suite 1210/1210 passing.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
The dashboard's send-email route fires the n8n webhook unconditionally
once pre-send validation passes. If a PO has no generated PDF (legacy
state — `documents` empty + `documents_history: [null]`), n8n tries to
download from PB with an empty filename, gets a 404, and the webhook
returns HTTP 500. The operator sees "Webhook returned HTTP 500. Status
reverted to mail_failed." with no indication of the actual cause.
Add a PDF-existence guard in `sendPurchaseOrderEmail` using the same
`documents` → `documents_history` fallback chain the route uses to
compute `latestFilename`. When both are empty / unusable, return a
`no_pdf` failure that flips the PO to `mail_failed` with a clear
operator-facing message BEFORE the webhook fires.
While here, rename the misleading `code: "no_pdf"` on the line-items
check to `code: "no_line_items"` — the old code name was wrong (it
checked for linked items, not PDFs) and would have collided with the
new `no_pdf` guard.
The new guard also filters null entries out of `documents_history`
before calling `.find()` / `.file` — the legacy `[null]` shape was
making the old route code throw.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Add ordered_value and received_value to StockPurchaseListRow. Each is
Σ line.{quantity|quantity_received} × line.cost_price, rounded to 2dp.
Replaces countItemsForPurchase with summarizeItemsForPurchase so the
per-row N+1 returns all three numbers in one fetch (no extra round
trip).
Co-authored-by: Orca <help@stably.ai>
Previously, when recordPurchaseOrderSendOutcome received a "success"
outcome from the n8n workflow, it auto-flipped every linked
order_items.item_status from "po_generated" to "collection_pool".
This dropped items off the buyer-tasks PO Sent tab immediately on
send — before the supplier had even confirmed receipt. The operator
had no chance to mark items ready-to-collect manually.
New flow: send mail → items stay "po_generated" and remain visible
in buyer-tasks PO Sent → operator manually marks items ready →
items drop off buyer-tasks.
The back-link sync (purchase_orders.order_item) still runs and now
reads all items with purchase_order = poId regardless of status, so
the array stays accurate as items progress through their lifecycle.
Adds src/lib/server/purchase-orders-service.test.ts with 4 cases:
- success does NOT auto-flip linked items
- success flips PO to sent + stamps email_message_id
- success keeps the PO's order_item back-link in sync
- failure flips PO to mail_failed + stamps error (also no item writes)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
The po_draft filter previously required po.status === 'draft', so
a failed send (status: 'mail_failed') caused the group to vanish
from buyer-tasks entirely. The only recovery path was the per-PO
Resend button on the order detail page, which is friction. Now
mail_failed groups stay in place so the operator can re-open the
SendPoModal and retry from the same list. po_sent is unchanged— mail_failed must not appear there.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Adds an emailError field on BuyerTaskGroup so the UI can render
the send-pipeline failure reason. The value is truncated to 200
chars at the service boundary to keep the card layout bounded.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Restores the 'Book to stock' action that the page rewrite in f7d3032 dropped from collection-confirm/page.tsx, and routes the cost pencil away from stock rows so the existing cost-edit endpoint (which queries order_items) can't 404 on a stock_purchase_items id.
- New POST /api/v1/collection-confirm/book-stock endpoint + route tests.
- New bookStockRows(rowIds, { actor }) service that mirrors the pre-rewire bookStockLineForRow flow per-row: ledger → cache increment → line update → auto-derive parent send→received.
- PoCard footer now switches on the row mix: stock rows → 'Book to stock'; customer rows → 'Confirm for invoicing'; mixed → both.
- ItemsTable hides the cost pencil when row.source === 'stock' (cost cell stays readable).
- Page-level handleBookStock wired through to the new endpoint with a reload on success.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Extends updateOrderReviewFields' patch type to accept company
(relation id) and rep (free-text snapshot) in addition to the
existing order_number and customer. The modal cascade writes these
when the operator changes the customer; the service now carries
them through to the PB update.
The order_number collision check is unchanged — it only runs for
order_number changes, so the new fields are unaffected.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Adds a TDD'd updateQuickDraftLine function that PATCHes
quantity and/or cost_price on a stock_purchase_items row.
Returns the same discriminated-union shape as its sibling
add/remove functions. No schema changes.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
- orders-service.test.ts: destructure capture-group bug on regex match
(was assigning m[0] to 'field', so filter clauses never matched).
Two pre-seed tests were masked by the bug.
- quotes-service.test.ts: createQuoteFromEmail now returns 0
attachments; the n8n flow uploads them in a follow-up step. Update
the assertion that still expected the old embedded-base64 count.
- collection-pool/page.test.tsx: tests assume ONE supplier group with
multiple rows; supply that shape and scope the selectors. Also wait
for data load in the chevron test and gather list items across all
<ul> elements in the sort control helper.
- pocketbase.ts: wrap authStore.onChange registration defensively so
the test mock (no authStore) doesn't throw at startup.
- stock-orders/page.tsx: drop the re-export of
STOCK_PURCHASE_STATUS_VALUES on the page module — Next.js page
route files only allow the default export.
- collection-confirm/view-model.test.ts: buildConfirmNote input is
Pick<InvoiceDetails, 'date' | 'number'>; drop the extra setBy fields.
Co-authored-by: Orca <help@stably.ai>
When the operator clicks "Mark as ready" on a buyer-tasks po_sent card
and fills in the note, the item should land in the collection pool
(`item_status: "collection_pool"` + `ready_for_collection: true`) so
a driver can be assigned. Previously the service set
`item_status: "ready_delivery"`, which was rendered as the word
"Collected" on /orders and on /orders tabs — operators reasonably
read that as "driver already picked it up", and the item never
appeared on /collection-pool where they expected to assign a driver.
Now:
- markGroupReady writes a single PB update with the same shape as
/api/v1/collection-map/items/[id]/assign (collection_pool +
ready_for_collection: true + notes)
- po_sent buyer-tasks filter excludes collection_pool and assigned,
so marked-ready items drop out of buyer-tasks and surface on
/collection-pool instead
- ITEM_STATUS_VISUAL.ready_delivery is labelled "Ready for
Collection" (kept distinct from the actual `collected` state) for
any other flow that legitimately sets this status (e.g. stock-out)
- new item-status-ui.test.ts locks down the label/tab mapping so
the regression can't return
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
generatePoForGroup was creating purchase_orders records without
`po_number`, which is required by the PB schema. PB rejected the
create with 500 ("Failed to create record.") and the operator never
got a PO. Fix:
- New src/lib/server/po-number.ts — server-side generatePONumber()
that mirrors the client version in pb-helpers.ts (PO-YYYY-MM-NNN
format) but uses getAdminPocketBase(). Per the data-access rules,
don't extend pb-helpers.ts; server business logic lives under
lib/server/.
- generatePoForGroup now generates the PO number, includes
total_amount (sum of costPrice * quantity) and order_item
(back-link to the items) in the create payload — mirrors the
canonical /orders/[id]/process PO-creation flow so the PB admin
row shows related lines without an extra getList call.
- Test updated to assert the new payload (po_number regex,
total_amount computed, order_item back-link).
UI while here:
- BuyerTaskGroupCard now renders a deterministic supplier colour
stripe on the left edge. New src/lib/supplier-color.ts hashes the
supplierId into a small Tailwind 600 palette (18 colours) so the
same supplier always shows the same colour, and the supplier name
gets tinted with the same colour for quick scanning.
- PO number is now shown prominently at the top of the card (in a
warm-toned badge that matches the AI Insights theme) when one
exists — po_draft and po_sent tabs. Needs PO tab omits it.
Co-authored-by: Orca <help@stably.ai>
The Mark ready action on the buyer-tasks page now mirrors the
collection-pool flow: the operator is asked for a free-text note
(name of who they spoke to, collection number, etc.) before the
items transition to ready_delivery. The note is required, is trimmed,
and is written to order_items.notes alongside the status change so
there's an audit trail.
- Service: markGroupReady now takes a required note param, trims it,
rejects empty values, and persists the trimmed text to notes.
- Route: /api/v1/buyer-tasks/mark-ready returns 400 when note is
missing or whitespace-only, and forwards the value to the service.
- Page: BuyerTasksPage swaps the no-input MarkReadyModal for the
shared MarkReadyDialog, threads the note through the POST body,
and surfaces server-side validation errors back into the dialog
rather than only as a toast.
- The old MarkReadyModal is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
- Switch from getList(1, 500) to getFullList so candidate sets
aren't silently truncated at 500 rows (matches orders-service
and quotes-service conventions).
- Tighten the po_draft filter to item_status = po_generated so
items in downstream statuses (collection_pool, delivered, etc.)
don't get pulled over the wire.
- Tighten the po_sent filter to exclude item_status in
{ready_delivery, invoiced, delivered, cancelled} for the same
reason.
- Drop the redundant ready_delivery post-filter on po_sent now
that the PB layer handles it.
- Tests capture the filter string passed to getFullList and assert
its clauses so regressions are caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Two related enhancements to the orders review modal:
* Tier 1 — header totals: the "Order items" header now shows the
item count plus the cost and selling totals (qty*price summed
across the live draft state). Uses formatCurrency from
pb-helpers.ts for the ZAR formatting.
* Tier 2 — add/remove items: the edit-mode items list now has an
"Add item" button (Plus icon) and a per-row Trash icon. New rows
get a sentinel id like "__new_1" and a left-edge accent stripe;
rows flagged for removal flip to a strikethrough + reduced opacity
with disabled inputs (toggle button flips to an Undo2 icon).
On save, the route posts to /api/v1/orders/[id]/review-save with
new itemsToAdd and itemsToRemove arrays. The service layer adds
createOrderItems + softFlagOrderItems handlers (set
item_status="removed" — operator confirmed the value is in the
PB admin schema). The modal re-keys the new drafts with the
returned PB ids so a follow-up save treats them as existing
items.
Partial-failure UI now has three sections (updates / adds /
removes) sharing a labelled FailureSection helper. Response
envelope now includes itemsAdded and itemsRemoved alongside the
existing items block, plus a flat partialFailures array for
the toast summary.
Type cleanup: "removed" added to ItemStatus and the
ITEM_STATUS_VISUAL map.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
The merge of feature/order-sprint-item-history into main concatenated
src/lib/types.ts by appending the last 50 lines of the feature branch's
version. The two helper interfaces ItemHistorySupplier and
ItemHistoryOtherQuote were defined earlier in the file (lines 1416-1433)
and were dropped, breaking the references in ItemHistory and the import
in item-history-service.ts. This commit restores them.
Co-authored-by: Orca <help@stably.ai>
Items in the orders review modal are read-only by default. A new
'Edit items' button (Pencil icon, top right of the items list)
opens a confirmation dialog before the inputs become editable.
- State: editingItems (default false), confirmingEdit (default false).
- Both reset to false whenever the modal re-opens (useEffect on
[open, order.id]) so re-opening always starts read-only.
- All 4 per-row inputs (qty, cost, markup, selling) carry
disabled={!editingItems || saving}.
- Visual hint: when editing, the items list gets a subtle warn
ring + tint (ring-1 ring-warn/40 bg-warn/5).
- data-testid hooks: review-edit-items-btn on the button,
review-edit-confirm-dialog on the dialog, review-items-list on
the ul with data-editing attribute.
- 4 new tests cover: read-only default, button opens dialog,
confirm enables editing, cancel keeps read-only.
Also adds order_attachements?: string[] to the Order type — the
PB file field on the orders collection used by the modal's source-
of-truth panel (separate commit wires the data).
Co-authored-by: Orca <help@stably.ai>
A post-commit re-edit of the test file introduced a duplicate
updateOrderItemsPrices describe block at the tail. All tests still
pass (the duplicates run independently). Keep the file aligned
with HEAD.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Cross-order rejection (Fix#3), PB filter quote escape (Fix#6), and
mapping of PB unique-index write errors to OrderNumberCollisionError
(Fix#7) get full coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
A request to order A could include an item ID from order B and
silently modify it. Read the current row and reject with failed[]
when current.order does not match orderId. Test covers the cross-
order case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Restores the /collection-pool feature in this codebase.
- src/lib/server/orders-service.ts
- Re-adds `listCollectionPool({ status, page, perPage })` which
queries the `collection_pool` PB collection via the admin
client (filter on pool_status, sort -created, expand
order_item/order/supplier/drv_id). MCP route.ts already
imported this — without it, the MCP route failed to
compile.
- src/app/(app)/collection-pool/{page,components,view-model}*.tsx
- Re-creates the page directory (5 files, 2 315 lines) carrying
the supplier-group + per-stop checkbox UI, the
`CollectionPoolRowSource = "customer" | "stock"` union, the
assignment-state badge helpers (buildProximityMarkerMap,
buildSupplierGroups, buildVisibleRows), and the
MarkReadyDialog / AssignDriverDialog wiring.
- src/app/api/mcp/route.test.ts
- Adds `const listCollectionPool = vi.fn()` and exposes it in
the vi.mock factory so the existing collection_pool.list test
cases (and the bulk_update_quote_items ones that call
`.mockReset()` on every fixture) stop crashing with
'ReferenceError: listCollectionPool is not defined'.
Not in this commit (kept narrow per request):
- The /api/v1/collection-pool route handler and its test
- The 'Collection Pool' entry in the sidebar nav
- The migration script that creates the live `collection_pool`
PB collection (it doesn't exist on the cloud PB instance
yet; the page render relies on it being created separately
or via `npm run pb:migrate:orders-v2`)
src/app/api/v1/sprint/attachment-counts/route.ts
- Chunk itemIds into batches of 80 before building the PocketBase filter;
the live cap was hit at ~115 IDs × 15 chars and surfaced as a
generic Next 500 ("Something went wrong while processing your
request.") that hid the real cause. 7 chunks for the sprint
page's max of 500 items.
src/app/layout.tsx
- Add metadataBase derived from NEXT_PUBLIC_SITE_URL /
NEXT_PUBLIC_POCKETBASE_URL. Silences the per-page
"metadataBase not set" warning during dev renders and gives
OG / Twitter cards a stable canonical base.
src/app/api/v1/{quotes,order-items,purchase-orders,stock,
stock-purchases,users,sprint,driver,ai-insights}/route.ts (NEW)
- Add the missing top-level collection routes the agent API
needs. seven are straight passthroughs to their PB collection;
/sprint proxies to quote_items with optional claimStatus and
category query params; /driver lists PB users (no role field
in PB so callers narrow with ?filter=); /ai-insights returns
a roll-up count + most-recent regenerate timestamp across the
four collections that carry ai_* JSON fields.
src/app/api/v1/collection-pool/{route.ts,route.test.ts},
src/app/(app)/collection-pool/{page.tsx,page.test.tsx,
view-model.ts,view-model.test.ts,components.tsx} (DELETED)
- Drop the collection_pool feature entirely: PB has no
collection_pool collection on the live instance
(pb:migrate:orders-v2 was never run), the codebase comment
in purchase-orders-service.ts says "now-removed", and the
sidebar entrypoint has been migrated to /sprint + /deliveries.
The replacement operational status is the `item_status =
"collection_pool"` enum value on order_items, which stays.
src/lib/server/orders-service.ts (+ test), src/lib/pb-helpers.ts,
src/components/sidebar.tsx, src/components/ui/assign-driver-dialog.tsx,
src/app/(app)/collection-map/sidebar.tsx,
src/app/api/mcp/route.ts (+ test) (MODIFIED)
- Remove listCollectionPool + getCollectionPool +
updateCollectionPoolStatus, the /collection-pool sidebar entry,
the deep-link in assign-driver-dialog and collection-map
sidebar, and the collection_pool.list MCP tool (the tool,
handler, and test mock).
scripts/migrations/2026-07-26-fix-quote-counter-and-reassign-numbers.ts (NEW)
- One-shot migration script that:
1. Reads every quote, sorts by created ASC.
2. Reassigns clean Q202607001 … Q202607{N} over the whole
table (including the e+22 garbage and the legacy
Qnnnnnn rows).
3. Sets quote_counters.(year=2026, month=7).last_seq = N
so the next /api/v1/quotes/next-number returns a clean,
atomic, unique number.
Default mode = dry-run; pass --apply to write.
Already executed against the live cloud PB during this session
(verified: 200/200 rows updated, counter at 200, next-number
returns Q202607201, Q202607202, Q202607203 on consecutive calls).
AGENTS.md (MODIFIED)
- Add data-layer-discipline block matching the style of the
three existing blocks. Documents which layer is legacy (pb-helpers)
vs current (lib/server/*-service.ts via /api/v1/*), gives the
migration order (customers → orders → quotes → suppliers →
companies → rest), and includes the grep one-liner future AI
agents should run before extending pb-helpers.
Not in this commit (your WIP, untouched):
src/app/(app)/{dashboard,delivery-notes,layout,orders/[id],
quotes/[id],quotes/approvals,quotes,sprint}/page.tsx,
src/app/(auth)/login/page.tsx, src/components/charts/*,
src/components/{company-form,customer-form,quote-email-card,
sprint-item-pricing-modal,supplier-form,theme-bootstrap,
theme-picker}*.{tsx,test.tsx}, src/app/api/invoicing/delivery-note/*
src/app/api/v1/quotes/[id]/pdf/route.ts, src/app/globals.css,
src/lib/{item-status-ui,pdf/quote-document,quote-status*,themes,
types}.ts, src/lib/server/{order-item-status*,quotes-service}.ts,
src/styles/themes.css, APP_CAPABILITIES.md, README.md,
docs/SHIPPED.md, scripts/smoke/screenshot-pages.mjs,
and the new untracked files under docs/, scripts/migrations/
2026-07-19…25, src/app/{(app)/deliveries,(app)/delivery-notes,
api/v1/ai-insights/rerun,api/v1/deliveries,api/v1/delivery-notes,
api/v1/quotes/[id]/partial-approve,api/v1/quotes/[id]/split},
src/components/{ai-insights-panel,ai-insights-renderer,
ai-item-history-trigger,brand-stripe,delivery-notes,ui/stock-item-search,
ui/duplicate-warning,ui/duplicate-warning.test}.tsx, supplier-form.test.tsx,
src/lib/{pb-helpers-duplicate.test,server/deliveries-service*,
server/delivery-notes-service*}, public/wfdash-logo*.png,
opencode.jsonc.
Add complete stock purchase order workflow including:
- Status tracking with sending/mail_failed states and UI labels
- Server-side PDF rendering and document storage
- File proxy API support for stock PO attachments
- Send outcome webhook callback for email notifications
Add driver app support:
- Unified stop list API for both customer deliveries and stock pickups
- Status update endpoint for collected quantities and problem reporting
- Collection map page support for stock pickup locations
Include matching PocketBase migrations, type definitions, and stock inventory fulfillment logic.
Add full integration for stock supplier pickup lines into the collection pool workflow:
- Add two idempotent database migrations for stock line drv_id and state tracking
- Update stock purchase number format to POyyMMddNNSTK convention
- Extend collection pool to load both customer order items and stock purchase items
- Add stock-specific UI, driver assignment flow, and mark collected action
- Update type definitions and service logic for stock purchases and line items
- Add comprehensive test coverage for all new workflows
This commit adds the complete v2/v3 stock management functionality:
- add new stock-specific item statuses and update the status visual map
- implement full stock purchase API suite (create, list, retrieve, send, receive)
- reorganize sidebar navigation to group stock-related pages together
- add stock item CRUD APIs, status utilities, low stock filtering, and surplus booking logic
- add database migration for the `stock_items.target_qty` field
- update type definitions and add comprehensive tests for all new stock components
This commit adds the full end-to-end delivery note workflow for the invoicing system:
- Added `letterheadForCompany` utility to infer the correct letterhead from a company name
- Created the DeliveryNoteDocument PDF component and server-side render function
- Added API endpoints for delivery note creation, PDF retrieval, printing, and print tracking
- Updated the PickingSlip component to support both invoice and delivery note headers
- Added view models for grouping, filtering, and displaying delivery note items
- Updated AGENTS.md with PocketBase schema and attachment best practices for new workflows
- Added comprehensive test coverage for all new components and API routes
Add full Tangerine brand theme with light and dark mode variants to the app theme system.
Add four new design and integration documents for collection pool, map and driver app workflows.
Add initial scaffolding for three new plugin source directories for the collection pool bulk assignment feature.
- Standardize quote number format from Q-YYYY-MM-NNN to QYYYYMMNNN across all client, server, test and API code
- Add atomic backfill logic for quote counters to avoid sequence collisions with existing records
- Rearrange Delivery Notes and Repairs to the Operations sidebar section
- Add supplier count badge to pricelist catalogue and improve filter tab overflow styling
- Optimize font loading in root layout to skip preloading unused font weights
- Improve table styling for customer and company pages with better text wrapping and empty states
- Fix rep display logic in quotes page to show "Unassigned" when no rep is assigned
- Add supporting documentation, PocketBase counter schemas and setup migration scripts
- Remove embedded SupplierMap from suppliers page for cleaner layout
- Improve invoice card layout for company and customer info spacing
- Improve filter label styling and accessibility in collection map page
- Add Supplier Map component to suppliers page and reorganize sidebar navigation
- Implement picking slip PDF generation with supplier per line item and picked-by signature footer
- Add invoice bulk print tracking API and view models to track printed invoice state
- Extend company search and form to support email/telephone fields and improve searchability
- Fix data table debounce race condition by using useRef for onSearch handler
- Fix supplier page fetch race condition with debounced timeout
- Reduce list page item counts to 10 per page for customers, companies, and suppliers
- Update company search filter to include email and telephone in search queries
- Add comprehensive test suite for new and existing components and APIs
- Add pdf-lib dependency for PDF generation and update related tooling
- Clean up unused code and fix minor linting issues
Add complete end-to-end QuickBooks invoicing workflow for the invoicing page:
- Add `jspdf` dependency for client-side PDF generation
- Implement updated item status handling with fallback for new invoice statuses
- Add server-side proxy API for secure QuickBooks customer lookup
- Build utilities to combine QuickBooks invoices and picking slips into a single PDF
- Add invoicing view models for row grouping, payload construction, and status checks
- Add full test coverage for all new components, APIs, and utilities
- Include feature design documentation and sample n8n workflow config
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.
Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
Add full sprint pricing workflow and supporting infrastructure:
- New sidebar navigation entry for the sprint dashboard
- PocketBase category collection schema and migration script
- Server API routes for sprint item claims, attachments, counts, and file proxying
- Dev identity utilities and UI switcher for multi-user testing
- Reusable FilterTabs component replacing ItemStatusTabs
- Updated TypeScript types for sprint-related entities
Also include:
- Fix PocketBase file URL method casing (getUrl → getURL) across codebase
- Add development utility, smoke test, and helper scripts
- Update package.json with new sprint migration command
This commit adds the full end-to-end SKU pricing match flow for quote items:
- Add new QuoteItem and PriceListRow fields for SKU and pricing metadata
- Add sku_normalised to price_list for robust format-agnostic SKU matching
- Refactor price list search to use modular search builder and filter helpers
- Add database migrations, backfill scripts, and new package commands
- Update catalogue search UI and fix quote list accidental click behavior
- Add comprehensive unit, smoke, and end-to-end test suites
- Update documentation for the new price search and extraction logic
The Catalogue tab's search box used to OR across just sku,
description, and brand. Now it hits:
- sku, description, brand, supplier, category, subcategory
(PB's case-insensitive `~` operator, each indexed)
- search_text (a denormalised text blob)
The denormalised column is the key part: PB can't substring-
match a JSON field, so without this, synonyms from
`specifications.synonyms` ("slugger cutter", "turbo tough",
etc.) would never match in the search box. The n8n workflow's
Code node now flattens every searchable bit — including the
synonyms array — into search_text on every POST. The 46 pre-
existing rows were backfilled once with the same formula.
The `price_list.supplier` field also stored the PB id on the
46 pre-existing rows (n8n was setting the resolved name, but
the original LLM tests that seeded the table didn't). Fixed
that in the same backfill — supplier now reads 'JEISA -
WESTSCOTT GROUP' instead of 'q6hpwbxsicyoiuu', which makes
the supplier filter dropdown work AND makes
'supplier ~ "jeisa"' match.
Migration `2026-06-30-add-price-list-search-text`:
- Adds `search_text` (text) column
- Adds `idx_pl_search_text` non-unique index
- Idempotent — re-runs are safe and self-heal the index
Updates:
- `getPriceLists` helper ORs across all 6 columns + search_text
- `PriceList` TS type gains `search_text: string`
- `pricelist-extraction.md` Code node snippet shows how the
n8n workflow should populate `search_text` on every POST
- Search input placeholder reads 'Search anything — SKU,
description, brand, supplier, synonyms…'
Tests: 229/230 (same pre-existing failure, unrelated).
TypeScript clean.
Adds a second tab on /pricelist that shows the structured
`price_list` collection (AI-extracted line items) instead of the
raw PDF uploads. Operators can now:
- Search across SKU, description, and brand
- Filter by category (twist_drill, hss_cutter, countersink, ...)
- Filter by brand (JEI, TurboSteel, TurboTough, ...)
- See cost vs selling price side-by-side in ZAR
- Click a row's 'Source' link to jump back to the raw PDF
The distinct filter values are pulled from a one-shot getList(1, 500)
on mount, with hardcoded fallbacks (FALLBACK_CATEGORIES /
FALLBACK_BRANDS) so the dropdowns are useful even before any
extractions have run.
Layout: Tabs (Raw Uploads | Catalogue) wrap both views; the
header + page chrome stay shared. The DeleteDialog stays outside
the Tabs since it's a modal.
Also ships an idempotent migration to add `source_file` (text)
to `price_list` — used by the n8n workflow to track which raw
PDF each extracted row came from, and by the verify-after-POST
step the workflow uses to decide whether to flip
`raw_price_list.status` to Completed vs Failed.
Price type is in PriceListRow (shared subset) and PriceList
(full record). Supplier names are resolved client-side via
getAllSuppliers() since the live schema has supplier as a text
field rather than a relation.
Tests: 229/230 (same pre-existing failure, unrelated). TypeScript
clean.
Operators can now type per-upload instructions for the AI extraction
step in the Pricelist upload form. The notes go into a new
`raw_price_list.notes` field that the n8n workflow reads and
injects into the LLM user message as per-upload context.
Common uses:
- "Brand is JEI" → AI stamps every item with brand="JEI"
- "Apply 30% markup" → Code node regexes this out for selling_price
- "All prices in ZAR excl. VAT" → AI uses ZAR
- "Page 4 has discount codes" → AI skips those rows
Also auto-populates `raw_price_list.file_name` (the original
browser-side filename) on upload — PB slugifies the upload to a
token stored inside supplier_file, so without this field the
operator saw the slugged version. Existing rows backfilled by the
admin script.
The `log_summary` field is now reserved for the AI's write-back
summary (set by the n8n workflow after extraction). It was
previously operator-editable on the form, which was wrong —
overwriting it clobbered the AI's output.
New migration: scripts/migrations/2026-06-30-add-pricelist-notes.ts
(idempotent, only adds `notes` if missing — `file_name` was
added manually via the PB admin UI before this commit).
doc/pricelist-extraction.md updated with the n8n LLM user message
shape, the markup regex, and the notes field's role in the pipeline.
Adds /pricelist under the Management section. Operators drag a supplier
price-list file (PDF / CSV / text / image) into the upload zone — or
use the explicit Choose file button — and it lands in PocketBase as an
attachment on the raw_price_list collection. The table below shows the
queue: file, supplier (relation to suppliers), status badge, log
summary preview, upload date, and a delete action per row.
Layout matches the rest of the app: Card for the upload zone, DataTable
for the queue, DeleteDialog for the destructive action. Live updates
via the PB subscribe channel so other tabs / the n8n ingest workflow
show new rows without a manual refresh.
PB schema is sourced from docs/pocketbase/raw_price_list.collection.json.
The migration script is idempotent: it creates the collection from the
schema file on a fresh deploy, and on a pre-existing one (the live PB
already had it, with PB's default null/superuser-only rules) it relaxes
the auth rules to @request.auth.id != '' so any logged-in user can
CRUD — matching every other collection in the app.
Field names match the live PB collection (discovered via admin API):
- supplier_file (the file)
- supplier_name (relation to suppliers)
- status (select: Pending / Processing / Completed / Failed)
- log_summary (free text)
The list page had grown 10 colour-coded tabs (one per legacy PB status)
that fragmented the workflow rather than grouping it. Merging the
overlapping concepts into workflow buckets per the operator's flow:
- new ← new + follow_up (follow-up rows show a bell chip)
- claimed ← claimed
- pending_approval ← processed + approval_pending + sending
- completed ← approved + complete + emailed + cancelled + rejected
(emailed renders as 'Awaiting reply', cancelled + rejected
are substatus pills inside the Completed tab)
- email_failed ← email_failed + mail_failed + unmatched_bounce (new tab)
- problem ← problem
- templates ← is_template = true
- all ← catch-all
Two row-level indicators added next to the quote number:
- Bell chip for follow-up rows (status=follow_up OR follow_up_due set
on an actionable status). Colour flips to red when overdue.
- Reply-bubble chip for rows where last_reply_at is set, so the
operator can distinguish 'fresh new' from 'replied new' at a glance.
Implementation notes:
- getQuotes now accepts string[] statuses and OR-chains them in the
PB filter (PB 0.26 has no IN operator). getQuoteStatusCounts sums
per-status totals into the new tab buckets in one batch.
- formatQuoteStatus gains workflow-friendly label overrides
('Awaiting reply', 'Sending…', 'Pending approval', etc.) shared by
the list, archived, and process pages.
- The detail page's local statusLabels map is kept in sync so toast
+ history-row interpolations read correctly.
Tests: 229/230 vitest (the 1 failure is the pre-existing attachments
off-by-one, unrelated to this change). Updated quote-status.test.ts
for the new label overrides.
This commit overhauls the order management system to use item-level status tracking instead of a single order-level status:
- Remove deprecated `orders.status` field and add `invoiced` and quote match audit fields
- Add `isOrderComplete` utility with tests to validate fully delivered orders
- Add purchase order PDF rendering support and tooling
- Add geo coordinate fields for companies/customers with map link UI
- Add REST management APIs for customers, companies, and suppliers
- Update customer/company services to use relation fields instead of free text
- Add status pill and tab filter components for item status visualization
- Add quote-to-order matching logic and unit tests
- Update all pages, components, and tests to match the new schema
- Include all required PocketBase migrations and schema updates
Add complete multi-theme dashboard system including:
- 8 selectable themes with persistent per-browser preferences
- Theme bootstrap script to prevent FOUC on page load
- 12+ reusable chart components (area, bar, donut, radial gauge, etc)
- Custom checkbox UI component
- New settings page for appearance customization
Other changes:
- Optimize supplier map API payload with getMappableSuppliers helper
- Add aria-label attributes to back buttons on detail pages for better accessibility
- Update next.config.ts to include new local development IP 192.168.1.128
- Update site manifest with all new favicon and PWA assets
- Fix styling and layout spacing in rewrite quote modal
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
This commit adds full quote PDF generation functionality with per-company letterhead branding:
- Server-side PDF rendering using @react-pdf/renderer matching the sample quote layout
- In-app modal PDF viewer with print, download, and new-tab controls
- Letterhead service to fetch and apply company-specific branding (CNL/ISHERWOOD)
- ZAR currency and date formatting utilities for South African quotes
- Updated QuoteAttachment type to handle PocketBase's array-based file tokens
- Fixed PocketBase attachment URL generation in quote email card
- Removed deprecated `included_in_quote` field from QuoteItem and quotes service
- Updated quote item creation test to validate draft item logic
- Added comprehensive unit tests for PDF rendering and formatting helpers
- Added dev scripts for debugging PDF and image rendering issues
- Added sample generated PDF files for visual verification
- Updated tsconfig to exclude development scripts directory
- Added @react-pdf/renderer production dependency
- Removed JEI as a valid letterhead option per requirements
- Add `user_edited` field to QuoteItem type to gate AI rewrites on user-edited content
- Add idempotent migration script to add and backfill the `user_edited` field across quote items
- Replace plain Select components with searchable SupplierSearch combobox across order and quote pages
- Add working days calculation utility and aged quote warning badges for overdue quotes
- Implement expandable row previews in the quotes list view
- Add full AI rewrite workflow: backend API endpoint, frontend modal, and n8n workflow definition
- Update package scripts for the new migration
Add comprehensive quote management and UI improvements:
- Add `email_received_at` field to quotes collection, TypeScript types, and setup script
- Update quote attachments schema to mark `kind` optional for legacy attachments, add `uploaded_at` field, and improve documentation comments
- Create `/api/v1/users/lookup` server endpoint to resolve user IDs to display names
- Add stable user ID → color mapping for visual grouping of claimed quotes
- Enhance quotes list page: reorder tab filters, show claimer names, color-code rows per claimant
- Update quote detail page to display claimer info with user icon
- Expand DataTable component to support per-row custom class and style props
- Overhaul QuoteEmailCard: add proper HTML/plain text body sanitization/linkification, inline PDF/image previews, improved metadata display, and fixed auth-aware attachment previews via blob URLs
Fix inconsistent line endings across all project files for cross-environment consistency.
Add complete unit test suite covering core utilities, API routes, and server services.
Add Playwright E2E test setup with smoke, auth, and agent API tests.
Update all project configuration files to match standardized formatting.
Fix MCP API error handling to properly propagate HTTP status codes and use correct JSON-RPC error codes.
Add VSCode workspace configuration, extensions, and example MCP config files.
Update .gitignore to exclude new test and build artifacts.
Introduce PocketBase workflow support, migration tooling, server APIs and documentation. Adds a set of PocketBase collection JSONs (order_items, order_items_v2, collection_pool, delivery_notes, delivery_note_items, repairs) and a large import manifest for workflow collections; includes docs describing MCP/API layer, migration audit, order-item schema and rollout plans. Adds server-side agent/MCP endpoints and services (api/mcp, api/v1/orders, collection-pool, order-items status, health) plus helper modules (pocketbase-admin, orders-service, order-item-status, agent-auth) and a migration script to create order_items_v2/collection_pool. UI: new pages for collection-pool, delivery-notes, repairs and several updates to orders/quotes pages, sidebar, tooltip and pb helpers/types. Also updates package.json (migration script and related changes) and global styles. Required env vars and suggested migration command are documented (e.g. NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, ORDER_ITEMS_COLLECTION; run npm run pb:migrate:orders-v2).
- Introduced a new Separator component for visual separation in UI.
- Added Sheet component for modal-like functionality with triggers and overlays.
- Implemented Toaster component for notifications using Sonner library.
- Created Switch component for toggle functionality.
- Developed Table component with headers, body, footer, and rows for structured data display.
- Added Tabs component for organizing content into tabbed interfaces.
- Implemented Textarea component for multi-line text input.
- Created Tooltip component for displaying additional information on hover.
feat: add PocketBase helpers and types
- Implemented various helper functions for interacting with PocketBase collections (customers, companies, suppliers, quotes, orders).
- Added type definitions for entities such as Customer, Company, Supplier, Quote, Order, and their respective items.
- Included utility functions for generating unique identifiers for quotes, orders, and purchase orders.
- Added functions for calculating totals and formatting currency and dates.
chore: add TypeScript configuration
- Added tsconfig.json for TypeScript support with strict type checking and module resolution.