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:
parent
8856146023
commit
8efb952c76
3 changed files with 114 additions and 15 deletions
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -608,7 +608,8 @@ export type ItemStatus =
|
|||
| "with_driver"
|
||||
| "checked_in"
|
||||
| "completed"
|
||||
| "removed";
|
||||
| "removed"
|
||||
| "delivery_note";
|
||||
|
||||
export interface Order extends BaseRecord {
|
||||
order_number: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue