From 286043cace131dad3115d45b6336b7f20873eac0 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 15:54:24 +0200 Subject: [PATCH] docs(stock-orders): design spec for cell wrap + PDF downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small additions to /stock-orders: - Wrap long text in Number + Supplier columns - Two PDF download buttons (Stocktake PDF, Orders PDF) Both PDFs always include all rows, ignore the active status filter and pagination. No persistence — throwaway reports. Design reviewed: covers architecture, data model, API contract, error handling, testing, and manual smoke. No new PB schema, no new types. Co-authored-by: Orca --- ...2026-08-03-stock-orders-wrap-pdf-design.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-stock-orders-wrap-pdf-design.md diff --git a/docs/superpowers/specs/2026-08-03-stock-orders-wrap-pdf-design.md b/docs/superpowers/specs/2026-08-03-stock-orders-wrap-pdf-design.md new file mode 100644 index 0000000..e6364be --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-stock-orders-wrap-pdf-design.md @@ -0,0 +1,269 @@ +# Stock Orders — Cell Wrap + PDF Downloads — Design + +Date: 2026-08-03 +Status: Approved (pending written-spec user review) +Scope: `/stock-orders` list page + +## Problem + +Two friction points on `/stock-orders`: + +1. **Long text gets clipped.** The PO Number and Supplier columns can occasionally be long (e.g. a supplier named "Steel & Pipe Co (Pty) Ltd — Durban Branch"). The current `` truncates overflow — the operator can't see the full value without opening the detail page. +2. **No reporting export.** Operators do periodic stocktakes and reporting. They want a one-click download of the stock list (stock items on hand, for stocktake) and the orders list (POs, for reporting / audit). Today they screenshot the table or copy to a spreadsheet manually. + +## Goal + +Two small additions to the `/stock-orders` page: + +- **Wrap long text in the Number and Supplier columns.** Allow the cell to break across multiple lines so the full value is visible without horizontal scrolling. +- **Two PDF download buttons** side-by-side with the existing "New Stock Item" button: + - **Download Stocktake PDF** — list of all active stock items with current quantity on hand, location, supplier. The thing to print for a stocktake walk-through. + - **Download Orders PDF** — list of all stock purchases, same columns as the table. The audit/reporting artefact. + +Both PDFs always include **all rows** — they ignore the active status filter and pagination. This is intentional: the report is a snapshot, not a "what I can see right now" view. + +## Non-goals + +- **No PDF persistence.** Unlike the single-PO PDF route (which writes to `stock_purchases.documents` + `documents_history`), neither list PDF is stored. They are throwaway reports. +- **No filtering / pagination on the PDF endpoints.** Per user direction — always all rows. The button says "Download" with no prompt for a status filter. +- **No per-line drill-down in the Orders PDF.** Same columns as the table. Detail-page style with line items is the existing single-PO PDF. +- **No new PB schema.** Both reports read existing collections (`stock_purchases`, `stock_purchase_items`, `stock_items`). +- **No footer totals row** (sum of visible rows). Deferred — same non-goal as the prior task. + +## Architecture + +Three layers, per `AGENTS.md` data-access discipline: + +1. **Service** + - **Orders PDF**: reuse `listStockPurchases(pb, { status: "", page: 1, perPage: 200 })` — already returns the right shape (rows with `expand.supplier`, `ordered_value`, `received_value`, `item_count`). The `perPage: 200` upper bound means a single fetch for realistic stock-order counts. If the org ever exceeds 200 POs, the PDF will silently truncate — acceptable for a reporting artefact; revisit if needed. + - **Stocktake PDF**: new `listStockItemsForStocktake(pb)` in a new `src/lib/server/stock-items-service.ts` — returns all `stock_items` where `is_active = true`, sorted by `name`, with `expand: "supplier"`. Single fetch (no pagination — stocktake is always the whole list). + +2. **PDF render layer** (`src/lib/pdf/`) + - New `StockPurchaseListDocument.tsx` — `@react-pdf/renderer` component reading the same columns as the table. + - New `StockItemStocktakeDocument.tsx` — `@react-pdf/renderer` component for stock items. + - New helpers in `render.tsx`: + - `renderStockPurchaseListPdf(props): Promise` + - `renderStockItemStocktakePdf(props): Promise` + - Both follow the existing `renderStockPurchasePdf` pattern (`toString()` → `Buffer.from(..., "binary")` for jsdom test compat). + +3. **API routes** (new, both GET-only) + - `src/app/api/v1/stock-purchases/list-pdf/route.ts` + - `src/app/api/v1/stock-items/stocktake-pdf/route.ts` + - Both validate auth via `assertAgentAuth`, fetch rows via the service, render PDF, return `application/pdf` binary with `Content-Disposition: attachment; filename="..."`. + +## Cell wrapping + +Two ``s in `StockOrdersTable.tsx`: + +| Line | Current | New | +|---|---|---| +| Number (around L209) | `className="font-medium"` | `className="font-medium whitespace-normal break-words max-w-[200px]"` | +| Supplier (around L212) | `className=` | `className="whitespace-normal break-words max-w-[200px]"` | + +Plus a row-level padding bump on the body ``s so wrapped cells look balanced: + +```tsx + +``` +becomes: +```tsx + +``` + +The `&>td]:py-3` targets only the row's data cells (not the header row), keeping the header unaffected. Single-line rows gain a few pixels of vertical space; wrapped rows look balanced. + +No other columns change. Status is a badge (won't wrap), value columns are short right-aligned numbers, dates are short. + +## UI changes — download buttons + +Two new buttons in the existing flex row, to the left of "New Stock Item": + +```tsx +
+ + + Download Stocktake PDF + + + + Download Orders PDF + + +
+``` + +Why `` and not a programmatic `window.open()`: +- The browser handles the download natively (no JS state needed). +- The new tab approach gives the operator a visible "the PDF is ready" signal without a loading spinner or toast. +- Auth is delegated to the existing v1 auth gate — the browser sends the session cookie with the GET. + +Why `rel="noopener"`: +- Standard hardening for `target="_blank"` — prevents the new tab from accessing `window.opener`. + +## API routes + +### `GET /api/v1/stock-purchases/list-pdf` + +```ts +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + assertAgentAuth(req); + const pb = await getAdminPocketBase(); + // perPage: 200 — the service's max. For a list PDF, 200+ truncates + // silently; revisit if a real org exceeds this. + const result = await listStockPurchases(pb, { status: "", page: 1, perPage: 200 }); + const buffer = await renderStockPurchaseListPdf({ rows: result.items }); + 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 }); + } +} +``` + +### `GET /api/v1/stock-items/stocktake-pdf` + +Same shape: + +```ts +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 }); + 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 }); + } +} +``` + +Both routes use the existing `assertAgentAuth` pattern (same as `GET /api/v1/stock-purchases/list`). The service-layer `getAdminPocketBase` is reused. + +## Data model + +No new types. The Orders PDF renderer reads `StockPurchaseListRow` (already exported from the service). The Stocktake PDF renderer reads `StockItem[]` (already in `@/lib/types`). + +`listStockItemsForStocktake` returns rows of type `StockItem & { expand?: { supplier?: Supplier } }` — equivalent to the existing `StockItem` after `expand: "supplier"`. + +## Error handling + +- **Auth failure** (`assertAgentAuth` throws `status: 401`) → `401 { error }`. +- **PB error** (`getAdminPocketBase()` or `listStockPurchases` / `listStockItemsForStocktake` throws) → `500 { error }`. Full error logged server-side via `console.error`. The user sees a generic message in the browser's PDF-error UI (Chrome's "Failed to load PDF" / Safari's "Can't open document"). No page-level error UI — the download is a separate browser action. +- **PDF render failure** → `500 { error }`. Same as above. +- **Empty result** (no rows) → still returns a valid PDF with header + "No rows" placeholder. The operator can decide whether to print or skip. + +## Testing + +### `src/lib/server/stock-items-service.test.ts` (new) + +- `listStockItemsForStocktake` returns active items sorted by name with supplier expanded. +- `listStockItemsForStocktake` returns an empty array when there are no active items. +- `listStockItemsForStocktake` excludes `is_active = false` rows. + +### `src/app/api/v1/stock-purchases/list-pdf/route.test.ts` (new) + +- Returns 200 with `Content-Type: application/pdf` and a non-empty PDF body. +- Returns 401 when auth is missing. +- Returns 500 with `{ error }` when the service throws. + +### `src/app/api/v1/stock-items/stocktake-pdf/route.test.ts` (new) + +- Same three cases. + +### `src/lib/pdf/render.test.ts` (extend) + +- `renderStockPurchaseListPdf` returns a `Buffer` whose first bytes are `%PDF`. +- `renderStockItemStocktakePdf` returns a `Buffer` whose first bytes are `%PDF`. + +### `src/app/(app)/stock-orders/StockOrdersTable.test.tsx` (extend) + +- Renders the "Download Stocktake PDF" link with the correct `href` and `target="_blank"`. +- Renders the "Download Orders PDF" link with the correct `href` and `target="_blank"`. +- (Optional) Renders all three buttons in the same row. + +The wrapping change is verified by manual smoke (described below) — no component test for `whitespace-normal`/`max-w`. Wrapping is a CSS concern; the JSX-side change is verified by `tsc` and the visual check. + +## Manual smoke + +1. **Stocktake PDF** + - Boot `pnpm dev`. Open `/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, Supplier. + - Verify an item you know is `is_active = false` is missing from the PDF. + +2. **Orders PDF** + - From `/stock-orders`, click "Download Orders PDF". + - Verify the PDF lists every stock purchase, sorted by created desc, with columns: Number, Supplier, Status, Ordered (R), Received (R), Items, Created. + - Verify the totals match the live table. + +3. **Cell wrapping** + - Edit a supplier's name to a long string (or seed a test row with `Steel & Pipe Co (Pty) Ltd — Durban Branch`). + - Reload `/stock-orders`. The Supplier cell should wrap to multiple lines, not truncate. + - Edit a PO number to be long (e.g. `PO26000001STK-EXTRA-LONG-SUFFIX`). Verify it wraps too. + +## Out of scope / follow-ups + +- **Footer totals row** (sum of visible rows on the Orders PDF). Defer until a user requests it. +- **PDF persistence** (no `documents` / `documents_history`). The list PDFs are throwaway reports. If a user wants audit trail, the single-PO PDF route already provides it. +- **Filtering** (status, date range). Both reports are always-all. If a user wants filtered PDFs, add `?status=` / `?from=` / `?to=` query params later. +- **Pagination above 200 POs.** The Orders PDF will silently truncate at 200 rows. Revisit if a real org exceeds this. +- **Manual smoke is operator action**, not a CI/automated test. + +## 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. |