From 8efb952c76db0afc41c4365c9d6421ab3e90d099 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 12 Aug 2026 08:09:36 +0200 Subject: [PATCH] feat(order-status): add delivery_note and fix tab mappings - add `delivery_note` to the ItemStatus union type - move delivery_note visual config from EXTRA_ITEM_STATUS_VISUAL to ITEM_STATUS_VISUAL - update tab group mappings: add delivery_note to processing tab, expand collected tab with post-pickup statuses - move removed status to the cancelled tab group - replace silent fallback in itemStatusToTabGroup with explicit error for unmapped statuses - add comprehensive regression test to ensure all ItemStatus values are mapped to a tab --- src/lib/item-status-ui.ts | 42 +++++++++----- src/lib/server/orders-service.test.ts | 84 +++++++++++++++++++++++++++ src/lib/types.ts | 3 +- 3 files changed, 114 insertions(+), 15 deletions(-) diff --git a/src/lib/item-status-ui.ts b/src/lib/item-status-ui.ts index 1173610..63006f9 100644 --- a/src/lib/item-status-ui.ts +++ b/src/lib/item-status-ui.ts @@ -76,15 +76,14 @@ export const ITEM_STATUS_VISUAL: Record = { // but the row is hidden from the active items list. The muted // red color matches the destructive intent without being loud. removed: { hex: "#71717a", label: "Removed" }, -}; - -const EXTRA_ITEM_STATUS_VISUAL: Record = { + // DN issued but the invoice hasn't been generated yet — operator + // action pending. Amber-brown so it doesn't blend with the + // processing (orange) or the collected (teal) families. delivery_note: { hex: "#7c2d12", label: "Delivery Note" }, }; export function getItemStatusVisual(status: string): ItemStatusVisual { return ITEM_STATUS_VISUAL[status as ItemStatus] - ?? EXTRA_ITEM_STATUS_VISUAL[status] ?? FALLBACK_ITEM_STATUS_VISUAL; } @@ -122,16 +121,27 @@ export const TAB_GROUP_ORDER: TabGroup[] = [ export const TAB_GROUP_ITEM_STATUSES: Record = { new: ["pending", "approval_needed", "approval_rejected"], - processing: ["processed", "po_generated"], + processing: ["processed", "po_generated", "delivery_note"], // Pre-pickup flow — collection_pool, assigned, AND ready_delivery // (supplier confirmed ready for pickup) all live here. ready_delivery // used to be lumped under "collected" which made it look like the // item had already been picked up by the driver, but it sits // *before* the pool, not after. collection_pool: ["collection_pool", "assigned", "ready_delivery"], - collected: ["collected", "stock_verified", "picking"], + // Post-pickup, pre-terminal: stock verified, item picked, awaiting + // invoice, invoice issued, in transit with the driver, and the + // downstream stock-booked / checked_in / completed steps all live + // here. The operator finishes the buyer's workflow at this point; + // `delivered` is the sales/delivery confirmation. + collected: ["collected", "stock_verified", "picking", + "ready_invoice", "invoiced", + "stock_booked", "with_driver", "checked_in", + "completed"], delivered: ["delivered"], - cancelled: ["cancelled"], + // `removed` joins `cancelled` — items struck from an order live with + // the other non-completions so the active workflow tabs don't carry + // them. The /orders/archive page surfaces both buckets. + cancelled: ["cancelled", "removed"], }; /** @@ -149,18 +159,22 @@ export function tabGroupFilter(tab: TabGroup): string { /** * Map an individual item status to the single tab group it belongs * to. The grouping is a partition (every status lands in exactly one - * tab) — derived by inverting `TAB_GROUP_ITEM_STATUSES`. + * tab) — derived from `TAB_GROUP_ITEM_STATUSES`. + * + * Every member of the `ItemStatus` union MUST be listed in + * `TAB_GROUP_ITEM_STATUSES`. If you add a status to the union and + * forget, this function throws rather than silently mis-bucketing — + * the bug would otherwise be invisible: the item would render on the + * wrong tab AND drop out of search counts (see `TAB_STATUSES`). */ export function itemStatusToTabGroup(s: ItemStatus): TabGroup { for (const tab of TAB_GROUP_ORDER) { if (TAB_GROUP_ITEM_STATUSES[tab].includes(s)) return tab; } - // Defensive fallback. The grouping above covers every member of - // the `ItemStatus` union, so this branch is unreachable on a typed - // input — but the function is also called from JS paths where the - // caller might pass a string that PB tolerated. Defaulting to - // "new" keeps the page rendering instead of throwing. - return "new"; + throw new Error( + `itemStatusToTabGroup: status "${s}" is not mapped to any tab. ` + + `Add it to TAB_GROUP_ITEM_STATUSES in src/lib/item-status-ui.ts.`, + ); } /* ============================================================================ diff --git a/src/lib/server/orders-service.test.ts b/src/lib/server/orders-service.test.ts index c48022c..cb17b5c 100644 --- a/src/lib/server/orders-service.test.ts +++ b/src/lib/server/orders-service.test.ts @@ -1315,6 +1315,90 @@ describe("listOrdersWithItems — search scope + per-tab match counts", () => { }); }); +describe("listOrdersWithItems — tab taxonomy completeness", () => { + /** + * Regression guard. The `ItemStatus` union and `TAB_GROUP_ITEM_STATUSES` + * must stay in lockstep — every status MUST be assigned to a tab, + * because: + * 1. Unmapped statuses are invisible to /orders (they fail the + * tab filter). + * 2. They drop out of `TAB_STATUSES` and therefore out of the + * search candidate set built by `getActiveOrderIds`. + * 3. They drop out of the tab counts query entirely. + * + * This caught the bug where 60 active items (ready_invoice, + * invoiced, delivery_note, removed) were silently missing from + * search and mis-bucketed as "new" — including order 4500704208 + * (ready_invoice, expected: Collected). + */ + it("returns search hits whose item_status is mapped to a non-new tab", async () => { + // ready_invoice should land on the Collected tab now. Previously + // it was bucketed as "new" and excluded from the search candidate + // set, so the order was unfindable. + await pbMock.collection("orders").create({ + id: "o-inv", + order_number: "ORD-invoice-ready", + quote: "q-inv", + created: "2025-01-01T00:00:00Z", + }); + await pbMock.collection("order_items").create({ + id: "oi-inv", + order: "o-inv", + description: "Widget", + item_status: "ready_invoice", + }); + + const result = await listOrdersWithItems({ search: "invoice" }); + + expect(result.items.map((i) => i.order.id)).toEqual(["o-inv"]); + expect(result.searchCounts).toMatchObject({ collected: 1 }); + expect(result.searchCounts?.new ?? 0).toBe(0); + }); + + it("counts every item_status in the live union across the active set", async () => { + // Build one item per ItemStatus union member, confirm each one + // surfaces in `getActiveOrderIds` (the search candidate set). + const allStatuses = [ + "pending", "approval_needed", "approval_rejected", + "processed", "po_generated", + "collection_pool", "assigned", "ready_delivery", + "collected", "stock_verified", "picking", + "delivered", "cancelled", + "ready_invoice", "invoiced", + "stock_booked", "with_driver", "checked_in", "completed", + "removed", "delivery_note", + ]; + for (const s of allStatuses) { + await pbMock.collection("orders").create({ + id: `o-${s}`, + order_number: `ORD-${s}`, + quote: `q-${s}`, + }); + await pbMock.collection("order_items").create({ + id: `oi-${s}-${allStatuses.indexOf(s)}`, + order: `o-${s}`, + description: "x", + item_status: s, + }); + } + // If this fails, an ItemStatus union member has been added to + // src/lib/types.ts without a matching TAB_GROUP_ITEM_STATUSES entry + // in src/lib/item-status-ui.ts — and that item is invisible to + // /orders search and counts. + const result = await listOrdersWithItems({ + search: "ORD-", + includeArchivedCounts: false, + }); + const foundIds = new Set(result.items.map(i => i.order.id)); + // Every active-tab status should appear; delivered and cancelled + // remain excluded by design (they live on /orders/archive). + const active = allStatuses.filter(s => s !== "delivered" && s !== "cancelled"); + for (const s of active) { + expect(foundIds, `item_status "${s}" should be in search results`).toContain(`o-${s}`); + } + }); +}); + describe("getOrdersDashboardData — search counts replace global counts", () => { it("promotes per-tab match counts over the global tab counts while searching", async () => { await pbMock.collection("orders").create({ diff --git a/src/lib/types.ts b/src/lib/types.ts index d4ef6a6..7c9e4e6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -608,7 +608,8 @@ export type ItemStatus = | "with_driver" | "checked_in" | "completed" - | "removed"; + | "removed" + | "delivery_note"; export interface Order extends BaseRecord { order_number: string;