diff --git a/src/lib/pdf/stock-item-stocktake-document.tsx b/src/lib/pdf/stock-item-stocktake-document.tsx index 325451c..4136d18 100644 --- a/src/lib/pdf/stock-item-stocktake-document.tsx +++ b/src/lib/pdf/stock-item-stocktake-document.tsx @@ -59,13 +59,13 @@ const styles = StyleSheet.create({ // pdf's flexbox doesn't support per-child gaps, so we render the // gaps as zero-content `` elements. The gap between SKU and // Name visually separates the identifier from the label; the equal - // 4% gaps between Name/Supplier/Current/Min/Max push the numeric - // columns further to the right so the eye groups {SKU, Name, - // Supplier} as "what" and {Current, Min, Max, Status} as "how much - // / how urgent". + // 4% gaps between Name/Supplier/Current/Min/Max/Location push the + // numeric + bin columns further to the right so the eye groups + // {SKU, Name, Supplier} as "what" and {Current, Min, Max, Location, + // Status} as "how much / where / how urgent". cSku: { width: "6%" }, gapSkuName: { width: "4%" }, - cName: { width: "28%" }, + cName: { width: "23%" }, gapNameSupplier: { width: "4%" }, cSupplier: { width: "11%" }, gapSupplierCurrent: { width: "4%" }, @@ -74,20 +74,23 @@ const styles = StyleSheet.create({ cMin: { width: "5%" }, gapMinMax: { width: "4%" }, cMax: { width: "6%" }, - cStatus: { width: "18%" }, + gapMaxLocation: { width: "4%" }, + cLocation: { width: "7%" }, + cStatus: { width: "12%" }, empty: { marginTop: 24, color: "#666", fontSize: 10, textAlign: "center" }, }); /** * Stock list PDF — landscape A4, every active stock item in a - * table sorted by name. Columns mirror the on-screen /stock table - * minus Location, with Target renamed to "Max". The Name column is - * the widest (operators scan by name first), the numeric columns - * (Current / Min / Max) are narrow, and the Status column is the - * rightmost column on the page — bold, right-aligned, and colour- - * coded against the on-screen badge palette so "Out of stock" - * reads as the visual anchor the eye lands on at the end of a row. - * Rows alternate subtle zebra tinting so adjacent rows are easy to + * table sorted by supplier name (then by item name within each + * supplier). Columns mirror the on-screen /stock table with Target + * renamed to "Max". The Name column is the widest (operators scan + * by name first), the numeric columns (Current / Min / Max) and + * Location are narrow, and the Status column is the rightmost + * column on the page — bold, right-aligned, and colour-coded + * against the on-screen badge palette so "Out of stock" reads as + * the visual anchor the eye lands on at the end of a row. Rows + * alternate subtle zebra tinting so adjacent rows are easy to * scan line-by-line. */ export function StockItemStocktakeDocument({ @@ -124,6 +127,8 @@ export function StockItemStocktakeDocument({ Max + {""} + Location Status @@ -167,6 +172,10 @@ export function StockItemStocktakeDocument({ {maxQty !== null && maxQty > 0 ? maxQty : "—"} + {""} + + {item.location || "—"} + diff --git a/src/lib/server/stock-items-service.test.ts b/src/lib/server/stock-items-service.test.ts index 7e2c9c5..a938f1c 100644 --- a/src/lib/server/stock-items-service.test.ts +++ b/src/lib/server/stock-items-service.test.ts @@ -3,22 +3,37 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; // --- PocketBase mock --------------------------------------------------------- // // Hoisted mock so the service module can call `pb.collection(...)`. -// We seed an in-memory map of `stock_items` rows. +// We seed two in-memory maps: `stock_items` rows and a small `suppliers` +// lookup so the `expand: "supplier"` call returns a populated +// `expand.supplier.suppliername` for the service's JS-side sort. const items = new Map>(); +const suppliers = new Map>(); const getFullList = vi.fn(async (opts: { filter?: string; sort?: string; expand?: string } = {}) => { let rows = Array.from(items.values()); if (opts.filter?.includes("is_active = true")) { rows = rows.filter((r) => r.is_active === true); } + if (opts.expand?.includes("supplier")) { + rows = rows.map((r) => ({ + ...r, + expand: { supplier: suppliers.get(r.supplier as string) ?? null }, + })); + } if (opts.sort) { + // PB accepts a comma-separated sort list (e.g. "supplier,name") + // and applies them in priority order. The mock does the same. const dir = opts.sort.startsWith("-") ? -1 : 1; - const key = opts.sort.replace(/^-/, ""); + const keys = opts.sort.replace(/^-/, "").split(","); rows = [...rows].sort((a, b) => { - const av = String(a[key] ?? ""); - const bv = String(b[key] ?? ""); - return dir * av.localeCompare(bv); + for (const key of keys) { + const av = String(a[key] ?? ""); + const bv = String(b[key] ?? ""); + const cmp = dir * av.localeCompare(bv); + if (cmp !== 0) return cmp; + } + return 0; }); } return rows; @@ -37,37 +52,56 @@ vi.mock("@/lib/server/pocketbase-admin", () => ({ import { listStockItemsForStocktake } from "./stock-items-service"; -function seed(id: string, fields: Record) { +function seedSupplier(id: string, fields: Record) { + suppliers.set(id, { id, ...fields }); +} + +function seedItem(id: string, fields: Record) { items.set(id, { id, ...fields }); } beforeEach(() => { items.clear(); + suppliers.clear(); getFullList.mockClear(); }); // --- Tests ------------------------------------------------------------------- describe("listStockItemsForStocktake", () => { - it("returns active stock items sorted by name with supplier expanded", async () => { - seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); - seed("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: true, supplier: "sup-1" }); + it("returns active stock items sorted by supplier name, then by name", async () => { + seedSupplier("sup-1", { suppliername: "AFRIQ LUBE" }); + seedSupplier("sup-2", { suppliername: "E-WELD" }); + seedItem("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); + seedItem("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: true, supplier: "sup-2" }); + seedItem("si-c", { name: "Axe", sku: "AXE-1", location: "A3", unit: "ea", current_qty: 5, is_active: true, supplier: "sup-1" }); const result = await listStockItemsForStocktake(pb as never); - expect(result.map((r) => r.name)).toEqual(["Bracket", "Widget"]); + // AFRIQ LUBE first (Axe < Widget within the supplier), then E-WELD. + expect(result.map((r) => r.name)).toEqual(["Axe", "Widget", "Bracket"]); expect(getFullList).toHaveBeenCalledWith(expect.objectContaining({ expand: "supplier" })); }); + it("sorts items with no supplier to the end", async () => { + seedSupplier("sup-1", { suppliername: "AFRIQ LUBE" }); + seedItem("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); + seedItem("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: true, supplier: "" }); + + const result = await listStockItemsForStocktake(pb as never); + expect(result.map((r) => r.name)).toEqual(["Widget", "Bracket"]); + }); + it("returns an empty array when there are no active stock items", async () => { - seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: false, supplier: "sup-1" }); + seedItem("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: false, supplier: "sup-1" }); const result = await listStockItemsForStocktake(pb as never); expect(result).toEqual([]); expect(getFullList).toHaveBeenCalledWith(expect.objectContaining({ expand: "supplier" })); }); it("excludes items with is_active = false", async () => { - seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); - seed("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: false, supplier: "sup-1" }); + seedSupplier("sup-1", { suppliername: "AFRIQ LUBE" }); + seedItem("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); + seedItem("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: false, supplier: "sup-1" }); const result = await listStockItemsForStocktake(pb as never); expect(result.map((r) => r.name)).toEqual(["Widget"]); expect(getFullList).toHaveBeenCalledWith(expect.objectContaining({ expand: "supplier" })); diff --git a/src/lib/server/stock-items-service.ts b/src/lib/server/stock-items-service.ts index c3bbb5c..0715b0d 100644 --- a/src/lib/server/stock-items-service.ts +++ b/src/lib/server/stock-items-service.ts @@ -12,15 +12,24 @@ export type StockItemsPb = { }; /** - * Fetch every active stock item sorted by name, with the supplier - * relation pre-expanded. The "Stocktake PDF" download endpoint - * uses this to render the printable stocktake list. + * Fetch every active stock item sorted by supplier name (then by + * item name within each supplier), with the supplier relation + * pre-expanded. The "Stocktake PDF" download endpoint uses this to + * render the printable stocktake list. * * Differs from the inline `stock_items` reads on the dashboard * page (which filter by `is_active = true` AND sort by `current_qty`) - * because a stocktake wants all SKUs alphabetically, not the + * because a stocktake wants all SKUs grouped by supplier so the + * operator can do a single walk-through per vendor, not the * low-stock ones first. * + * PB's `sort` parameter sorts by the supplier relation field (the + * ID), not by the expanded supplier's name — so we re-sort in JS by + * `expand.supplier.suppliername` to get the printable grouping + * right. Items without a supplier sort to the end. Name is the + * secondary sort so items within the same supplier stay + * alphabetically grouped. + * * No pagination — the stocktake is always the whole list. If the * catalogue grows past `getFullList`'s default page size, PB will * fetch subsequent pages transparently. The PDF renderer downstream @@ -30,11 +39,26 @@ export type StockItemsPb = { export async function listStockItemsForStocktake( pb: StockItemsPb ): Promise { - return (await pb.collection("stock_items").getFullList({ + const items = (await pb.collection("stock_items").getFullList({ filter: "is_active = true", - sort: "name", + // PB-side sort by supplier (relation ID), then by name. The JS + // re-sort below overrides this with the expanded supplier name, + // but the PB-side sort keeps the input roughly grouped so the + // JS comparator does fewer swaps on large lists. + sort: "supplier,name", expand: "supplier", })) as StockItem[]; + + return items.sort((a, b) => { + const aName = a.expand?.supplier?.suppliername ?? ""; + const bName = b.expand?.supplier?.suppliername ?? ""; + // Items without a supplier sort to the end of the PDF. + if (aName === "" && bName !== "") return 1; + if (aName !== "" && bName === "") return -1; + const cmp = aName.localeCompare(bName); + if (cmp !== 0) return cmp; + return a.name.localeCompare(b.name); + }); } // Re-export the type so the route can alias it without poking