feat(stocktake-pdf): add Location column, sort by supplier name
PDF: - Add Location column between Max and Status (4% gap on each side) - Re-balance widths: Name 28% → 23%, Status 18% → 12% to make room - Total still 100% (with consistent 4% gaps between data columns) - Update JSDoc to reflect new column + sort Service: - Sort by expanded supplier name (with item name as secondary sort) - Items without a supplier sort to the end - PB-side sort becomes 'supplier,name' so the JS comparator does fewer swaps on large lists - PB can't sort by an expanded relation's field, so the JS re-sort is what actually produces the printable grouping Tests: - Update mock to handle 'expand: supplier' and populate expand.supplier.suppliername on each row - Update mock to handle comma-separated sort lists (PB accepts 'supplier,name') - Add a 'sorts items with no supplier to the end' test - Update existing sort test to cover multi-supplier grouping Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
0cecc7208f
commit
532f1986e4
3 changed files with 100 additions and 33 deletions
|
|
@ -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 `<Text>` 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({
|
|||
<Text style={[styles.th, styles.cMax, { textAlign: "right" }]}>
|
||||
Max
|
||||
</Text>
|
||||
<Text style={[styles.th, styles.gapMaxLocation]}>{""}</Text>
|
||||
<Text style={[styles.th, styles.cLocation]}>Location</Text>
|
||||
<Text style={[styles.th, styles.cStatus, { textAlign: "right" }]}>
|
||||
Status
|
||||
</Text>
|
||||
|
|
@ -167,6 +172,10 @@ export function StockItemStocktakeDocument({
|
|||
<Text style={[styles.tdRight, styles.cMax]}>
|
||||
{maxQty !== null && maxQty > 0 ? maxQty : "—"}
|
||||
</Text>
|
||||
<Text style={[styles.td, styles.gapMaxLocation]}>{""}</Text>
|
||||
<Text style={[styles.td, styles.cLocation]}>
|
||||
{item.location || "—"}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.tdStatus, styles.cStatus, { color: statusStyle.hex }]}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, unknown>>();
|
||||
const suppliers = new Map<string, Record<string, unknown>>();
|
||||
|
||||
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<string, unknown>) {
|
||||
function seedSupplier(id: string, fields: Record<string, unknown>) {
|
||||
suppliers.set(id, { id, ...fields });
|
||||
}
|
||||
|
||||
function seedItem(id: string, fields: Record<string, unknown>) {
|
||||
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" }));
|
||||
|
|
|
|||
|
|
@ -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<StockItem[]> {
|
||||
return (await pb.collection("stock_items").getFullList<StockItem>({
|
||||
const items = (await pb.collection("stock_items").getFullList<StockItem>({
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue