rider status
This commit is contained in:
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
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 { errorMessage } from '@/api/client';
|
||||||
import {
|
import {
|
||||||
RIDER_MESSAGE,
|
RIDER_MESSAGE,
|
||||||
@@ -14,6 +14,7 @@ import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
|
|||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { useBranchScope } from './BranchScope';
|
import { useBranchScope } from './BranchScope';
|
||||||
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
|
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
|
||||||
|
import { hasNoDevice } from './riderReach';
|
||||||
import { RoutePlanDrawer } from './RoutePlanDrawer';
|
import { RoutePlanDrawer } from './RoutePlanDrawer';
|
||||||
import './pages/deliveries.css';
|
import './pages/deliveries.css';
|
||||||
|
|
||||||
@@ -35,11 +36,20 @@ export interface AssignBarProps {
|
|||||||
orders: OrderRow[];
|
orders: OrderRow[];
|
||||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||||
assigned: ReadonlySet<number>;
|
assigned: ReadonlySet<number>;
|
||||||
|
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||||
|
released?: ReadonlySet<number>;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onDone: () => 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 client = useQueryClient();
|
||||||
const [riderId, setRiderId] = useState('');
|
const [riderId, setRiderId] = useState('');
|
||||||
const [outcome, setOutcome] = useState<string | null>(null);
|
const [outcome, setOutcome] = useState<string | null>(null);
|
||||||
@@ -103,7 +113,7 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
|||||||
const send = useMutation({
|
const send = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!rider) throw new Error('Pick a rider first');
|
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) {
|
if (drafts.length === 0) {
|
||||||
throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned');
|
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>
|
</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
|
{/* 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
|
second button: "Assign" hands the orders over as they are, which is
|
||||||
right for one or two. "Plan the route" sequences them first — real
|
right for one or two. "Plan the route" sequences them first — real
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function AutoAssignDrawer({
|
|||||||
branches,
|
branches,
|
||||||
fleet,
|
fleet,
|
||||||
assigned,
|
assigned,
|
||||||
|
released,
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
@@ -63,6 +64,8 @@ export function AutoAssignDrawer({
|
|||||||
/** Riders we know about, for the absentee picker and for naming. */
|
/** Riders we know about, for the absentee picker and for naming. */
|
||||||
fleet: readonly RiderInfo[];
|
fleet: readonly RiderInfo[];
|
||||||
assigned: ReadonlySet<number>;
|
assigned: ReadonlySet<number>;
|
||||||
|
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||||
|
released?: ReadonlySet<number>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -114,7 +117,7 @@ export function AutoAssignDrawer({
|
|||||||
// rest of the roster row matters for notifying, which happens after.
|
// rest of the roster row matters for notifying, which happens after.
|
||||||
const rider = (fleet.find((r) => r.userid === proposal.userid) ??
|
const rider = (fleet.find((r) => r.userid === proposal.userid) ??
|
||||||
({ userid: proposal.userid } as RiderInfo));
|
({ 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) {
|
if (drafts.length === 0) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { deliveriesApi } from '@/api/deliveries';
|
|||||||
import type { DeliveryRow } from '@/api/types';
|
import type { DeliveryRow } from '@/api/types';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { stampNow } from './assignDelivery';
|
import { stampNow } from './assignDelivery';
|
||||||
|
import { isSettled, isStalled, matchesStatus } from './orderStatus';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moving a delivery along from the back office.
|
* Moving a delivery along from the back office.
|
||||||
@@ -37,8 +38,12 @@ const MOVES = [
|
|||||||
{ to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' },
|
{ to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** The end states. Nothing follows them, so nothing is offered. */
|
/** What to do about a stalled job, which is never "nothing". */
|
||||||
const SETTLED = ['delivered', 'cancelled'];
|
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.
|
* The moves, split from their rendering.
|
||||||
@@ -54,7 +59,8 @@ export function useDeliveryMoves(job: DeliveryRow) {
|
|||||||
const [problem, setProblem] = useState<string | null>(null);
|
const [problem, setProblem] = useState<string | null>(null);
|
||||||
|
|
||||||
const status = (job.orderstatus ?? '').trim().toLowerCase();
|
const status = (job.orderstatus ?? '').trim().toLowerCase();
|
||||||
const isSettled = SETTLED.includes(status);
|
const settled = isSettled(status);
|
||||||
|
const stalled = isStalled(status);
|
||||||
|
|
||||||
const move = useMutation({
|
const move = useMutation({
|
||||||
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
|
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
|
||||||
@@ -70,7 +76,8 @@ export function useDeliveryMoves(job: DeliveryRow) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
isSettled,
|
isSettled: settled,
|
||||||
|
isStalled: stalled,
|
||||||
isPending: move.isPending,
|
isPending: move.isPending,
|
||||||
problem,
|
problem,
|
||||||
moves: MOVES,
|
moves: MOVES,
|
||||||
@@ -86,7 +93,8 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
|
|||||||
const [problem, setProblem] = useState<string | null>(null);
|
const [problem, setProblem] = useState<string | null>(null);
|
||||||
|
|
||||||
const status = (job.orderstatus ?? '').trim().toLowerCase();
|
const status = (job.orderstatus ?? '').trim().toLowerCase();
|
||||||
const isSettled = SETTLED.includes(status);
|
const settled = isSettled(status);
|
||||||
|
const stalled = isStalled(status);
|
||||||
|
|
||||||
const move = useMutation({
|
const move = useMutation({
|
||||||
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
|
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
|
||||||
@@ -100,7 +108,7 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
|
|||||||
onError: (error) => setProblem(errorMessage(error)),
|
onError: (error) => setProblem(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isSettled) {
|
if (settled) {
|
||||||
return (
|
return (
|
||||||
<Text type="body" size="xsm" color="secondary">
|
<Text type="body" size="xsm" color="secondary">
|
||||||
This job is {status}. Nothing further to record.
|
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 (
|
return (
|
||||||
<VStack gap={1}>
|
<VStack gap={1}>
|
||||||
<div className="progress-moves">
|
<div className="progress-moves">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
||||||
import type { DeliveryRow, OrderRow } from '@/api/types';
|
import type { DeliveryRow, OrderRow } from '@/api/types';
|
||||||
|
import { orderStage, type Stage } from './orderProgress';
|
||||||
import { useDeliveryMoves } from './DeliveryProgress';
|
import { useDeliveryMoves } from './DeliveryProgress';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
|
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
|
||||||
@@ -47,10 +48,21 @@ const STEPS: { label: string; fields: string[] }[] = [
|
|||||||
export function OrderDetailDrawer({
|
export function OrderDetailDrawer({
|
||||||
row,
|
row,
|
||||||
kind,
|
kind,
|
||||||
|
stages,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
row: Row_;
|
row: Row_;
|
||||||
kind: RowKind;
|
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;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
/*
|
/*
|
||||||
@@ -65,7 +77,7 @@ export function OrderDetailDrawer({
|
|||||||
return kind === 'delivery' ? (
|
return kind === 'delivery' ? (
|
||||||
<DeliveryDrawer job={row as DeliveryRow} onClose={onClose} />
|
<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}
|
row={job}
|
||||||
isDelivery
|
isDelivery
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
{...(moves.isSettled ? {} : { footer: <MoveActions moves={moves} /> })}
|
{...(moves.isSettled || moves.isStalled ? {} : { footer: <MoveActions moves={moves} /> })}
|
||||||
caption={<MoveCaption moves={moves} />}
|
caption={<MoveCaption moves={moves} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -84,14 +96,21 @@ function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => voi
|
|||||||
|
|
||||||
type Moves = ReturnType<typeof useDeliveryMoves>;
|
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({
|
function Sheet({
|
||||||
row,
|
row,
|
||||||
isDelivery,
|
isDelivery,
|
||||||
|
stage,
|
||||||
onClose,
|
onClose,
|
||||||
footer,
|
footer,
|
||||||
caption,
|
caption,
|
||||||
}: {
|
}: {
|
||||||
row: Row_;
|
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
|
/** Passed explicitly, never inferred from the presence of a footer — a
|
||||||
settled job has no actions and would otherwise read as an order. */
|
settled job has no actions and would otherwise read as an order. */
|
||||||
isDelivery: boolean;
|
isDelivery: boolean;
|
||||||
@@ -108,8 +127,10 @@ function Sheet({
|
|||||||
const order = delivery ? undefined : (row as OrderRow);
|
const order = delivery ? undefined : (row as OrderRow);
|
||||||
|
|
||||||
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
|
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
|
||||||
const status = row.orderstatus;
|
/* An order shows the stage its delivery reports, which may be a word from the
|
||||||
const colour = statusColor(delivery ? DELIVERY_STATUS : ORDER_STATUS, status);
|
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 value = job ? (job.deliveryamt ?? 0) : orderValue(row);
|
||||||
const cancelled = (status ?? '').toLowerCase().includes('cancel');
|
const cancelled = (status ?? '').toLowerCase().includes('cancel');
|
||||||
@@ -278,9 +299,9 @@ function MoveActions({ moves: state }: { moves: Moves }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MoveCaption({ moves }: { 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>;
|
return <Note>This job is {status}. Nothing further to record.</Note>;
|
||||||
}
|
}
|
||||||
if (problem) {
|
if (problem) {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface RoutePlanDrawerProps {
|
|||||||
rider: RiderInfo;
|
rider: RiderInfo;
|
||||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||||
assigned: ReadonlySet<number>;
|
assigned: ReadonlySet<number>;
|
||||||
|
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||||
|
released?: ReadonlySet<number>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ export function RoutePlanDrawer({
|
|||||||
rider,
|
rider,
|
||||||
branchOf,
|
branchOf,
|
||||||
assigned,
|
assigned,
|
||||||
|
released,
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
onDone,
|
||||||
}: RoutePlanDrawerProps) {
|
}: RoutePlanDrawerProps) {
|
||||||
@@ -130,7 +133,7 @@ export function RoutePlanDrawer({
|
|||||||
// fills `deliveries.kms` / `actualkms` with real numbers instead of the
|
// fills `deliveries.kms` / `actualkms` with real numbers instead of the
|
||||||
// literal "null" found in production.
|
// literal "null" found in production.
|
||||||
const ordered = plan.riders.flatMap((r) => r.orders) as OrderRow[];
|
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');
|
if (drafts.length === 0) throw new Error('None of these orders can be assigned');
|
||||||
|
|
||||||
await deliveriesApi.assign(drafts);
|
await deliveriesApi.assign(drafts);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
buildDelivery,
|
buildDelivery,
|
||||||
isUnassigned,
|
isUnassigned,
|
||||||
needsDelivery,
|
needsDelivery,
|
||||||
|
releasedFrom,
|
||||||
riderName,
|
riderName,
|
||||||
riderVehicle,
|
riderVehicle,
|
||||||
stampNow,
|
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 ──────────────────────────────────────── */
|
/* ── 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
|
* 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.
|
* 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;
|
if (row.deliveryid) return false;
|
||||||
return !assigned?.has(row.orderheaderid);
|
return !assigned?.has(row.orderheaderid);
|
||||||
}
|
}
|
||||||
@@ -52,6 +75,47 @@ export function assignedFrom(deliveries: readonly { orderheaderid?: number }[]):
|
|||||||
return out;
|
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?
|
* Does this order need a rider at all?
|
||||||
*
|
*
|
||||||
@@ -74,9 +138,13 @@ export function needsDelivery(row: OrderRow): boolean {
|
|||||||
export function awaitingRider(
|
export function awaitingRider(
|
||||||
rows: readonly OrderRow[],
|
rows: readonly OrderRow[],
|
||||||
assigned?: ReadonlySet<number>,
|
assigned?: ReadonlySet<number>,
|
||||||
|
released?: ReadonlySet<number>,
|
||||||
): OrderRow[] {
|
): OrderRow[] {
|
||||||
return rows
|
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));
|
.sort((a, b) => placedAt(a) - placedAt(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +193,7 @@ export function assignability(
|
|||||||
row: OrderRow,
|
row: OrderRow,
|
||||||
branch: TenantLocation | undefined,
|
branch: TenantLocation | undefined,
|
||||||
assigned?: ReadonlySet<number>,
|
assigned?: ReadonlySet<number>,
|
||||||
|
released?: ReadonlySet<number>,
|
||||||
): Assignability {
|
): Assignability {
|
||||||
const applocationid = row.applocationid || branch?.applocationid || 0;
|
const applocationid = row.applocationid || branch?.applocationid || 0;
|
||||||
|
|
||||||
@@ -144,7 +213,7 @@ export function assignability(
|
|||||||
if (!needsDelivery(row)) {
|
if (!needsDelivery(row)) {
|
||||||
return { canAssign: false, reason: 'This order has no delivery address.' };
|
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: false, reason: 'A rider is already on this one.' };
|
||||||
}
|
}
|
||||||
return { canAssign: true, reason: '' };
|
return { canAssign: true, reason: '' };
|
||||||
@@ -320,13 +389,14 @@ export function buildDeliveries(
|
|||||||
branchOf: (row: OrderRow) => TenantLocation | undefined,
|
branchOf: (row: OrderRow) => TenantLocation | undefined,
|
||||||
now = new Date(),
|
now = new Date(),
|
||||||
assigned?: ReadonlySet<number>,
|
assigned?: ReadonlySet<number>,
|
||||||
|
released?: ReadonlySet<number>,
|
||||||
): { drafts: DeliveryDraft[]; skipped: { row: OrderRow; reason: string }[] } {
|
): { drafts: DeliveryDraft[]; skipped: { row: OrderRow; reason: string }[] } {
|
||||||
const drafts: DeliveryDraft[] = [];
|
const drafts: DeliveryDraft[] = [];
|
||||||
const skipped: { row: OrderRow; reason: string }[] = [];
|
const skipped: { row: OrderRow; reason: string }[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const branch = branchOf(row);
|
const branch = branchOf(row);
|
||||||
const verdict = assignability(row, branch, assigned);
|
const verdict = assignability(row, branch, assigned, released);
|
||||||
if (verdict.canAssign) {
|
if (verdict.canAssign) {
|
||||||
drafts.push(buildDelivery(row, rider, branch, now));
|
drafts.push(buildDelivery(row, rider, branch, now));
|
||||||
} else {
|
} 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 { CustomerInfo } from '@/api/customers';
|
||||||
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
||||||
import { awaitingRider, assignedFrom } from './assignDelivery';
|
import { awaitingRider, assignedFrom, releasedFrom } from './assignDelivery';
|
||||||
import { isRealName } from './orderStatus';
|
import { isRealName } from './orderStatus';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,9 +152,12 @@ export function toStops(
|
|||||||
orders: readonly OrderRow[],
|
orders: readonly OrderRow[],
|
||||||
): Stop[] {
|
): Stop[] {
|
||||||
const assigned = assignedFrom(deliveries);
|
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 [
|
return [
|
||||||
...deliveries.map((row): Stop => ({ kind: 'delivery', row })),
|
...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',
|
picked: '#8b5cf6',
|
||||||
active: '#14b8a6',
|
active: '#14b8a6',
|
||||||
skipped: '#f97316',
|
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',
|
delivered: '#10b981',
|
||||||
cancelled: '#ef4444',
|
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.
|
* The money on an order row, in the order the old console reads it.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
Navigation,
|
Navigation,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ruler,
|
Ruler,
|
||||||
|
TriangleAlert,
|
||||||
} from 'lucide-react';
|
} 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 { KpiCard } from '@/components/KpiCard';
|
||||||
import { Tab, TabBar } from '@/components/TabBar';
|
import { Tab, TabBar } from '@/components/TabBar';
|
||||||
import { StickyRow } from '@/components/StickyRow';
|
import { StickyRow } from '@/components/StickyRow';
|
||||||
@@ -29,13 +30,15 @@ import { TablePager } from '@/components/TablePager';
|
|||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
import { AssignBar } from '../AssignBar';
|
import { AssignBar } from '../AssignBar';
|
||||||
import { AutoAssignDrawer } from '../AutoAssignDrawer';
|
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 { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks';
|
||||||
import { useDateScope } from '@/components/shell/DateScope';
|
import { useDateScope } from '@/components/shell/DateScope';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope';
|
import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope';
|
||||||
import { count, money, moneyExact } from '../format';
|
import { count, money, moneyExact, plural } from '../format';
|
||||||
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
|
import { DELIVERY_STATUS, isLive, statusColor } from '../orderStatus';
|
||||||
import { ActivePanel } from '../ActivePanel';
|
import { ActivePanel } from '../ActivePanel';
|
||||||
import { GroupMap } from '../GroupMap';
|
import { GroupMap } from '../GroupMap';
|
||||||
import { ProfitPanel } from '../ProfitPanel';
|
import { ProfitPanel } from '../ProfitPanel';
|
||||||
@@ -194,6 +197,10 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
const open = groups.find((group) => group.id === focused) ?? null;
|
const open = groups.find((group) => group.id === focused) ?? null;
|
||||||
|
|
||||||
const assigned = useMemo(() => assignedFrom(filteredDeliveries), [filteredDeliveries]);
|
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(
|
const branchOf = useMemo(
|
||||||
() => (row: OrderRow): TenantLocation | undefined =>
|
() => (row: OrderRow): TenantLocation | undefined =>
|
||||||
@@ -212,7 +219,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
const selectable = useMemo(
|
const selectable = useMemo(
|
||||||
() =>
|
() =>
|
||||||
waiting
|
waiting
|
||||||
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
|
.filter((row) => assignability(row, branchOf(row), assigned, released).canAssign)
|
||||||
.map((row) => row.orderheaderid),
|
.map((row) => row.orderheaderid),
|
||||||
[waiting, branchOf, assigned],
|
[waiting, branchOf, assigned],
|
||||||
);
|
);
|
||||||
@@ -345,6 +352,16 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ──────────────────────────────────────── */}
|
{/* ── Main Operations Workspace ──────────────────────────────────────── */}
|
||||||
<div className="dispatch-content-area">
|
<div className="dispatch-content-area">
|
||||||
{mode === 'active' ? (
|
{mode === 'active' ? (
|
||||||
@@ -425,7 +442,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
{...(open.id === UNASSIGNED && canAssign(scope)
|
{...(open.id === UNASSIGNED && canAssign(scope)
|
||||||
? {
|
? {
|
||||||
selection: picked,
|
selection: picked,
|
||||||
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
|
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned, released),
|
||||||
assignBar: (
|
assignBar: (
|
||||||
<>
|
<>
|
||||||
{waiting.length > 0 ? (
|
{waiting.length > 0 ? (
|
||||||
@@ -448,6 +465,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
orders={pickedOrders}
|
orders={pickedOrders}
|
||||||
branchOf={branchOf}
|
branchOf={branchOf}
|
||||||
assigned={assigned}
|
assigned={assigned}
|
||||||
|
released={released}
|
||||||
onClear={picked.clear}
|
onClear={picked.clear}
|
||||||
onDone={picked.clear}
|
onDone={picked.clear}
|
||||||
/>
|
/>
|
||||||
@@ -542,6 +560,7 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
|
|||||||
branches={branches}
|
branches={branches}
|
||||||
fleet={fleet.data ?? []}
|
fleet={fleet.data ?? []}
|
||||||
assigned={assigned}
|
assigned={assigned}
|
||||||
|
released={released}
|
||||||
onClose={() => setAutoOpen(false)}
|
onClose={() => setAutoOpen(false)}
|
||||||
onDone={picked.clear}
|
onDone={picked.clear}
|
||||||
/>
|
/>
|
||||||
@@ -636,11 +655,11 @@ function EnhancedGroupList({
|
|||||||
}, 0);
|
}, 0);
|
||||||
const displayKm = totalKm > 0 ? totalKm.toFixed(1) : (total * 1.6).toFixed(1);
|
const displayKm = totalKm > 0 ? totalKm.toFixed(1) : (total * 1.6).toFixed(1);
|
||||||
|
|
||||||
// Active order / in progress
|
/* The stop somebody is actually working right now.
|
||||||
const activeStop = group.stops.find((s) => {
|
`isLive` rather than a list of words: this read its own three-word
|
||||||
const st = stopStatus(s);
|
exclusion and left `rejected` out of it, so a job the rider had
|
||||||
return st !== 'delivered' && st !== 'cancelled' && st !== 'skipped';
|
DECLINED was shown as the group's order in progress. */
|
||||||
});
|
const activeStop = group.stops.find((s) => isLive(stopStatus(s)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -939,3 +958,57 @@ function shortTime(stamp: string): string {
|
|||||||
const match = stamp.match(/(\d{1,2}):(\d{2})/);
|
const match = stamp.match(/(\d{1,2}):(\d{2})/);
|
||||||
return match ? `${match[1]}:${match[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 { useBranchScope } from '../BranchScope';
|
||||||
import { branchOrderStats, NO_ORDERS } from '../branchStats';
|
import { branchOrderStats, NO_ORDERS } from '../branchStats';
|
||||||
import { branchLabel, count, money, percent, plural, share } from '../format';
|
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 { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { Panel } from '@/components/Panel';
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
@@ -521,9 +522,10 @@ function InventoryReport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const moving = [...rows].sort((a, b) => b.debit - a.debit);
|
/* Two ends of one list, and they must not be the same end — on a shop with
|
||||||
const fast = moving.slice(0, 5);
|
under about ten products the old two-slice version printed the same item
|
||||||
const slow = moving.filter((row) => row.closing > 0).slice(-5).reverse();
|
under both headings. See `inventoryMovers.ts`. */
|
||||||
|
const { fast, slow } = movers(rows);
|
||||||
const value = rows.reduce((sum, row) => sum + row.closing * row.retailprice, 0);
|
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
|
/* 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 { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||||
import { shortAge } from '../posStatus';
|
import { shortAge } from '../posStatus';
|
||||||
import { AssignBar } from '../AssignBar';
|
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 { useSelection } from '@/components/useSelection';
|
||||||
import { Panel } from '@/components/Panel';
|
import { Panel } from '@/components/Panel';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
@@ -140,6 +141,16 @@ export function SalesPage() {
|
|||||||
const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]);
|
const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]);
|
||||||
const allDeliveries = deliveries.data ?? [];
|
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(
|
const orderRows = useMemo(
|
||||||
() => allOrders.filter((row) => matchesStatus(status, row.orderstatus)),
|
() => allOrders.filter((row) => matchesStatus(status, row.orderstatus)),
|
||||||
[allOrders, status],
|
[allOrders, status],
|
||||||
@@ -270,6 +281,15 @@ export function SalesPage() {
|
|||||||
* of production's delivery history a second time.
|
* of production's delivery history a second time.
|
||||||
*/
|
*/
|
||||||
const assigned = useMemo(() => assignedFrom(allDeliveries), [allDeliveries]);
|
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(
|
const branchOf = useMemo(
|
||||||
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
|
() => (row: OrderRow) => branches.find((branch) => branch.locationid === row.locationid),
|
||||||
@@ -288,7 +308,7 @@ export function SalesPage() {
|
|||||||
const selectable = useMemo(
|
const selectable = useMemo(
|
||||||
() =>
|
() =>
|
||||||
orderRows
|
orderRows
|
||||||
.filter((row) => assignability(row, branchOf(row), assigned).canAssign)
|
.filter((row) => assignability(row, branchOf(row), assigned, released).canAssign)
|
||||||
.map((row) => row.orderheaderid),
|
.map((row) => row.orderheaderid),
|
||||||
[orderRows, branchOf, assigned],
|
[orderRows, branchOf, assigned],
|
||||||
);
|
);
|
||||||
@@ -410,6 +430,7 @@ export function SalesPage() {
|
|||||||
orders={pickedOrders}
|
orders={pickedOrders}
|
||||||
branchOf={branchOf}
|
branchOf={branchOf}
|
||||||
assigned={assigned}
|
assigned={assigned}
|
||||||
|
released={released}
|
||||||
onClear={picked.clear}
|
onClear={picked.clear}
|
||||||
onDone={picked.clear}
|
onDone={picked.clear}
|
||||||
/>
|
/>
|
||||||
@@ -422,6 +443,8 @@ export function SalesPage() {
|
|||||||
selection={picked}
|
selection={picked}
|
||||||
branchOf={branchOf}
|
branchOf={branchOf}
|
||||||
assigned={assigned}
|
assigned={assigned}
|
||||||
|
released={released}
|
||||||
|
stages={stages}
|
||||||
onOpen={(row) => setDetail({ row, kind: 'order' })}
|
onOpen={(row) => setDetail({ row, kind: 'order' })}
|
||||||
/>
|
/>
|
||||||
{orderRows.length > 0 ? (
|
{orderRows.length > 0 ? (
|
||||||
@@ -564,6 +587,7 @@ export function SalesPage() {
|
|||||||
<OrderDetailDrawer
|
<OrderDetailDrawer
|
||||||
row={freshRow(detail, allOrders, allDeliveries)}
|
row={freshRow(detail, allOrders, allDeliveries)}
|
||||||
kind={detail.kind}
|
kind={detail.kind}
|
||||||
|
stages={stages}
|
||||||
onClose={() => setDetail(null)}
|
onClose={() => setDetail(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -656,6 +680,8 @@ function OrdersTable({
|
|||||||
selection,
|
selection,
|
||||||
branchOf,
|
branchOf,
|
||||||
assigned,
|
assigned,
|
||||||
|
released,
|
||||||
|
stages,
|
||||||
onOpen,
|
onOpen,
|
||||||
queryKey,
|
queryKey,
|
||||||
}: {
|
}: {
|
||||||
@@ -667,6 +693,10 @@ function OrdersTable({
|
|||||||
selection: ReturnType<typeof useSelection>;
|
selection: ReturnType<typeof useSelection>;
|
||||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||||
assigned: ReadonlySet<number>;
|
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;
|
onOpen: (row: OrderRow) => void;
|
||||||
}) {
|
}) {
|
||||||
// Above the early returns — a hook cannot be called conditionally.
|
// Above the early returns — a hook cannot be called conditionally.
|
||||||
@@ -723,7 +753,8 @@ function OrdersTable({
|
|||||||
{paged.rows.map((row, index) => {
|
{paged.rows.map((row, index) => {
|
||||||
const cod = row.collectionamt ?? 0;
|
const cod = row.collectionamt ?? 0;
|
||||||
const amount = orderValue(row);
|
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);
|
const isPicked = selection.has(row.orderheaderid);
|
||||||
return (
|
return (
|
||||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)} isPicked={isPicked}>
|
<Row key={row.orderheaderid} onClick={() => onOpen(row)} isPicked={isPicked}>
|
||||||
@@ -802,7 +833,14 @@ function OrdersTable({
|
|||||||
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
|
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
|
||||||
</Td>
|
</Td>
|
||||||
<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>
|
||||||
<Td>
|
<Td>
|
||||||
<ViewHint />
|
<ViewHint />
|
||||||
|
|||||||
@@ -48,6 +48,26 @@
|
|||||||
/* Pushed to the far end so the result reads as an outcome rather than as
|
/* 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
|
another control. Wraps to its own line on a narrow window rather than
|
||||||
squeezing the picker. */
|
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 {
|
.assign-bar-outcome {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font: 500 12px/1.4 var(--font-sans);
|
font: 500 12px/1.4 var(--font-sans);
|
||||||
|
|||||||
@@ -758,3 +758,99 @@
|
|||||||
background: #501b67;
|
background: #501b67;
|
||||||
transform: translateY(-1px);
|
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