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>
7.7 KiB
Stock Orders — Total Value Columns — Design
Date: 2026-08-03
Status: Approved (pending written-spec user review)
Scope: /stock-orders list table only
Problem
The /stock-orders page shows each stock purchase as a row with number, supplier, status, item count, created date. The operator cannot see what each PO is worth at a glance, nor spot short-shipments against original value.
Goal
Add two currency columns to the /stock-orders table:
- Ordered (R) — sum of
line.quantity × line.cost_priceacross all lines on the PO. Visible from draft onwards. Reflects what was committed to the supplier. - Received (R) — sum of
line.quantity_received × line.cost_price. Zero for drafts, grows as goods are booked into stock. Lets the operator spot short-shipped POs.
Place both columns after Status and before Items so item-count and value columns stay grouped.
Non-goals
- Per-line totals (the detail page already exposes them via the PO drill-down).
- Currency conversion or live FX. The system uses ZAR (
formatCurrencyinpb-helpers.ts:1868). - Footer totals row across the visible page (no precedent in
/orders; deferred). - Performance optimisation beyond the existing N+1.
Architecture
Three layers, in line with AGENTS.md data-access discipline:
- Service (
src/lib/server/stock-purchases-service.ts) —listStockPurchases(pb, opts)widensStockPurchaseListRowwithordered_valueandreceived_value. The existing per-rowcountItemsForPurchasehelper is replaced by a richer helper that returns{ count, ordered, received }in the same per-row fetch — no extra round-trip. - API route (
src/app/api/v1/stock-purchases/list/route.ts, new) — thin GET wrapper around the service. Exists for external callers and consistency with other v1 list routes. - Page (
src/app/(app)/stock-orders/page.tsx) — migrates from client-side direct PB read to a Server Component that calls the service directly with the admin PB instance. The interactive bits (modal, buttons, status tabs, pagination) move to a new client siblingStockOrdersTable.tsx.
Data model
StockPurchaseListRow (src/lib/server/stock-purchases-service.ts:162):
export interface StockPurchaseListRow extends StockPurchase {
item_count: number;
ordered_value: number; // Σ line.quantity * line.cost_price, 2dp
received_value: number; // Σ line.quantity_received * line.cost_price, 2dp
}
Currency values are kept as raw number in the service layer (consistent with calculateTotals at src/lib/pb-helpers.ts:1861). UI formatting uses the existing formatCurrency(amount) helper.
Aggregation helper
Replace countItemsForPurchase(pb, purchaseId) with summarizeItemsForPurchase(pb, purchaseId) returning:
{ count: number; ordered: number; received: number }
ordered and received are each computed by reducing over the fetched items and rounded to 2dp via Number((...).toFixed(2)), matching calculateTotals rounding behaviour.
API route
GET /api/v1/stock-purchases/list
Query parameters:
status— optional. One ofSTOCK_PURCHASE_STATUS_VALUESor empty for "all". Validated against the enum; unknown value →400 { error }.page— default1, min1.perPage— default50, min1, max200.
Uses the admin-scoped PB client (the same instance used by other v1 service routes). Response shape passes the service return through unchanged:
{ items: StockPurchaseListRow[]; totalItems: number; page: number; perPage: number; totalPages: number }
PB errors → 500 { error: <message> }. Unknown status → 400 { error: "unknown status: <value>" }.
Page migration
src/app/(app)/stock-orders/page.tsx (Server Component)
- Drops
"use client". - Reads
searchParamsforstatus(validated against the enum, default"") andpage(default1, min1). - Calls
listStockPurchases(adminPb, { status, page, perPage: 50 })directly with the admin PB instance. - Renders the page header (title + description), the "Reorder from low stock" and "New stock order" buttons (Link-based, no state), and passes
rows,pagination,currentStatusto<StockOrdersTable>. - Renders the empty-state Card when
rows.length === 0.
src/app/(app)/stock-orders/StockOrdersTable.tsx (new, client)
"use client". HoldsquickDraftOpenandeditingPurchaseIdstate.- Renders the status filter tabs — each a
<Link href="?status={value}&page=1">so the server re-renders the table on tab change. - Renders the table body with the new columns:
| Number | Supplier | Status | Ordered (R) | Received (R) | Items | Created | ⌄ |
- Each value cell:
<TableCell className="text-right tabular-nums">{formatCurrency(row.ordered_value)}</TableCell>(and same forreceived_value). - Renders the
+ New Stock Itembutton (opens the modal in new-draft mode) and the per-row Edit pencil (setseditingPurchaseIdthen opens modal). Both pre-existing behaviours preserved. - Renders pagination as Prev/Next
<Link href="?status=...&page={next}">controls.
QuickDraftModal — no change
Already a client component; mounts inside StockOrdersTable.
Error handling
- Server Component fetches throw → caught at the route / page boundary; we keep the existing
Card+AlertCircleempty-state pattern by adding anerror.tsxboundary or by re-throwing (Server Component rethrow surfaces Next's default error UI). Decision: keep it simple — wrap the fetch in try/catch in the Server Component and render the existingAlertCircleCard state with the caught message. Mirrors the current client-side behaviour.
Testing
Extend src/lib/server/stock-purchases-service.test.ts
Add five cases to the existing listStockPurchases describe block:
- "returns
ordered_value= Σ quantity × cost_price across lines" - "returns
received_value= Σ quantity_received × cost_price across lines" - "rounds both totals to 2dp"
- "returns
{ ordered_value: 0, received_value: 0 }when a purchase has no lines" - "computes each row's totals from its own items only (no cross-row leakage)"
New src/app/api/v1/stock-purchases/list/route.test.ts
- "returns 200 with the service return shape for valid query"
- "passes
status,page,perPagethrough to the service" - "returns 400 for an unknown
statusvalue" - "returns 500 with
{ error }when the service throws"
Move src/app/(app)/stock-orders/page.test.tsx → src/app/(app)/stock-orders/StockOrdersTable.test.tsx
The seven existing cases (modal mount/open/close, edit button presence/absence/click) all assert on the buttons + modal, which now live in the new client component. Move verbatim, change the import to StockOrdersTable, and update makeRow to include ordered_value: 0, received_value: 0 defaults. Render as <StockOrdersTable rows={[...]} pagination={...} currentStatus="" /> in tests.
Manual smoke
- Create a draft PO with two lines (e.g. 10 × R 12.50 and 4 × R 99.00).
/stock-ordersshould showOrdered: R 521,00,Received: R 0,00. - Mark it sent (status →
sent). - Partial-book via
/collection-confirm(book 6 of the first line). Return to/stock-orders.Receivedshould showR 75,00(6 × 12.50),Orderedunchanged. - Book the rest.
Receivedshould equalOrdered.
Out of scope / follow-ups
- Footer totals row (sum of visible rows). Defer until a user requests it; no precedent in
/orders. - Performance: the per-row N+1 is unchanged in count (one items fetch per row) but wider in payload. Page rendering time not expected to regress; revisit if a user reports a slowdown.
- Migrate the rest of the page away from
pb-helpers.ts(none used here — the page only imports types andgetPocketBase).