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
This commit is contained in:
Dave 2026-08-12 08:09:36 +02:00
parent 8856146023
commit 8efb952c76
3 changed files with 114 additions and 15 deletions

View file

@ -76,15 +76,14 @@ export const ITEM_STATUS_VISUAL: Record<ItemStatus, ItemStatusVisual> = {
// 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<string, ItemStatusVisual> = {
// 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<TabGroup, ItemStatus[]> = {
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.`,
);
}
/* ============================================================================

View file

@ -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({

View file

@ -608,7 +608,8 @@ export type ItemStatus =
| "with_driver"
| "checked_in"
| "completed"
| "removed";
| "removed"
| "delivery_note";
export interface Order extends BaseRecord {
order_number: string;