WFDASH-WEBAPP/docs/superpowers/specs/2026-08-03-stock-orders-total-value-design.md
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

7.7 KiB
Raw Blame History

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_price across 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 (formatCurrency in pb-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:

  1. Service (src/lib/server/stock-purchases-service.ts) — listStockPurchases(pb, opts) widens StockPurchaseListRow with ordered_value and received_value. The existing per-row countItemsForPurchase helper is replaced by a richer helper that returns { count, ordered, received } in the same per-row fetch — no extra round-trip.
  2. 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.
  3. 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 sibling StockOrdersTable.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 of STOCK_PURCHASE_STATUS_VALUES or empty for "all". Validated against the enum; unknown value → 400 { error }.
  • page — default 1, min 1.
  • perPage — default 50, min 1, max 200.

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 searchParams for status (validated against the enum, default "") and page (default 1, min 1).
  • 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, currentStatus to <StockOrdersTable>.
  • Renders the empty-state Card when rows.length === 0.

src/app/(app)/stock-orders/StockOrdersTable.tsx (new, client)

  • "use client". Holds quickDraftOpen and editingPurchaseId state.
  • 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 for received_value).
  • Renders the + New Stock Item button (opens the modal in new-draft mode) and the per-row Edit pencil (sets editingPurchaseId then 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 + AlertCircle empty-state pattern by adding an error.tsx boundary 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 existing AlertCircle Card 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, perPage through to the service"
  • "returns 400 for an unknown status value"
  • "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

  1. Create a draft PO with two lines (e.g. 10 × R 12.50 and 4 × R 99.00). /stock-orders should show Ordered: R 521,00, Received: R 0,00.
  2. Mark it sent (status → sent).
  3. Partial-book via /collection-confirm (book 6 of the first line). Return to /stock-orders. Received should show R 75,00 (6 × 12.50), Ordered unchanged.
  4. Book the rest. Received should equal Ordered.

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 and getPocketBase).