fix(stocktake-pdf): match /stock columns + add button on /stock page

The original Stocktake PDF columns (Name, SKU, Location, Unit,
Current qty, blank Actual, Supplier) didn't match what the
operator sees on /stock. They pointed at the wrong list — they
worked for a blank-column walk-through stocktake form, but the
user wants to print the /stock list as it appears on screen.

This rewrite makes the Stocktake PDF mirror the /stock table
column-for-column:
- SKU, Name, Supplier, Current, Min, Target, Location, Status
- Status derived via getStockStatus + STOCK_STATUS_STYLES so
  it matches the on-screen badge label
- Current qty now renders with the unit suffix (matching the
  on-screen cell)
- Target renders '—' when null/undefined/0, matching /stock

Also:
- Switched to landscape A4 to give 8 columns room (was
  portrait).
- Added a copy of the 'Download Stocktake PDF' button on the
  /stock page next to 'New stock item' — both /stock and
  /stock-orders now expose the same endpoint. Button uses the
  same plain <a target='_blank' rel='noopener'> pattern as
  the existing stock-orders buttons, with aria-hidden on the
  Download icon.
- Added a test on /stock for the new link (href, target,
  rel). 33/33 tests pass.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 18:18:58 +02:00
parent b1c0c6030f
commit 6033aba18d
3 changed files with 99 additions and 44 deletions

View file

