Commit graph

114 commits

Author SHA1 Message Date
SJCoetz
d79c433704 fix(quotes): import QuoteItem + return real array from dedup branch
Production deploy failed the TypeScript type-check (next build exited 1)
because commit 2acfd2d changed CreateQuoteFromEmailResult.items from
'number' to 'QuoteItem[]' without updating:

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

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

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

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

Co-authored-by: Orca <help@stably.ai>
2026-08-05 16:18:31 +02:00
SJCoetz
ba1f916102 feat(orders): show From + Subject in review modal Source of truth header
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>
2026-08-05 16:09:53 +02:00
SJCoetz
0ac05b847b test(mcp): cover quotes.update + create_from_email unit field
- 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>
2026-08-05 11:39:16 +02:00
SJCoetz
2acfd2d346 fix(mcp): add quotes.update + preserve unit in create_from_email
Closes the two real gaps from the agent's gap analysis (2026-08-05):

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

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

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

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

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

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

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

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

Production build failed with:

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

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

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

All 1331 tests pass; npm run build completes cleanly.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 20:36:50 +02:00
SJCoetz
3ea7c9c189 feat(quotes): move completed+all to dedicated /quotes/archive page
Parallel to the orders archive treatment. On the live PB the
'Completed' tab on /quotes held 7,849 of 8,466 quotes (93%) and
the 'All' tab was 8,454. Same shape as orders: a 4k-id IN clause
blowing past PB's filter cap, with 'Page 1 of 392' on the tab
that operators didn't actually use for active work.

This commit moves both buckets to a dedicated /quotes/archive
page so:
  - Main /quotes is fast (active workflow: new, claimed,
    pending_approval, cancelled, delivery_note, email_failed,
    problem, templates = 542 quotes vs 8,466)
  - Archive can be honest about pagination
  - The badge counts on the main page stop being dominated by
    'Completed (7,849)'

# Service layer

- getQuotesArchiveData(params) in src/lib/server/quotes-service.ts
  - view: 'completed' (default, 7,849) or 'all' (8,466)
  - Strategy: one getFullList on quotes (fields: id,status) to
    populate the matching set AND the header counts in a single
    walk, chunk by 100 ids (PB's URL/filter cap is between
    4.5-6.7 KB), fire parallel getFullList queries on quotes
    with deep expand, sort + paginate in memory
  - Search hits quote_number, quote_reference, rfq_number,
    company, customer

- /api/v1/quotes/archive thin route: page, perPage, search,
  sort, view

# Page

- /quotes/archive/page.tsx: card list with header counts
  (completed / all badges), view toggle (Completed | All),
  search input, pagination footer. Click a quote to open
  /quotes/[id] in detail.

# Main /quotes updates

- Completed + All removed from the tab strip
- Counts on the main page now exclude completed + all
- 'Archive' button in the page header takes the operator to
  /quotes/archive
- Safety useEffect: if activeTab is completed/all (e.g.
  stale URL or local state), reset to 'new'

# Tests

4 new tests in quotes-service.test.ts:
  - Default 'completed' view returns only completed quotes,
    counts header includes both
  - 'all' view returns every counted-status quote
  - Search by quote_number and quote_reference
  - Empty result when no quotes match

# Also: webview tweaks by another agent

- /webview?src=hermes label in sidebar
- Hermes URL whitelist updated
- Some header text removed (the probe status remains)

Full suite: 1331/1331 pass.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 20:25:13 +02:00
SJCoetz
3664eef898 feat(po): resend purchase order from sent / mail_failed / sending statuses
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>
2026-08-04 20:05:52 +02:00
SJCoetz
d33bb41a59 feat(orders): move delivered+cancelled to dedicated /orders/archive page
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>
2026-08-04 19:58:43 +02:00
SJCoetz
292cf2468e fix(orders): remove broken fields param + show contact_name + REP label
# Regression: orders/quotes list lost customer/company/rep data

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

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

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

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

# Bonus: show contact_name on the order card

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

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

# REP label

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

Co-authored-by: Orca <help@stably.ai>
2026-08-04 19:27:02 +02:00
SJCoetz
db1e7d6f76 perf(quotes): server-side tab filter, slim expand, combined counts + bonus bugfix
Same shape as the orders refactor \u2014 applied to the quotes page,
with a fix for a long-standing tab-count bug along the way.

# Measured against the live PB (8,466 quotes, 4,514 orders, 25,612 items)

Before:
  - 22 parallel per-status getList(1, 1) queries for the badge
    counts: 2.6s wall time (no quotes.status index)
  - 64 KB JSON response for 20 quotes with full deep-expand
    (45+ fields per quote, including unbounded email_body)
  - Page footer shows 'Page 1 of N' where N is the global count
    \u2014 'Completed' tab had 7,849 quotes = 'Page 1 of 393'

