diff --git a/docs/superpowers/specs/2026-08-03-stock-orders-total-value-design.md b/docs/superpowers/specs/2026-08-03-stock-orders-total-value-design.md new file mode 100644 index 0000000..3aa4eec --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-stock-orders-total-value-design.md @@ -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: }`. Unknown `status` → `400 { error: "unknown status: " }`. + +## 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 ``. +- 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 `` 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: `{formatCurrency(row.ordered_value)}` (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 `` 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 `` 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`). \ No newline at end of file