@ -146,6 +146,15 @@ describe("StockListPage", () => {
).toBeInTheDocument();
});
it("renders a 'Download Stocktake PDF' link to the stocktake PDF endpoint", async () => {
mockListResponse([]);
render(<Page />);
const link = await screen.findByRole("link", { name: "Download Stocktake PDF" });
expect(link).toHaveAttribute("href", "/api/v1/stock-items/stocktake-pdf");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener");
});
it("renders one table row per stock item with badge colour matching qty", async () => {
mockListResponse([
makeItem({ id: "ok", name: "Green item", current_qty: 100, min_qty: 20 }),

View file

@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@ -43,6 +43,7 @@ import {
ExternalLink,
ChevronsUpDown,
Check,
Download,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@ -199,10 +200,21 @@ export default function StockListPage() {
)}
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New stock item
</Button>
<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")}
>
<Download aria-hidden="true" className="h-4 w-4" />
Download Stocktake PDF
</a>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New stock item
</Button>
</div>
</div>
{/* Search + low-stock toggle row. */}

View file

@ -1,5 +1,6 @@
import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer";
import type { StockItem } from "@/lib/server/stock-items-service";
import { getStockStatus, STOCK_STATUS_STYLES } from "@/lib/stock-status";
export interface StockItemStocktakeDocumentProps {
items: StockItem[];
@ -31,24 +32,27 @@ const styles = StyleSheet.create({
},
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%" },
// Columns mirror the /stock page table so a printed copy feels
// familiar to the operator. Landscape A4 gives 8 columns room to
// breathe — same allocation as the stock-orders list PDF.
cSku: { width: "9%" },
cName: { width: "18%" },
cSupplier: { width: "16%" },
cCurrent: { width: "8%" },
cMin: { width: "7%" },
cTarget: { width: "8%" },
cLocation: { width: "14%" },
cStatus: { width: "12%" },
spacer: { width: "8%" },
empty: { marginTop: 24, color: "#666", fontSize: 10, textAlign: "center" },
});
/**
* 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).
* Stock list PDF — landscape A4, every active stock item in a
* table sorted by name. Columns mirror the on-screen /stock table
* (SKU, Name, Supplier, Current, Min, Target, Location, Status) so
* the printed copy is recognisable. The Status column uses the same
* `getStockStatus` derivation as the on-screen badge.
*/
export function StockItemStocktakeDocument({
items,
@ -57,43 +61,73 @@ export function StockItemStocktakeDocument({
const generatedAt = new Date(generatedAtIso).toLocaleString();
return (
<Document>
<Page size="A4" style={styles.page}>
<Page size="A4" orientation="landscape" style={styles.page}>
<View style={styles.header}>
<Text style={styles.title}>Stocktake — All Active Items</Text>
<Text style={styles.title}>Stock — Full List</Text>
<Text style={styles.subtitle}>
Generated {generatedAt} · {items.length} {items.length === 1 ? "item" : "items"}
Generated {generatedAt} · {items.length}{" "}
{items.length === 1 ? "item" : "items"}
</Text>
</View>
<View style={styles.thead} fixed>
<Text style={[styles.th, styles.cName]}>Name</Text>
<Text style={[styles.th, styles.cSku]}>SKU</Text>
<Text style={[styles.th, styles.cLocation]}>Location</Text>
<Text style={[styles.th, styles.cUnit]}>Unit</Text>
<Text style={[styles.th, styles.cQty, { textAlign: "right" }]}>Current qty</Text>
<Text style={[styles.th, styles.cActual, { textAlign: "right" }]}>Actual</Text>
<Text style={[styles.th, styles.cName]}>Name</Text>
<Text style={[styles.th, styles.cSupplier]}>Supplier</Text>
<Text style={[styles.th, styles.cCurrent, { textAlign: "right" }]}>
Current
</Text>
<Text style={[styles.th, styles.cMin, { textAlign: "right" }]}>
Min
</Text>
<Text style={[styles.th, styles.cTarget, { textAlign: "right" }]}>
Target
</Text>
<Text style={[styles.th, styles.cLocation]}>Location</Text>
<Text style={[styles.th, styles.cStatus]}>Status</Text>
<Text style={[styles.th, styles.spacer]} />
</View>
{items.length === 0 ? (
<Text style={{ marginTop: 24, color: "#666", fontSize: 10, textAlign: "center" }}>
No active stock items.
</Text>
<Text style={styles.empty}>No active stock items.</Text>
) : (
<View style={styles.table}>
{items.map((item) => (
<View key={item.id} style={styles.tr} wrap={false}>
<Text style={[styles.td, styles.cName]}>{item.name}</Text>
<Text style={[styles.td, styles.cSku]}>{item.sku}</Text>
<Text style={[styles.td, styles.cLocation]}>{item.location}</Text>
<Text style={[styles.td, styles.cUnit]}>{item.unit}</Text>
<Text style={[styles.tdRight, styles.cQty]}>{item.current_qty}</Text>
<Text style={[styles.tdRight, styles.cActual]}>{""}</Text>
<Text style={[styles.td, styles.cSupplier]}>
{item.expand?.supplier?.suppliername ?? ""}
</Text>
</View>
))}
{items.map((item) => {
const status = getStockStatus(
item.current_qty,
item.min_qty,
item.is_active,
);
const statusLabel = STOCK_STATUS_STYLES[status].label;
const currentQty = Number(item.current_qty ?? 0);
const minQty = Number(item.min_qty ?? 0);
const targetQty =
item.target_qty !== undefined && item.target_qty !== null
? Number(item.target_qty)
: null;
return (
<View key={item.id} style={styles.tr} wrap={false}>
<Text style={[styles.td, styles.cSku]}>{item.sku || "—"}</Text>
<Text style={[styles.td, styles.cName]}>{item.name}</Text>
<Text style={[styles.td, styles.cSupplier]}>
{item.expand?.supplier?.suppliername || "—"}
</Text>
<Text style={[styles.tdRight, styles.cCurrent]}>
{currentQty}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text style={[styles.tdRight, styles.cMin]}>{minQty}</Text>
<Text style={[styles.tdRight, styles.cTarget]}>
{targetQty !== null && targetQty > 0 ? targetQty : "—"}
</Text>
<Text style={[styles.td, styles.cLocation]}>
{item.location || "—"}
</Text>
<Text style={[styles.td, styles.cStatus]}>{statusLabel}</Text>
<Text style={[styles.td, styles.spacer]} />
</View>
);
})}
</View>
)}
</Page>