After (no PB index, just code):
  - 1 combined counts query: 138ms (19x faster)
  - 5.5 KB JSON response (12x smaller)
  - Server-side tab filter: page 1 of N reflects the tab's set
  - Cancelled tab badge now correctly counts rejected+cancelled

# Server-side read model

- getQuotesDashboardData in src/lib/server/quotes-service.ts
  - tab-scoped list + global counts in one round-trip
  - tab param drives a server-side filter (status IN (...) for
    workflow tabs, relation filter for delivery_note, is_template
    for templates, excludeTemplate for 'all')
  - pagination reflects the active tab's matching set, not the
    global ~8.5k quote count

- getQuoteStatusTabCounts: one combined getFullList with all
  17 counted statuses ORed (fields: 'status'), deduped in memory.
  Replaces 22 separate per-status scans. Cached 60s on the server
  with bypassCache option for future realtime refetches.

- /api/v1/quotes/list thin route: validates tab against
  QUOTE_TAB_ORDER, threads tab + nocache params to the service.

# Slim wire payload

- fields param on the orders list: just the columns the page reads
  + per-expand limits (quote.quote_number doesn't need
  email_body, company.company doesn't need its address field, etc.)
- Dropped the customer.company 3-level expand (the page never read
  expand.customer.expand.company \u2014 only expand.company.company,
  which is a direct relation from quote)
- searchQuotesWithItems: same fields added, with
  fetchQuoteItemsForQuotes now accepting fields

# Bonus bug: 'Cancelled' tab badge was always empty

The old getQuoteStatusCounts mapped both 'cancelled' AND 'rejected'
to the 'completed' count, so the page's 'Cancelled' tab badge
showed 0 even when 60+ rejected quotes existed. The page's
tabFilters separately grouped them under 'cancelled', so a quote
that was rejected showed up in the list but not the count.

The new QUOTE_TAB_STATUSES in the service layer is the canonical
mapping (cancelled absorbs both statuses). Page constants and
service constants now derive from the same source \u2014 no more drift.

# Index migration

scripts/migrations/2026-08-04-add-quotes-status-index.ts. Idempotent.
The live PB has no quotes.status index today (verified by hitting
/api/collections/quotes). With the index, the combined counts
query drops from 138ms to sub-10ms on the live instance.

# Tests

6 new tests in quotes-service.test.ts:
  - QUOTE_TAB_STATUSES contract (cancelled absorbs rejected,
    no empty status lists unless special-cased)
  - getQuoteStatusTabCounts buckets correctly into tabs
  - Cache hit returns the same counts on second call
  - bypassCache: true sees new state
  - getQuotesDashboardData end-to-end: tab-scoped items +
    global counts
  - Regression guard for the cancelled/rejected bug

Mock infrastructure upgrade: the quotes-service mock's where
function is now a proper recursive-descent filter parser (handles
nested || inside parens, empty quoted values). getList mock now
returns totalPages.

Full suite: 1312/1312 pass.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 19:01:31 +02:00
SJCoetz
720f01e5b6 perf(orders): server-side tab filter, slim expand payload, single combined count query
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>
2026-08-04 18:17:59 +02:00
SJCoetz
f819478bc0 feat(order-review): edit item description, show email body, fix duplicate key
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>
2026-08-04 08:51:22 +02:00
SJCoetz
69175e493d feat(orders): show resolved sales rep name on order list and sprint
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>
2026-08-04 08:50:54 +02:00
SJCoetz
e409e28ff3 feat(order-items): below-22% margin approval workflow
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>
2026-08-04 00:58:33 +02:00
SJCoetz
76947a9711 chore(stock): remove Location from PDF and UI
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>
2026-08-03 19:34:56 +02:00
SJCoetz
532f1986e4 feat(stocktake-pdf): add Location column, sort by supplier name
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>
2026-08-03 19:21:50 +02:00
SJCoetz
0cecc7208f polish(stocktake-pdf): widen SKU→Name gap so the separation is visible
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>
2026-08-03 19:06:39 +02:00
SJCoetz
b506670dde polish(stocktake-pdf): status anchors right edge, gaps between columns
- 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>
2026-08-03 19:00:29 +02:00
SJCoetz
b84aefc2e2 feat: unify buyer task flows by supporting both customer orders and stock purchases in mark-ready API and UI, and fix PDF table layout responsiveness. 2026-08-03 18:52:30 +02:00
SJCoetz
21c54ad24e fix(stocktake-pdf): add cell padding so columns have visible gaps
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>
2026-08-03 18:29:25 +02:00
SJCoetz
a35e715b21 polish(stocktake-pdf): zebra rows, drop Location, Target→Max, prominent Status, wider Name
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>
2026-08-03 18:23:11 +02:00
SJCoetz
6033aba18d fix(stocktake-pdf): match /stock columns + add button on /stock page
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>
2026-08-03 18:18:58 +02:00
SJCoetz
91d8363380 fix(stock-purchase-send): render auto-PDF in-process instead of HTTP self-call
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>
2026-08-03 16:51:07 +02:00
SJCoetz
907a7414b4 fix(pdf): repeat table headers on every page in list reports
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>
2026-08-03 16:23:12 +02:00
SJCoetz
45f1ec6bb7 feat(pdf): list-pdf documents for stock orders + stocktake
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>
2026-08-03 16:13:14 +02:00
SJCoetz
a5567f41cf test(stock-items): assert expand: supplier on stocktake calls
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>
2026-08-03 16:08:03 +02:00
SJCoetz
88678cea49 feat(stock-items): listStockItemsForStocktake service helper
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>
2026-08-03 16:04:46 +02:00
SJCoetz
e75f0d6157 fix(stock-purchase-send): stop auto-flipping stock_purchase_items on success
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>
2026-08-03 16:04:21 +02:00
SJCoetz
690b8a2c26 fix(po-send): reject sends for POs without a generated PDF
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>
2026-08-03 15:47:01 +02:00
SJCoetz
92febfb51c feat(stock-purchases): aggregate ordered/received value on list rows
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>
2026-08-03 14:24:30 +02:00
SJCoetz
79b93096ec fix(po-send): stop auto-flipping order_items.item_status to collection_pool
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>
2026-08-03 14:10:34 +02:00
SJCoetz
5959d2a1aa fix(buyer-tasks): keep mail_failed POs visible in the PO Draft tab
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>
2026-08-03 10:46:32 +02:00
SJCoetz
964b3d51b2 feat(buyer-tasks): surface email_error on buyer task groups
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>
2026-08-03 10:31:33 +02:00
SJCoetz
4444842ef5 feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components

Co-authored-by: Orca <help@stably.ai>
2026-08-03 09:33:45 +02:00
SJCoetz
e2896264f4 fix(collection-confirm): add route validation + server-side actor derivation
Co-authored-by: Orca <help@stably.ai>
2026-08-03 02:35:58 +02:00
SJCoetz
dbf02e2511 fix(collection-confirm): restore Book to stock for stock rows + cost guard
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>
2026-08-03 02:29:20 +02:00
SJCoetz
c856db1617 fix(collection-confirm): thread null cost through appendCostNote to preserve audit on null→0
Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:46:50 +02:00
SJCoetz
47eff9a6fe feat(collection-confirm): implement confirmOrderItemsForInvoicing with createBatch
Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:40:29 +02:00
SJCoetz
77d2c32df4 fix(collection-confirm): add cost_price to ConfirmRow, rename body to args
Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:29:56 +02:00
SJCoetz
2997b8dcba feat(collection-confirm): implement updateOrderItemCost service
Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:23:37 +02:00
SJCoetz
8b6b83583d fix(collection-confirm): use getOrderItemsCollectionName + populate stock supplier
Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:17:56 +02:00
SJCoetz
15f6ec638d fix(collection-confirm): make listCollectedItems stock leg fail-soft
Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:12:02 +02:00
SJCoetz
efe10b9474 feat(collection-confirm): add service skeleton with listCollectedItems
Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:08:57 +02:00
SJCoetz
f7b5f3677e feat(orders): persist company and rep from review-modal cascade
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>
2026-07-31 15:29:10 +02:00
SJCoetz
da0a689507 feat(stock-quick-draft): add updateQuickDraftLine service
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>
2026-07-31 13:38:59 +02:00
SJCoetz
f9a0d08dab fix(tests): mock scaffold destructure + collection-pool + pocketbase startup
- 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>
2026-07-31 00:01:24 +02:00
SJCoetz
6329a100e7 fix(stock-quick-draft): isolate new-SKU suppliers, fix empty-string pick, service layer
Co-authored-by: Orca <help@stably.ai>
2026-07-30 23:31:21 +02:00
SJCoetz
98e1f25fd5 fix(stock-quick-draft): supplier-name schema + remove dead resolvePb + pagination cap
Co-authored-by: Orca <help@stably.ai>
2026-07-30 23:14:11 +02:00
SJCoetz
8acb6dcf95 fix(stock-quick-draft): dedicated /drafts endpoint with nested shape, browser-auth
Co-authored-by: Orca <help@stably.ai>
2026-07-30 23:11:27 +02:00
SJCoetz
249a78a7ef fix(stock-quick-draft): add trailing newlines
Co-authored-by: Orca <help@stably.ai>
2026-07-30 22:06:24 +02:00