diff --git a/src/features/store-admin/orderStatus.test.ts b/src/features/store-admin/orderStatus.test.ts index fc60001..2846042 100644 --- a/src/features/store-admin/orderStatus.test.ts +++ b/src/features/store-admin/orderStatus.test.ts @@ -19,6 +19,7 @@ import { DELIVERY_STATUS_TABS, ORDER_STATUS_TABS, } from './orderStatus'; +import { UNMIRRORED_STAGES } from './orderProgress'; /* Set after the imports on purpose, and it still takes: `billedAtMs` reads the zone when it is CALLED, not when this module loads, and assigning TZ calls @@ -247,3 +248,50 @@ test('the delivery strip offers the whole ladder', () => { ['All', 'Pending', 'Accepted', 'Arrived', 'Picked', 'Active', 'Skipped', 'Rejected', 'Delivered', 'Cancelled'], ); }); + +/* +The orders table shows one vocabulary and used to filter by another. + +`orderStage` resolves each order row to the stage its DELIVERY reached, because +`orders.orderstatus` sits on `pending` from the moment a rider is assigned until +the parcel is dropped. So the column read "Picked" while the tabs filtered on +"pending": the row sat under Pending, there was no Picked tab to find it in, and +the counts described a set nobody could see. + +The strip now carries every word that column can display. +*/ + +test('every stage the orders table can display has an order tab', () => { + // The six the order row is never told about, plus the ones it is. If the + // column can render it, an operator must be able to filter to it. + for (const status of [...UNMIRRORED_STAGES, ...LIVE_ORDER_STATUSES]) { + const hits = ORDER_STATUS_TABS.filter( + (tab) => tab.key !== 'all' && matchesStatus(tab.key, status), + ); + assert.equal( + hits.length, + 1, + `"${status}" is shown on an order row but matched ${hits.length} tabs`, + ); + } +}); + +test('an order mid-delivery is filed under the stage it shows', () => { + // The whole bug in one assertion: a row displaying "picked" must not answer + // to the Pending tab, or pressing Picked finds nothing and pressing Pending + // shows a row labelled Picked. + assert.equal(matchesStatus('picked', 'picked'), true); + assert.equal(matchesStatus('pending', 'picked'), false); + assert.equal(matchesStatus('arrived', 'arrived'), true); + assert.equal(matchesStatus('pending', 'arrived'), false); +}); + +test('created stays on the order strip and off the delivery one', () => { + // The one word that is genuinely order-only — a delivery is never created, + // it is requested. Losing it would drop every brand-new order into All alone. + assert.ok(ORDER_STATUS_TABS.some((tab) => tab.key === 'created')); + // Widened on purpose: the key union already proves `created` is not a + // delivery tab, and TypeScript rejects the comparison outright. The runtime + // check stays because the union is what a future edit would change first. + assert.ok(!DELIVERY_STATUS_TABS.some((tab) => (tab.key as string) === 'created')); +}); diff --git a/src/features/store-admin/orderStatus.ts b/src/features/store-admin/orderStatus.ts index 12216f6..70a4beb 100644 --- a/src/features/store-admin/orderStatus.ts +++ b/src/features/store-admin/orderStatus.ts @@ -114,10 +114,33 @@ export const statusColor = (map: Record, status: string | undefi * else, so an operator working through the tabs never sees those rows at all. * That is the failure this list is checked against. */ +/* + * ── Why the order strip carries the delivery stages too ───────────────────── + * + * It used to be the five words an order row writes about itself. But the orders + * table does not SHOW those words any more — `orderStage` resolves each row to + * the stage its delivery reached, because `orders.orderstatus` sits on + * `pending` from the moment a rider is assigned until the parcel is dropped, + * and an operator staring at "pending" cannot tell a job nobody has accepted + * from one already on a bike. + * + * So the table showed `Picked` and the tabs filtered on `pending`, and a row + * labelled Picked lived under the Pending tab with no Picked tab to find it in. + * The screen displayed one vocabulary and filtered by another. + * + * The strip now carries every word the column can display. `created` stays, + * because an order reaches it and a delivery never does. + */ export const ORDER_STATUS_TABS = [ { key: 'all', label: 'All' }, { key: 'created', label: 'Created' }, { key: 'pending', label: 'Pending' }, + { key: 'accepted', label: 'Accepted' }, + { key: 'arrived', label: 'Arrived' }, + { key: 'picked', label: 'Picked' }, + { key: 'active', label: 'Active' }, + { key: 'skipped', label: 'Skipped' }, + { key: 'rejected', label: 'Rejected' }, { key: 'delivered', label: 'Delivered' }, { key: 'cancelled', label: 'Cancelled' }, ] as const; diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index 03893b1..01be0db 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -141,19 +141,28 @@ export function SalesPage() { const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]); const allDeliveries = deliveries.data ?? []; + /* How far each order has actually got. The order row is only ever told + pending/delivered/cancelled, so the six stages in between are read off the + deliveries list — same window, same query, no extra fetch. See gap D in + `orderProgress.ts`. + + Built here, above the filter, because the filter and the tab counts now + read the same resolved word the table renders. */ + const stages = useMemo(() => deliveryStageFrom(allDeliveries), [allDeliveries]); + /* - * Filtered on the ORDER ladder, deliberately, even though the chip now shows - * the delivery stage. + * Filtered on the word the row actually SHOWS. * - * `ORDER_STATUS_TABS` has no Picked or Arrived tab — an order never reaches - * those words — so filtering on the stage would drop every order in progress - * out of every tab except All. Under Pending the rows are the outstanding - * orders, which is what Pending means here, and the chip says how far along - * each one is. The tab counts stay on the same footing for the same reason. + * This used to filter on `row.orderstatus` while the chip rendered + * `orderStage(...)`, on the reasoning that the order strip had no Picked or + * Arrived tab to put those rows in. That reasoning held the wrong end: a row + * labelled Picked sat under the Pending tab, there was no Picked tab to look + * in, and the counts described a set the operator could not see. The strip + * carries those tabs now, so the filter follows the column. */ const orderRows = useMemo( - () => allOrders.filter((row) => matchesStatus(status, row.orderstatus)), - [allOrders, status], + () => allOrders.filter((row) => matchesStatus(status, orderStage(row, stages).status)), + [allOrders, status, stages], ); const deliveryRows = useMemo( () => allDeliveries.filter((row) => matchesStatus(status, row.orderstatus)), @@ -219,17 +228,31 @@ export function SalesPage() { */ const statusTabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS; - /** Counts for the tab strip, from the unfiltered set. */ + /** + * Counts for the tab strip, from the unfiltered set. + * + * Read through the same resolution the rows are filtered and rendered by. A + * count taken from `row.orderstatus` while the tab selects on the stage is a + * number describing a different set from the one that appears when it is + * pressed — Pending would say 41 and then show 6. + */ const tabCounts = useMemo(() => { - const source = tab === 'deliveries' ? allDeliveries : allOrders; + const statusOf = + tab === 'deliveries' + ? (row: DeliveryRow | OrderRow) => row.orderstatus + : (row: DeliveryRow | OrderRow) => orderStage(row as OrderRow, stages).status; + + const source: (DeliveryRow | OrderRow)[] = + tab === 'deliveries' ? allDeliveries : allOrders; const tabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS; + return Object.fromEntries( tabs.map((entry) => [ entry.key, - source.filter((row) => matchesStatus(entry.key, row.orderstatus)).length, + source.filter((row) => matchesStatus(entry.key, statusOf(row))).length, ]), ) as Record; - }, [tab, allOrders, allDeliveries]); + }, [tab, allOrders, allDeliveries, stages]); const counterTotals = useMemo(() => { const fromTills = posSummary.reduce( @@ -285,12 +308,6 @@ export function SalesPage() { for a rider again, and without this the delivery row left behind keeps them out of every assign surface for good — see `releasedFrom`. */ const released = useMemo(() => releasedFrom(allDeliveries), [allDeliveries]); - /* How far each order has actually got. The order row is only ever told - pending/delivered/cancelled, so the six stages in between are read off the - deliveries list — same window, same query, no extra fetch. See gap D in - `orderProgress.ts`. */ - const stages = useMemo(() => deliveryStageFrom(allDeliveries), [allDeliveries]); - const branchOf = useMemo( () => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid), [branches],