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:
parent
b1c0c6030f
commit
6033aba18d
3 changed files with 99 additions and 44 deletions
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue