From 94931524e96ffaf4285d257b0b41b6be4c6d9b21 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 14:20:48 +0200 Subject: [PATCH] docs(plan): stock-orders total value columns Six-task TDD plan to widen listStockPurchases with ordered_value + received_value per row, expose it via a new v1 list route, migrate the /stock-orders page from a client component reading PB directly to a Server Component + sibling client table component, and move the existing 7 page tests + add an 8th. Co-authored-by: Orca --- .../2026-08-03-stock-orders-total-value.md | 1154 +++++++++++++++++ 1 file changed, 1154 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-stock-orders-total-value.md diff --git a/docs/superpowers/plans/2026-08-03-stock-orders-total-value.md b/docs/superpowers/plans/2026-08-03-stock-orders-total-value.md new file mode 100644 index 0000000..3bc46a2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-stock-orders-total-value.md @@ -0,0 +1,1154 @@ +# Stock Orders — Total Value Columns — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two currency columns (Ordered R, Received R) to the `/stock-orders` table and migrate the page from client-side direct PocketBase reads to a Server Component backed by the service layer — per `AGENTS.md`. + +**Architecture:** Widen `listStockPurchases()` in the service to aggregate per-row ordered/received totals from each purchase's `stock_purchase_items`. Expose the same shape via a new thin `GET /api/v1/stock-purchases/list` route. Convert the page from `"use client"` to a Server Component that awaits `searchParams` and calls the service directly (per AGENTS.md); the modal and tabs/pagination move to a sibling client component `StockOrdersTable`. + +**Tech Stack:** Next.js App Router (Server Components), React 19, PocketBase via the admin client (`getAdminPocketBase`), vitest + @testing-library/react, `formatCurrency` from `pb-helpers.ts`. + +--- + +## File map + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/server/stock-purchases-service.ts` | modify | Add `ordered_value` + `received_value` to `StockPurchaseListRow`; replace `countItemsForPurchase` with `summarizeItemsForPurchase` returning `{ count, ordered, received }`. | +| `src/lib/server/stock-purchases-service.test.ts` | modify | Add five tests for the new aggregation behaviour. | +| `src/app/api/v1/stock-purchases/list/route.ts` | create | Thin GET wrapper around `listStockPurchases`. Status enum validation; 400 on bad status, 500 on PB error. | +| `src/app/api/v1/stock-purchases/list/route.test.ts` | create | Four tests covering success, param pass-through, bad-status, and error. | +| `src/app/(app)/stock-orders/page.tsx` | modify (rewrite) | Drop `"use client"`; become a Server Component that awaits `searchParams`, calls `listStockPurchases`, renders the empty state + ``. | +| `src/app/(app)/stock-orders/StockOrdersTable.tsx` | create | Client component: holds modal state, renders status tabs (as ``), the table body with two new columns, pagination ``s. | +| `src/app/(app)/stock-orders/page.test.tsx` | delete | All assertions target the modal/buttons which now live in `StockOrdersTable`. | +| `src/app/(app)/stock-orders/StockOrdersTable.test.tsx` | create | Move the seven existing test cases verbatim; update `makeRow` to include `ordered_value`/`received_value` defaults. | + +--- + +## Task 1: Add `summarizeItemsForPurchase` helper + widen `StockPurchaseListRow` + +**Files:** +- Modify: `src/lib/server/stock-purchases-service.ts:155-213` +- Test: `src/lib/server/stock-purchases-service.test.ts` + +- [ ] **Step 1: Write the failing test for `summarizeItemsForPurchase` aggregation** + +Append at the bottom of `src/lib/server/stock-purchases-service.test.ts`: + +```ts +// ============================================================================ +// listStockPurchases — total value aggregation +// ============================================================================ + +describe("listStockPurchases — total value", () => { + async function seedPurchaseWithItems(pb: typeof pbMock, id: string, items: Array<{ + quantity: number; + quantity_received: number; + cost_price: number; + }>) { + await pb.collection("stock_purchases").create({ + id, + purchase_number: `PO26000100STK-${id}`, + supplier: "sup-1", + status: "draft", + notes: "", + email_error: "", + email_message_id: "", + }); + for (const item of items) { + await pb.collection("stock_purchase_items").create({ + purchase: id, + stock_item: "si-1", + description: "Widget", + ...item, + notes: "", + }); + } + } + + it("returns ordered_value = Σ quantity × cost_price across lines", async () => { + await seedPurchaseWithItems(pbMock, "sp-a", [ + { quantity: 10, quantity_received: 0, cost_price: 12.5 }, + { quantity: 4, quantity_received: 0, cost_price: 99 }, + ]); + const { items } = await listStockPurchases(pbMock); + const row = items.find((r) => r.id === "sp-a")!; + expect(row.ordered_value).toBe(521); // 125 + 396 + }); + + it("returns received_value = Σ quantity_received × cost_price across lines", async () => { + await seedPurchaseWithItems(pbMock, "sp-a", [ + { quantity: 10, quantity_received: 6, cost_price: 12.5 }, + { quantity: 4, quantity_received: 4, cost_price: 99 }, + ]); + const { items } = await listStockPurchases(pbMock); + const row = items.find((r) => r.id === "sp-a")!; + expect(row.received_value).toBe(471); // 75 + 396 + }); + + it("rounds ordered_value and received_value to 2dp", async () => { + await seedPurchaseWithItems(pbMock, "sp-a", [ + { quantity: 3, quantity_received: 1, cost_price: 0.1 + 0.2 }, // 0.30000000000000004 + ]); + const { items } = await listStockPurchases(pbMock); + const row = items.find((r) => r.id === "sp-a")!; + expect(row.ordered_value).toBe(0.9); + expect(row.received_value).toBe(0.3); + }); + + it("returns { ordered_value: 0, received_value: 0 } when a purchase has no lines", async () => { + await pbMock.collection("stock_purchases").create({ + id: "sp-empty", + purchase_number: "PO26000200STK", + supplier: "sup-1", + status: "draft", + notes: "", + email_error: "", + email_message_id: "", + }); + const { items } = await listStockPurchases(pbMock); + const row = items.find((r) => r.id === "sp-empty")!; + expect(row.ordered_value).toBe(0); + expect(row.received_value).toBe(0); + expect(row.item_count).toBe(0); + }); + + it("computes each row's totals from its own items only (no cross-row leakage)", async () => { + await seedPurchaseWithItems(pbMock, "sp-x", [ + { quantity: 1, quantity_received: 1, cost_price: 100 }, + ]); + await seedPurchaseWithItems(pbMock, "sp-y", [ + { quantity: 2, quantity_received: 2, cost_price: 50 }, + ]); + const { items } = await listStockPurchases(pbMock); + const x = items.find((r) => r.id === "sp-x")!; + const y = items.find((r) => r.id === "sp-y")!; + expect(x.ordered_value).toBe(100); + expect(x.received_value).toBe(100); + expect(y.ordered_value).toBe(100); + expect(y.received_value).toBe(100); + }); +}); +``` + +- [ ] **Step 2: Run the new tests to confirm they fail** + +Run: `pnpm vitest run src/lib/server/stock-purchases-service.test.ts -t "total value"` +Expected: FAIL — `listStockPurchases` does not yet return `ordered_value` / `received_value` on the row type. + +- [ ] **Step 3: Widen `StockPurchaseListRow` and replace the helper** + +In `src/lib/server/stock-purchases-service.ts`: + +Replace the existing `StockPurchaseListRow` block (around line 162): + +```ts +export interface StockPurchaseListRow extends StockPurchase { + item_count: number; + /** + * Sum of `line.quantity * line.cost_price` across every line on + * this purchase. Reflects the value committed to the supplier — + * visible from draft stage onwards. Rounded to 2dp (matches + * `calculateTotals` in `pb-helpers.ts`). + */ + ordered_value: number; + /** + * Sum of `line.quantity_received * line.cost_price`. Zero for + * drafts; grows as goods are booked into stock via collection-confirm. + * Lets the operator spot short-shipped POs at a glance. + */ + received_value: number; +} +``` + +Replace the call to `countItemsForPurchase` inside `listStockPurchases` (around line 192) so it uses the new helper and writes both new fields onto each row: + +```ts + const items: StockPurchaseListRow[] = []; + for (const row of result.items) { + const summary = await summarizeItemsForPurchase(pb, String(row.id)); + items.push({ + ...(row as unknown as StockPurchase), + item_count: summary.count, + ordered_value: summary.ordered, + received_value: summary.received, + }); + } +``` + +Replace `countItemsForPurchase` (around line 205) with the richer helper: + +```ts +/** + * Fetch all `stock_purchase_items` rows for a single purchase and + * return the line count plus the two value aggregates the list + * table needs. Pulls `quantity`, `quantity_received`, and + * `cost_price` in one round-trip; replacing the older id-only + * helper so the cost is the same. + */ +async function summarizeItemsForPurchase( + pb: StockPurchasesPb, + purchaseId: string +): Promise<{ count: number; ordered: number; received: number }> { + type ItemShape = { + quantity?: number; + quantity_received?: number; + cost_price?: number; + }; + const items = (await pb + .collection(STOCK_PURCHASE_ITEMS) + .getFullList({ filter: `purchase = "${purchaseId}"` })) as unknown as ItemShape[]; + let ordered = 0; + let received = 0; + for (const item of items) { + const qty = Number(item.quantity ?? 0); + const recv = Number(item.quantity_received ?? 0); + const cost = Number(item.cost_price ?? 0); + ordered += qty * cost; + received += recv * cost; + } + return { + count: items.length, + ordered: Number(ordered.toFixed(2)), + received: Number(received.toFixed(2)), + }; +} +``` + +- [ ] **Step 4: Run the new tests to confirm they pass** + +Run: `pnpm vitest run src/lib/server/stock-purchases-service.test.ts -t "total value"` +Expected: PASS — all 5 new cases. + +- [ ] **Step 5: Run the rest of the service tests to confirm no regressions** + +Run: `pnpm vitest run src/lib/server/stock-purchases-service.test.ts` +Expected: PASS — pre-existing list tests still pass; the helper change is internal. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/stock-purchases-service.ts src/lib/server/stock-purchases-service.test.ts +git commit -m "feat(stock-purchases): aggregate ordered/received value on list rows + +Add ordered_value and received_value to StockPurchaseListRow. Each is +Σ line.{quantity|quantity_received} × line.cost_price, rounded to 2dp. +Replaces countItemsForPurchase with summarizeItemsForPurchase so the +per-row N+1 returns all three numbers in one fetch (no extra round +trip)." +``` + +--- + +## Task 2: Add `GET /api/v1/stock-purchases/list` route + +**Files:** +- Create: `src/app/api/v1/stock-purchases/list/route.ts` +- Create: `src/app/api/v1/stock-purchases/list/route.test.ts` + +- [ ] **Step 1: Write the failing route test** + +Create `src/app/api/v1/stock-purchases/list/route.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +// --- Mocks ---------------------------------------------------------------- + +const listStockPurchases = vi.fn(); +vi.mock("@/lib/server/stock-purchases-service", () => ({ + listStockPurchases: (...args: unknown[]) => listStockPurchases(...args), +})); + +const ORIGINAL_KEY = process.env.AGENT_API_KEY; +function authedRequest(url: string) { + process.env.AGENT_API_KEY = "test-token"; + return new NextRequest(url, { + headers: { authorization: "Bearer test-token" }, + }); +} + +import { GET } from "./route"; + +// --- Tests ---------------------------------------------------------------- + +describe("/api/v1/stock-purchases/list", () => { + beforeEach(() => { + process.env.AGENT_API_KEY = "test-token"; + listStockPurchases.mockReset(); + }); + + afterEach(() => { + if (ORIGINAL_KEY === undefined) delete process.env.AGENT_API_KEY; + else process.env.AGENT_API_KEY = ORIGINAL_KEY; + }); + + it("returns 200 with the service return shape", async () => { + const payload = { + items: [{ id: "sp-1", ordered_value: 100, received_value: 50, item_count: 2 }], + totalItems: 1, + page: 1, + perPage: 50, + totalPages: 1, + }; + listStockPurchases.mockResolvedValueOnce(payload); + + const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list")); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(payload); + }); + + it("passes status, page, perPage through to the service", async () => { + listStockPurchases.mockResolvedValueOnce({ + items: [], + totalItems: 0, + page: 2, + perPage: 25, + totalPages: 0, + }); + + await GET( + authedRequest( + "http://localhost/api/v1/stock-purchases/list?status=sent&page=2&perPage=25", + ), + ); + + expect(listStockPurchases).toHaveBeenCalledWith(expect.anything(), { + status: "sent", + page: 2, + perPage: 25, + }); + }); + + it("returns 400 for an unknown status value", async () => { + const res = await GET( + authedRequest("http://localhost/api/v1/stock-purchases/list?status=bogus"), + ); + expect(res.status).toBe(400); + expect(listStockPurchases).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.error).toMatch(/status/i); + }); + + it("returns 500 with { error } when the service throws", async () => { + listStockPurchases.mockRejectedValueOnce(new Error("PB is down")); + const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list")); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("PB is down"); + }); +}); +``` + +- [ ] **Step 2: Run the route test to confirm it fails** + +Run: `pnpm vitest run src/app/api/v1/stock-purchases/list/route.test.ts` +Expected: FAIL — `./route` does not exist. + +- [ ] **Step 3: Create the route handler** + +Create `src/app/api/v1/stock-purchases/list/route.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { listStockPurchases } from "@/lib/server/stock-purchases-service"; +import { STOCK_PURCHASE_STATUS_VALUES, type StockPurchaseStatus } from "@/lib/types"; + +/** + * GET /api/v1/stock-purchases/list + * + * List stock purchases with aggregated ordered/received value per row. + * + * Auth: same as the rest of `/api/v1` — `Authorization: Bearer `. + * + * Query params: + * status — optional, one of STOCK_PURCHASE_STATUS_VALUES or empty for "all" + * page — pagination, default 1, min 1 + * perPage — pagination, default 50, min 1, max 200 + * + * Distinct from the bare-collection `/api/v1/stock-purchases` route + * (which proxies arbitrary PB queries for the agent API). This route + * is the typed/validated list with value aggregation — it's what the + * `/stock-orders` page would hit if it ever calls over HTTP instead + * of importing the service directly. + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + assertAgentAuth(req); + + const rawStatus = req.nextUrl.searchParams.get("status") ?? ""; + if (rawStatus && !STOCK_PURCHASE_STATUS_VALUES.includes(rawStatus as StockPurchaseStatus)) { + return NextResponse.json( + { error: `unknown status: ${rawStatus}` }, + { status: 400 }, + ); + } + const status = rawStatus as StockPurchaseStatus | ""; + + const page = Math.max(1, Number(req.nextUrl.searchParams.get("page") || "1")); + const perPage = Math.min( + 200, + Math.max(1, Number(req.nextUrl.searchParams.get("perPage") || "50")), + ); + + const pb = await getAdminPocketBase(); + const result = await listStockPurchases(pb, { status, page, perPage }); + return NextResponse.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to fetch stock-purchases"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} +``` + +- [ ] **Step 4: Run the route test to confirm it passes** + +Run: `pnpm vitest run src/app/api/v1/stock-purchases/list/route.test.ts` +Expected: PASS — all 4 cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/v1/stock-purchases/list/route.ts src/app/api/v1/stock-purchases/list/route.test.ts +git commit -m "feat(api): GET /api/v1/stock-purchases/list with value aggregation + +Thin wrapper around listStockPurchases that validates the status +param against STOCK_PURCHASE_STATUS_VALUES (400 on bad input) and +caps perPage at 200. Mirrors the agent-auth pattern used by the +other v1 list routes." +``` + +--- + +## Task 3: Create the `StockOrdersTable` client component + +**Files:** +- Create: `src/app/(app)/stock-orders/StockOrdersTable.tsx` + +This task creates the client component in isolation. The next task wires the page to it. + +- [ ] **Step 1: Create `StockOrdersTable.tsx`** + +Create `src/app/(app)/stock-orders/StockOrdersTable.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ChevronRight, Package, Pencil } from "lucide-react"; +import { QuickDraftModal } from "@/components/stock-orders/quick-draft-modal"; +import { + STOCK_PURCHASE_STATUS_VALUES, + type StockPurchase, + type StockPurchaseStatus, + type Supplier, +} from "@/lib/types"; + +// `formatCurrency` lives in `pb-helpers.ts` but that file is the +// legacy data-access layer (AGENTS.md forbids importing it from +// new components). Inlined here — same ZAR / en-ZA shape used +// elsewhere in the app. +function formatCurrency(amount: number): string { + return new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }).format(amount); +} + +/** + * Client-side table view for the `/stock-orders` list. Holds the + * QuickDraftModal open state + the per-row "edit draft" target. + * The page itself is a Server Component that fetches rows; this + * component receives them as a prop and renders tabs + table + + * pagination as ``s so the server re-renders on filter/page + * change. + */ + +type ListRow = StockPurchase & { + item_count: number; + ordered_value: number; + received_value: number; + expand?: { supplier?: Supplier }; +}; + +const STATUS_TABS: Array<{ value: StockPurchaseStatus | ""; label: string }> = [ + { value: "", label: "All" }, + ...STOCK_PURCHASE_STATUS_VALUES.filter((s) => + ["draft", "sent", "received", "cancelled"].includes(s), + ).map((value) => ({ + value: value as StockPurchaseStatus, + label: value.charAt(0).toUpperCase() + value.slice(1), + })), +]; + +const STATUS_VARIANT: Record< + StockPurchaseStatus, + "default" | "secondary" | "destructive" | "outline" +> = { + draft: "outline", + sending: "secondary", + sent: "secondary", + mail_failed: "destructive", + received: "default", + cancelled: "destructive", +}; + +const STATUS_LABEL: Record = { + draft: "Draft", + sending: "Sending", + sent: "Sent", + mail_failed: "Mail failed", + received: "Received", + cancelled: "Cancelled", +}; + +function formatDate(s: string): string { + if (!s) return ""; + const d = new Date(s); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + }); +} + +export interface StockOrdersTableProps { + rows: ListRow[]; + currentStatus: StockPurchaseStatus | ""; + page: number; + totalPages: number; +} + +export default function StockOrdersTable({ + rows, + currentStatus, + page, + totalPages, +}: StockOrdersTableProps) { + const [quickDraftOpen, setQuickDraftOpen] = useState(false); + const [editingPurchaseId, setEditingPurchaseId] = useState(null); + + const openEditForDraft = (purchaseId: string) => { + setEditingPurchaseId(purchaseId); + setQuickDraftOpen(true); + }; + + const tabHref = (value: StockPurchaseStatus | "") => { + const params = new URLSearchParams(); + if (value) params.set("status", value); + const qs = params.toString(); + return qs ? `/stock-orders?${qs}` : "/stock-orders"; + }; + + const pageHref = (target: number) => { + const params = new URLSearchParams(); + if (currentStatus) params.set("status", currentStatus); + if (target > 1) params.set("page", String(target)); + const qs = params.toString(); + return qs ? `/stock-orders?${qs}` : "/stock-orders"; + }; + + return ( + <> + {/* Status filter — server-rendered tabs. */} +
+ {STATUS_TABS.map((tab) => { + const active = currentStatus === tab.value; + return ( + + {tab.label} + + ); + })} +
+ +
+ +
+ + {rows.length === 0 ? ( + + + +

+ {currentStatus + ? `No ${STATUS_LABEL[currentStatus].toLowerCase()} stock orders.` + : "No stock orders yet."} +

+ +
+
+ ) : ( + + + + + + Number + Supplier + Status + Ordered (R) + Received (R) + Items + Created + + + + + {rows.map((row) => { + const supplier = row.expand?.supplier; + return ( + { + window.location.href = `/stock-orders/${row.id}`; + }} + > + + {row.purchase_number} + + + {supplier?.suppliername ?? ( + + Unknown supplier + + )} + + + + {STATUS_LABEL[row.status]} + + + + {formatCurrency(row.ordered_value)} + + + {formatCurrency(row.received_value)} + + + {row.item_count} + + + {formatDate(row.created)} + + +
+ {row.status === "draft" && ( + + )} + +
+
+
+ ); + })} +
+
+
+
+ )} + + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + + { + setQuickDraftOpen(next); + if (!next) setEditingPurchaseId(null); + }} + editPurchaseId={editingPurchaseId ?? undefined} + /> + + ); +} +``` + +- [ ] **Step 2: Type-check the new component** + +Run: `pnpm tsc --noEmit -p tsconfig.json 2>&1 | head -40` +Expected: no errors from `StockOrdersTable.tsx`. + +- [ ] **Step 3: Commit (so we can revert cleanly if the page wiring breaks)** + +```bash +git add src/app/(app)/stock-orders/StockOrdersTable.tsx +git commit -m "feat(stock-orders): extract StockOrdersTable client component + +Holds the QuickDraftModal open state, renders status filter tabs as +s to ?status=..., and adds two new columns (Ordered R, Received R) +showing per-row aggregates from the service layer." +``` + +--- + +## Task 4: Migrate `page.tsx` to a Server Component + +**Files:** +- Modify: `src/app/(app)/stock-orders/page.tsx` (rewrite) +- Delete: `src/app/(app)/stock-orders/page.test.tsx` (its assertions belong to the new client component) + +- [ ] **Step 1: Delete the old page test** + +```bash +git rm src/app/(app)/stock-orders/page.test.tsx +``` + +- [ ] **Step 2: Write the new Server Component page** + +Replace the contents of `src/app/(app)/stock-orders/page.tsx` with: + +```tsx +import { Button } from "@/components/ui/button"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { listStockPurchases } from "@/lib/server/stock-purchases-service"; +import { + STOCK_PURCHASE_STATUS_VALUES, + type StockPurchaseStatus, + type Supplier, +} from "@/lib/types"; +import StockOrdersTable from "./StockOrdersTable"; + +/** + * /stock-orders — server-rendered list view for pure stock + * purchases (v2 stock-orders flow, no customer order attached). + * + * Migrated from a client component (which read PB directly from the + * browser) to a Server Component per AGENTS.md: + * + * - Reads search params from the App Router prop. + * - Calls `listStockPurchases(adminPb, ...)` directly — same shape + * the `/api/v1/stock-purchases/list` route exposes, without the + * HTTP round-trip when rendering server-side. + * - Hands the result to `` for the interactive + * bits (modal + edit-draft). + * + * Status and page are validated before reaching the service so a + * stray URL param can't reach PB. + */ + +type ListRow = Awaited>["items"][number] & { + expand?: { supplier?: Supplier }; +}; + +function parseStatus(raw: string | string[] | undefined): StockPurchaseStatus | "" { + const value = Array.isArray(raw) ? raw[0] : raw; + if (!value) return ""; + return STOCK_PURCHASE_STATUS_VALUES.includes(value as StockPurchaseStatus) + ? (value as StockPurchaseStatus) + : ""; +} + +function parsePage(raw: string | string[] | undefined): number { + const value = Array.isArray(raw) ? raw[0] : raw; + const n = Number(value ?? "1"); + return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1; +} + +export default async function StockOrdersPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const params = await searchParams; + const status = parseStatus(params.status); + const page = parsePage(params.page); + + let result: Awaited>; + try { + const pb = await getAdminPocketBase(); + result = await listStockPurchases(pb, { status, page, perPage: 50 }); + } catch (err) { + // Surface the failure inline rather than redirect to a global + // error boundary — operators need the URL context to retry. + return ( +
+

Stock Orders

+

+ Failed to load stock orders:{" "} + {err instanceof Error ? err.message : "unknown error"} +

+
+ ); + } + + return ( +
+
+
+

Stock Orders

+

+ Pure stock purchases — supplier + lines, no customer order + attached. Receive the goods to book them into stock. +

+
+ +
+ + +
+ ); +} +``` + +Notes for the implementer: +- `asChild` + `` keeps the existing button styles while making the buttons proper links — works without JS for the navigation case. + +- [ ] **Step 3: Verify the page type-checks** + +Run: `pnpm tsc --noEmit -p tsconfig.json 2>&1 | head -40` +Expected: no errors from `page.tsx`. + +- [ ] **Step 4: Manual smoke — boot the dev server and load `/stock-orders`** + +```bash +pnpm dev +``` + +Then in a browser, open `http://localhost:3000/stock-orders`. + +Expected: +- Page renders the existing rows. +- Each row shows Ordered (R) and Received (R) columns next to Status, formatted as `R 1 234,56`. +- Tabs filter via URL (`?status=draft`, `?status=sent`). +- Pagination links go to `?page=N&status=...`. +- "+ New Stock Item" opens the QuickDraftModal. +- Pencil icon on draft rows opens the modal in edit mode with that row's id. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/stock-orders/page.tsx +git commit -m "refactor(stock-orders): migrate list page to Server Component + +Reads searchParams (status, page), validates both, calls +listStockPurchases via the admin PB, and renders the new +StockOrdersTable client component. Drops the legacy direct-PB +read path per AGENTS.md data-access discipline. + +The old page.test.tsx is removed; its assertions are mirrored +in the new StockOrdersTable.test.tsx (next task)." +``` + +--- + +## Task 5: Add `StockOrdersTable.test.tsx` (move the 7 existing assertions) + +**Files:** +- Create: `src/app/(app)/stock-orders/StockOrdersTable.test.tsx` + +- [ ] **Step 1: Write the test file** + +Create `src/app/(app)/stock-orders/StockOrdersTable.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, render, screen, fireEvent } from "@testing-library/react"; + +// Stub the QuickDraftModal — it's tested in +// `src/components/stock-orders/quick-draft-modal.test.tsx`. +vi.mock("@/components/stock-orders/quick-draft-modal", () => ({ + QuickDraftModal: ({ + open, + onOpenChange, + editPurchaseId, + }: { + open: boolean; + onOpenChange: (b: boolean) => void; + editPurchaseId?: string; + }) => ( +
+ +
+ ), +})); + +// Stub Next's so the test doesn't pull the router — these +// tests don't assert navigation. +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: React.ReactNode; href: string; [k: string]: unknown }) => ( +
{children} + ), +})); + +import StockOrdersTable from "./StockOrdersTable"; + +// --- Helpers -------------------------------------------------------------- + +type Row = Parameters[0]["rows"][number]; + +function makeRow(overrides: { + id: string; + status: "draft" | "sent" | "received" | "cancelled"; + purchase_number: string; +}) { + return { + id: overrides.id, + created: "2026-07-30 10:00:00.000Z", + updated: "2026-07-30 10:00:00.000Z", + collectionId: "stock_purchases", + collectionName: "stock_purchases", + purchase_number: overrides.purchase_number, + supplier: "supplier-1", + status: overrides.status, + notes: "", + email_error: "", + email_message_id: "", + item_count: 0, + ordered_value: 0, + received_value: 0, + expand: { supplier: { id: "supplier-1", suppliername: "Steel & Pipe Co" } }, + }; +} + +// --- Tests ---------------------------------------------------------------- + +describe("StockOrdersTable — quick draft button", () => { + beforeEach(() => { + // no state to reset; vitest's auto-mock-cleanup handles vi.fn() + }); + + afterEach(() => { + cleanup(); + }); + + it("renders a New Stock Item button", () => { + render(); + const button = screen.getByTestId("new-stock-item-button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent(/new stock item/i); + }); + + it("mounts the QuickDraftModal (closed by default)", () => { + render(); + const modal = screen.getByTestId("quick-draft-modal"); + expect(modal).toBeInTheDocument(); + expect(modal.dataset.open).toBe("false"); + }); + + it("opens the QuickDraftModal when the button is clicked", () => { + render(); + fireEvent.click(screen.getByTestId("new-stock-item-button")); + expect(screen.getByTestId("quick-draft-modal").dataset.open).toBe("true"); + }); + + it("closes the QuickDraftModal when the modal calls onOpenChange(false)", () => { + render(); + fireEvent.click(screen.getByTestId("new-stock-item-button")); + expect(screen.getByTestId("quick-draft-modal").dataset.open).toBe("true"); + fireEvent.click(screen.getByTestId("quick-draft-modal-close")); + expect(screen.getByTestId("quick-draft-modal").dataset.open).toBe("false"); + }); +}); + +describe("StockOrdersTable — edit draft button", () => { + afterEach(() => { + cleanup(); + }); + + it("renders an Edit button on draft rows", () => { + const rows = [ + makeRow({ id: "po-abc", status: "draft", purchase_number: "PO26000001STK" }), + ]; + render(); + const editButton = screen.getByTestId("edit-draft-button"); + expect(editButton).toBeInTheDocument(); + expect(editButton).toHaveAttribute("aria-label", "Edit draft PO26000001STK"); + }); + + it("does NOT render an Edit button on non-draft rows", () => { + const rows = [ + makeRow({ id: "po-sent", status: "sent", purchase_number: "PO26000002STK" }), + ]; + render(); + expect(screen.queryByTestId("edit-draft-button")).toBeNull(); + }); + + it("clicking Edit opens the modal with editPurchaseId set to the row id", () => { + const rows = [ + makeRow({ id: "po-abc", status: "draft", purchase_number: "PO26000001STK" }), + ]; + render(); + const modal = screen.getByTestId("quick-draft-modal"); + expect(modal.dataset.open).toBe("false"); + expect(modal.dataset.editPurchaseId).toBe(""); + fireEvent.click(screen.getByTestId("edit-draft-button")); + expect(modal.dataset.open).toBe("true"); + expect(modal.dataset.editPurchaseId).toBe("po-abc"); + }); +}); + +describe("StockOrdersTable — total value columns", () => { + afterEach(() => { + cleanup(); + }); + + it("renders ordered_value and received_value formatted as ZAR currency", () => { + const row = makeRow({ + id: "po-1", + status: "draft", + purchase_number: "PO26000001STK", + }); + row.ordered_value = 521; + row.received_value = 75; + render(); + // formatCurrency uses Intl with en-ZA / ZAR — the exact glyph + // and spacing can vary by ICU version, so assert on the numeric + // parts instead. + expect(screen.getAllByText(/521/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/75/).length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run the test file** + +Run: `pnpm vitest run src/app/(app)/stock-orders/StockOrdersTable.test.tsx` +Expected: PASS — all 8 cases (the 7 migrated + the new total-value case). + +- [ ] **Step 3: Run the full stock-orders test directory to confirm nothing else regressed** + +Run: `pnpm vitest run src/app/\(app\)/stock-orders` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/(app)/stock-orders/StockOrdersTable.test.tsx +git commit -m "test(stock-orders): move page tests to StockOrdersTable + add total-value case + +The seven pre-existing assertions on the quick-draft modal and edit +button now exercise the new client component. New case confirms +ordered_value/received_value render in the row. Uses Intl-format +substring assertions because the currency glyph/spacing varies by +ICU version." +``` + +--- + +## Task 6: Full-repo verification + +**Files:** none new; runs the full project test suite + lint + type-check. + +- [ ] **Step 1: Type-check the entire repo** + +Run: `pnpm tsc --noEmit -p tsconfig.json 2>&1 | tail -20` +Expected: no errors. + +- [ ] **Step 2: Run all unit tests** + +Run: `pnpm vitest run 2>&1 | tail -30` +Expected: PASS — full suite green; no regressions in `stock-purchases-service`, the new route test, or `StockOrdersTable`. + +- [ ] **Step 3: Run lint** + +Run: `pnpm lint 2>&1 | tail -20` +Expected: no new warnings. + +- [ ] **Step 4: Manual end-to-end smoke** + +Boot `pnpm dev` and: +1. Create a draft PO with two lines (10 × R 12.50 and 4 × R 99.00). `/stock-orders` shows `Ordered: R 521,00`, `Received: R 0,00`. +2. Mark it sent (status → `sent`). +3. Partial-book 6 of the first line via `/collection-confirm`. Return to `/stock-orders`. `Received` shows `R 75,00`; `Ordered` unchanged. +4. Book the rest. `Received` equals `Ordered`. +5. Click the "All" tab → URL is `/stock-orders`. Click "Draft" → URL is `/stock-orders?status=draft`. Page re-renders server-side with the filtered set. +6. With multiple pages of rows, click "Next" → URL adds `&page=2`; rows update. +7. Click the pencil on a draft row → QuickDraftModal opens with `editPurchaseId` set; editing and saving returns to the list and the row reflects the change. + +- [ ] **Step 5: Final commit (no-op if everything above is already committed)** + +If anything from manual smoke needed a fix, commit it: + +```bash +git status +# if anything is unstaged: +git add -A +git commit -m "chore(stock-orders): post-smoke fixes" +``` + +If `git status` is clean, no commit is needed. \ No newline at end of file