From 6033aba18dd9876ff95f536831d7defb73bdc210 Mon Sep 17 00:00:00 2001
From: SJCoetz
Date: Mon, 3 Aug 2026 18:18:58 +0200
Subject: [PATCH] fix(stocktake-pdf): match /stock columns + add button on
/stock page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 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
---
src/app/(app)/stock/page.test.tsx | 9 ++
src/app/(app)/stock/page.tsx | 22 +++-
src/lib/pdf/stock-item-stocktake-document.tsx | 112 ++++++++++++------
3 files changed, 99 insertions(+), 44 deletions(-)
diff --git a/src/app/(app)/stock/page.test.tsx b/src/app/(app)/stock/page.test.tsx
index 654b81a..353baba 100644
--- a/src/app/(app)/stock/page.test.tsx
+++ b/src/app/(app)/stock/page.test.tsx
@@ -146,6 +146,15 @@ describe("StockListPage", () => {
).toBeInTheDocument();
});
+ it("renders a 'Download Stocktake PDF' link to the stocktake PDF endpoint", async () => {
+ mockListResponse([]);
+ render();
+ 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 }),
diff --git a/src/app/(app)/stock/page.tsx b/src/app/(app)/stock/page.tsx
index 101d4be..11a59e5 100644
--- a/src/app/(app)/stock/page.tsx
+++ b/src/app/(app)/stock/page.tsx
@@ -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() {
)}
-
+
{/* Search + low-stock toggle row. */}
diff --git a/src/lib/pdf/stock-item-stocktake-document.tsx b/src/lib/pdf/stock-item-stocktake-document.tsx
index 2fb67ac..c93d53b 100644
--- a/src/lib/pdf/stock-item-stocktake-document.tsx
+++ b/src/lib/pdf/stock-item-stocktake-document.tsx
@@ -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 (
-
+
- Stocktake — All Active Items
+ Stock — Full List
- Generated {generatedAt} · {items.length} {items.length === 1 ? "item" : "items"}
+ Generated {generatedAt} · {items.length}{" "}
+ {items.length === 1 ? "item" : "items"}
- Name
SKU
- Location
- Unit
- Current qty
- Actual
+ Name
Supplier
+
+ Current
+
+
+ Min
+
+
+ Target
+
+ Location
+ Status
+
{items.length === 0 ? (
-
- No active stock items.
-
+ No active stock items.
) : (
- {items.map((item) => (
-
- {item.name}
- {item.sku}
- {item.location}
- {item.unit}
- {item.current_qty}
- {""}
-
- {item.expand?.supplier?.suppliername ?? ""}
-
-
- ))}
+ {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 (
+
+ {item.sku || "—"}
+ {item.name}
+
+ {item.expand?.supplier?.suppliername || "—"}
+
+
+ {currentQty}
+ {item.unit ? ` ${item.unit}` : ""}
+
+ {minQty}
+
+ {targetQty !== null && targetQty > 0 ? targetQty : "—"}
+
+
+ {item.location || "—"}
+
+ {statusLabel}
+
+
+ );
+ })}
)}