WFDASH-WEBAPP/docs/superpowers/specs/2026-08-03-stock-orders-wrap-pdf-design.md
SJCoetz 286043cace 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>
2026-08-03 15:54:24 +02:00

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:

  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:

<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 accessing window.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 (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.