rider status
This commit is contained in:
@@ -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<number>;
|
||||
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||
released?: ReadonlySet<number>;
|
||||
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<string | null>(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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
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) ? (
|
||||
<span className="assign-bar-warn" role="status">
|
||||
<TriangleAlert size={14} />
|
||||
No app on this rider’s phone yet — they will not be told. Call them.
|
||||
</span>
|
||||
) : 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
|
||||
|
||||
@@ -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<number>;
|
||||
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||
released?: ReadonlySet<number>;
|
||||
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) {
|
||||
|
||||
@@ -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<string | null>(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<string | null>(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 (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
This job is {status}. Nothing further to record.
|
||||
@@ -108,6 +116,14 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (stalled) {
|
||||
return (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
This job is {status}. {stalledAdvice(status)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<div className="progress-moves">
|
||||
|
||||
@@ -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<number, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
/*
|
||||
@@ -65,7 +77,7 @@ export function OrderDetailDrawer({
|
||||
return kind === 'delivery' ? (
|
||||
<DeliveryDrawer job={row as DeliveryRow} onClose={onClose} />
|
||||
) : (
|
||||
<Sheet row={row} isDelivery={false} onClose={onClose} />
|
||||
<Sheet row={row} isDelivery={false} stage={orderStage(row as OrderRow, stages ?? EMPTY_STAGES)} onClose={onClose} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +88,7 @@ function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => voi
|
||||
row={job}
|
||||
isDelivery
|
||||
onClose={onClose}
|
||||
{...(moves.isSettled ? {} : { footer: <MoveActions moves={moves} /> })}
|
||||
{...(moves.isSettled || moves.isStalled ? {} : { footer: <MoveActions moves={moves} /> })}
|
||||
caption={<MoveCaption moves={moves} />}
|
||||
/>
|
||||
);
|
||||
@@ -84,14 +96,21 @@ function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => voi
|
||||
|
||||
type Moves = ReturnType<typeof useDeliveryMoves>;
|
||||
|
||||
/** A drawer opened without the index falls back to the order's own word. */
|
||||
const EMPTY_STAGES: ReadonlyMap<number, string> = 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 <Note>This job is {status}. Nothing further to record.</Note>;
|
||||
}
|
||||
if (problem) {
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface RoutePlanDrawerProps {
|
||||
rider: RiderInfo;
|
||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||
assigned: ReadonlySet<number>;
|
||||
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||
released?: ReadonlySet<number>;
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<number>): boolean {
|
||||
export function isUnassigned(
|
||||
row: OrderRow,
|
||||
assigned?: ReadonlySet<number>,
|
||||
released?: ReadonlySet<number>,
|
||||
): 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<number> {
|
||||
const live = new Set<number>();
|
||||
const dead = new Set<number>();
|
||||
|
||||
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<number>,
|
||||
released?: ReadonlySet<number>,
|
||||
): 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<number>,
|
||||
released?: ReadonlySet<number>,
|
||||
): 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<number>,
|
||||
released?: ReadonlySet<number>,
|
||||
): { 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 {
|
||||
|
||||
64
src/features/store-admin/deliveryPhase.test.ts
Normal file
64
src/features/store-admin/deliveryPhase.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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 })),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
92
src/features/store-admin/inventoryMovers.test.ts
Normal file
92
src/features/store-admin/inventoryMovers.test.ts
Normal file
@@ -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: [] });
|
||||
});
|
||||
71
src/features/store-admin/inventoryMovers.ts
Normal file
71
src/features/store-admin/inventoryMovers.ts
Normal file
@@ -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<T extends Mover>(
|
||||
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 };
|
||||
}
|
||||
136
src/features/store-admin/orderProgress.test.ts
Normal file
136
src/features/store-admin/orderProgress.test.ts
Normal file
@@ -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<OrderRow> = {}) =>
|
||||
({ orderheaderid: 1, orderstatus: 'pending', ...over }) as OrderRow;
|
||||
|
||||
const delivery = (over: Partial<DeliveryRow> = {}) =>
|
||||
({ 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);
|
||||
});
|
||||
146
src/features/store-admin/orderProgress.ts
Normal file
146
src/features/store-admin/orderProgress.ts
Normal file
@@ -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<number, string> {
|
||||
const best = new Map<number, DeliveryRow>();
|
||||
|
||||
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<number, string>();
|
||||
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<number, string>): 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;
|
||||
}
|
||||
@@ -33,6 +33,20 @@ export const DELIVERY_STATUS: Record<string, string> = {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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 }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<ReachStrip jobs={filteredDeliveries} fleet={fleet.data ?? []} />
|
||||
|
||||
{/* ── Main Operations Workspace ──────────────────────────────────────── */}
|
||||
<div className="dispatch-content-area">
|
||||
{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 (
|
||||
<div
|
||||
@@ -939,3 +958,57 @@ function shortTime(stamp: string): string {
|
||||
const match = stamp.match(/(\d{1,2}):(\d{2})/);
|
||||
return match ? `${match[1]}:${match[2]}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What is not getting through, worst first.
|
||||
*
|
||||
* Two failures used to be invisible here. A rider with no app on their phone
|
||||
* could be handed work all day — the push failure was a sentence in a toast
|
||||
* that did not survive a navigation — and a job nobody accepted sat at
|
||||
* `pending` with nothing counting how long. Both are derived per poll in
|
||||
* `riderReach`, so neither depends on somebody having been watching when it
|
||||
* happened.
|
||||
*
|
||||
* It renders nothing when the board is healthy. An always-present strip
|
||||
* reading "all fine" is one the eye learns to skip, and then it is not there
|
||||
* on the day it says something.
|
||||
*/
|
||||
function ReachStrip({ jobs, fleet }: { jobs: readonly DeliveryRow[]; fleet: RiderInfo[] }) {
|
||||
/* Recomputed each render rather than memoised on a clock: the page already
|
||||
polls, and a wait that freezes while the screen is open is worse than one
|
||||
that costs a map lookup. */
|
||||
const problems = reachProblems(jobs, fleet);
|
||||
if (problems.length === 0) return null;
|
||||
|
||||
const urgent = problems.filter((entry) => entry.problem.bucket === 'now').length;
|
||||
|
||||
return (
|
||||
<section className="reach-strip" role="status" aria-label="Jobs not reaching their rider">
|
||||
<header className="reach-strip-head">
|
||||
<TriangleAlert size={15} />
|
||||
<strong>
|
||||
{plural(problems.length, "job")} not reaching {problems.length === 1 ? "its" : "their"} rider
|
||||
</strong>
|
||||
{urgent > 0 ? <span className="reach-strip-now">{urgent} need someone now</span> : null}
|
||||
</header>
|
||||
|
||||
<ul className="reach-strip-list">
|
||||
{problems.slice(0, 6).map(({ job, problem }) => (
|
||||
<li key={job.deliveryid} data-bucket={problem.bucket}>
|
||||
<span className="reach-strip-who">
|
||||
{riderName(fleet.find((r) => r.userid === job.userid) ?? { userid: job.userid ?? 0 })}
|
||||
</span>
|
||||
<span className="reach-strip-what">{problem.detail}</span>
|
||||
<span className="reach-strip-do">{problem.action}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{problems.length > 6 ? (
|
||||
<p className="reach-strip-more">
|
||||
and {plural(problems.length - 6, "more job")} — the rider list below has them all.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<OrderDetailDrawer
|
||||
row={freshRow(detail, allOrders, allDeliveries)}
|
||||
kind={detail.kind}
|
||||
stages={stages}
|
||||
onClose={() => setDetail(null)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -656,6 +680,8 @@ function OrdersTable({
|
||||
selection,
|
||||
branchOf,
|
||||
assigned,
|
||||
released,
|
||||
stages,
|
||||
onOpen,
|
||||
queryKey,
|
||||
}: {
|
||||
@@ -667,6 +693,10 @@ function OrdersTable({
|
||||
selection: ReturnType<typeof useSelection>;
|
||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||
assigned: ReadonlySet<number>;
|
||||
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||
released?: ReadonlySet<number>;
|
||||
/** Where each order's delivery has got to — see `orderProgress.ts`. */
|
||||
stages: ReadonlyMap<number, string>;
|
||||
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 (
|
||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)} isPicked={isPicked}>
|
||||
@@ -802,7 +833,14 @@ function OrdersTable({
|
||||
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
|
||||
</Td>
|
||||
<Td>
|
||||
<Chip label={row.orderstatus} color={statusColor(ORDER_STATUS, row.orderstatus)} />
|
||||
{/* 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. */}
|
||||
<Chip
|
||||
label={stage.status}
|
||||
color={statusColor(stage.isDelivery ? DELIVERY_STATUS : ORDER_STATUS, stage.status)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<ViewHint />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
157
src/features/store-admin/riderReach.test.ts
Normal file
157
src/features/store-admin/riderReach.test.ts
Normal file
@@ -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<DeliveryRow> = {}) =>
|
||||
({ 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), []);
|
||||
});
|
||||
166
src/features/store-admin/riderReach.ts
Normal file
166
src/features/store-admin/riderReach.ts
Normal file
@@ -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<RiderInfo, 'userfcmtoken'> | 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<RiderInfo, 'userfcmtoken'> | 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<number, RiderInfo>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user