Commit graph

335 commits

Author SHA1 Message Date
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
b66e1b6b6b feat(webview): probe upstream + show status badge in header
The /webview page embeds supplier portals in an iframe. When the
target sets X-Frame-Options: DENY or frame-ancestors 'none' the
browser silently renders a blank iframe with no visible signal
to the operator that the load failed — the page just looks empty
and it's not obvious whether the portal is down or whether the
operator is hitting an embed restriction.

Surface the most common cause up-front:

- Server-side HEAD probe before render: fetches the target URL
  with `redirect: "manual"` and a 5s timeout. The probe bypasses
  browser frame restrictions (it's a server-to-server call) and
  reports the raw HTTP status, including 302 redirects (counted
  as `ok` since suppliers often gate their portals behind a
  login redirect).
- Status badge in the header next to the URL: green dot +
  `up (200)` when reachable, amber dot + `HTTP <status>` for
  non-2xx, amber dot + `unreachable` on network / timeout
  failures. Title attribute carries the full probe detail.
- Banner placeholder inside the iframe container when JS is
  disabled (defensive — the existing iframe is JS-required).

The badge gives the operator an immediate "is this a portal
problem or an embed problem?" answer before they spend time
clicking around inside the iframe.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:42:31 +02:00
SJCoetz
5291a044ed feat(collection-confirm): in-app invoice viewer with zoom + mobile layout
The photo-strip chips on the collection-confirm page used to call
`window.open(url, "_blank")` for the supplier invoice, kicking the
operator out to a new tab to verify the invoice against the items
table. That tab switch broke the side-by-side comparison the
operator actually wants to do — look at a line on the invoice,
look at the same line in the table, look back.

Replace that flow with a floating, draggable invoice viewer
that lives on the page:

- New `InvoiceViewer` component renders the PDF in an `<iframe>`
  against the existing `/api/v1/files/order_items/<id>/` route,
  which already serves PDFs with `Content-Disposition: inline`.
- Desktop: floating box anchored to the bottom-right (720×560),
  header is draggable. Position is clamped to the viewport so
  the close button can't be dragged off-screen. State lives in
  the component; not persisted to localStorage.
- Mobile (≤ 640px): full-screen panel (`100vw × 100dvh`,
  border-radius 0). Drag affordance, grip icon, and "Viewing"
  badge are dropped — they're meaningless on touch or when the
  panel fills the screen. Layout flips live on viewport change
  via a small `useMediaQuery` hook.
- Zoom controls: + / − / Reset with discrete levels
  (50 / 75 / 100 / 125 / 150 / 200 / 300%). The % display is
  itself a click-to-reset button. Implemented as
  `transform: scale()` on the iframe with a pre-shrunk CSS box
  (`${100 / zoom}%`) and a `${zoom * 100}%` scrollable wrapper
  so scrollbars appear at higher zoom levels. CSS-resize-only
  does not work for PDF iframes — the browser's native PDF
  viewer caches the initial render size and ignores post-load
  CSS resizes, so the page content never actually re-renders.
  `transform: scale()` works around that across Chrome / Safari
  / Firefox.
- "Open in new tab" header button is preserved as an opt-in
  escape hatch (landscape invoices, full-page viewing).

The PhotoStrip no longer opens a new tab by default. Clicking a
chip calls a page-level `onOpen` handler that toggles the viewer
(open / swap / close); the matching chip is highlighted with
`aria-pressed="true"` + a primary variant so the operator knows
which invoice is open. Clicking the active chip closes the
viewer. The chip falls back to `window.open(...)` only when no
`onOpen` is passed, so the strip stays usable in isolation.

Tests: 126 pass across the collection-confirm suite. New tests
cover viewer open / close (X + Esc), drag math + drag-bail-out
on header buttons, the active-state highlight, swap-on-different
chip, the in-viewer new-tab escape hatch, every zoom level +
clamping + reset, the PDF wrapper / iframe transform math at
multiple zooms, and the desktop ↔ mobile layout flip on a
mocked viewport change.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:38:25 +02:00
SJCoetz
db2d43ae84 feat: web view page + sidebar link
Lightweight dashboard view surfaced under the 'Other' sidebar group
for client-facing use cases that need a stripped-down WFDash
surface (e.g. embed iframe, kiosk mode).

Co-authored-by: Orca <help@stably.ai>
2026-08-04 00:58:40 +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
b1c0c6030f fix(api): make list-pdf routes browser-accessible
Both new routes — GET /api/v1/stock-purchases/list-pdf and
GET /api/v1/stock-items/stocktake-pdf — were gated on
`assertAgentAuth`, which requires an `Authorization: Bearer
<AGENT_API_KEY>` header. Browser <a target='_blank'> clicks
don't send that header, so the buttons returned 401
'Unauthorized'.

The existing single-PO PDF route at
/api/v1/stock-purchases/[id]/pdf doesn't gate on
`assertAgentAuth` — it relies on the in-app session and uses
the admin PB instance. The two list PDFs are the same shape
(throwing reports, in-app origin, admin-scoped reads) so the
fix is to drop the gate. Auth now matches the single-PO PDF
and the invoicing delivery-note PDFs.

- Removed `assertAgentAuth(req)` from both routes; dropped the
  `agent-auth` import and the unused `req` parameter.
- Updated route JSDoc to describe the new auth contract and
  reference the single-PO PDF route as the precedent.
- Updated both route tests: dropped the 'returns 401 when auth
  is missing' case (no longer applicable) and the
  `AGENT_API_KEY` env-setup boilerplate. Test names updated
  to reflect the new contract — 'no auth required'.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 18:12:38 +02:00
SJCoetz
6bf8310ef6 fix(stock-purchase-send): use DASHBOARD_URL for outcomeUrl (was req.nextUrl.origin)
\`req.nextUrl.origin\` inside the Next.js process resolves to the
container bind address (e.g. \`0.0.0.0:3000\`), which the n8n
container can't reach — the v4 workflow's outcome callback
fails with \`ECONNREFUSED 0.0.0.0:3000\` and the stock PO gets
stuck on \`sending\` because the dashboard never receives the
status-flip callback.

Mirror the customer-PO route: build the callback URL from
\`process.env.DASHBOARD_URL\` (with trailing-slash trim). When
\`DASHBOARD_URL\` is unset the outcomeUrl is empty, and the
workflow's "outcomeUrl missing" branch fires cleanly.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 17:44:38 +02:00
SJCoetz
e795e8584d chore(scripts): one-time stock-order loaders + source screenshots
- scripts/2026-08-03-load-husqvarna-cart.ts: parses two cart
  screenshots into a stock_purchase draft.
- scripts/2026-08-03-load-yg1-quote-soq078608.ts: extracts YG-1
  quote SOQ078608 into a stock_purchase draft.
- scripts/2026-08-03-merge-yg1-drafts.ts: consolidates the new
  YG-1 draft with the existing PO26073102STK into a single
  PO26080301STK.

Source images (img1_*.png, img2_*.png) are kept alongside the
husqvarna loader so the parse step is reproducible. The
stock-orders-after.png screenshot documents the page state after
the recent wrap + PDF-download fixes.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 17:33:28 +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
86534f738a test(stock-orders): tighten download-button assertions + mark icons decorative
Two review follow-ups from the code-quality reviewer:
- Switch the two new PDF-button tests from getByTestId to
  getByRole('link', { name }) so visible-label regressions fail.
  Add a rel='noopener' assertion so the security contract on
  target='_blank' is locked down.
- Add aria-hidden='true' to both <Download> icons. The visible
  text already supplies each link's accessible name, but marking
  the icon decorative explicitly is more robust to future
  library behavior changes.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 16:43:59 +02:00
SJCoetz
1b2f4a4f52 feat(stock-orders): wrap cells + add download PDF buttons
Two UX improvements on the /stock-orders list:
- Number + Supplier cells now wrap long text instead of
  clipping. Uses whitespace-normal + break-words + max-w-[200px]
  on the cell, plus a row-level py-3 bump so wrapped cells look
  balanced with single-line rows.
- Two new outline buttons next to 'New Stock Item':
  'Download Stocktake PDF' -> /api/v1/stock-items/stocktake-pdf
  'Download Orders PDF' -> /api/v1/stock-purchases/list-pdf
  Both open the PDF in a new tab via target=_blank; the browser
  handles the download.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 16:40:24 +02:00
SJCoetz
e025b340b8 fix(api): use SAST date for PDF filenames + tighten list-pdf tests
Two changes:
- Filename dates now use the Africa/Johannesburg timezone rather than
  UTC, so a download generated at 00:30 SAST gets today's date
  (not yesterday's). The renderer call still uses ISO UTC for
  generatedAtIso — that's an audit timestamp, not a human-readable
  date. One 'now' is captured at the top so the two values can't
  drift across a millisecond boundary.
- The 200 tests now assert Content-Disposition (including the
  filename), Cache-Control, the service arguments, and the
  renderer arguments. vi.useFakeTimers pins the clock so the
  filename is deterministic.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 16:37:12 +02:00
SJCoetz
bbe3e1bd1f feat(api): GET /api/v1/stock-purchases/list-pdf + stocktake-pdf
Two list-report endpoints wired to the new PDF renderers. Both
auth via assertAgentAuth, ignore query params (always all rows),
and return application/pdf with attachment Content-Disposition.
The Stocktake PDF is sourced from the new listStockItemsForStocktake
service helper; the Orders PDF reuses the existing listStockPurchases
with perPage=200.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 16:29:57 +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
a8231fec07 docs(stock-orders): wrap + PDF implementation plan
5 tasks: stock-items service helper, two PDF document components +
render helpers, two GET endpoints, UI changes (wrap + buttons).
TDD per task with test-first steps. Final task is full-repo
verification + manual smoke checklist.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 15:57:55 +02:00
SJCoetz
286043cace docs(stock-orders): design spec for cell wrap + PDF downloads
Two small additions to /stock-orders:
- Wrap long text in Number + Supplier columns
- Two PDF download buttons (Stocktake PDF, Orders PDF)

Both PDFs always include all rows, ignore the active status
filter and pagination. No persistence — throwaway reports.

Design reviewed: covers architecture, data model, API contract,
error handling, testing, and manual smoke. No new PB schema,
no new types.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 15:54:24 +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
18e1d97b6c test(stock-orders): tighten total-value + edit-reset assertions
The total-value case now locates each cell by its column header so a
regression that swapped Ordered (R) and Received (R) would fail. The
edit-draft case now closes the modal after Edit and asserts
editPurchaseId resets to '', so a regression that leaked the id into
a subsequent 'New' click would fail.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 15:18:39 +02:00
SJCoetz
760f088ec9 test(stock-orders): move page tests to StockOrdersTable + add total-value case
The seven pre-existing assertions on the quick-draft modal and edit
button now exercise the new client component. New case confirms
ordered_value/received_value render in the row. Uses Intl-format
substring assertions because the currency glyph/spacing varies by
ICU version.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 15:13:12 +02:00
SJCoetz
b89a4a7fa3 fix(stock-orders): preserve header nav + sanitize error message
- Header (title, description, Reorder, + New stock order) now
  renders unconditionally, so operators can navigate away even when
  the list query throws.
- Full error is logged server-side via console.error; the user
  sees a generic 'Failed to load stock orders. Please try again.'
  message that doesn't leak PB URLs, admin env var names, or
  filter syntax.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 15:07:46 +02:00
SJCoetz
dedd5013b9 refactor(stock-orders): migrate list page to Server Component
Reads searchParams (status, page), validates both, calls
listStockPurchases via the admin PB, and renders the new
StockOrdersTable client component. Drops the legacy direct-PB
read path per AGENTS.md data-access discipline.

The old page.test.tsx is removed; its assertions are mirrored
in the new StockOrdersTable.test.tsx (next task).

Co-authored-by: Orca <help@stably.ai>
2026-08-03 15:02:59 +02:00
SJCoetz
fcb15a20e2 fix(stock-orders): keyboard a11y on pagination + soft row nav
- Disabled pagination links (Previous at page 1, Next at last page)
  now also set tabIndex=-1 and preventDefault on click, so keyboard
  users can't activate them when at the boundary. aria-disabled +
  pointer-events-none blocked only mouse clicks.
- Row click uses useRouter().push() instead of window.location.href
  for a soft navigation that preserves client state (consistent
  with the original /stock-orders page).

Co-authored-by: Orca <help@stably.ai>
2026-08-03 14:51:41 +02:00
SJCoetz
0c556a0513 feat(stock-orders): extract StockOrdersTable client component
Holds the QuickDraftModal open state, renders status filter tabs as <Link>s to ?status=..., and adds two new columns (Ordered R, Received R) showing per-row aggregates from the service layer.\n\nReplaces <Button asChild><Link/></Button> with <Link className={cn(buttonVariants(...))}> because the project's Button (built on @base-ui/react/button) doesn't support asChild. Visually identical; preserves navigation semantics.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 14:46:50 +02:00
SJCoetz
469b2c0460 fix(api): preserve auth status + guard pagination against NaN
- Catch block now preserves error.status so assertAgentAuth's 401
  surfaces as 401, not 500. Matches sibling /api/v1/stock-purchases
  pattern.
- page/perPage parsing falls back to documented defaults on
  non-numeric or zero input rather than passing NaN to the service.
- Add 2 tests covering the auth-status and pagination boundaries.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 14:38:51 +02:00
SJCoetz
8cb4e9a60c feat(api): GET /api/v1/stock-purchases/list with value aggregation
Thin wrapper around listStockPurchases that validates the status
param against STOCK_PURCHASE_STATUS_VALUES (400 on bad input) and
caps perPage at 200. Mirrors the agent-auth pattern used by the
other v1 list routes.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 14:31:22 +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
94931524e9 docs(plan): stock-orders total value columns
Six-task TDD plan to widen listStockPurchases with ordered_value +
received_value per row, expose it via a new v1 list route, migrate
the /stock-orders page from a client component reading PB directly
to a Server Component + sibling client table component, and move the
existing 7 page tests + add an 8th.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 14:20:48 +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
9489cf3a44 docs(stock-orders): design total-value columns on list table
Add an Approved design doc for the /stock-orders list table: two new
currency columns (Ordered, Received) showing sum(line.quantity *
line.cost_price) and sum(line.quantity_received * line.cost_price) per
stock purchase. Migrates the page from client-side direct PB read to
the service layer per AGENTS.md.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 14:09:10 +02:00
SJCoetz
aea1ab67f3 fix(po-send): push dashboardBaseUrl + outcomeUrl on n8n webhook body
The n8n Code node that posts the SMTP outcome back to the
/api/v1/purchase-orders/[id]/send-email/outcome route couldn't
read env vars at runtime (N8N_BLOCK_ENV_ACCESS_IN_NODE=true in
docker-compose), so it composed the callback URL from fields on
the webhook body. The route never pushed dashboardBaseUrl or
outcomeUrl, so n8n fell back to a relative URL, the request
died, n8n returned HTTP 500, and the route flipped the PO to
mail_failed with a misleading '[webhook] Webhook returned HTTP
500' message.

DASHBOARD_URL is already defined in .env (labelled 'used by n8n
workflows to call back into this app') — the route just never
read it. Now the route pushes both the dashboard base and a
pre-built absolute outcome URL, trimmed of trailing slashes, and
omitted (not empty) when DASHBOARD_URL is unset so the existing
'missing' branch in n8n fires clearly.

A new test file asserts all three cases: with DASHBOARD_URL,
with a trailing slash, and without the env var.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 11:23:40 +02:00
SJCoetz
faaa47a4fe test(buyer-tasks): lock wire shape of mail_failed group in po_draft
The route is a pass-through, but the smoke test guards against a
future refactor silently dropping the new emailError field — the
operator's retry affordance depends on it.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 10:55:10 +02:00
SJCoetz
010bba475c fix(buyer-tasks): pass status + email_error into SendPoModal stub
The SendPoModal renders its 'Resend' label and inline failure
banner from purchaseOrder.status / email_error, but the buyer-tasks
page stub didn't include either field. As a result, the modal
showed 'Send' and an empty banner even when the underlying PO was
mail_failed. Now the stub includes both so the operator sees the
correct affordance.

Co-authored-by: Orca <help@stably.ai>
2026-08-03 10:53:54 +02:00
SJCoetz
8616d5dbef fix(buyer-tasks): add emailError to route test fixture
The BuyerTaskGroup type gained an emailError field in the previous
commit. This pre-existing fixture constructs a BuyerTaskGroup
literal and now needs the field to compile. No behaviour change
in the test — emailError: null matches a clean draft.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 10:52:58 +02:00
SJCoetz
c481cbeb1c feat(buyer-tasks): render mail-failed banner + Resend button
When a card's poStatus is 'mail_failed', the card now renders a
red warning row with the email_error reason and switches the
action button label to 'Resend' with the destructive colour. A
new data-po-status attribute on the button lets tests target the
state without depending on the human-readable label.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-03 10:51:38 +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