docs(stock-orders): design spec for cell wrap + PDF downloads

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 <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 15:54:24 +02:00
parent 690b8a2c26
commit 286043cace

View file

@ -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 `<TableCell>` 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<Buffer>`
- `renderStockItemStocktakePdf(props): Promise<Buffer>`
- 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 `<TableCell>`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 `<TableRow>`s so wrapped cells look balanced:
```tsx
<TableRow key={row.id} className="cursor-pointer">
```
becomes:
```tsx
<TableRow key={row.id} className="cursor-pointer [&>td]:py-3">
```
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
<div className="flex items-center gap-2 flex-wrap">
<a
href="/api/v1/stock-items/stocktake-pdf"
target="_blank"
rel="noopener"
className={cn(buttonVariants({ variant: "outline" }), "gap-1.5")}
data-testid="download-stocktake-pdf"
>
<Download className="h-4 w-4" />
Download Stocktake PDF
</a>
<a
href="/api/v1/stock-purchases/list-pdf"
target="_blank"
rel="noopener"
className={cn(buttonVariants({ variant: "outline" }), "gap-1.5")}
data-testid="download-orders-pdf"
>
<Download className="h-4 w-4" />
Download Orders PDF
</a>
<Button
type="button"
variant="outline"
data-testid="new-stock-item-button"
onClick={() => setQuickDraftOpen(true)}
>
New Stock Item
</Button>
</div>
```
Why `<a target="_blank">` 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. |