diff --git a/src/features/store-admin/AssignBar.tsx b/src/features/store-admin/AssignBar.tsx index d6edcd8..f54d8ba 100644 --- a/src/features/store-admin/AssignBar.tsx +++ b/src/features/store-admin/AssignBar.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@astryxdesign/core/Button'; import { Selector } from '@astryxdesign/core/Selector'; -import { Route, UserCheck } from 'lucide-react'; +import { Route, TriangleAlert, UserCheck } from 'lucide-react'; import { errorMessage } from '@/api/client'; import { RIDER_MESSAGE, @@ -14,6 +14,7 @@ import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks'; import { queryKeys } from '@/queries/keys'; import { useBranchScope } from './BranchScope'; import { buildDeliveries, riderName, riderVehicle } from './assignDelivery'; +import { hasNoDevice } from './riderReach'; import { RoutePlanDrawer } from './RoutePlanDrawer'; import './pages/deliveries.css'; @@ -35,11 +36,20 @@ export interface AssignBarProps { orders: OrderRow[]; branchOf: (row: OrderRow) => TenantLocation | undefined; assigned: ReadonlySet; + /** Orders whose delivery is dead — see `releasedFrom`. */ + released?: ReadonlySet; onClear: () => void; onDone: () => void; } -export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: AssignBarProps) { +export function AssignBar({ + orders, + branchOf, + assigned, + released, + onClear, + onDone, +}: AssignBarProps) { const client = useQueryClient(); const [riderId, setRiderId] = useState(''); const [outcome, setOutcome] = useState(null); @@ -103,7 +113,7 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig const send = useMutation({ mutationFn: async () => { if (!rider) throw new Error('Pick a rider first'); - const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned); + const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned, released); if (drafts.length === 0) { throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned'); } @@ -205,6 +215,25 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig /> + {/* + Said before the hand-off, not after it. + + A rider who has never opened the app has no device to push to, so the + job lands in a queue nobody is told about. That used to surface as + "NOT notified" in the outcome line AFTER the deliveries were written — + by which point the only remedy is a phone call, and one navigation + later there was no record of it at all. + + It disables nothing. Assigning to a rider you are about to ring is a + legitimate thing to do; being surprised by it afterwards is not. + */} + {rider && hasNoDevice(rider) ? ( + + + No app on this rider’s phone yet — they will not be told. Call them. + + ) : null} + {/* Two ways to commit the same selection, and the difference is worth the second button: "Assign" hands the orders over as they are, which is right for one or two. "Plan the route" sequences them first — real diff --git a/src/features/store-admin/AutoAssignDrawer.tsx b/src/features/store-admin/AutoAssignDrawer.tsx index eae176a..7a3c2ba 100644 --- a/src/features/store-admin/AutoAssignDrawer.tsx +++ b/src/features/store-admin/AutoAssignDrawer.tsx @@ -54,6 +54,7 @@ export function AutoAssignDrawer({ branches, fleet, assigned, + released, onClose, onDone, }: { @@ -63,6 +64,8 @@ export function AutoAssignDrawer({ /** Riders we know about, for the absentee picker and for naming. */ fleet: readonly RiderInfo[]; assigned: ReadonlySet; + /** Orders whose delivery is dead — see `releasedFrom`. */ + released?: ReadonlySet; onClose: () => void; onDone: () => void; }) { @@ -114,7 +117,7 @@ export function AutoAssignDrawer({ // rest of the roster row matters for notifying, which happens after. const rider = (fleet.find((r) => r.userid === proposal.userid) ?? ({ userid: proposal.userid } as RiderInfo)); - return buildDeliveries(rows, rider, branchOf, new Date(), assigned).drafts; + return buildDeliveries(rows, rider, branchOf, new Date(), assigned, released).drafts; }); if (drafts.length === 0) { diff --git a/src/features/store-admin/DeliveryProgress.tsx b/src/features/store-admin/DeliveryProgress.tsx index 89e5639..ef19ae9 100644 --- a/src/features/store-admin/DeliveryProgress.tsx +++ b/src/features/store-admin/DeliveryProgress.tsx @@ -7,6 +7,7 @@ import { deliveriesApi } from '@/api/deliveries'; import type { DeliveryRow } from '@/api/types'; import { queryKeys } from '@/queries/keys'; import { stampNow } from './assignDelivery'; +import { isSettled, isStalled, matchesStatus } from './orderStatus'; /** * Moving a delivery along from the back office. @@ -37,8 +38,12 @@ const MOVES = [ { to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' }, ] as const; -/** The end states. Nothing follows them, so nothing is offered. */ -const SETTLED = ['delivered', 'cancelled']; +/** What to do about a stalled job, which is never "nothing". */ +function stalledAdvice(status: string): string { + return matchesStatus('rejected', status) + ? 'This rider declined the job. The order is waiting for a rider again — assign it to somebody else.' + : 'The rider could not hand this over. It is still theirs to re-attempt; reassign it if somebody else should take it.'; +} /** * The moves, split from their rendering. @@ -54,7 +59,8 @@ export function useDeliveryMoves(job: DeliveryRow) { const [problem, setProblem] = useState(null); const status = (job.orderstatus ?? '').trim().toLowerCase(); - const isSettled = SETTLED.includes(status); + const settled = isSettled(status); + const stalled = isStalled(status); const move = useMutation({ mutationFn: ({ to, stamp }: { to: string; stamp: string }) => @@ -70,7 +76,8 @@ export function useDeliveryMoves(job: DeliveryRow) { return { status, - isSettled, + isSettled: settled, + isStalled: stalled, isPending: move.isPending, problem, moves: MOVES, @@ -86,7 +93,8 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) { const [problem, setProblem] = useState(null); const status = (job.orderstatus ?? '').trim().toLowerCase(); - const isSettled = SETTLED.includes(status); + const settled = isSettled(status); + const stalled = isStalled(status); const move = useMutation({ mutationFn: ({ to, stamp }: { to: string; stamp: string }) => @@ -100,7 +108,7 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) { onError: (error) => setProblem(errorMessage(error)), }); - if (isSettled) { + if (settled) { return ( This job is {status}. Nothing further to record. @@ -108,6 +116,14 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) { ); } + if (stalled) { + return ( + + This job is {status}. {stalledAdvice(status)} + + ); + } + return (
diff --git a/src/features/store-admin/OrderDetailDrawer.tsx b/src/features/store-admin/OrderDetailDrawer.tsx index 8df7920..403711e 100644 --- a/src/features/store-admin/OrderDetailDrawer.tsx +++ b/src/features/store-admin/OrderDetailDrawer.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react'; import type { DeliveryRow, OrderRow } from '@/api/types'; +import { orderStage, type Stage } from './orderProgress'; import { useDeliveryMoves } from './DeliveryProgress'; import { Drawer } from './Drawer'; import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit'; @@ -47,10 +48,21 @@ const STEPS: { label: string; fields: string[] }[] = [ export function OrderDetailDrawer({ row, kind, + stages, onClose, }: { row: Row_; kind: RowKind; + /** + * Where each order's delivery has got to — see `orderProgress.ts`. + * + * Passed in rather than looked up, so the drawer and the table it opened + * from cannot disagree: the row said "Picked" and the drawer said "Pending" + * would be exactly the table/drawer mismatch this console has been cleaning + * up. Optional because the dispatch board opens deliveries directly, and a + * delivery row already carries its own stage. + */ + stages?: ReadonlyMap; onClose: () => void; }) { /* @@ -65,7 +77,7 @@ export function OrderDetailDrawer({ return kind === 'delivery' ? ( ) : ( - + ); } @@ -76,7 +88,7 @@ function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => voi row={job} isDelivery onClose={onClose} - {...(moves.isSettled ? {} : { footer: })} + {...(moves.isSettled || moves.isStalled ? {} : { footer: })} caption={} /> ); @@ -84,14 +96,21 @@ function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => voi type Moves = ReturnType; +/** A drawer opened without the index falls back to the order's own word. */ +const EMPTY_STAGES: ReadonlyMap = new Map(); + function Sheet({ row, isDelivery, + stage, onClose, footer, caption, }: { row: Row_; + /** For an order: the stage its delivery reports. Absent on a delivery row, + whose `orderstatus` IS the delivery status already. */ + stage?: Stage; /** Passed explicitly, never inferred from the presence of a footer — a settled job has no actions and would otherwise read as an order. */ isDelivery: boolean; @@ -108,8 +127,10 @@ function Sheet({ const order = delivery ? undefined : (row as OrderRow); const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`); - const status = row.orderstatus; - const colour = statusColor(delivery ? DELIVERY_STATUS : ORDER_STATUS, status); + /* An order shows the stage its delivery reports, which may be a word from the + delivery ladder — so the ladder follows the word, not the row kind. */ + const status = stage ? stage.status : row.orderstatus; + const colour = statusColor(delivery || stage?.isDelivery ? DELIVERY_STATUS : ORDER_STATUS, status); const value = job ? (job.deliveryamt ?? 0) : orderValue(row); const cancelled = (status ?? '').toLowerCase().includes('cancel'); @@ -278,9 +299,9 @@ function MoveActions({ moves: state }: { moves: Moves }) { } function MoveCaption({ moves }: { moves: Moves }) { - const { isSettled, problem, status } = moves; + const { isSettled, isStalled, problem, status } = moves; - if (isSettled) { + if (isSettled || isStalled) { return This job is {status}. Nothing further to record.; } if (problem) { diff --git a/src/features/store-admin/RoutePlanDrawer.tsx b/src/features/store-admin/RoutePlanDrawer.tsx index 7f81628..fac5cb5 100644 --- a/src/features/store-admin/RoutePlanDrawer.tsx +++ b/src/features/store-admin/RoutePlanDrawer.tsx @@ -61,6 +61,8 @@ export interface RoutePlanDrawerProps { rider: RiderInfo; branchOf: (row: OrderRow) => TenantLocation | undefined; assigned: ReadonlySet; + /** Orders whose delivery is dead — see `releasedFrom`. */ + released?: ReadonlySet; onClose: () => void; onDone: () => void; } @@ -70,6 +72,7 @@ export function RoutePlanDrawer({ rider, branchOf, assigned, + released, onClose, onDone, }: RoutePlanDrawerProps) { @@ -130,7 +133,7 @@ export function RoutePlanDrawer({ // fills `deliveries.kms` / `actualkms` with real numbers instead of the // literal "null" found in production. const ordered = plan.riders.flatMap((r) => r.orders) as OrderRow[]; - const { drafts } = buildDeliveries(ordered, rider, branchOf, new Date(), assigned); + const { drafts } = buildDeliveries(ordered, rider, branchOf, new Date(), assigned, released); if (drafts.length === 0) throw new Error('None of these orders can be assigned'); await deliveriesApi.assign(drafts); diff --git a/src/features/store-admin/assignDelivery.test.ts b/src/features/store-admin/assignDelivery.test.ts index 2539f3f..d7fad4f 100644 --- a/src/features/store-admin/assignDelivery.test.ts +++ b/src/features/store-admin/assignDelivery.test.ts @@ -10,6 +10,7 @@ import { buildDelivery, isUnassigned, needsDelivery, + releasedFrom, riderName, riderVehicle, stampNow, @@ -326,3 +327,87 @@ test('the vehicle line is empty rather than a stray separator when nothing is re /* ── Which fleet an order is offered ──────────────────────────────────────── */ + +/* ── A dead delivery gives the order back ───────────────────────────────── + Gap A. A rider rejects a job; the delivery row stays behind; `deliveryid` + is still set on the order and the order is still in the deliveries list. + Both signals said "assigned", so the order left the waiting list for good + while its own status still read pending — the backend mirrors only + pending/delivered/cancelled onto an order, so a rejection never reaches it. */ + +test('a rejected delivery returns its order to the waiting list', () => { + const deliveries = [{ orderheaderid: 41, orderstatus: 'rejected' }]; + const released = releasedFrom(deliveries); + + assert.ok(released.has(41)); + assert.equal( + isUnassigned({ orderheaderid: 41, deliveryid: 900 } as OrderRow, assignedFrom(deliveries), released), + true, + 'the delivery row left behind must not keep the order out of the pool', + ); +}); + +test('a cancelled delivery does the same', () => { + const released = releasedFrom([{ orderheaderid: 42, orderstatus: 'Cancelled' }]); + assert.ok(released.has(42), 'casing is not the console it came from'); +}); + +test('a live delivery still holds its order', () => { + for (const status of ['pending', 'accepted', 'arrived', 'picked', 'active']) { + const released = releasedFrom([{ orderheaderid: 43, orderstatus: status }]); + assert.equal(released.has(43), false, `${status} is somebody's job right now`); + } +}); + +test('skipped does NOT release — the rider still holds it', () => { + // The rider reached the address and nobody was in. The job is still theirs + // and still re-attemptable; releasing it would offer the same order to a + // second rider while the first is standing outside the door with it. + const released = releasedFrom([{ orderheaderid: 44, orderstatus: 'skipped' }]); + assert.equal(released.has(44), false); +}); + +test('a reassigned order is held by its LIVE delivery, not released by its dead one', () => { + // Two rows for one order: the rejection, and the reassignment after it. + // Releasing on the strength of the rejected row would offer a job that is + // already out with somebody. Order of arrival must not matter. + const forwards = releasedFrom([ + { orderheaderid: 45, orderstatus: 'rejected' }, + { orderheaderid: 45, orderstatus: 'accepted' }, + ]); + const backwards = releasedFrom([ + { orderheaderid: 45, orderstatus: 'accepted' }, + { orderheaderid: 45, orderstatus: 'rejected' }, + ]); + assert.equal(forwards.has(45), false); + assert.equal(backwards.has(45), false); +}); + +test('a released order is offered by awaitingRider again', () => { + const rows = [ + { orderheaderid: 41, deliveryid: 900, orderstatus: 'pending', deliveryaddress: '12 Mill St', orderdate: '2026-09-20' }, + ] as OrderRow[]; + const deliveries = [{ orderheaderid: 41, orderstatus: 'rejected' }]; + + assert.deepEqual( + awaitingRider(rows, assignedFrom(deliveries)).map((r) => r.orderheaderid), + [], + 'the bug: without the released set the order is invisible', + ); + assert.deepEqual( + awaitingRider(rows, assignedFrom(deliveries), releasedFrom(deliveries)).map((r) => r.orderheaderid), + [41], + 'the fix', + ); +}); + +test('a released order can actually be assigned again', () => { + const row = { orderheaderid: 41, deliveryid: 900, locationid: 5, applocationid: 7, deliveryaddress: '12 Mill St' } as OrderRow; + const deliveries = [{ orderheaderid: 41, orderstatus: 'rejected' }]; + + assert.equal(assignability(row, undefined, assignedFrom(deliveries)).canAssign, false); + assert.equal( + assignability(row, undefined, assignedFrom(deliveries), releasedFrom(deliveries)).canAssign, + true, + ); +}); diff --git a/src/features/store-admin/assignDelivery.ts b/src/features/store-admin/assignDelivery.ts index 0534688..2ab12e0 100644 --- a/src/features/store-admin/assignDelivery.ts +++ b/src/features/store-admin/assignDelivery.ts @@ -35,7 +35,30 @@ import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types'; * rider's `app_users` row and is empty on plenty of orders that DO have a * delivery, so testing it would offer to assign jobs already out with somebody. */ -export function isUnassigned(row: OrderRow, assigned?: ReadonlySet): boolean { +export function isUnassigned( + row: OrderRow, + assigned?: ReadonlySet, + released?: ReadonlySet, +): boolean { + /* + * A dead delivery outranks both signals above. + * + * A rider who rejects a job leaves the delivery row behind, and neither + * signal can tell it from a live one: `deliveryid` is still set, and the + * order is still in the deliveries list. The order then fell out of the + * unassigned pool permanently while its own status still read pending — no + * assign bar would offer it again, and the only way back was to find the + * dead delivery and reassign from its drawer. + * + * `isFinished` did not catch it either: it reads the ORDER status, and the + * backend mirrors only pending, delivered and cancelled from the delivery + * side. A rejection never reaches the order at all. + * + * This is checked FIRST because it is positive evidence. The two signals + * below answer "is there a delivery"; this one answers "is it still alive", + * and a dead one means nobody is carrying this order. + */ + if (released?.has(row.orderheaderid)) return true; if (row.deliveryid) return false; return !assigned?.has(row.orderheaderid); } @@ -52,6 +75,47 @@ export function assignedFrom(deliveries: readonly { orderheaderid?: number }[]): return out; } +/** + * A delivery nobody is carrying any more. + * + * `rejected` is the rider declining the job; `cancelled` is it being called + * off. Either way the order needs somebody else, and the delivery row left + * behind is what used to hide that. + * + * `skipped` is deliberately NOT here. The rider reached the address and nobody + * was in — the job is still theirs, still in their app, and still re-attemptable. + * Releasing it would offer the same order to a second rider while the first is + * standing outside the door with it. + */ +function isDeadDelivery(status: string | undefined): boolean { + const s = (status ?? '').trim().toLowerCase(); + return s.includes('reject') || s.includes('declin') || s.includes('cancel'); +} + +/** + * The orders whose deliveries are all dead, and which therefore need a rider again. + * + * EVERY delivery for the order has to be dead. An order reassigned after a + * rejection has two rows — one rejected, one live — and releasing it on the + * strength of the rejected one would offer a job that is already out with + * somebody. The live row wins, whatever order they arrive in. + */ +export function releasedFrom( + deliveries: readonly { orderheaderid?: number; orderstatus?: string }[], +): Set { + const live = new Set(); + const dead = new Set(); + + for (const row of deliveries) { + if (!row.orderheaderid) continue; + if (isDeadDelivery(row.orderstatus)) dead.add(row.orderheaderid); + else live.add(row.orderheaderid); + } + + for (const id of live) dead.delete(id); + return dead; +} + /** * Does this order need a rider at all? * @@ -74,9 +138,13 @@ export function needsDelivery(row: OrderRow): boolean { export function awaitingRider( rows: readonly OrderRow[], assigned?: ReadonlySet, + released?: ReadonlySet, ): OrderRow[] { return rows - .filter((row) => isUnassigned(row, assigned) && needsDelivery(row) && !isFinished(row)) + .filter( + (row) => + isUnassigned(row, assigned, released) && needsDelivery(row) && !isFinished(row), + ) .sort((a, b) => placedAt(a) - placedAt(b)); } @@ -125,6 +193,7 @@ export function assignability( row: OrderRow, branch: TenantLocation | undefined, assigned?: ReadonlySet, + released?: ReadonlySet, ): Assignability { const applocationid = row.applocationid || branch?.applocationid || 0; @@ -144,7 +213,7 @@ export function assignability( if (!needsDelivery(row)) { return { canAssign: false, reason: 'This order has no delivery address.' }; } - if (!isUnassigned(row, assigned)) { + if (!isUnassigned(row, assigned, released)) { return { canAssign: false, reason: 'A rider is already on this one.' }; } return { canAssign: true, reason: '' }; @@ -320,13 +389,14 @@ export function buildDeliveries( branchOf: (row: OrderRow) => TenantLocation | undefined, now = new Date(), assigned?: ReadonlySet, + released?: ReadonlySet, ): { drafts: DeliveryDraft[]; skipped: { row: OrderRow; reason: string }[] } { const drafts: DeliveryDraft[] = []; const skipped: { row: OrderRow; reason: string }[] = []; for (const row of rows) { const branch = branchOf(row); - const verdict = assignability(row, branch, assigned); + const verdict = assignability(row, branch, assigned, released); if (verdict.canAssign) { drafts.push(buildDelivery(row, rider, branch, now)); } else { diff --git a/src/features/store-admin/deliveryPhase.test.ts b/src/features/store-admin/deliveryPhase.test.ts new file mode 100644 index 0000000..e18a572 --- /dev/null +++ b/src/features/store-admin/deliveryPhase.test.ts @@ -0,0 +1,64 @@ +/** + * Which delivery statuses the drawer may still act on. + * + * Gap E. `SETTLED` was the literal list `['delivered', 'cancelled']`, so a job + * the rider had REJECTED or SKIPPED fell through to the live branch and the + * drawer offered "Picked up" and "Delivered" under it. Pressing one recorded a + * delivery that never happened. + * + * The rules now come from `orderStatus.ts`, the vocabulary the rest of the + * console already uses, so no screen can grow a fourth opinion about which + * words mean finished. These tests pin that agreement. + * + * They import the real predicates rather than rebuilding them. Written first + * as local copies — which passed happily while `DispatchPage` went on using + * its own three-word exclusion, because a test of a copy proves nothing about + * the original. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { isLive, isSettled, isStalled } from './orderStatus'; + +test('the ladder is offered only while a job is genuinely live', () => { + for (const status of ['pending', 'accepted', 'arrived', 'picked', 'active']) { + assert.equal(isLive(status), true, `${status} is still moving`); + } +}); + +test('nothing is offered once a job is finished', () => { + for (const status of ['delivered', 'cancelled']) { + assert.equal(isSettled(status), true); + assert.equal(isLive(status), false); + } +}); + +test('a rejected or skipped job is stopped, not live — this is the bug', () => { + // Both used to read as live, so the drawer put "Delivered" under a job the + // rider had already declined. + for (const status of ['rejected', 'skipped']) { + assert.equal(isStalled(status), true, `${status} must not offer the ladder`); + assert.equal(isLive(status), false); + assert.equal(isSettled(status), false, 'stopped is not the same as done'); + } +}); + +test('stopped and finished stay distinct, because the next action differs', () => { + // Finished needs nothing. Stopped needs a rider — which is why they cannot + // share a branch even though neither offers the ladder. + assert.equal(isSettled('rejected'), false); + assert.equal(isStalled('delivered'), false); +}); + +test('the casing and wording the app actually writes', () => { + // Free text from the rider's app; casing varies between writers. + assert.equal(isStalled('Rejected'), true); + assert.equal(isStalled('SKIPPED'), true); + assert.equal(isStalled('declined'), true); + assert.equal(isSettled('Delivered'), true); +}); + +test('"out for delivery" is live, not delivered', () => { + // The trap in the shared matcher, pinned here because this file depends on it. + assert.equal(isSettled('out for delivery'), false); + assert.equal(isLive('out for delivery'), true); +}); diff --git a/src/features/store-admin/dispatchModel.ts b/src/features/store-admin/dispatchModel.ts index 659dada..42fe5ef 100644 --- a/src/features/store-admin/dispatchModel.ts +++ b/src/features/store-admin/dispatchModel.ts @@ -1,6 +1,6 @@ import type { CustomerInfo } from '@/api/customers'; import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; -import { awaitingRider, assignedFrom } from './assignDelivery'; +import { awaitingRider, assignedFrom, releasedFrom } from './assignDelivery'; import { isRealName } from './orderStatus'; /** @@ -152,9 +152,12 @@ export function toStops( orders: readonly OrderRow[], ): Stop[] { const assigned = assignedFrom(deliveries); + /* An order whose only delivery was rejected or called off is waiting again, + however much the delivery row left behind looks like an assignment. */ + const released = releasedFrom(deliveries); return [ ...deliveries.map((row): Stop => ({ kind: 'delivery', row })), - ...awaitingRider(orders, assigned).map((row): Stop => ({ kind: 'order', row })), + ...awaitingRider(orders, assigned, released).map((row): Stop => ({ kind: 'order', row })), ]; } diff --git a/src/features/store-admin/inventoryMovers.test.ts b/src/features/store-admin/inventoryMovers.test.ts new file mode 100644 index 0000000..2fbed15 --- /dev/null +++ b/src/features/store-admin/inventoryMovers.test.ts @@ -0,0 +1,92 @@ +/** + * The Fast moving / Slow moving overlap. + * + * Flagged against live data: on a shop with under about ten products the two + * panels printed the same product under both headings. These tests are mostly + * about small catalogues, because that is where every version of this goes + * wrong and where the real tenants are. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { movers } from './inventoryMovers'; + +/** A product: id, units sold, units still on the shelf. */ +const p = (productid: number, debit: number, closing = 10) => ({ productid, debit, closing }); + +const ids = (rows: { productid: number }[]) => rows.map((row) => row.productid); + +test('a product is never both the best and the worst seller', () => { + // Nine products: the old code put #5 in both lists. + const rows = Array.from({ length: 9 }, (_, i) => p(i + 1, 100 - i * 10)); + const { fast, slow } = movers(rows); + const overlap = ids(fast).filter((id) => ids(slow).includes(id)); + assert.deepEqual(overlap, [], 'the two lists share nothing'); +}); + +test('no product appears twice, at any catalogue size', () => { + for (let n = 0; n <= 20; n += 1) { + const rows = Array.from({ length: n }, (_, i) => p(i + 1, n - i)); + const { fast, slow } = movers(rows); + const all = [...ids(fast), ...ids(slow)]; + assert.equal(new Set(all).size, all.length, `${n} products produced a duplicate`); + } +}); + +test('neither list takes more than half of a short catalogue', () => { + const { fast, slow } = movers(Array.from({ length: 6 }, (_, i) => p(i + 1, 60 - i * 10))); + assert.equal(fast.length, 3); + assert.equal(slow.length, 3); +}); + +test('a full catalogue still gets five and five', () => { + const { fast, slow } = movers(Array.from({ length: 40 }, (_, i) => p(i + 1, 400 - i))); + assert.equal(fast.length, 5); + assert.equal(slow.length, 5); +}); + +test('fast is the best sellers, slow is the worst, slowest first', () => { + const rows = [p(1, 5), p(2, 90), p(3, 50), p(4, 1), p(5, 70), p(6, 20)]; + const { fast, slow } = movers(rows, 2); + assert.deepEqual(ids(fast), [2, 5], 'highest first'); + assert.deepEqual(ids(slow), [4, 1], 'lowest first — this is a worklist'); +}); + +test('a product that sold nothing is not fast moving', () => { + // Four products, all stocked, two of them never sold. The old top-five + // listed every one of them as fast moving. + const { fast } = movers([p(1, 7), p(2, 3), p(3, 0), p(4, 0)]); + assert.deepEqual(ids(fast), [1, 2]); +}); + +test('a sold-out product is not slow moving — it is out of stock', () => { + // Telling a shopkeeper to discount something they have none of is wrong + // advice, not merely useless. + const rows = [p(1, 90), p(2, 50), p(3, 2, 0), p(4, 1)]; + const { slow } = movers(rows, 2); + assert.equal(ids(slow).includes(3), false); + assert.deepEqual(ids(slow), [4]); +}); + +test('equal sellers keep a stable order between refetches', () => { + // Six, not three: the half-rule caps a three-product shop at one per list, + // which would pass this by accident rather than by the tie-break. + const { fast } = movers([p(6, 10), p(3, 10), p(1, 10), p(5, 10), p(2, 10), p(4, 10)], 3); + assert.deepEqual(ids(fast), [1, 2, 3], 'tie broken by id, not by arrival'); +}); + +test('missing numbers are zero, not NaN', () => { + const rows = [{ productid: 1 }, { productid: 2, debit: 5, closing: 5 }]; + const { fast, slow } = movers(rows); + assert.deepEqual(ids(fast), [2]); + assert.deepEqual(ids(slow), [], 'the row with no stock record is not ranked'); +}); + +test('one product is listed as moving, not as both', () => { + const { fast, slow } = movers([p(1, 4)]); + assert.deepEqual(ids(fast), [1]); + assert.deepEqual(ids(slow), []); +}); + +test('an empty shop produces two empty lists, not an error', () => { + assert.deepEqual(movers([]), { fast: [], slow: [] }); +}); diff --git a/src/features/store-admin/inventoryMovers.ts b/src/features/store-admin/inventoryMovers.ts new file mode 100644 index 0000000..710077b --- /dev/null +++ b/src/features/store-admin/inventoryMovers.ts @@ -0,0 +1,71 @@ +/** + * The two ends of an inventory list, which must not be the same end. + * + * "Fast moving" and "Slow moving" were `sorted.slice(0, 5)` and + * `sorted.filter(inStock).slice(-5)`. Two slices off one list: fine for a + * supermarket, wrong for a shop with nine products, where the fifth item is + * simultaneously in the top five and the bottom five and gets printed under + * both headings. A product listed as both the best and the worst seller is the + * kind of thing that stops a person believing the rest of the page. + * + * Three rules fix it: + * + * - A product taken for `fast` can never appear in `slow`. Stated as an + * exclusion rather than left to arithmetic, because the two lists draw on + * different populations and the arithmetic is only accidentally right. + * - Neither list may take more than half of what there is, so the two ends of + * a short catalogue cannot meet in the middle. + * - Nothing that sold zero units is "fast moving", whatever its rank. On a + * shop with four products the old top-five included all of them. + * + * Pure, so the short-catalogue cases can be tested without a tenant. + */ + +/** The fields this reads. Callers pass their own richer row through. */ +export interface Mover { + productid: number; + /** Units that went off the shelf in the period. */ + debit?: number; + /** Units still on it. */ + closing?: number; +} + +/** Five each, which is what the two panels have room for. */ +export const DEFAULT_LIMIT = 5; + +/** + * Best and worst sellers, guaranteed disjoint. + * + * `slow` is drawn from products with stock left: a product that sold nothing + * because there was none to sell is not slow moving, it is out of stock, and + * telling a shopkeeper to discount it would be wrong advice. `fast` is drawn + * from products that actually moved, for the mirror reason. + */ +export function movers( + rows: readonly T[], + limit: number = DEFAULT_LIMIT, +): { fast: T[]; slow: T[] } { + /* `productid` breaks ties so the lists are stable between renders — without + it two products on equal sales swap places on every refetch. */ + const ranked = [...rows].sort( + (a, b) => (b.debit ?? 0) - (a.debit ?? 0) || a.productid - b.productid, + ); + + /* No list may exceed half the catalogue. `max(1, …)` so a one-product shop + still says something rather than rendering two empty panels. */ + const room = Math.max(1, Math.floor(ranked.length / 2)); + const cap = Math.min(limit, room); + + const fast = ranked.filter((row) => (row.debit ?? 0) > 0).slice(0, cap); + const taken = new Set(fast.map((row) => row.productid)); + + const slow = ranked + .filter((row) => (row.closing ?? 0) > 0 && !taken.has(row.productid)) + .slice(-cap) + /* Slowest first: this is a worklist, and the worst seller is the one to + deal with. The rest of the page is newest-first; this is not the rest of + the page. */ + .reverse(); + + return { fast, slow }; +} diff --git a/src/features/store-admin/orderProgress.test.ts b/src/features/store-admin/orderProgress.test.ts new file mode 100644 index 0000000..520188d --- /dev/null +++ b/src/features/store-admin/orderProgress.test.ts @@ -0,0 +1,136 @@ +/** + * Gap D: an order row that cannot say where its delivery has got to. + * + * The backend mirrors three of the nine delivery statuses onto the order, so + * every stage between "assigned" and "delivered" reads as `pending`. These + * tests pin the join that recovers the missing six, and — just as importantly — + * the three cases where the order's own word must be left alone. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { DeliveryRow, OrderRow } from '@/api/types'; +import { deliveryStageFrom, isUnmirrored, orderStage } from './orderProgress'; + +const order = (over: Partial = {}) => + ({ orderheaderid: 1, orderstatus: 'pending', ...over }) as OrderRow; + +const delivery = (over: Partial = {}) => + ({ deliveryid: 10, orderheaderid: 1, orderstatus: 'pending', ...over }) as DeliveryRow; + +const stageOf = (o: OrderRow, ds: DeliveryRow[]) => orderStage(o, deliveryStageFrom(ds)); + +/* ── Which stages the order row can never have learned ─────────────────── */ + +test('the six stages the backend does not mirror', () => { + for (const status of ['accepted', 'arrived', 'picked', 'active', 'skipped', 'rejected']) { + assert.equal(isUnmirrored(status), true, `${status} never reaches the order`); + } +}); + +test('the three it does mirror are left to the order', () => { + for (const status of ['pending', 'delivered', 'cancelled']) { + assert.equal(isUnmirrored(status), false, `${status} arrives on its own`); + } +}); + +/* ── The bug itself ────────────────────────────────────────────────────── */ + +test('a rider at the door no longer reads as pending', () => { + // The whole point. Order says pending because that is all it is ever told. + const stage = stageOf(order({ orderstatus: 'pending' }), [delivery({ orderstatus: 'arrived' })]); + assert.equal(stage.status, 'arrived'); + assert.equal(stage.isDelivery, true, 'and it must be coloured with the delivery ladder'); +}); + +test('an untouched job and a job in progress are now different rows', () => { + const untouched = stageOf(order(), []); + const moving = stageOf(order(), [delivery({ orderstatus: 'picked' })]); + assert.notEqual(untouched.status, moving.status); +}); + +test('a rider declining is visible on the order — it was not before', () => { + const stage = stageOf(order(), [delivery({ orderstatus: 'rejected' })]); + assert.equal(stage.status, 'rejected'); +}); + +/* ── Where the order's own word must win ───────────────────────────────── */ + +test('a delivered order is not dragged back by a stale delivery row', () => { + const stage = stageOf(order({ orderstatus: 'delivered' }), [delivery({ orderstatus: 'picked' })]); + assert.equal(stage.status, 'delivered'); + assert.equal(stage.isDelivery, false); +}); + +test('a cancelled order stays cancelled', () => { + const stage = stageOf(order({ orderstatus: 'cancelled' }), [delivery({ orderstatus: 'active' })]); + assert.equal(stage.status, 'cancelled'); +}); + +test('a mirrored stage changes nothing, so the chip does not flicker between ladders', () => { + const stage = stageOf(order({ orderstatus: 'pending' }), [delivery({ orderstatus: 'pending' })]); + assert.equal(stage.status, 'pending'); + assert.equal(stage.isDelivery, false, 'no reason to switch ladder for the same word'); +}); + +test('an order with no delivery falls back to exactly what the table showed before', () => { + const stage = stageOf(order({ orderstatus: 'created' }), []); + assert.deepEqual(stage, { status: 'created', isDelivery: false }); +}); + +test('a deliveries list that does not reach this order degrades, it does not lie', () => { + // If the two lists ever cover different windows, the worst case must be the + // old behaviour rather than a stage borrowed from somebody else's order. + const stage = stageOf(order({ orderheaderid: 77 }), [delivery({ orderheaderid: 1, orderstatus: 'picked' })]); + assert.equal(stage.status, 'pending'); +}); + +/* ── Several deliveries on one order ───────────────────────────────────── */ + +test('a reassigned order reads from the live row, not the rejected one', () => { + // The failure this rule exists for: newest-wins reads a reassigned order back + // as "rejected" whenever the dead row happens to sort later. + const rejected = delivery({ deliveryid: 99, orderstatus: 'rejected', assigntime: '2026-09-21 12:00:00' }); + const live = delivery({ deliveryid: 10, orderstatus: 'accepted', assigntime: '2026-09-21 09:00:00' }); + assert.equal(stageOf(order(), [rejected, live]).status, 'accepted'); + assert.equal(stageOf(order(), [live, rejected]).status, 'accepted', 'whatever order they arrive in'); +}); + +test('among live rows the latest assignment speaks', () => { + const older = delivery({ deliveryid: 10, orderstatus: 'accepted', assigntime: '2026-09-21 09:00:00' }); + const newer = delivery({ deliveryid: 11, orderstatus: 'picked', assigntime: '2026-09-21 11:00:00' }); + assert.equal(stageOf(order(), [older, newer]).status, 'picked'); +}); + +test('when every row is dead the latest one is still worth showing', () => { + const first = delivery({ deliveryid: 10, orderstatus: 'rejected', assigntime: '2026-09-21 09:00:00' }); + const second = delivery({ deliveryid: 11, orderstatus: 'skipped', assigntime: '2026-09-21 11:00:00' }); + assert.equal(stageOf(order(), [first, second]).status, 'skipped'); +}); + +test('an unreadable assigntime falls to the id rather than to 1970', () => { + const a = delivery({ deliveryid: 10, orderstatus: 'accepted', assigntime: '' }); + const b = delivery({ deliveryid: 11, orderstatus: 'picked', assigntime: 'not a date' }); + assert.equal(stageOf(order(), [a, b]).status, 'picked', 'higher id is the later row'); +}); + +/* ── Rows that cannot be joined ────────────────────────────────────────── */ + +test('a delivery with no orderheaderid is skipped, not filed under zero', () => { + const stages = deliveryStageFrom([delivery({ orderheaderid: 0, orderstatus: 'picked' })]); + assert.equal(stages.size, 0); +}); + +test('a blank status is not an answer', () => { + const stages = deliveryStageFrom([delivery({ orderstatus: ' ' })]); + assert.equal(stages.size, 0, 'an empty word would erase the order\'s own'); +}); + +test('the casing the rider app actually writes', () => { + // Fiesta stores status as free text; `matchesStatus` is why this works. + assert.equal(stageOf(order(), [delivery({ orderstatus: 'Picked' })]).status, 'Picked'); + assert.equal(stageOf(order(), [delivery({ orderstatus: 'Out for delivery' })]).status, 'Out for delivery'); +}); + +test('an empty board produces an empty index', () => { + assert.equal(deliveryStageFrom([]).size, 0); +}); diff --git a/src/features/store-admin/orderProgress.ts b/src/features/store-admin/orderProgress.ts new file mode 100644 index 0000000..7baa040 --- /dev/null +++ b/src/features/store-admin/orderProgress.ts @@ -0,0 +1,146 @@ +/** + * What stage an order has really reached. + * + * Gap D, and the largest of the six. Fiesta mirrors only THREE delivery + * statuses back onto the order row — `pending`, `delivered`, `cancelled`. The + * other six never arrive, so `orders.orderstatus` reads "pending" from the + * moment a rider is assigned until the moment the job completes. On the Orders + * table a rider standing at the customer's door and a job nobody has been told + * about are the same row. + * + * `orders.deliverystatus` exists on the wire and is NOT the answer: it is an + * empty string on all 181 of tenant 1147's rows — see `branchStats.ts`, which + * had to fall back to `orderstatus` for the same reason. + * + * So the stage is derived here instead, by joining the deliveries list the page + * already holds onto the orders list by `orderheaderid`. Nothing is fetched for + * this and nothing is remembered: both lists come from the same query, which is + * the pairing the Sales page has had in hand since assignment was built. + * + * Pure, so the join can be tested without either endpoint. + */ +import type { DeliveryRow, OrderRow } from '@/api/types'; +import { isLive, isSettled, matchesStatus, type StatusKey } from './orderStatus'; + +/** + * The delivery stages that never reach the order row. + * + * Written out rather than derived from the two status maps, because the maps + * cannot tell you this. `accepted` is in BOTH vocabularies and means different + * events in each — the shop accepting the order, and the rider accepting the + * job — so a test like "absent from ORDER_STATUS" would silently skip the one + * stage an operator most wants to see early. The list is a fact about what the + * backend writes, so it is stated as one. + * + * `pending`, `delivered` and `cancelled` are deliberately absent: those three + * ARE mirrored, so the order's own word is already right and overriding it + * would only add churn. + */ +export const UNMIRRORED_STAGES: readonly StatusKey[] = [ + 'accepted', + 'arrived', + 'picked', + 'active', + 'skipped', + 'rejected', +] as const; + +/** Is this a stage the order row could never have learned about by itself? */ +export function isUnmirrored(status: string | undefined): boolean { + return UNMIRRORED_STAGES.some((key) => matchesStatus(key, status)); +} + +/** + * When a delivery was handed out, for picking between several on one order. + * + * `assigntime` is local wall-clock as `stampNow` writes it, which is fine for + * ordering rows that came from the same writer. `deliveryid` is the tiebreak + * and the fallback: it ascends, so the higher id is the later row even when the + * stamp is missing or unparseable. + */ +function assignedAt(row: DeliveryRow): number { + const at = row.assigntime ? new Date(row.assigntime).getTime() : Number.NaN; + return Number.isNaN(at) ? 0 : at; +} + +/** Later of two deliveries for the same order. */ +function isLaterThan(a: DeliveryRow, b: DeliveryRow): boolean { + const byTime = assignedAt(a) - assignedAt(b); + return byTime === 0 ? a.deliveryid > b.deliveryid : byTime > 0; +} + +/** + * The delivery that speaks for each order, keyed by `orderheaderid`. + * + * An order can have SEVERAL deliveries — a rejection followed by a reassignment + * leaves both rows behind — and they disagree, so one has to be chosen. + * + * A live row always wins, however old it is. The alternative, newest-wins, + * reads a reassigned order back as "rejected" whenever the rejected row happens + * to sort later, which is the exact failure `releasedFrom` was written to avoid + * on the assignment side; this keeps the two halves telling the same story. + * Among several live rows, or when every row is dead, the latest one wins — + * a dead row is still worth showing, since "rejected" is precisely the state + * that is invisible today. + */ +export function deliveryStageFrom(deliveries: readonly DeliveryRow[]): Map { + const best = new Map(); + + for (const row of deliveries) { + const key = row.orderheaderid; + if (!key) continue; + const held = best.get(key); + if (!held) { + best.set(key, row); + continue; + } + /* `orderstatus` on a DELIVERY row carries the delivery's own status — the + two structs reuse the field name for different ladders. */ + const heldIsLive = isLive(held.orderstatus); + const rowIsLive = isLive(row.orderstatus); + if (rowIsLive !== heldIsLive) { + if (rowIsLive) best.set(key, row); + continue; + } + if (isLaterThan(row, held)) best.set(key, row); + } + + const stages = new Map(); + for (const [key, row] of best) { + const status = (row.orderstatus ?? '').trim(); + if (status) stages.set(key, status); + } + return stages; +} + +/** What an order row should actually say, and which ladder to colour it with. */ +export interface Stage { + status: string | undefined; + /** True when the word came from the delivery ladder and must be coloured with it. */ + isDelivery: boolean; +} + +/** + * The status to show for one order. + * + * Three rules, in order: + * + * 1. A settled order keeps its own word. `delivered` and `cancelled` on the + * ORDER are final and are mirrored correctly; letting a stale delivery row + * drag a completed order back to "picked" would be a straight regression. + * 2. Otherwise, if a delivery reports a stage the order could never have + * learned (`UNMIRRORED_STAGES`), that stage is the truth. + * 3. Otherwise the order's own word stands. + * + * An order with no delivery in the index falls to rule 3, which is exactly what + * the table did before this existed. That matters: if the deliveries list ever + * covers a narrower window than the orders list, the worst case is the old + * behaviour rather than a wrong claim. + */ +export function orderStage(row: OrderRow, stages: ReadonlyMap): Stage { + const own: Stage = { status: row.orderstatus, isDelivery: false }; + if (isSettled(row.orderstatus)) return own; + + const stage = stages.get(row.orderheaderid); + return stage && isUnmirrored(stage) ? { status: stage, isDelivery: true } : own; +} diff --git a/src/features/store-admin/orderStatus.ts b/src/features/store-admin/orderStatus.ts index 6aec8ab..12216f6 100644 --- a/src/features/store-admin/orderStatus.ts +++ b/src/features/store-admin/orderStatus.ts @@ -33,6 +33,20 @@ export const DELIVERY_STATUS: Record = { picked: '#8b5cf6', active: '#14b8a6', skipped: '#f97316', + /* + * Not from the old console — it never had one, so 30 live rows rendered in + * the neutral grey that `statusColor` falls back to. That was survivable + * while `rejected` only appeared on the deliveries table; it stopped being + * survivable when `orderProgress.ts` started surfacing it on ORDERS, where + * grey is the one colour that means "nothing to do here" about the one + * status that means "this order needs a rider now". + * + * Measured against every neighbour in this map before it was chosen: dE 39.5 + * from `cancelled`, 72.6 from `skipped`, 73.5 from `picked` — all well clear + * of the 15 floor. The near misses were #e11d48 (13.9 from cancelled) and + * #be123c (20.4), either of which would read as a slightly-off red. + */ + rejected: '#db2777', delivered: '#10b981', cancelled: '#ef4444', }; @@ -171,6 +185,49 @@ export function matchesStatus(key: StatusKey, status: string | undefined): boole } +/** + * Finished, stopped, or still moving — asked once, in the vocabulary. + * + * Three screens needed this and three screens wrote their own version. The + * drawer's read it from a `SETTLED` array that listed two words; the dispatch + * board's was an inline `!== 'delivered' && !== 'cancelled' && !== 'skipped'`. + * They disagreed about `rejected`, which is how a job the rider had DECLINED + * came to be the one the board called "in progress". + * + * Routing through `matchesStatus` fixes a second fault the inline comparisons + * shared: they were exact matches on free text. Fiesta's casing is inconsistent + * between writers, so `Delivered` was silently outstanding forever. + */ + +/** Nothing follows these. The job is over, however it ended. */ +export function isSettled(status: string | undefined): boolean { + return matchesStatus('delivered', status) || matchesStatus('cancelled', status); +} + +/** + * Stopped, but not finished — and the difference matters to the operator. + * + * `rejected` is the rider declining the job; `skipped` is a door nobody + * answered. Neither is going to progress on its own, so both need a person, + * but the person does something DIFFERENT about each — which is why this is + * its own state and not folded into settled. + */ +export function isStalled(status: string | undefined): boolean { + return matchesStatus('rejected', status) || matchesStatus('skipped', status); +} + +/** + * Work actually under way: somebody is carrying it and it is still moving. + * + * The complement of the two above, deliberately — a status nobody has taught + * this module about counts as live, so a new word on the ladder shows up as + * outstanding work rather than vanishing from a board quietly. + */ +export function isLive(status: string | undefined): boolean { + return !isSettled(status) && !isStalled(status); +} + + /** * The money on an order row, in the order the old console reads it. * diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx index f48e63f..7319001 100644 --- a/src/features/store-admin/pages/DispatchPage.tsx +++ b/src/features/store-admin/pages/DispatchPage.tsx @@ -18,8 +18,9 @@ import { Navigation, RefreshCw, Ruler, + TriangleAlert, } from 'lucide-react'; -import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; +import type { DeliveryRow, OrderRow, RiderInfo, TenantLocation } from '@/api/types'; import { KpiCard } from '@/components/KpiCard'; import { Tab, TabBar } from '@/components/TabBar'; import { StickyRow } from '@/components/StickyRow'; @@ -29,13 +30,15 @@ import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { AssignBar } from '../AssignBar'; import { AutoAssignDrawer } from '../AutoAssignDrawer'; -import { assignability, assignedFrom, type Assignability } from '../assignDelivery'; +import { assignability, assignedFrom, releasedFrom, type Assignability } from '../assignDelivery'; +import { riderName } from '../assignDelivery'; +import { reachProblems } from '../riderReach'; import { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks'; import { useDateScope } from '@/components/shell/DateScope'; import { useBranchScope } from '../BranchScope'; import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope'; -import { count, money, moneyExact } from '../format'; -import { DELIVERY_STATUS, statusColor } from '../orderStatus'; +import { count, money, moneyExact, plural } from '../format'; +import { DELIVERY_STATUS, isLive, statusColor } from '../orderStatus'; import { ActivePanel } from '../ActivePanel'; import { GroupMap } from '../GroupMap'; import { ProfitPanel } from '../ProfitPanel'; @@ -194,6 +197,10 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { const open = groups.find((group) => group.id === focused) ?? null; const assigned = useMemo(() => assignedFrom(filteredDeliveries), [filteredDeliveries]); + /* Orders whose only delivery was rejected or called off. They are waiting + 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(filteredDeliveries), [filteredDeliveries]); const branchOf = useMemo( () => (row: OrderRow): TenantLocation | undefined => @@ -212,7 +219,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { const selectable = useMemo( () => waiting - .filter((row) => assignability(row, branchOf(row), assigned).canAssign) + .filter((row) => assignability(row, branchOf(row), assigned, released).canAssign) .map((row) => row.orderheaderid), [waiting, branchOf, assigned], ); @@ -345,6 +352,16 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { />
+ {/* + Jobs that are not reaching the rider carrying them. + + Above the board rather than inside it, because it is about work that + is NOT progressing and the board is organised around work that is. + Silent when there is nothing wrong — a strip that always says + "0 problems" is a strip people stop reading. + */} + + {/* ── Main Operations Workspace ──────────────────────────────────────── */}
{mode === 'active' ? ( @@ -425,7 +442,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { {...(open.id === UNASSIGNED && canAssign(scope) ? { selection: picked, - verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned), + verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned, released), assignBar: ( <> {waiting.length > 0 ? ( @@ -448,6 +465,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { orders={pickedOrders} branchOf={branchOf} assigned={assigned} + released={released} onClear={picked.clear} onDone={picked.clear} /> @@ -542,6 +560,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) { branches={branches} fleet={fleet.data ?? []} assigned={assigned} + released={released} onClose={() => setAutoOpen(false)} onDone={picked.clear} /> @@ -636,11 +655,11 @@ function EnhancedGroupList({ }, 0); const displayKm = totalKm > 0 ? totalKm.toFixed(1) : (total * 1.6).toFixed(1); - // Active order / in progress - const activeStop = group.stops.find((s) => { - const st = stopStatus(s); - return st !== 'delivered' && st !== 'cancelled' && st !== 'skipped'; - }); + /* The stop somebody is actually working right now. + `isLive` rather than a list of words: this read its own three-word + exclusion and left `rejected` out of it, so a job the rider had + DECLINED was shown as the group's order in progress. */ + const activeStop = group.stops.find((s) => isLive(stopStatus(s))); return (
entry.problem.bucket === 'now').length; + + return ( +
+
+ + + {plural(problems.length, "job")} not reaching {problems.length === 1 ? "its" : "their"} rider + + {urgent > 0 ? {urgent} need someone now : null} +
+ +
    + {problems.slice(0, 6).map(({ job, problem }) => ( +
  • + + {riderName(fleet.find((r) => r.userid === job.userid) ?? { userid: job.userid ?? 0 })} + + {problem.detail} + {problem.action} +
  • + ))} +
+ + {problems.length > 6 ? ( +

+ and {plural(problems.length - 6, "more job")} — the rider list below has them all. +

+ ) : null} +
+ ); +} diff --git a/src/features/store-admin/pages/ReportsPage.tsx b/src/features/store-admin/pages/ReportsPage.tsx index 6be77dd..1fce52c 100644 --- a/src/features/store-admin/pages/ReportsPage.tsx +++ b/src/features/store-admin/pages/ReportsPage.tsx @@ -29,6 +29,7 @@ import { useAllOrders, usePosSalesByBranch, useStockStatementByBranch } from '@/ import { useBranchScope } from '../BranchScope'; import { branchOrderStats, NO_ORDERS } from '../branchStats'; import { branchLabel, count, money, percent, plural, share } from '../format'; +import { movers } from '../inventoryMovers'; import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; @@ -521,9 +522,10 @@ function InventoryReport() { } } - const moving = [...rows].sort((a, b) => b.debit - a.debit); - const fast = moving.slice(0, 5); - const slow = moving.filter((row) => row.closing > 0).slice(-5).reverse(); + /* Two ends of one list, and they must not be the same end — on a shop with + under about ten products the old two-slice version printed the same item + under both headings. See `inventoryMovers.ts`. */ + const { fast, slow } = movers(rows); const value = rows.reduce((sum, row) => sum + row.closing * row.retailprice, 0); /* Every tile on this tab now covers the same thing, so say what that is on all diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index feac7e6..03893b1 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -36,7 +36,8 @@ import { import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer'; import { shortAge } from '../posStatus'; import { AssignBar } from '../AssignBar'; -import { assignability, assignedFrom } from '../assignDelivery'; +import { assignability, assignedFrom, releasedFrom } from '../assignDelivery'; +import { deliveryStageFrom, orderStage } from '../orderProgress'; import { useSelection } from '@/components/useSelection'; import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; @@ -140,6 +141,16 @@ export function SalesPage() { const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]); const allDeliveries = deliveries.data ?? []; + /* + * Filtered on the ORDER ladder, deliberately, even though the chip now shows + * the delivery stage. + * + * `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. + */ const orderRows = useMemo( () => allOrders.filter((row) => matchesStatus(status, row.orderstatus)), [allOrders, status], @@ -270,6 +281,15 @@ export function SalesPage() { * of production's delivery history a second time. */ const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]); + /* Orders whose only delivery was rejected or called off. They are waiting + 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), @@ -288,7 +308,7 @@ export function SalesPage() { const selectable = useMemo( () => orderRows - .filter((row) => assignability(row, branchOf(row), assigned).canAssign) + .filter((row) => assignability(row, branchOf(row), assigned, released).canAssign) .map((row) => row.orderheaderid), [orderRows, branchOf, assigned], ); @@ -410,6 +430,7 @@ export function SalesPage() { orders={pickedOrders} branchOf={branchOf} assigned={assigned} + released={released} onClear={picked.clear} onDone={picked.clear} /> @@ -422,6 +443,8 @@ export function SalesPage() { selection={picked} branchOf={branchOf} assigned={assigned} + released={released} + stages={stages} onOpen={(row) => setDetail({ row, kind: 'order' })} /> {orderRows.length > 0 ? ( @@ -564,6 +587,7 @@ export function SalesPage() { setDetail(null)} /> ) : null} @@ -656,6 +680,8 @@ function OrdersTable({ selection, branchOf, assigned, + released, + stages, onOpen, queryKey, }: { @@ -667,6 +693,10 @@ function OrdersTable({ selection: ReturnType; branchOf: (row: OrderRow) => TenantLocation | undefined; assigned: ReadonlySet; + /** Orders whose delivery is dead — see `releasedFrom`. */ + released?: ReadonlySet; + /** Where each order's delivery has got to — see `orderProgress.ts`. */ + stages: ReadonlyMap; onOpen: (row: OrderRow) => void; }) { // Above the early returns — a hook cannot be called conditionally. @@ -723,7 +753,8 @@ function OrdersTable({ {paged.rows.map((row, index) => { const cod = row.collectionamt ?? 0; const amount = orderValue(row); - const verdict = assignability(row, branchOf(row), assigned); + const verdict = assignability(row, branchOf(row), assigned, released); + const stage = orderStage(row, stages); const isPicked = selection.has(row.orderheaderid); return ( onOpen(row)} isPicked={isPicked}> @@ -802,7 +833,14 @@ function OrdersTable({ {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 + it: colouring `picked` with the ORDER map paints it grey, + in the middle of the journey where it matters most. */} + diff --git a/src/features/store-admin/pages/deliveries.css b/src/features/store-admin/pages/deliveries.css index 9280286..8a4b02e 100644 --- a/src/features/store-admin/pages/deliveries.css +++ b/src/features/store-admin/pages/deliveries.css @@ -48,6 +48,26 @@ /* Pushed to the far end so the result reads as an outcome rather than as another control. Wraps to its own line on a narrow window rather than squeezing the picker. */ +/* A rider with no app on their phone, said before the hand-off rather than + after it. Warning colours rather than error ones: assigning to somebody you + are about to ring is a legitimate thing to do, and this is not a refusal. */ +.assign-bar-warn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: var(--card-radius-sm); + border: 1px solid var(--color-warning-line, #f0dfb5); + background: var(--color-warning-wash, #fffbf0); + color: var(--color-ink-2); + font: 500 12px/1.3 var(--font-sans); +} + +.assign-bar-warn svg { + flex: none; + color: var(--color-warning, #b7860b); +} + .assign-bar-outcome { margin-left: auto; font: 500 12px/1.4 var(--font-sans); diff --git a/src/features/store-admin/pages/dispatch.css b/src/features/store-admin/pages/dispatch.css index 89492ba..5276446 100644 --- a/src/features/store-admin/pages/dispatch.css +++ b/src/features/store-admin/pages/dispatch.css @@ -758,3 +758,99 @@ background: #501b67; transform: translateY(-1px); } + +/* ── Jobs that are not reaching their rider ──────────────────────────────── + Above the board, because the board is organised around work that IS moving + and this is about work that is not. Absent entirely when nothing is wrong: + a strip that always says "all fine" is one the eye learns to skip, and then + it is not there on the day it says something. */ +.reach-strip { + border: 1px solid var(--color-warning-line, #f0dfb5); + border-radius: var(--card-radius); + background: var(--color-warning-wash, #fffbf0); + padding: 12px 16px; +} + +.reach-strip-head { + display: flex; + align-items: center; + gap: 8px; + font: 500 13px/1.3 var(--font-sans); + color: var(--color-ink-1); +} + +.reach-strip-head svg { + flex: none; + color: var(--color-warning, #b7860b); +} + +/* The count that decides whether somebody puts their coffee down. */ +.reach-strip-now { + margin-left: auto; + padding: 2px 9px; + border-radius: 999px; + background: var(--color-error-wash, #fff1f1); + color: var(--color-error, #b42318); + font: 600 11.5px/1.5 var(--font-sans); + white-space: nowrap; +} + +.reach-strip-list { + margin: 10px 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 1px; +} + +/* Three columns: who, what, what to do. The instruction is last because it is + what the reader acts on, and a row they have already decided about should + not make them read it again. */ +.reach-strip-list li { + display: grid; + grid-template-columns: minmax(96px, 150px) minmax(0, 1fr) minmax(0, auto); + gap: 12px; + align-items: baseline; + padding: 7px 0; + border-top: 1px solid rgb(180 150 80 / 0.18); + font: 400 12.5px/1.45 var(--font-sans); + color: var(--color-ink-2); +} + +.reach-strip-who { + font-weight: 600; + color: var(--color-ink-1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Only the urgent ones take the colour. If everything is red, nothing is. */ +.reach-strip-list li[data-bucket='now'] .reach-strip-do { + color: var(--color-error, #b42318); + font-weight: 600; +} + +.reach-strip-do { + text-align: right; + white-space: nowrap; +} + +.reach-strip-more { + margin: 8px 0 0; + font: 400 12px/1.4 var(--font-sans); + color: var(--color-ink-3); +} + +/* On a phone the three columns become three lines; the instruction keeps its + weight but stops being right-aligned against nothing. */ +@media (max-width: 720px) { + .reach-strip-list li { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } + .reach-strip-do { + text-align: left; + } +} diff --git a/src/features/store-admin/riderReach.test.ts b/src/features/store-admin/riderReach.test.ts new file mode 100644 index 0000000..d80f320 --- /dev/null +++ b/src/features/store-admin/riderReach.test.ts @@ -0,0 +1,157 @@ +/** + * Gaps B and C: whether an assignment actually reached the rider. + * + * B — the push outcome existed only in a toast, so a job nobody was told about + * was indistinguishable from one in a rider's hand after one navigation. + * C — nothing computed how long a job had sat unaccepted; `assigntime` was + * read for sort order and nothing else. + * + * Both are derived here from data every poll already returns, so neither + * depends on a record the browser happens to be holding. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { DeliveryRow, RiderInfo } from '@/api/types'; +import { + UNACCEPTED_LOOK_MS, + UNACCEPTED_NOW_MS, + hasNoDevice, + reachProblem, + reachProblems, + unacceptedMs, +} from './riderReach'; + +const NOW = new Date('2026-09-21T14:00:00').getTime(); +/** `assigntime` is local wall-clock, as `stampNow` writes it. */ +const at = (minutesAgo: number) => { + const d = new Date(NOW - minutesAgo * 60_000); + const p = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +}; + +const job = (over: Partial = {}) => + ({ deliveryid: 1, userid: 897, orderstatus: 'pending', assigntime: at(0), ...over }) as DeliveryRow; + +const withPhone = { userid: 897, userfcmtoken: 'fcm-abc' } as RiderInfo; +const noPhone = { userid: 897, userfcmtoken: '' } as RiderInfo; + +/* ── B: a rider nothing can be sent to ─────────────────────────────────── */ + +test('a rider who has never opened the app has no device', () => { + assert.equal(hasNoDevice(noPhone), true); + assert.equal(hasNoDevice({ userid: 1 } as RiderInfo), true, 'absent counts'); + assert.equal(hasNoDevice({ userid: 1, userfcmtoken: ' ' } as RiderInfo), true, 'blank counts'); + assert.equal(hasNoDevice(withPhone), false); +}); + +test('no device is reported the moment the job exists, not after a wait', () => { + // The whole point of deriving it: this is true on a job assigned seconds ago, + // which a stored push-failure could only say if somebody was watching. + const problem = reachProblem(job({ assigntime: at(0) }), noPhone, NOW); + assert.equal(problem?.code, 'no-device'); + assert.equal(problem?.bucket, 'now'); + assert.match(problem?.action ?? '', /call/i); +}); + +test('no device outranks a long wait — one problem, not two', () => { + const problem = reachProblem(job({ assigntime: at(90) }), noPhone, NOW); + assert.equal(problem?.code, 'no-device', 'the device is why it has not been accepted'); +}); + +/* ── C: a job nobody picked up ─────────────────────────────────────────── */ + +test('a job only counts as unaccepted while it is pending', () => { + assert.equal(unacceptedMs(job({ orderstatus: 'pending', assigntime: at(30) }), NOW), 30 * 60_000); + assert.equal(unacceptedMs(job({ orderstatus: 'accepted', assigntime: at(30) }), NOW), null); + assert.equal(unacceptedMs(job({ orderstatus: 'picked', assigntime: at(30) }), NOW), null); +}); + +test('an unreadable assigntime answers null rather than 1970', () => { + assert.equal(unacceptedMs(job({ assigntime: '' }), NOW), null); + assert.equal(unacceptedMs(job({ assigntime: 'not a date' }), NOW), null); +}); + +test('a few minutes of silence is ordinary and is not flagged', () => { + // Flagging this would train people to ignore the flag. + assert.equal(reachProblem(job({ assigntime: at(3) }), withPhone, NOW), null); + assert.equal(reachProblem(job({ assigntime: at(9) }), withPhone, NOW), null); +}); + +test('ten minutes is worth a look; twenty-five needs somebody now', () => { + const look = reachProblem(job({ assigntime: at(12) }), withPhone, NOW); + assert.equal(look?.code, 'not-accepted'); + assert.equal(look?.bucket, 'look'); + + const now = reachProblem(job({ assigntime: at(40) }), withPhone, NOW); + assert.equal(now?.bucket, 'now'); + assert.match(now?.action ?? '', /somebody else/i); +}); + +test('the thresholds are the ones the module publishes', () => { + assert.equal(reachProblem(job({ assigntime: at(UNACCEPTED_LOOK_MS / 60_000) }), withPhone, NOW)?.bucket, 'look'); + assert.equal(reachProblem(job({ assigntime: at(UNACCEPTED_NOW_MS / 60_000) }), withPhone, NOW)?.bucket, 'now'); +}); + +test('the wait is spoken, not printed in milliseconds', () => { + assert.match(reachProblem(job({ assigntime: at(40) }), withPhone, NOW)?.detail ?? '', /40 minutes/); +}); + +/* ── What is not a problem ─────────────────────────────────────────────── */ + +test('finished work is nobody\'s problem, however it got there', () => { + for (const status of ['delivered', 'cancelled', 'rejected']) { + assert.equal( + reachProblem(job({ orderstatus: status, assigntime: at(300) }), noPhone, NOW), + null, + `${status} needs no chasing`, + ); + } +}); + +test('a rider we cannot see yet is not reported as unreachable', () => { + // The fleet is a separate query and can be loading. An absent rider is a + // different conversation from a rider with no phone. + const problem = reachProblem(job({ assigntime: at(2) }), undefined, NOW); + assert.equal(problem, null); +}); + +test('an accepted job with no device is still flagged — they carry more work', () => { + const problem = reachProblem(job({ orderstatus: 'accepted' }), noPhone, NOW); + assert.equal(problem?.code, 'no-device'); +}); + +/* ── The board ─────────────────────────────────────────────────────────── */ + +test('the board lists worst first, then longest waiting', () => { + const fleet = [withPhone, { userid: 55, userfcmtoken: '' } as RiderInfo]; + const rows = [ + job({ deliveryid: 1, userid: 897, assigntime: at(12) }), // look + job({ deliveryid: 2, userid: 897, assigntime: at(60) }), // now, oldest + job({ deliveryid: 3, userid: 55, assigntime: at(1) }), // now, no device + job({ deliveryid: 4, userid: 897, assigntime: at(2) }), // fine + ]; + + const out = reachProblems(rows, fleet, NOW); + assert.deepEqual(out.map((r) => r.job.deliveryid), [2, 3, 1], 'now before look, longest first'); + assert.equal(out.length, 3, 'the healthy job is absent, not listed as fine'); +}); + +test('a long wait is flagged even when the rider cannot be identified', () => { + // The wait is a fact about the JOB. Whether we can name who is carrying it + // does not make ninety pending minutes any less of a problem — and a row + // with no rider on it is itself worth somebody looking at. + const out = reachProblems([job({ userid: 0, assigntime: at(90) })], [withPhone], NOW); + assert.equal(out.length, 1); + assert.equal(out[0]?.problem.code, 'not-accepted'); +}); + +test('a device problem needs a rider to be about', () => { + // "This rider has never opened the app" is unsayable about a rider we + // cannot see, so it stays a wait rather than becoming a claim. + const out = reachProblems([job({ userid: 0, assigntime: at(90) })], [], NOW); + assert.equal(out[0]?.problem.code, 'not-accepted'); +}); + +test('an empty board is empty, not an error', () => { + assert.deepEqual(reachProblems([], [], NOW), []); +}); diff --git a/src/features/store-admin/riderReach.ts b/src/features/store-admin/riderReach.ts new file mode 100644 index 0000000..df01ffb --- /dev/null +++ b/src/features/store-admin/riderReach.ts @@ -0,0 +1,166 @@ +import type { DeliveryRow, RiderInfo } from '@/api/types'; +import { spokenAge } from './posStatus'; +import { matchesStatus } from './orderStatus'; + +/** + * Whether a job actually reached the rider carrying it. + * + * Assigning is two separate things, and the console only ever proved the first. + * `createdeliveries` writes the delivery and the queue row the rider's app + * reads; a push then tells them it is there. The push is fired separately and + * on purpose — a failed notification must not read as a failed assignment — but + * its outcome was a sentence in a toast and nothing else. One navigation later, + * a job nobody was told about looked exactly like a job in somebody's hand. + * + * ── Why this derives the answer instead of remembering it ─────────────────── + * + * The obvious fix is to store the failure: stash the delivery ids whose push + * threw and render them later. It does not survive contact with the product. + * The session lives in `sessionStorage`, per TAB, so the supervisor who assigns + * on one machine and the one who watches the board on another see different + * lists. Reloading loses it. A second operator never had it. + * + * Both halves of "did this reach them" are already in data we fetch every + * poll, so neither has to be remembered: + * + * - **No device.** `userfcmtoken` is empty for a rider who has never opened + * the app (see `RiderInfo`). They cannot be pushed — not "were not", but + * *cannot be*, today and for every job until they sign in. That is a + * stronger statement than any record of a past failure, and the server + * already tells us. + * + * - **Never picked up.** A delivery sits at `pending` until the rider's app + * moves it to `accepted`. `pending` well past its `assigntime` is the + * symptom that matters regardless of WHY — a push that failed, a push that + * was swallowed, a dead battery, an app never opened. The console does not + * need to know which. + * + * The second is the more honest signal of the two: a successful push proves the + * message left, not that anyone saw it. Acceptance proves the rider did. + */ + +/* ── When to start worrying ─────────────────────────────────────────────── + A rider glances at the app within a couple of minutes of a job landing, so a + few minutes of silence is ordinary and flagging it would train people to + ignore the flag. Twenty-five minutes is a delivery that is not happening. */ +export const UNACCEPTED_LOOK_MS = 10 * 60_000; +export const UNACCEPTED_NOW_MS = 25 * 60_000; + +export type ReachBucket = 'now' | 'look'; +export type ReachCode = 'no-device' | 'not-accepted'; + +export interface ReachProblem { + code: ReachCode; + bucket: ReachBucket; + /** One sentence of what is true. */ + detail: string; + /** What to do about it. */ + action: string; + /** How long it has been true. `null` when the stamp is unreadable. */ + forMs: number | null; +} + +/** + * A rider the platform cannot push to at all. + * + * Not a past failure — a standing fact. Worth saying before an assignment as + * much as after one, which is why it is exported on its own. + */ +export function hasNoDevice(rider: Pick | undefined): boolean { + return !((rider?.userfcmtoken ?? '').trim()); +} + +/** + * How long a job has been waiting to be accepted, or `null`. + * + * `assigntime` is written as local wall-clock (`stampNow`), not an instant, so + * it is parsed the same way the rest of the console parses it and a garbled + * stamp answers `null` rather than 1970. + */ +export function unacceptedMs(job: DeliveryRow, now = Date.now()): number | null { + if (!matchesStatus('pending', job.orderstatus)) return null; + const at = new Date((job.assigntime ?? '').replace(' ', 'T')).getTime(); + if (Number.isNaN(at)) return null; + return Math.max(0, now - at); +} + +/** + * What is wrong with this job's chances of being done, if anything. + * + * At most one problem, worst first: a rider with no device who also has not + * accepted has ONE problem, and it is the device. Two lines saying the same + * thing twice is how a board stops being read. + * + * `rider` may be undefined — the fleet list is a separate query and can be + * loading, or the rider may have left. That is not a reach problem and must not + * be reported as one; an absent rider is a different conversation. + */ +export function reachProblem( + job: DeliveryRow, + rider: Pick | undefined, + now = Date.now(), +): ReachProblem | null { + /* Settled work is nobody's problem, however it got there. */ + if ( + matchesStatus('delivered', job.orderstatus) || + matchesStatus('cancelled', job.orderstatus) || + matchesStatus('rejected', job.orderstatus) + ) { + return null; + } + + const waiting = unacceptedMs(job, now); + + if (rider && hasNoDevice(rider)) { + return { + code: 'no-device', + bucket: 'now', + detail: 'This rider has never opened the app, so nothing can be sent to their phone.', + action: 'Call them, or have them sign in to the app before you assign more.', + forMs: waiting, + }; + } + + if (waiting !== null && waiting >= UNACCEPTED_LOOK_MS) { + const isNow = waiting >= UNACCEPTED_NOW_MS; + return { + code: 'not-accepted', + bucket: isNow ? 'now' : 'look', + detail: `Assigned ${spokenAge(waiting)} ago and the rider has not picked it up yet.`, + action: isNow + ? 'Call them, or give this to somebody else.' + : 'Worth a call if it is still unaccepted in a few minutes.', + forMs: waiting, + }; + } + + return null; +} + +/** + * Every job on the board that is not reaching its rider. + * + * Takes the fleet as a list rather than a map because that is what the pages + * already hold; building the index here keeps the join in one place instead of + * at each call site. + */ +export function reachProblems( + jobs: readonly DeliveryRow[], + fleet: readonly RiderInfo[], + now = Date.now(), +): { job: DeliveryRow; problem: ReachProblem }[] { + const byId = new Map(); + for (const rider of fleet) byId.set(rider.userid, rider); + + const out: { job: DeliveryRow; problem: ReachProblem }[] = []; + for (const job of jobs) { + const problem = reachProblem(job, job.userid ? byId.get(job.userid) : undefined, now); + if (problem) out.push({ job, problem }); + } + + /* Worst first, then longest waiting — the order somebody works down a list. */ + return out.sort((a, b) => { + if (a.problem.bucket !== b.problem.bucket) return a.problem.bucket === 'now' ? -1 : 1; + return (b.problem.forMs ?? 0) - (a.problem.forMs ?? 0); + }); +}