diff --git a/src/api/types.ts b/src/api/types.ts index c24b6da..f51ade3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -698,6 +698,11 @@ export interface OrderRow { */ deliveryslotid?: number; deliveryslotdate?: string; + /** Joined from `deliveryslots`, so a renamed window reads correctly on old orders. */ + slotkey?: string; + deliveryslotname?: string; + deliveryslotstart?: string; + deliveryslotend?: string; } /** @@ -912,6 +917,19 @@ export interface DeliveryRow { deliverytime?: string; canceltime?: string; expecteddeliverytime?: string; + + /** + * The delivery window the customer asked for. + * + * Joined from the ORDER — `deliveries` has no window of its own, on purpose, + * so there is one truth about what was asked for. Absent on most rows. + */ + deliveryslotid?: number; + deliveryslotdate?: string; + slotkey?: string; + deliveryslotname?: string; + deliveryslotstart?: string; + deliveryslotend?: string; itemcount?: number; orderamount?: number; pickupcustomer?: string; diff --git a/src/features/store-admin/deliveryWindow.test.ts b/src/features/store-admin/deliveryWindow.test.ts new file mode 100644 index 0000000..4ff4fe8 --- /dev/null +++ b/src/features/store-admin/deliveryWindow.test.ts @@ -0,0 +1,126 @@ +/** + * Reading a delivery window off a row. + * + * The filter and the column both run through these, and the case that matters + * most is the dull one: a row with NO window. Windows are set per branch and + * most branches have none, so "absent" is the ordinary state and anything that + * treats it as missing data would mislabel almost every row on the screen. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + hasWindow, + matchesWindow, + windowHasPassed, + windowHours, + windowLabel, + type WithWindow, +} from './deliveryWindow'; + +const evening: WithWindow = { + deliveryslotid: 3, + deliveryslotdate: '2026-10-06', + slotkey: 'evening', + deliveryslotname: 'Evening', + deliveryslotstart: '17:00', + deliveryslotend: '20:00', +}; + +/** The common row: placed before windows existed, or from a branch with none. */ +const noWindow: WithWindow = {}; + +describe('hasWindow', () => { + it('is false for a row that named none', () => { + assert.equal(hasWindow(noWindow), false); + }); + + it('is false for an explicit zero, not just an absent key', () => { + // The backend sends 0 rather than omitting the field on some responses. + assert.equal(hasWindow({ deliveryslotid: 0 }), false); + }); + + it('is true once a window is named', () => { + assert.equal(hasWindow(evening), true); + }); +}); + +describe('matchesWindow', () => { + it('lets everything through on "all", including rows with no window', () => { + assert.equal(matchesWindow('all', evening), true); + assert.equal(matchesWindow('all', noWindow), true); + }); + + it('matches on the key', () => { + assert.equal(matchesWindow('evening', evening), true); + assert.equal(matchesWindow('morning', evening), false); + }); + + it('"none" finds exactly the rows that chose nothing', () => { + // The whole point of that option: while this rolls out, these are most + // orders, and an operator needs to be able to see them as a group. + assert.equal(matchesWindow('none', noWindow), true); + assert.equal(matchesWindow('none', evening), false); + }); + + it('is not confused by case from the server', () => { + assert.equal(matchesWindow('evening', { ...evening, slotkey: 'Evening' }), true); + }); +}); + +describe('windowLabel', () => { + it('is null when there is no window, so the cell can say so plainly', () => { + assert.equal(windowLabel(noWindow), null); + }); + + it('carries the date as well as the name', () => { + // "Morning" alone cannot distinguish an order placed at 9pm for tomorrow + // from one placed at 7am for today, and a dispatcher reading a list of them + // needs to know which day each is for. + const label = windowLabel(evening); + assert.ok(label?.startsWith('Evening'), `got ${label}`); + assert.ok(label?.includes('6'), `date missing from ${label}`); + }); + + it('falls back to the key when a shop cleared the name', () => { + assert.ok(windowLabel({ ...evening, deliveryslotname: '' })?.startsWith('Evening')); + }); + + it('shows the name alone rather than a half-parsed date', () => { + assert.equal(windowLabel({ ...evening, deliveryslotdate: 'not-a-date' }), 'Evening'); + }); +}); + +describe('windowHours', () => { + it('reads as a range', () => { + assert.equal(windowHours(evening), '17:00–20:00'); + }); + + it('is null when either end is missing', () => { + assert.equal(windowHours({ ...evening, deliveryslotend: '' }), null); + }); +}); + +describe('windowHasPassed', () => { + it('is false for a row with no window', () => { + // Nothing was promised, so nothing can be late. + assert.equal(windowHasPassed(noWindow), false); + }); + + it('is true once the window has ended', () => { + assert.equal( + windowHasPassed({ ...evening, deliveryslotdate: '2020-01-01' }), + true, + ); + }); + + it('is false for a window still to come', () => { + assert.equal(windowHasPassed({ ...evening, deliveryslotdate: '2099-01-01' }), false); + }); + + it('is false rather than true when the time is unreadable', () => { + // This only tints a row. Guessing "late" from unreadable data would put a + // red mark on rows nobody can act on. + assert.equal(windowHasPassed({ ...evening, deliveryslotend: 'evening' }), false); + }); +}); diff --git a/src/features/store-admin/deliveryWindow.ts b/src/features/store-admin/deliveryWindow.ts new file mode 100644 index 0000000..76b2ffe --- /dev/null +++ b/src/features/store-admin/deliveryWindow.ts @@ -0,0 +1,113 @@ +/** + * Reading the delivery window off an order or a delivery row. + * + * Both carry the same six fields — the order holds them, and the delivery + * query joins them off the order — so one set of helpers serves the orders + * table, the deliveries table and anything added later. + * + * ── Most rows have no window ──────────────────────────────────────────────── + * + * Windows are new and set per branch, so the overwhelming majority of rows have + * none and always will. Absence is the ordinary case, not missing data, and + * every helper here answers it plainly rather than with a blank that reads like + * a loading state. + */ + +/** The shape both OrderRow and DeliveryRow satisfy once the window is joined. */ +export interface WithWindow { + deliveryslotid?: number; + deliveryslotdate?: string; + slotkey?: string; + deliveryslotname?: string; + deliveryslotstart?: string; + deliveryslotend?: string; +} + +/** + * The filter's options. + * + * `none` is not a leftover — it is how an operator finds the orders that were + * placed without choosing a window, which is most of them today and is exactly + * the population worth looking at while windows are being rolled out. + */ +export const WINDOW_FILTERS = [ + { key: 'all', label: 'All windows' }, + { key: 'morning', label: 'Morning' }, + { key: 'afternoon', label: 'Afternoon' }, + { key: 'evening', label: 'Evening' }, + { key: 'none', label: 'No window' }, +] as const; + +export type WindowFilterKey = (typeof WINDOW_FILTERS)[number]['key']; + +export function hasWindow(row: WithWindow): boolean { + return Number(row.deliveryslotid ?? 0) > 0; +} + +export function matchesWindow(filter: WindowFilterKey, row: WithWindow): boolean { + if (filter === 'all') return true; + if (filter === 'none') return !hasWindow(row); + return hasWindow(row) && (row.slotkey ?? '').toLowerCase() === filter; +} + +/** + * What the cell reads: "Evening · 6 Oct". + * + * The DATE is part of it and not decoration. An order placed at 9pm for + * tomorrow morning and one placed at 7am for this morning are both "Morning", + * and a dispatcher reading a list of them needs to know which day each is for. + */ +export function windowLabel(row: WithWindow): string | null { + if (!hasWindow(row)) return null; + + const name = (row.deliveryslotname ?? '').trim() || titleCase(row.slotkey ?? ''); + const date = shortDate(row.deliveryslotdate); + + return date ? `${name} · ${date}` : name; +} + +/** The hours, for a tooltip or a second line: "17:00–20:00". */ +export function windowHours(row: WithWindow): string | null { + const start = (row.deliveryslotstart ?? '').trim(); + const end = (row.deliveryslotend ?? '').trim(); + return start && end ? `${start}–${end}` : null; +} + +/** + * Is this window already over? + * + * Only ever used to TINT a row that is still undelivered — never to filter one + * away. A morning order still sitting there at 2pm is the most important thing + * on the screen, and hiding it to keep the list tidy would bury the one row + * somebody needs to act on. + * + * Decided on the browser's clock, which is fine for a visual hint and is not + * fine for anything else: the server owns whether a window is open, because the + * device's clock is not something we control. + */ +export function windowHasPassed(row: WithWindow): boolean { + if (!hasWindow(row)) return false; + + const date = (row.deliveryslotdate ?? '').trim(); + const end = (row.deliveryslotend ?? '').trim(); + if (!date || !/^\d{2}:\d{2}$/.test(end)) return false; + + const ended = new Date(`${date}T${end}:00`); + return !Number.isNaN(ended.getTime()) && ended.getTime() < Date.now(); +} + +function titleCase(value: string): string { + return value ? value[0]!.toUpperCase() + value.slice(1) : ''; +} + +/** "2026-10-06" → "6 Oct". Returns null for anything unreadable rather than + printing a half-parsed date beside a window name. */ +function shortDate(value: string | undefined): string | null { + const trimmed = (value ?? '').trim(); + if (!/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return null; + + const parsed = new Date(`${trimmed.slice(0, 10)}T00:00:00`); + if (Number.isNaN(parsed.getTime())) return null; + + return parsed.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); +} diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx index 7319001..f2818d0 100644 --- a/src/features/store-admin/pages/DispatchPage.tsx +++ b/src/features/store-admin/pages/DispatchPage.tsx @@ -21,6 +21,7 @@ import { TriangleAlert, } from 'lucide-react'; import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types'; +import { WINDOW_FILTERS, matchesWindow, type WindowFilterKey } from '../deliveryWindow'; import { KpiCard } from '@/components/KpiCard'; import { Tab, TabBar } from '@/components/TabBar'; import { StickyRow } from '@/components/StickyRow'; @@ -97,6 +98,15 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { const [detail, setDetail] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all'); + /* + Which delivery window the board is showing. + + Applied to the DELIVERIES, before they become stops — so the map, the + totals, the rider groups and the list all narrow together. Filtering only + the visible list would leave the counters above it describing a different + day's work to the one on screen. + */ + const [windowFilter, setWindowFilter] = useState('all'); /* One id or the other, never both — see `DispatchScope`. */ const isPartnerBoard = scope.kind === 'partner'; @@ -148,7 +158,13 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { vanished from every chip except "All". The date filter in the top bar already narrows the day and never drops a row. */ - const filteredDeliveries = rawDeliveries; + const filteredDeliveries = useMemo( + () => + windowFilter === 'all' + ? rawDeliveries + : rawDeliveries.filter((row) => matchesWindow(windowFilter, row)), + [rawDeliveries, windowFilter], + ); const isOneDay = Boolean( dates.range.fromdate && dates.range.fromdate === dates.range.todate, @@ -420,6 +436,30 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { > Unassigned + + {/* Narrows the whole board, not just this list — the counters + and the map follow it, because a count that describes more + work than the screen shows is worse than no count. */} + ('orders'); const [status, setStatus] = useState('all'); + /* + Which delivery window to show. + + Separate from `status`, and both apply: "evening orders that are still + pending" is the question a dispatcher actually asks, and folding the two + into one control would make it unaskable. + */ + const [windowFilter, setWindowFilter] = useState('all'); const dates = useDateScope(); const [keyword, setKeyword] = useState(''); /* What the tables are currently showing. Any change to it sends every pager @@ -181,11 +197,19 @@ export function SalesPage() { * carries those tabs now, so the filter follows the column. */ const orderRows = useMemo( - () => allOrders.filter((row) => matchesStatus(status, orderStage(row, stages).status)), + () => + allOrders.filter( + (row) => + matchesStatus(status, orderStage(row, stages).status) && + matchesWindow(windowFilter, row), + ), [allOrders, status, stages], ); const deliveryRows = useMemo( - () => allDeliveries.filter((row) => matchesStatus(status, row.orderstatus)), + () => + allDeliveries.filter( + (row) => matchesStatus(status, row.orderstatus) && matchesWindow(windowFilter, row), + ), [allDeliveries, status], ); @@ -453,6 +477,10 @@ export function SalesPage() { counts={tabCounts} onStatus={setStatus} /> + {/* Beside the status tabs, not inside them. Both filters apply at + once, because "evening orders still pending" is the question + somebody actually has. */} + {/* Above the table, not below it and not in a drawer. Assigning is something you do WHILE reading the day's orders — glancing between the drop addresses and who is free — so the @@ -772,6 +800,7 @@ function OrdersTable({ Drop Qty Amount + Window Status @@ -855,6 +884,7 @@ function OrdersTable({ 0} isMuted={amount === 0}> {amount > 0 ? moneyExact(amount) : '—'} + {/* The stage, not the order's own word — which sits on `pending` from assignment until delivery. The ladder has to follow @@ -924,6 +954,7 @@ function DeliveriesTable({ Rider KM Charge / value + Window Status Notes @@ -1027,6 +1058,7 @@ function DeliveriesTable({ — ) : null} + + — + + ); + } + + const hours = windowHours(row); + // A window that has ended while the order is still on the board is the one + // thing here worth noticing. Tinted, never hidden — burying it would hide the + // row somebody most needs to act on. + const isLate = windowHasPassed(row); + + return ( + + + {label} + + + ); +} + +/** + * Which window to show. + * + * "No window" earns its place: it is how an operator finds orders placed + * without choosing one, which is most of them while this rolls out, and is the + * population worth watching to see the feature being used at all. + */ +function WindowFilter({ + value, + onChange, +}: { + value: WindowFilterKey; + onChange: (next: WindowFilterKey) => void; +}) { + return ( + + ); +}