From a8231fec07b5623a260e121d79d4b6abac08b89b Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 15:57:55 +0200 Subject: [PATCH] docs(stock-orders): wrap + PDF implementation plan 5 tasks: stock-items service helper, two PDF document components + render helpers, two GET endpoints, UI changes (wrap + buttons). TDD per task with test-first steps. Final task is full-repo verification + manual smoke checklist. Co-authored-by: Orca --- .../plans/2026-08-03-stock-orders-wrap-pdf.md | 1109 +++++++++++++++++ 1 file changed, 1109 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-stock-orders-wrap-pdf.md diff --git a/docs/superpowers/plans/2026-08-03-stock-orders-wrap-pdf.md b/docs/superpowers/plans/2026-08-03-stock-orders-wrap-pdf.md new file mode 100644 index 0000000..c851a9f --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-stock-orders-wrap-pdf.md @@ -0,0 +1,1109 @@ +# Stock Orders — Wrap + PDF Downloads — 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:** Allow long text to wrap in the Number + Supplier cells on `/stock-orders`, and add two PDF download buttons (Stocktake PDF, Orders PDF) next to the existing "New Stock Item" button. + +**Architecture:** Wrap the two cells with `whitespace-normal break-words max-w-[200px]`. Add two new PDF document components in `src/lib/pdf/`, two new render helpers in `render.tsx`, two new GET endpoints under `/api/v1/`, and two new `` buttons in `StockOrdersTable` that point at those endpoints with `target="_blank"`. The Orders PDF reuses the existing `listStockPurchases` service; the Stocktake PDF adds a new `listStockItemsForStocktake` service helper. + +**Tech Stack:** Next.js App Router (Server Components), React 19, `@react-pdf/renderer` (already in use), PocketBase via the admin client (`getAdminPocketBase`), vitest + @testing-library/react, lucide-react. + +--- + +## File map + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/server/stock-items-service.ts` | create | `listStockItemsForStocktake(pb)` — returns active stock items sorted by name with supplier expanded. | +| `src/lib/server/stock-items-service.test.ts` | create | 3 tests for the new service helper. | +| `src/lib/pdf/stock-purchase-list-document.tsx` | create | `@react-pdf/renderer` document for the Orders PDF. | +| `src/lib/pdf/stock-item-stocktake-document.tsx` | create | `@react-pdf/renderer` document for the Stocktake PDF. | +| `src/lib/pdf/render.tsx` | modify | Add `renderStockPurchaseListPdf` and `renderStockItemStocktakePdf`. | +| `src/lib/pdf/render.test.ts` | modify | Add two `%PDF`-prefix tests. | +| `src/app/api/v1/stock-purchases/list-pdf/route.ts` | create | GET-only Orders PDF endpoint. | +| `src/app/api/v1/stock-purchases/list-pdf/route.test.ts` | create | 3 route tests. | +| `src/app/api/v1/stock-items/stocktake-pdf/route.ts` | create | GET-only Stocktake PDF endpoint. | +| `src/app/api/v1/stock-items/stocktake-pdf/route.test.ts` | create | 3 route tests. | +| `src/app/(app)/stock-orders/StockOrdersTable.tsx` | modify | Add wrapping to Number + Supplier cells; add two download buttons. | +| `src/app/(app)/stock-orders/StockOrdersTable.test.tsx` | modify | Add 2 tests for the new buttons. | + +--- + +## Task 1: Add `listStockItemsForStocktake` service helper + +**Files:** +- Create: `src/lib/server/stock-items-service.ts` +- Test: `src/lib/server/stock-items-service.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/stock-items-service.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// --- PocketBase mock --------------------------------------------------------- +// +// Hoisted mock so the service module can call `pb.collection(...)`. +// We seed an in-memory map of `stock_items` rows. + +const items = new Map>(); + +const pb = { + collection: (id: string) => ({ + getFullList: async (opts: { filter?: string; sort?: string; expand?: string } = {}) => { + if (id !== "stock_items") throw new Error(`unexpected collection: ${id}`); + let rows = Array.from(items.values()); + if (opts.filter?.includes("is_active = true")) { + rows = rows.filter((r) => r.is_active === true); + } + if (opts.sort) { + const dir = opts.sort.startsWith("-") ? -1 : 1; + const key = opts.sort.replace(/^-/, ""); + rows = [...rows].sort((a, b) => { + const av = String(a[key] ?? ""); + const bv = String(b[key] ?? ""); + return dir * av.localeCompare(bv); + }); + } + return rows; + }, + }), +}; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: async () => pb, +})); + +import { listStockItemsForStocktake } from "./stock-items-service"; + +function seed(id: string, fields: Record) { + items.set(id, { id, ...fields }); +} + +beforeEach(() => { + items.clear(); +}); + +// --- Tests ------------------------------------------------------------------- + +describe("listStockItemsForStocktake", () => { + it("returns active stock items sorted by name with supplier expanded", async () => { + seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); + seed("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: true, supplier: "sup-1" }); + + const result = await listStockItemsForStocktake(pb as never); + expect(result.map((r) => r.name)).toEqual(["Bracket", "Widget"]); + }); + + it("returns an empty array when there are no active stock items", async () => { + seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: false, supplier: "sup-1" }); + const result = await listStockItemsForStocktake(pb as never); + expect(result).toEqual([]); + }); + + it("excludes items with is_active = false", async () => { + seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); + seed("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: false, supplier: "sup-1" }); + const result = await listStockItemsForStocktake(pb as never); + expect(result.map((r) => r.name)).toEqual(["Widget"]); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `pnpm vitest run src/lib/server/stock-items-service.test.ts` +Expected: FAIL — `./stock-items-service` does not exist. + +- [ ] **Step 3: Implement the service helper** + +Create `src/lib/server/stock-items-service.ts`: + +```ts +import type { StockItem, Supplier } from "@/lib/types"; + +/** + * PB client type kept loose so both the real `PocketBase` instance + * and the test stub satisfy the contract. The service only uses + * `pb.collection("stock_items").getFullList(...)`. + */ +export type StockItemsPb = { + collection: (id: string) => { + getFullList: (opts?: Record) => Promise; + }; +}; + +/** + * Fetch every active stock item sorted by name, with the supplier + * relation pre-expanded. The "Stocktake PDF" download endpoint + * uses this to render the printable stocktake list. + * + * Differs from the inline `stock_items` reads on the dashboard + * page (which filter by `is_active = true` AND sort by `current_qty`) + * because a stocktake wants all SKUs alphabetically, not the + * low-stock ones first. + * + * No pagination — the stocktake is always the whole list. If the + * catalogue grows past `getFullList`'s default page size, PB will + * fetch subsequent pages transparently. The PDF renderer downstream + * is the only consumer; if it becomes a bottleneck, this is the + * place to swap in a paginated stream. + */ +export async function listStockItemsForStocktake( + pb: StockItemsPb +): Promise { + return (await pb.collection("stock_items").getFullList({ + filter: "is_active = true", + sort: "name", + expand: "supplier", + })) as StockItem[]; +} + +// Re-export the type so the route can alias it without poking +// into `@/lib/types` directly. +export type { StockItem, Supplier }; +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `pnpm vitest run src/lib/server/stock-items-service.test.ts` +Expected: PASS — 3 cases. + +- [ ] **Step 5: Type-check** + +Run: `pnpm tsc --noEmit -p tsconfig.json 2>&1 | head -40` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/stock-items-service.ts src/lib/server/stock-items-service.test.ts +git commit -m "feat(stock-items): listStockItemsForStocktake service helper + +Returns active stock items sorted by name with supplier expanded, +for the new Stocktake PDF download endpoint. Distinct from the +dashboard's low-stock query: stocktake wants every SKU +alphabetically, not the under-stocked ones first." +``` + +--- + +## Task 2: PDF render layer — two list documents + render helpers + +**Files:** +- Create: `src/lib/pdf/stock-purchase-list-document.tsx` +- Create: `src/lib/pdf/stock-item-stocktake-document.tsx` +- Modify: `src/lib/pdf/render.tsx` +- Modify: `src/lib/pdf/render.test.ts` + +- [ ] **Step 1: Create `StockPurchaseListDocument`** + +Create `src/lib/pdf/stock-purchase-list-document.tsx`: + +```tsx +import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer"; +import type { StockPurchaseListRow } from "@/lib/server/stock-purchases-service"; +import { formatZAR, formatDateLong } from "./format"; + +export interface StockPurchaseListDocumentProps { + rows: StockPurchaseListRow[]; + /** ISO timestamp captured server-side at render time. */ + generatedAtIso: string; +} + +const STATUS_LABEL: Record = { + draft: "Draft", + sending: "Sending", + sent: "Sent", + mail_failed: "Mail failed", + received: "Received", + cancelled: "Cancelled", +}; + +const styles = StyleSheet.create({ + page: { padding: 28, fontSize: 9, fontFamily: "Helvetica" }, + header: { marginBottom: 18 }, + title: { fontSize: 16, fontFamily: "Helvetica-Bold", marginBottom: 4 }, + subtitle: { color: "#555", fontSize: 9 }, + table: { marginTop: 12 }, + thead: { + flexDirection: "row", + backgroundColor: "#f1f5f9", + borderBottomWidth: 1, + borderBottomColor: "#333", + paddingVertical: 4, + paddingHorizontal: 4, + }, + th: { fontFamily: "Helvetica-Bold", fontSize: 8 }, + tr: { + flexDirection: "row", + borderBottomWidth: 0.5, + borderBottomColor: "#ddd", + paddingVertical: 4, + paddingHorizontal: 4, + }, + td: { fontSize: 8 }, + tdRight: { fontSize: 8, textAlign: "right" }, + // Column widths chosen to fit landscape A4 with breathing room. + cNumber: { width: "14%" }, + cSupplier: { width: "22%" }, + cStatus: { width: "11%" }, + cOrdered: { width: "12%" }, + cReceived: { width: "12%" }, + cItems: { width: "7%" }, + cCreated: { width: "12%" }, + spacer: { width: "10%" }, + empty: { marginTop: 24, color: "#666", fontSize: 10, textAlign: "center" }, +}); + +/** + * Stock Orders list PDF — landscape A4, every purchase row in a + * table. Mirrors the on-screen table so a printed copy feels + * familiar to the operator. No logo (these are internal reports, + * not supplier-facing — the single-PO PDF route handles that). + */ +export function StockPurchaseListDocument({ + rows, + generatedAtIso, +}: StockPurchaseListDocumentProps) { + const generatedAt = new Date(generatedAtIso).toLocaleString(); + return ( + + + + Stock Orders — Full List + + Generated {generatedAt} · {rows.length} {rows.length === 1 ? "row" : "rows"} + + + + {rows.length === 0 ? ( + No stock orders. + ) : ( + + + Number + Supplier + Status + Ordered (R) + Received (R) + Items + Created + + + {rows.map((row) => ( + + {row.purchase_number} + + {row.expand?.supplier?.suppliername ?? "Unknown supplier"} + + + {STATUS_LABEL[row.status] ?? row.status} + + + {formatZAR(row.ordered_value)} + + + {formatZAR(row.received_value)} + + {row.item_count} + + {formatDateLong(row.created)} + + + + ))} + + )} + + + ); +} +``` + +Before writing this, confirm the props of `formatZAR` and `formatDateLong` by reading `src/lib/pdf/format.ts`. If their signatures differ, adjust the code blocks accordingly. + +- [ ] **Step 2: Create `StockItemStocktakeDocument`** + +Create `src/lib/pdf/stock-item-stocktake-document.tsx`: + +```tsx +import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer"; +import type { StockItem } from "@/lib/server/stock-items-service"; + +export interface StockItemStocktakeDocumentProps { + items: StockItem[]; + /** ISO timestamp captured server-side at render time. */ + generatedAtIso: string; +} + +const styles = StyleSheet.create({ + page: { padding: 28, fontSize: 9, fontFamily: "Helvetica" }, + header: { marginBottom: 18 }, + title: { fontSize: 16, fontFamily: "Helvetica-Bold", marginBottom: 4 }, + subtitle: { color: "#555", fontSize: 9 }, + table: { marginTop: 12 }, + thead: { + flexDirection: "row", + backgroundColor: "#f1f5f9", + borderBottomWidth: 1, + borderBottomColor: "#333", + paddingVertical: 4, + paddingHorizontal: 4, + }, + th: { fontFamily: "Helvetica-Bold", fontSize: 9 }, + tr: { + flexDirection: "row", + borderBottomWidth: 0.5, + borderBottomColor: "#ddd", + paddingVertical: 4, + paddingHorizontal: 4, + }, + td: { fontSize: 9 }, + tdRight: { fontSize: 9, textAlign: "right" }, + // Stocktake is for a walk-through — wider name column than the + // SKU/location/unit/qty columns so the operator can tick the + // actual on-hand qty next to the row. + cName: { width: "30%" }, + cSku: { width: "12%" }, + cLocation: { width: "10%" }, + cUnit: { width: "8%" }, + cQty: { width: "10%" }, + cActual: { width: "10%" }, + cSupplier: { width: "20%" }, +}); + +/** + * Stocktake PDF — A4 portrait, every active stock item in a + * table sorted by name. The "Actual" column is intentionally blank + * — the operator fills it on the printed page during the stocktake + * walk-through and writes the difference back to the system by hand + * (or another flow, not in scope here). + */ +export function StockItemStocktakeDocument({ + items, + generatedAtIso, +}: StockItemStocktakeDocumentProps) { + const generatedAt = new Date(generatedAtIso).toLocaleString(); + return ( + + + + Stocktake — All Active Items + + Generated {generatedAt} · {items.length} {items.length === 1 ? "item" : "items"} + + + + {items.length === 0 ? ( + + No active stock items. + + ) : ( + + + Name + SKU + Location + Unit + Current qty + Actual + Supplier + + {items.map((item) => ( + + {item.name} + {item.sku} + {item.location} + {item.unit} + {item.current_qty} + {""} + + {item.expand?.supplier?.suppliername ?? ""} + + + ))} + + )} + + + ); +} +``` + +- [ ] **Step 3: Add the two render helpers in `render.tsx`** + +Modify `src/lib/pdf/render.tsx`. Add the new imports at the top, then add the two new functions at the bottom: + +```tsx +import { pdf } from "@react-pdf/renderer"; +import { QuoteDocument, type QuoteDocumentProps } from "./quote-document"; +import { + PurchaseOrderDocument, + type PurchaseOrderDocumentProps, +} from "./purchase-order-document"; +import { + StockPurchaseDocument, + type StockPurchaseDocumentProps, +} from "./stock-purchase-document"; +import { + StockPurchaseListDocument, + type StockPurchaseListDocumentProps, +} from "./stock-purchase-list-document"; +import { + StockItemStocktakeDocument, + type StockItemStocktakeDocumentProps, +} from "./stock-item-stocktake-document"; +import { + DeliveryNoteDocument, + type DeliveryNoteDocumentProps, +} from "./delivery-note-document"; +``` + +Then add at the end of the file: + +```tsx +/** + * Server-side render of the stock-orders *list* PDF — every + * purchase in one document, not one PDF per PO. Sibling of + * `renderStockPurchasePdf` (which is the single-PO, supplier-facing + * PDF). Both walk `@react-pdf/renderer`'s `pdf().toString()` + * pipeline so the test suite runs under jsdom without dragging in + * a Chromium binary. + */ +export async function renderStockPurchaseListPdf( + props: StockPurchaseListDocumentProps +): Promise { + const instance = pdf(); + const pdfString = await instance.toString(); + return Buffer.from(pdfString, "binary"); +} + +/** + * Server-side render of the stocktake PDF — every active stock + * item in one document. Same in-process Node pipeline as the other + * renderers. The blank "Actual" column is intentional — the operator + * fills it in on the printed page during the walk-through. + */ +export async function renderStockItemStocktakePdf( + props: StockItemStocktakeDocumentProps +): Promise { + const instance = pdf(); + const pdfString = await instance.toString(); + return Buffer.from(pdfString, "binary"); +} +``` + +- [ ] **Step 4: Extend `render.test.ts`** + +Open `src/lib/pdf/render.test.ts` and append two new tests: + +```ts +import { renderStockPurchaseListPdf } from "./render"; +import { renderStockItemStocktakePdf } from "./render"; +import type { StockPurchaseListRow } from "@/lib/server/stock-purchases-service"; +import type { StockItem } from "@/lib/server/stock-items-service"; + +// ... + +describe("renderStockPurchaseListPdf", () => { + it("returns a PDF buffer (starts with %PDF)", async () => { + const rows: StockPurchaseListRow[] = [ + { + id: "sp-1", + purchase_number: "PO26000001STK", + supplier: "sup-1", + status: "draft", + notes: "", + email_error: "", + email_message_id: "", + created: "2026-08-01 10:00:00.000Z", + updated: "2026-08-01 10:00:00.000Z", + collectionId: "stock_purchases", + collectionName: "stock_purchases", + item_count: 2, + ordered_value: 100, + received_value: 50, + }, + ]; + const buffer = await renderStockPurchaseListPdf({ + rows, + generatedAtIso: "2026-08-03T10:00:00.000Z", + }); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.slice(0, 4).toString("binary")).toBe("%PDF"); + }); +}); + +describe("renderStockItemStocktakePdf", () => { + it("returns a PDF buffer (starts with %PDF)", async () => { + const items: StockItem[] = [ + { + id: "si-1", + sku: "WID-1", + name: "Widget", + description: "", + unit: "ea", + location: "A1", + current_qty: 10, + min_qty: 0, + supplier: "sup-1", + is_active: true, + notes: "", + created: "2026-08-01 10:00:00.000Z", + updated: "2026-08-01 10:00:00.000Z", + collectionId: "stock_items", + collectionName: "stock_items", + }, + ]; + const buffer = await renderStockItemStocktakePdf({ + items, + generatedAtIso: "2026-08-03T10:00:00.000Z", + }); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.slice(0, 4).toString("binary")).toBe("%PDF"); + }); +}); +``` + +Note: the existing `render.test.ts` may use a different describe block style for the single-PO renderers. Match the existing style (e.g. `vi.mock`'s, `beforeEach` setup) — don't introduce a new pattern. The two new tests should slot into the same file alongside the existing ones. + +- [ ] **Step 5: Run the new tests** + +Run: `pnpm vitest run src/lib/pdf/render.test.ts` +Expected: PASS — all existing tests plus the 2 new ones. + +- [ ] **Step 6: Type-check** + +Run: `pnpm tsc --noEmit -p tsconfig.json 2>&1 | head -40` +Expected: no new errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/pdf/stock-purchase-list-document.tsx src/lib/pdf/stock-item-stocktake-document.tsx src/lib/pdf/render.tsx src/lib/pdf/render.test.ts +git commit -m "feat(pdf): list-pdf documents for stock orders + stocktake + +Two new @react-pdf/renderer documents backed by renderStockPurchaseListPdf +and renderStockItemStocktakePdf in render.tsx. The Orders PDF mirrors +the on-screen table; the Stocktake PDF adds a blank 'Actual' column +for the operator to fill in during the walk-through. Both inherit +the toString() → Buffer pattern so they run under the same jsdom +test setup as the other PDF renderers." +``` + +--- + +## Task 3: API routes — both PDF endpoints + +**Files:** +- Create: `src/app/api/v1/stock-purchases/list-pdf/route.ts` +- Create: `src/app/api/v1/stock-purchases/list-pdf/route.test.ts` +- Create: `src/app/api/v1/stock-items/stocktake-pdf/route.ts` +- Create: `src/app/api/v1/stock-items/stocktake-pdf/route.test.ts` + +- [ ] **Step 1: Write the failing test for the Orders PDF route** + +Create `src/app/api/v1/stock-purchases/list-pdf/route.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const renderStockPurchaseListPdf = vi.fn(); +vi.mock("@/lib/pdf/render", () => ({ + renderStockPurchaseListPdf: (...args: unknown[]) => renderStockPurchaseListPdf(...args), +})); + +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) { + return new NextRequest(url, { + headers: { authorization: "Bearer test-token" }, + }); +} + +import { GET } from "./route"; + +describe("/api/v1/stock-purchases/list-pdf", () => { + beforeEach(() => { + process.env.AGENT_API_KEY = "test-token"; + renderStockPurchaseListPdf.mockReset(); + 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 an application/pdf body", async () => { + listStockPurchases.mockResolvedValueOnce({ + items: [{ id: "sp-1", ordered_value: 100, received_value: 50, item_count: 2 }], + totalItems: 1, + page: 1, + perPage: 200, + totalPages: 1, + }); + renderStockPurchaseListPdf.mockResolvedValueOnce(Buffer.from("%PDF-1.4\nbinary stuff", "binary")); + + const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list-pdf")); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/pdf"); + const body = Buffer.from(await res.arrayBuffer()); + expect(body.slice(0, 4).toString("binary")).toBe("%PDF"); + }); + + it("returns 401 when auth is missing", async () => { + process.env.AGENT_API_KEY = "test-token"; + const req = new NextRequest("http://localhost/api/v1/stock-purchases/list-pdf"); + const res = await GET(req); + expect(res.status).toBe(401); + expect(listStockPurchases).not.toHaveBeenCalled(); + expect(renderStockPurchaseListPdf).not.toHaveBeenCalled(); + }); + + it("returns 500 when the service throws", async () => { + listStockPurchases.mockRejectedValueOnce(new Error("PB is down")); + const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list-pdf")); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("PB is down"); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `pnpm vitest run src/app/api/v1/stock-purchases/list-pdf/route.test.ts` +Expected: FAIL — `./route` does not exist. + +- [ ] **Step 3: Create the Orders PDF route** + +Create `src/app/api/v1/stock-purchases/list-pdf/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 { renderStockPurchaseListPdf } from "@/lib/pdf/render"; + +/** + * GET /api/v1/stock-purchases/list-pdf + * + * Streams the stock-orders list as a PDF. Sibling of the single-PO + * PDF route at `/api/v1/stock-purchases/[id]/pdf` — that one is the + * supplier-facing stock PO; this one is the internal report (every + * PO in a table, no letterhead, no signing block). + * + * Auth: same `assertAgentAuth` as the rest of `/api/v1`. + * No query params — always all rows, sorted by created desc (the + * default service sort). + * + * perPage: 200 — the service's max. If a real org exceeds 200 POs + * the PDF will silently truncate. Acceptable for a reporting + * artefact; revisit if needed. + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + assertAgentAuth(req); + const pb = await getAdminPocketBase(); + const result = await listStockPurchases(pb, { status: "", page: 1, perPage: 200 }); + const buffer = await renderStockPurchaseListPdf({ + rows: result.items, + generatedAtIso: new Date().toISOString(), + }); + const filename = `stock-orders-${new Date().toISOString().slice(0, 10)}.pdf`; + return new NextResponse(buffer, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "private, no-store", + }, + }); + } catch (error) { + const status = (error as Error & { status?: number }).status || 500; + return NextResponse.json( + { error: error instanceof Error ? error.message : "PDF render failed" }, + { status }, + ); + } +} +``` + +- [ ] **Step 4: Run the Orders PDF route test** + +Run: `pnpm vitest run src/app/api/v1/stock-purchases/list-pdf/route.test.ts` +Expected: PASS — 3 cases. + +- [ ] **Step 5: Write the failing test for the Stocktake PDF route** + +Create `src/app/api/v1/stock-items/stocktake-pdf/route.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const renderStockItemStocktakePdf = vi.fn(); +vi.mock("@/lib/pdf/render", () => ({ + renderStockItemStocktakePdf: (...args: unknown[]) => renderStockItemStocktakePdf(...args), +})); + +const listStockItemsForStocktake = vi.fn(); +vi.mock("@/lib/server/stock-items-service", () => ({ + listStockItemsForStocktake: (...args: unknown[]) => listStockItemsForStocktake(...args), +})); + +const ORIGINAL_KEY = process.env.AGENT_API_KEY; +function authedRequest(url: string) { + return new NextRequest(url, { + headers: { authorization: "Bearer test-token" }, + }); +} + +import { GET } from "./route"; + +describe("/api/v1/stock-items/stocktake-pdf", () => { + beforeEach(() => { + process.env.AGENT_API_KEY = "test-token"; + renderStockItemStocktakePdf.mockReset(); + listStockItemsForStocktake.mockReset(); + }); + + afterEach(() => { + if (ORIGINAL_KEY === undefined) delete process.env.AGENT_API_KEY; + else process.env.AGENT_API_KEY = ORIGINAL_KEY; + }); + + it("returns 200 with an application/pdf body", async () => { + listStockItemsForStocktake.mockResolvedValueOnce([ + { id: "si-1", name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }, + ]); + renderStockItemStocktakePdf.mockResolvedValueOnce(Buffer.from("%PDF-1.4\nbinary stuff", "binary")); + + const res = await GET(authedRequest("http://localhost/api/v1/stock-items/stocktake-pdf")); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/pdf"); + const body = Buffer.from(await res.arrayBuffer()); + expect(body.slice(0, 4).toString("binary")).toBe("%PDF"); + }); + + it("returns 401 when auth is missing", async () => { + process.env.AGENT_API_KEY = "test-token"; + const req = new NextRequest("http://localhost/api/v1/stock-items/stocktake-pdf"); + const res = await GET(req); + expect(res.status).toBe(401); + expect(listStockItemsForStocktake).not.toHaveBeenCalled(); + expect(renderStockItemStocktakePdf).not.toHaveBeenCalled(); + }); + + it("returns 500 when the service throws", async () => { + listStockItemsForStocktake.mockRejectedValueOnce(new Error("PB is down")); + const res = await GET(authedRequest("http://localhost/api/v1/stock-items/stocktake-pdf")); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("PB is down"); + }); +}); +``` + +- [ ] **Step 6: Run the test to confirm it fails** + +Run: `pnpm vitest run src/app/api/v1/stock-items/stocktake-pdf/route.test.ts` +Expected: FAIL — `./route` does not exist. + +- [ ] **Step 7: Create the Stocktake PDF route** + +Create `src/app/api/v1/stock-items/stocktake-pdf/route.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { listStockItemsForStocktake } from "@/lib/server/stock-items-service"; +import { renderStockItemStocktakePdf } from "@/lib/pdf/render"; + +/** + * GET /api/v1/stock-items/stocktake-pdf + * + * Streams the printable stocktake PDF — every active stock item + * sorted by name, with a blank "Actual" column for the operator to + * fill in on the printed page during the walk-through. + * + * Auth: same `assertAgentAuth` as the rest of `/api/v1`. + * No query params — always all rows. + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + assertAgentAuth(req); + const pb = await getAdminPocketBase(); + const items = await listStockItemsForStocktake(pb); + const buffer = await renderStockItemStocktakePdf({ + items, + generatedAtIso: new Date().toISOString(), + }); + const filename = `stocktake-${new Date().toISOString().slice(0, 10)}.pdf`; + return new NextResponse(buffer, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "private, no-store", + }, + }); + } catch (error) { + const status = (error as Error & { status?: number }).status || 500; + return NextResponse.json( + { error: error instanceof Error ? error.message : "PDF render failed" }, + { status }, + ); + } +} +``` + +- [ ] **Step 8: Run the Stocktake PDF route test** + +Run: `pnpm vitest run src/app/api/v1/stock-items/stocktake-pdf/route.test.ts` +Expected: PASS — 3 cases. + +- [ ] **Step 9: Type-check** + +Run: `pnpm tsc --noEmit -p tsconfig.json 2>&1 | head -40` +Expected: no new errors. + +- [ ] **Step 10: Commit** + +```bash +git add src/app/api/v1/stock-purchases/list-pdf/route.ts src/app/api/v1/stock-purchases/list-pdf/route.test.ts src/app/api/v1/stock-items/stocktake-pdf/route.ts src/app/api/v1/stock-items/stocktake-pdf/route.test.ts +git commit -m "feat(api): GET /api/v1/stock-purchases/list-pdf + stocktake-pdf + +Two list-report endpoints wired to the new PDF renderers. Both +auth via assertAgentAuth, ignore query params (always all rows), +and return application/pdf with attachment Content-Disposition. +The Stocktake PDF is sourced from the new listStockItemsForStocktake +service helper; the Orders PDF reuses the existing listStockPurchases +with perPage=200." +``` + +--- + +## Task 4: UI — wrap Number + Supplier cells, add two download buttons + +**Files:** +- Modify: `src/app/(app)/stock-orders/StockOrdersTable.tsx` +- Modify: `src/app/(app)/stock-orders/StockOrdersTable.test.tsx` + +- [ ] **Step 1: Update the test file first (TDD)** + +Open `src/app/(app)/stock-orders/StockOrdersTable.test.tsx`. Add a new `describe` block (use the same patterns as the existing test file — the file already mocks `next/link`, `next/navigation`, and the modal): + +```tsx +describe("StockOrdersTable — download PDFs", () => { + afterEach(() => { + cleanup(); + }); + + it("renders a 'Download Stocktake PDF' link to the Stocktake PDF endpoint", () => { + render(); + const link = screen.getByTestId("download-stocktake-pdf"); + expect(link).toBeInTheDocument(); + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", "/api/v1/stock-items/stocktake-pdf"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("renders a 'Download Orders PDF' link to the Orders PDF endpoint", () => { + render(); + const link = screen.getByTestId("download-orders-pdf"); + expect(link).toBeInTheDocument(); + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", "/api/v1/stock-purchases/list-pdf"); + expect(link).toHaveAttribute("target", "_blank"); + }); +}); +``` + +Note: the existing `next/link` mock in the test file is ``. Since the new download buttons are plain `` (not ``), they won't even hit the mock — they'll render as real `` tags. The `tagName === "A"` assertion is defensive. + +- [ ] **Step 2: Run the new tests to confirm they fail** + +Run: `pnpm vitest run src/app/(app)/stock-orders/StockOrdersTable.test.tsx -t "download"` +Expected: FAIL — no `data-testid="download-stocktake-pdf"` / `data-testid="download-orders-pdf"` yet. + +- [ ] **Step 3: Add the `Download` icon import** + +In `src/app/(app)/stock-orders/StockOrdersTable.tsx`, extend the existing `lucide-react` import: + +```tsx +import { ChevronRight, Download, Package, Pencil } from "lucide-react"; +``` + +- [ ] **Step 4: Add the two download buttons before "New Stock Item"** + +In the same file, replace the existing flex row (the "
" block containing only the "New Stock Item" button, around line 154) with: + +```tsx +
+ + + Download Stocktake PDF + + + + Download Orders PDF + + +
+``` + +- [ ] **Step 5: Add wrapping to the Number + Supplier cells** + +In the same file, update the two ``s in the body row: + +```tsx + + {row.purchase_number} + + + {supplier?.suppliername ?? ( + + Unknown supplier + + )} + +``` + +- [ ] **Step 6: Add row padding so wrapped cells look balanced** + +Update the body row to add tailwind `&>td]:py-3` (or whatever existing pattern this file uses for row padding — check the existing ``s and match the convention): + +```tsx + { + router.push(`/stock-orders/${row.id}`); + }} + > +``` + +If the existing file already uses a different vertical-padding pattern, match it instead. + +- [ ] **Step 7: Run the StockOrdersTable tests** + +Run: `pnpm vitest run src/app/(app)/stock-orders/StockOrdersTable.test.tsx` +Expected: PASS — all 10 tests (8 existing + 2 new). + +- [ ] **Step 8: Type-check** + +Run: `pnpm tsc --noEmit -p tsconfig.json 2>&1 | head -40` +Expected: no new errors. + +- [ ] **Step 9: Visual smoke (wrapped cells)** + +Boot `pnpm dev` and open `/stock-orders`. Visually confirm: +- The two new download buttons appear in the same row as "New Stock Item". +- A long PO number that previously truncated now wraps to multiple lines. +- A long supplier name that previously truncated now wraps to multiple lines. +- Single-line rows look balanced (slightly more vertical padding but not gappy). + +- [ ] **Step 10: Commit** + +```bash +git add src/app/(app)/stock-orders/StockOrdersTable.tsx src/app/(app)/stock-orders/StockOrdersTable.test.tsx +git commit -m "feat(stock-orders): wrap cells + add download PDF buttons + +Two UX improvements on the /stock-orders list: +- Number + Supplier cells now wrap long text instead of + clipping. Uses whitespace-normal + break-words + max-w-[200px] + on the cell, plus a row-level py-3 bump so wrapped cells look + balanced with single-line rows. +- Two new outline buttons next to 'New Stock Item': + 'Download Stocktake PDF' → /api/v1/stock-items/stocktake-pdf + 'Download Orders PDF' → /api/v1/stock-purchases/list-pdf + Both open the PDF in a new tab via target=_blank; the browser + handles the download." +``` + +--- + +## Task 5: 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 new errors. + +- [ ] **Step 2: Run all unit tests** + +Run: `pnpm vitest run 2>&1 | tail -30` +Expected: PASS — full suite green. Look for: +- `stock-items-service` (3 new) +- `render.test.ts` (2 new — `%PDF` prefix) +- `stock-purchases/list-pdf/route.test.ts` (3 new) +- `stock-items/stocktake-pdf/route.test.ts` (3 new) +- `StockOrdersTable.test.tsx` (2 new — 10 total) + +- [ ] **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. **Stocktake PDF** — From `/stock-orders`, click "Download Stocktake PDF". A new tab opens with the PDF. + - Verify the PDF lists every active stock item sorted by name, with columns: Name, SKU, Location, Unit, Current qty, Actual (blank), Supplier. + - In the PB admin, mark one stock item as `is_active = false`. Refresh `/stock-orders`, click the button again. That item is gone. +2. **Orders PDF** — From `/stock-orders`, click "Download Orders PDF". A new tab opens. + - Verify every stock purchase is listed, sorted by created desc, with columns: Number, Supplier, Status, Ordered (R), Received (R), Items, Created. + - Totals match the live table values. +3. **Cell wrapping** — In the PB admin, set one supplier's name to a long string (e.g. "Steel & Pipe Co (Pty) Ltd — Durban Branch"). Reload `/stock-orders`. The Supplier cell now wraps to multiple lines instead of truncating. + - Also test a long PO number (e.g. `PO26000001STK-EXTRA-LONG-SUFFIX`). It wraps too. + +- [ ] **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.