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:
parent
aea1ab67f3
commit
9489cf3a44
1 changed files with 140 additions and 0 deletions
|
|
@ -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`).
|
||||
Loading…
Reference in a new issue