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>
This commit is contained in:
SJCoetz 2026-08-03 14:09:10 +02:00
parent aea1ab67f3
commit 9489cf3a44

View file

@ -0,0 +1,140 @@
# 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`):
```ts
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:
```ts
{ 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:
```ts
{ 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`).