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>
13 KiB
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:
- 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. - 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:
-
Service
- Orders PDF: reuse
listStockPurchases(pb, { status: "", page: 1, perPage: 200 })— already returns the right shape (rows withexpand.supplier,ordered_value,received_value,item_count). TheperPage: 200upper 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 newsrc/lib/server/stock-items-service.ts— returns allstock_itemswhereis_active = true, sorted byname, withexpand: "supplier". Single fetch (no pagination — stocktake is always the whole list).
- Orders PDF: reuse
-
PDF render layer (
src/lib/pdf/)- New
StockPurchaseListDocument.tsx—@react-pdf/renderercomponent reading the same columns as the table. - New
StockItemStocktakeDocument.tsx—@react-pdf/renderercomponent for stock items. - New helpers in
render.tsx:renderStockPurchaseListPdf(props): Promise<Buffer>renderStockItemStocktakePdf(props): Promise<Buffer>
- Both follow the existing
renderStockPurchasePdfpattern (toString()→Buffer.from(..., "binary")for jsdom test compat).
- New
-
API routes (new, both GET-only)
src/app/api/v1/stock-purchases/list-pdf/route.tssrc/app/api/v1/stock-items/stocktake-pdf/route.ts- Both validate auth via
assertAgentAuth, fetch rows via the service, render PDF, returnapplication/pdfbinary withContent-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:
<TableRow key={row.id} className="cursor-pointer">
becomes:
<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":
<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 accessingwindow.opener.
API routes
GET /api/v1/stock-purchases/list-pdf
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:
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 (
assertAgentAuththrowsstatus: 401) →401 { error }. - PB error (
getAdminPocketBase()orlistStockPurchases/listStockItemsForStocktakethrows) →500 { error }. Full error logged server-side viaconsole.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)
listStockItemsForStocktakereturns active items sorted by name with supplier expanded.listStockItemsForStocktakereturns an empty array when there are no active items.listStockItemsForStocktakeexcludesis_active = falserows.
src/app/api/v1/stock-purchases/list-pdf/route.test.ts (new)
- Returns 200 with
Content-Type: application/pdfand 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)
renderStockPurchaseListPdfreturns aBufferwhose first bytes are%PDF.renderStockItemStocktakePdfreturns aBufferwhose first bytes are%PDF.
src/app/(app)/stock-orders/StockOrdersTable.test.tsx (extend)
- Renders the "Download Stocktake PDF" link with the correct
hrefandtarget="_blank". - Renders the "Download Orders PDF" link with the correct
hrefandtarget="_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
-
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 = falseis missing from the PDF.
- Boot
-
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.
- From
-
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.
- Edit a supplier's name to a long string (or seed a test row with
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. |