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} + + + ); + })} )}