updats on the dispatch page
This commit is contained in:
236
src/features/store-admin/ActivePanel.tsx
Normal file
236
src/features/store-admin/ActivePanel.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { BatteryLow, Info, MapPin, Navigation, WifiOff } from 'lucide-react';
|
||||
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
|
||||
import type { Stop } from './dispatchModel';
|
||||
import { useRiderLive } from '@/queries/hooks';
|
||||
import { mapStop } from './deliveryTrack';
|
||||
import {
|
||||
activeBoard,
|
||||
agoOf,
|
||||
concerns,
|
||||
distanceLabel,
|
||||
readSnapshot,
|
||||
type LiveRider,
|
||||
} from './riderLive';
|
||||
import './dispatchPanels.css';
|
||||
|
||||
/**
|
||||
* Who is out right now, closest to finishing first.
|
||||
*
|
||||
* ── Why the order is "distance to the drop" ─────────────────────────────────
|
||||
*
|
||||
* A dispatcher watching a live board is answering one question: who will be
|
||||
* free next. Sorting by that puts the answer at the top, and it re-sorts itself
|
||||
* as riders move. Sorting by name or by order count makes the reader do the
|
||||
* comparison themselves, every fifteen seconds.
|
||||
*
|
||||
* ── Live is a claim, and most of these positions are not ────────────────────
|
||||
*
|
||||
* The service answers for every rider, with their LAST known fix however old —
|
||||
* riders on this platform routinely come back with positions days stale and
|
||||
* nothing in the payload admitting it. So each card carries its age, the map
|
||||
* draws a stale fix hollow, and anything over an hour old is called out rather
|
||||
* than mixed in. A board that shows a twelve-day-old dot next to a live one is
|
||||
* worse than no board.
|
||||
*/
|
||||
export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
|
||||
const mapped = useMemo(() => stops.map(mapStop), [stops]);
|
||||
|
||||
/* Only riders carrying something today — polling the whole roster would ask a
|
||||
third-party service about people who are not working. */
|
||||
const userids = useMemo(
|
||||
() => [...new Set(mapped.map((stop) => stop.userid).filter((id) => id > 0))],
|
||||
[mapped],
|
||||
);
|
||||
|
||||
const live = useRiderLive(userids);
|
||||
|
||||
const nameOf = useMemo(() => {
|
||||
const names = new Map<number, string>();
|
||||
for (const stop of mapped) {
|
||||
if (stop.userid > 0 && stop.ridername && !names.has(stop.userid)) {
|
||||
names.set(stop.userid, stop.ridername);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [mapped]);
|
||||
|
||||
const riders = useMemo(() => {
|
||||
const out: LiveRider[] = [];
|
||||
for (const userid of userids) {
|
||||
const read = readSnapshot(
|
||||
live.data.get(userid) ?? null,
|
||||
nameOf.get(userid) ?? `Rider ${userid}`,
|
||||
);
|
||||
if (read) out.push(read);
|
||||
}
|
||||
return out;
|
||||
}, [userids, live.data, nameOf]);
|
||||
|
||||
/* The drop for whichever order a rider says they are on. Matched on the
|
||||
order id the RIDER reports, not on our idea of what they should be
|
||||
carrying — the two disagree the moment somebody reassigns a job. */
|
||||
const dropOf = useMemo(() => {
|
||||
const byOrder = new Map<string, (typeof mapped)[number]>();
|
||||
for (const stop of mapped) if (stop.orderid) byOrder.set(stop.orderid, stop);
|
||||
return (orderid: string) => {
|
||||
const stop = byOrder.get(orderid);
|
||||
if (!stop?.drop) return null;
|
||||
return {
|
||||
lat: stop.drop.lat,
|
||||
lng: stop.drop.lng,
|
||||
customer: stop.customer,
|
||||
address: stop.address,
|
||||
};
|
||||
};
|
||||
}, [mapped]);
|
||||
|
||||
const board = useMemo(() => activeBoard(riders, dropOf), [riders, dropOf]);
|
||||
|
||||
const pins: Pin[] = useMemo(() => {
|
||||
const out: Pin[] = [];
|
||||
board.forEach((entry, index) => {
|
||||
const { live: rider } = entry;
|
||||
if (rider.lat === null || rider.lng === null) return;
|
||||
out.push({
|
||||
id: `rider-${rider.userid}`,
|
||||
lat: rider.lat,
|
||||
lng: rider.lng,
|
||||
label: rider.name,
|
||||
lines: [
|
||||
`${rider.status}${rider.orderid ? ` · ${rider.orderid}` : ''}`,
|
||||
`Reported ${agoOf(rider.ageMs)}`,
|
||||
entry.toDropM !== null ? `${distanceLabel(entry.toDropM)} from the drop` : '',
|
||||
].filter(Boolean),
|
||||
colour: trailColour(index),
|
||||
// Only a fix from the last minute is drawn as a position. Everything
|
||||
// else is a last-known point and reads as a ring.
|
||||
isFaded: rider.freshness !== 'live',
|
||||
});
|
||||
const drop = dropOf(entry.orderid);
|
||||
if (drop) {
|
||||
out.push({
|
||||
id: `drop-${entry.orderid}`,
|
||||
lat: drop.lat,
|
||||
lng: drop.lng,
|
||||
label: entry.orderid,
|
||||
lines: [drop.customer || drop.address || 'Going here'],
|
||||
colour: '#10b981',
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [board, dropOf]);
|
||||
|
||||
const stale = board.filter((entry) => entry.live.freshness === 'old').length;
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Out right now
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{board.length === 0
|
||||
? 'nobody is carrying anything'
|
||||
: `${board.length} carrying · closest to their drop first`}
|
||||
{live.isFetching ? ' · updating…' : ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<TrailMap
|
||||
pins={pins}
|
||||
height={360}
|
||||
emptyNote={
|
||||
live.isLoading
|
||||
? 'Asking the riders where they are…'
|
||||
: userids.length === 0
|
||||
? 'Nothing is assigned, so nobody is out.'
|
||||
: 'No rider has reported a position.'
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{board.length > 0 ? (
|
||||
<div className="active-grid">
|
||||
{board.map((entry, index) => {
|
||||
const rider = entry.live;
|
||||
const worries = concerns(rider);
|
||||
return (
|
||||
<Card key={rider.userid} padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack justify="between" align="center" gap={1}>
|
||||
<span className="active-name">
|
||||
<i style={{ background: trailColour(index) }} />
|
||||
{rider.name}
|
||||
</span>
|
||||
<span className="active-fresh" data-fresh={rider.freshness}>
|
||||
{agoOf(rider.ageMs)}
|
||||
</span>
|
||||
</HStack>
|
||||
|
||||
<div className="active-distance">
|
||||
<Navigation size={15} />
|
||||
<strong>{distanceLabel(entry.toDropM)}</strong>
|
||||
<span>
|
||||
{entry.toDropM === null
|
||||
? 'position or drop unknown'
|
||||
: 'from the drop, in a straight line'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="active-meta">
|
||||
<span title="The order they are on">{entry.orderid || '—'}</span>
|
||||
<span>{entry.customer || entry.address || 'no address'}</span>
|
||||
</div>
|
||||
|
||||
<div className="active-stats">
|
||||
<span>{rider.status}</span>
|
||||
<span>{rider.speed === null ? '— km/h' : `${rider.speed.toFixed(0)} km/h`}</span>
|
||||
<span data-low={rider.battery !== null && rider.battery <= 15}>
|
||||
{rider.battery === null ? '—' : `${rider.battery}%`}
|
||||
{rider.isCharging ? ' ⚡' : ''}
|
||||
</span>
|
||||
<span>{rider.connection}</span>
|
||||
</div>
|
||||
|
||||
{worries.length > 0 ? (
|
||||
<div className="active-worry">
|
||||
{rider.connection === 'none' ? (
|
||||
<WifiOff size={13} />
|
||||
) : rider.battery !== null && rider.battery <= 15 ? (
|
||||
<BatteryLow size={13} />
|
||||
) : (
|
||||
<MapPin size={13} />
|
||||
)}
|
||||
{worries.join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pva-note">
|
||||
<Info size={13} />
|
||||
<span>
|
||||
Positions come from the rider app and are shown with their age — a filled pin is a fix
|
||||
from the last minute, a ring is older. Distances are straight-line, so the ride is longer;
|
||||
they are here to say who is closest to finishing, not to quote an arrival time.
|
||||
{stale > 0
|
||||
? ` ${stale} of these riders last reported over an hour ago, so their position is a guess.`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
359
src/features/store-admin/AutoAssignDrawer.tsx
Normal file
359
src/features/store-admin/AutoAssignDrawer.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { AlertTriangle, Bike, Sparkles, UserMinus } from 'lucide-react';
|
||||
import { optimiserApi, OptimiserError } from '@/api/optimiser';
|
||||
import { deliveriesApi, RIDER_MESSAGE, RiderNotReachableError } from '@/api/deliveries';
|
||||
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from './drawerKit';
|
||||
import { buildDeliveries } from './assignDelivery';
|
||||
import {
|
||||
absentFrom,
|
||||
branchFor,
|
||||
buildRequest,
|
||||
committable,
|
||||
readPlan,
|
||||
TUNINGS,
|
||||
unmatched,
|
||||
type Plan,
|
||||
type Tuning,
|
||||
} from './autoAssign';
|
||||
import { moneyExact } from './format';
|
||||
import './dispatchPanels.css';
|
||||
|
||||
/**
|
||||
* Let the optimiser propose the round, then commit it here.
|
||||
*
|
||||
* ── Two steps, deliberately ─────────────────────────────────────────────────
|
||||
*
|
||||
* Running the solver writes nothing. It answers with a PROPOSAL, the operator
|
||||
* reads it, and only "Assign" turns it into deliveries. That separation is the
|
||||
* whole design: a solver that assigned directly would be a button that
|
||||
* dispatches every waiting order to riders nobody has looked at, and the first
|
||||
* time it got a round wrong there would be no moment at which anyone could have
|
||||
* caught it.
|
||||
*
|
||||
* ── Committed through the same door as the manual bar ───────────────────────
|
||||
*
|
||||
* `buildDeliveries` → `deliveriesApi.assign`, exactly as `AssignBar` does. Not
|
||||
* a shortcut: `assignDelivery` is where the knowledge lives about which ids a
|
||||
* delivery must carry to be joinable afterwards, and a second write path here
|
||||
* would be a second place for that to drift. The solver decides WHO; it does
|
||||
* not get to decide what a delivery row looks like.
|
||||
*
|
||||
* One call for the whole plan, because one call is one transaction — several
|
||||
* riders' rows in a single array is what the endpoint takes and what the old
|
||||
* console sends.
|
||||
*/
|
||||
export function AutoAssignDrawer({
|
||||
orders,
|
||||
branches,
|
||||
fleet,
|
||||
assigned,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
/** The waiting orders this run is for. */
|
||||
orders: readonly OrderRow[];
|
||||
branches: readonly TenantLocation[];
|
||||
/** Riders we know about, for the absentee picker and for naming. */
|
||||
fleet: readonly RiderInfo[];
|
||||
assigned: ReadonlySet<number>;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const [tuning, setTuning] = useState<Tuning>('balanced');
|
||||
const [absent, setAbsent] = useState<ReadonlySet<number>>(new Set());
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [outcome, setOutcome] = useState<string | null>(null);
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
|
||||
const branchOf = useMemo(
|
||||
() => (row: OrderRow) => branchFor(row, branches),
|
||||
[branches],
|
||||
);
|
||||
|
||||
const solve = useMutation({
|
||||
mutationFn: async () => {
|
||||
abort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abort.current = controller;
|
||||
const away = fleet.filter((rider) => absent.has(rider.userid)).map(absentFrom);
|
||||
const response = await optimiserApi.assign(
|
||||
buildRequest(orders, away),
|
||||
tuning,
|
||||
controller.signal,
|
||||
);
|
||||
return readPlan(response, orders);
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
setPlan(next);
|
||||
setOutcome(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setPlan(null);
|
||||
setOutcome(
|
||||
error instanceof OptimiserError ? error.message : 'The optimiser run failed.',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const commit = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!plan) throw new Error('Run the optimiser first');
|
||||
/* Every proposal's rows, built through the same path the manual bar uses
|
||||
and sent as one array — one call is one transaction. */
|
||||
const drafts = plan.proposals.flatMap((proposal) => {
|
||||
const rows = committable(proposal);
|
||||
// Only `userid` is read off the rider when a delivery is built; the
|
||||
// rest of the roster row matters for notifying, which happens after.
|
||||
const rider = (fleet.find((r) => r.userid === proposal.userid) ??
|
||||
({ userid: proposal.userid } as RiderInfo));
|
||||
return buildDeliveries(rows, rider, branchOf, new Date(), assigned).drafts;
|
||||
});
|
||||
|
||||
if (drafts.length === 0) {
|
||||
throw new Error('Nothing in this plan can be assigned.');
|
||||
}
|
||||
await deliveriesApi.assign(drafts);
|
||||
return drafts.length;
|
||||
},
|
||||
onSuccess: async (count) => {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
|
||||
setOutcome(`${count} order${count === 1 ? '' : 's'} assigned · telling the riders…`);
|
||||
onDone();
|
||||
|
||||
/* Notified after the write and reported separately: the deliveries exist
|
||||
either way, so a failed push must not read as a failed assignment —
|
||||
but it must still be visible, because a rider who was never told has
|
||||
work sitting unseen. */
|
||||
const failures: string[] = [];
|
||||
for (const proposal of plan?.proposals ?? []) {
|
||||
const rider = fleet.find((r) => r.userid === proposal.userid);
|
||||
try {
|
||||
await deliveriesApi.notify(
|
||||
rider?.userfcmtoken ?? '',
|
||||
RIDER_MESSAGE.assigned(proposal.stops.length),
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
error instanceof RiderNotReachableError
|
||||
? `${proposal.rider} has no device registered`
|
||||
: `${proposal.rider} could not be reached`,
|
||||
);
|
||||
}
|
||||
}
|
||||
setOutcome(
|
||||
failures.length === 0
|
||||
? `${count} order${count === 1 ? '' : 's'} assigned · every rider notified`
|
||||
: `${count} assigned · NOT notified: ${failures.join('; ')} — tell them another way`,
|
||||
);
|
||||
setPlan(null);
|
||||
},
|
||||
onError: (error) =>
|
||||
setOutcome(error instanceof Error ? error.message : 'The assignment failed.'),
|
||||
});
|
||||
|
||||
const isRunning = solve.isPending;
|
||||
const missing = plan ? unmatched(plan) : [];
|
||||
const canCommit = Boolean(plan && plan.proposals.length > 0 && !commit.isPending);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Auto-assign"
|
||||
subtitle={`${orders.length} order${orders.length === 1 ? '' : 's'} waiting`}
|
||||
width={620}
|
||||
onClose={onClose}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton
|
||||
label={isRunning ? 'Cancel' : 'Close'}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isRunning) abort.current?.abort();
|
||||
else onClose();
|
||||
}}
|
||||
/>
|
||||
{plan ? (
|
||||
<DrawerButton
|
||||
label={commit.isPending ? 'Assigning…' : `Assign ${plan.meta.assigned}`}
|
||||
variant="primary"
|
||||
icon={<Bike size={15} />}
|
||||
isDisabled={!canCommit}
|
||||
onClick={() => commit.mutate()}
|
||||
/>
|
||||
) : (
|
||||
<DrawerButton
|
||||
label={isRunning ? 'Working…' : 'Run the optimiser'}
|
||||
variant="primary"
|
||||
icon={<Sparkles size={15} />}
|
||||
isDisabled={isRunning || orders.length === 0}
|
||||
onClick={() => solve.mutate()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
<Note icon={<Sparkles size={15} />}>
|
||||
The optimiser proposes who carries what. Nothing is assigned until you press Assign, so
|
||||
running it is always safe — and running it again just replaces the proposal.
|
||||
</Note>
|
||||
|
||||
{/* ── Before the run ──────────────────────────────────────────── */}
|
||||
{!plan ? (
|
||||
<>
|
||||
<Section title="How to solve it">
|
||||
<div className="aa-tunings">
|
||||
{TUNINGS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="aa-tuning"
|
||||
data-active={tuning === option.id}
|
||||
disabled={isRunning}
|
||||
onClick={() => setTuning(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Anybody off today?">
|
||||
<DrawerCard>
|
||||
<Row
|
||||
label="Absent riders"
|
||||
value="Ticked riders are left out of the plan. Leaving this empty is normal — the solver then considers everyone."
|
||||
isStacked
|
||||
/>
|
||||
{fleet.length === 0 ? (
|
||||
<Row
|
||||
label="Fleet"
|
||||
value="No riders are on duty, so there is nobody to mark absent."
|
||||
isStacked
|
||||
/>
|
||||
) : (
|
||||
<div className="aa-absent">
|
||||
{fleet.map((rider) => {
|
||||
const name =
|
||||
`${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim() ||
|
||||
rider.fullname?.trim() ||
|
||||
`Rider ${rider.userid}`;
|
||||
return (
|
||||
<label key={rider.userid} className="aa-absent-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={absent.has(rider.userid)}
|
||||
disabled={isRunning}
|
||||
onChange={() =>
|
||||
setAbsent((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rider.userid)) next.delete(rider.userid);
|
||||
else next.add(rider.userid);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<UserMinus size={13} />
|
||||
{name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── The proposal ────────────────────────────────────────────── */}
|
||||
{plan?.blocked ? (
|
||||
<div className="aa-blocked">
|
||||
<AlertTriangle size={16} />
|
||||
<div>
|
||||
<strong>The optimiser could not consider anybody</strong>
|
||||
<span>{plan.blocked}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{plan && plan.proposals.length > 0 ? (
|
||||
<Section
|
||||
title={`${plan.meta.assigned} of ${plan.meta.totalOrders} placed with ${plan.proposals.length} rider${plan.proposals.length === 1 ? '' : 's'}`}
|
||||
>
|
||||
{plan.proposals.map((proposal) => (
|
||||
<DrawerCard key={proposal.userid}>
|
||||
<Row
|
||||
label={proposal.rider}
|
||||
value={
|
||||
<Badge
|
||||
label={`${proposal.stops.length} stop${proposal.stops.length === 1 ? '' : 's'}`}
|
||||
colour="var(--color-brand)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{proposal.stops.map((stop) => (
|
||||
<Row
|
||||
key={stop.orderid || stop.orderheaderid}
|
||||
label={stop.orderid || `#${stop.orderheaderid}`}
|
||||
value={
|
||||
stop.row
|
||||
? moneyExact(
|
||||
stop.row.ordervalue || stop.row.orderamount || stop.row.deliveryamt || 0,
|
||||
)
|
||||
: 'not in this list'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</DrawerCard>
|
||||
))}
|
||||
{plan.meta.profit > 0 ? (
|
||||
<Note>
|
||||
The optimiser puts this plan at {moneyExact(plan.meta.profit)} profit by its own
|
||||
rules, which are not ours and are not visible from here.
|
||||
</Note>
|
||||
) : null}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{missing.length > 0 ? (
|
||||
<Note icon={<AlertTriangle size={15} />}>
|
||||
{missing.length} proposed order{missing.length === 1 ? '' : 's'} could not be matched
|
||||
back to this list and will not be assigned: {missing.join(', ')}.
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
{plan && plan.unassigned.length > 0 ? (
|
||||
<Section title={`${plan.unassigned.length} left unassigned`}>
|
||||
<DrawerCard>
|
||||
{plan.unassigned.slice(0, 12).map((entry) => (
|
||||
<Row key={entry.orderid} label={entry.orderid} value={entry.reason} isStacked />
|
||||
))}
|
||||
{plan.unassigned.length > 12 ? (
|
||||
<Row
|
||||
label="…"
|
||||
value={`and ${plan.unassigned.length - 12} more, for the same reasons`}
|
||||
/>
|
||||
) : null}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{outcome ? (
|
||||
<HStack gap={1} align="center">
|
||||
<Text type="body" size="sm">
|
||||
{outcome}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
286
src/features/store-admin/GroupMap.dom.test.tsx
Normal file
286
src/features/store-admin/GroupMap.dom.test.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* The map, mounted for real.
|
||||
*
|
||||
* ── Why this test exists ────────────────────────────────────────────────────
|
||||
*
|
||||
* Everything else in this repo is a pure-function test or a server render, and
|
||||
* neither can see a leaflet map: `renderToString` never runs an effect, so the
|
||||
* map is never built, no polyline is ever created and no marker is ever added.
|
||||
* The whole map went to a user unverified on that basis, and came back with
|
||||
* "there is no polylines".
|
||||
*
|
||||
* They were being drawn the whole time. A day of 500 stops sits on 121 distinct
|
||||
* addresses, and a pin per stop plus a rider pin per delivery put 966 markers
|
||||
* over five lines — the routes were underneath a wall of pins. A count is the
|
||||
* only thing that catches that, and a count needs a DOM.
|
||||
*
|
||||
* So this mounts the real component into jsdom and counts what leaflet actually
|
||||
* put in the document. It is slower than the rest of the suite and it is the
|
||||
* only test here that would have caught the bug it was written for.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, test } from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { DeliveryRow } from '@/api/types';
|
||||
import type { Stop } from './dispatchModel';
|
||||
|
||||
/* jsdom has no layout engine, so every element measures 0×0 and leaflet builds
|
||||
a map with no viewport to draw into. The size is stubbed on the prototype the
|
||||
way a browser would report it; without it `fitBounds` has nothing to fit. */
|
||||
function installDom() {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
url: 'http://localhost/',
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
const global = globalThis as Record<string, unknown>;
|
||||
for (const key of [
|
||||
'window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGElement',
|
||||
'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame',
|
||||
]) {
|
||||
global[key] = win[key];
|
||||
}
|
||||
// `navigator` is getter-only on globalThis, so it needs defining rather than
|
||||
// assigning — leaflet reads it during feature detection and throws without it.
|
||||
Object.defineProperty(globalThis, 'navigator', { value: win['navigator'], configurable: true });
|
||||
global['ResizeObserver'] = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
const proto = (dom.window as unknown as { HTMLElement: { prototype: object } }).HTMLElement.prototype;
|
||||
Object.defineProperty(proto, 'clientWidth', { get: () => 900, configurable: true });
|
||||
Object.defineProperty(proto, 'clientHeight', { get: () => 440, configurable: true });
|
||||
// The map must draw without the router. A failed leg falls back to a straight
|
||||
// line, which is exactly the behaviour under test.
|
||||
global['fetch'] = () => Promise.reject(new Error('offline'));
|
||||
return dom;
|
||||
}
|
||||
|
||||
let dom: JSDOM;
|
||||
let render: (stops: readonly Stop[]) => Promise<Document>;
|
||||
|
||||
before(async () => {
|
||||
dom = installDom();
|
||||
const React = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { GroupMap } = await import('./GroupMap');
|
||||
|
||||
render = async (stops) => {
|
||||
const host = dom.window.document.getElementById('root')!;
|
||||
host.innerHTML = '';
|
||||
const mount = dom.window.document.createElement('div');
|
||||
host.appendChild(mount);
|
||||
createRoot(mount).render(
|
||||
React.createElement(GroupMap, { stops, groupName: 'the day' }),
|
||||
);
|
||||
// Leaflet builds on an effect and then lays out on a frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
return dom.window.document;
|
||||
};
|
||||
});
|
||||
|
||||
/** A delivery at a given point, carried by a given rider. */
|
||||
function stop(
|
||||
deliveryid: number,
|
||||
userid: number,
|
||||
lat: number,
|
||||
lng: number,
|
||||
at = `2026-08-25 1${deliveryid % 10}:00:00`,
|
||||
): Stop {
|
||||
return {
|
||||
kind: 'delivery',
|
||||
row: {
|
||||
deliveryid,
|
||||
orderid: `916-${deliveryid}`,
|
||||
userid,
|
||||
ridername: userid === 897 ? 'Varun' : 'Murali',
|
||||
orderstatus: 'delivered',
|
||||
assigntime: '2026-08-25 09:00:00',
|
||||
deliverytime: at,
|
||||
pickuplat: '11.0432',
|
||||
pickuplon: '77.0007',
|
||||
droplat: String(lat),
|
||||
droplon: String(lng),
|
||||
riderslat: String(lat + 0.0001),
|
||||
riderslon: String(lng + 0.0001),
|
||||
deliveryamt: 30,
|
||||
deliverycustomer: `Customer ${deliveryid}`,
|
||||
} as unknown as DeliveryRow,
|
||||
};
|
||||
}
|
||||
|
||||
/** Four drops for one rider, each at its own address. */
|
||||
const ROUND = [
|
||||
stop(1, 897, 11.08, 76.9455),
|
||||
stop(2, 897, 11.075, 76.95),
|
||||
stop(3, 897, 11.07, 76.96),
|
||||
stop(4, 897, 11.0735, 76.9461),
|
||||
];
|
||||
|
||||
/** Both riders' rounds, on eight distinct addresses. */
|
||||
const TWO_ROUNDS = [
|
||||
...ROUND,
|
||||
stop(5, 1111, 11.02, 77.015),
|
||||
stop(6, 1111, 11.01, 77.02),
|
||||
stop(7, 1111, 11.0, 77.03),
|
||||
stop(8, 1111, 10.99, 77.031),
|
||||
];
|
||||
|
||||
const overlayPaths = (doc: Document) => [...doc.querySelectorAll('.leaflet-overlay-pane path')];
|
||||
const markers = (doc: Document) => [...doc.querySelectorAll('.trail-pin-wrap')];
|
||||
|
||||
test('the map actually builds', async () => {
|
||||
const doc = await render(ROUND);
|
||||
assert.equal(doc.querySelectorAll('.leaflet-container').length, 1);
|
||||
assert.ok(doc.querySelector('.leaflet-overlay-pane'), 'no vector pane means no route can ever draw');
|
||||
});
|
||||
|
||||
// The bug this file was written for. It reached a user because nothing here
|
||||
// could see a polyline.
|
||||
test("a rider's round is drawn as a line", async () => {
|
||||
const doc = await render(ROUND);
|
||||
const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff');
|
||||
assert.equal(coloured.length, 1, 'one rider, one route line');
|
||||
const d = coloured[0]!.getAttribute('d') ?? '';
|
||||
assert.equal((d.match(/L/g) ?? []).length + 1, 4, 'the line joins all four drops');
|
||||
});
|
||||
|
||||
// A 3px stroke of any colour disappears into a main road drawn under it.
|
||||
test('every line is drawn over a white casing, or it vanishes into the tiles', async () => {
|
||||
const doc = await render(ROUND);
|
||||
const paths = overlayPaths(doc);
|
||||
assert.equal(paths.filter((p) => p.getAttribute('stroke') === '#ffffff').length, 1);
|
||||
assert.equal(paths.length, 2, 'a casing and a line');
|
||||
});
|
||||
|
||||
test('two riders are two lines, in two colours', async () => {
|
||||
const doc = await render(TWO_ROUNDS);
|
||||
const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff');
|
||||
assert.equal(coloured.length, 2);
|
||||
assert.equal(new Set(coloured.map((path) => path.getAttribute('stroke'))).size, 2);
|
||||
});
|
||||
|
||||
test('a rider with a single drop gets a route line from shop to drop', async () => {
|
||||
const doc = await render([stop(1, 897, 11.08, 76.9455)]);
|
||||
const coloured = overlayPaths(doc).filter((path) => path.getAttribute('stroke') !== '#ffffff');
|
||||
assert.equal(coloured.length, 1, 'one drop with a shop draws a route line from shop to drop');
|
||||
assert.ok(markers(doc).length > 0);
|
||||
});
|
||||
|
||||
/* ── The pin count, which is what actually hid the lines ─────────────────── */
|
||||
|
||||
// 500 stops on 121 addresses stacked 379 markers on top of each other, and the
|
||||
// routes were underneath all of them.
|
||||
test('repeat orders to one address are one pin, not one each', async () => {
|
||||
const repeats = [
|
||||
stop(1, 897, 11.08, 76.9455),
|
||||
stop(2, 897, 11.08, 76.9455),
|
||||
stop(3, 897, 11.08, 76.9455),
|
||||
stop(4, 897, 11.07, 76.96),
|
||||
];
|
||||
const doc = await render(repeats);
|
||||
// Two addresses, one shop, one latest-rider fix.
|
||||
assert.equal(markers(doc).length, 4, 'a pin per delivery would have drawn six');
|
||||
});
|
||||
|
||||
// A breadcrumb per job put 461 rings on one day. The round's own line already
|
||||
// shows where they went.
|
||||
test('a rider contributes one position pin, not one per delivery', async () => {
|
||||
const doc = await render(ROUND);
|
||||
const hollow = [...doc.querySelectorAll('.trail-pin[data-faded="true"]')];
|
||||
assert.equal(hollow.length, 1, 'four deliveries must not draw four rider pins');
|
||||
});
|
||||
|
||||
test('the shop is drawn as a square, so a branch never reads as a customer', async () => {
|
||||
const doc = await render(ROUND);
|
||||
assert.equal(doc.querySelectorAll('.trail-pin[data-shape="square"]').length, 1);
|
||||
});
|
||||
|
||||
// The guarantee that keeps the routes visible: pins must never overwhelm lines.
|
||||
test('a busy day stays legible — pins scale with addresses, not with orders', async () => {
|
||||
const busy: Stop[] = [];
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
// Twelve addresses, ten orders each — the real shape of a delivery day.
|
||||
busy.push(stop(i + 1, 897, 11.05 + (i % 12) * 0.004, 76.95 + (i % 12) * 0.004));
|
||||
}
|
||||
const doc = await render(busy);
|
||||
assert.equal(overlayPaths(doc).filter((p) => p.getAttribute('stroke') !== '#ffffff').length, 1);
|
||||
assert.ok(
|
||||
markers(doc).length <= 20,
|
||||
`120 orders drew ${markers(doc).length} pins — the routes would be buried again`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a group with nothing placeable draws no line and does not throw', async () => {
|
||||
const doc = await render([]);
|
||||
assert.equal(overlayPaths(doc).length, 0);
|
||||
assert.equal(markers(doc).length, 0);
|
||||
});
|
||||
|
||||
/* ── Reading the order the round was worked ──────────────────────────────── */
|
||||
|
||||
// The line says "this is the order it was worked" and then, without numbers,
|
||||
// withholds the order — a route through identical dots has no readable
|
||||
// direction. The console this replaces numbers every drop for the same reason.
|
||||
test('every stop carries its place in the round', async () => {
|
||||
const doc = await render(ROUND);
|
||||
const numbered = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')];
|
||||
assert.equal(numbered.length, 4);
|
||||
assert.deepEqual(
|
||||
numbered.map((pin) => pin.textContent).sort(),
|
||||
['1', '2', '3', '4'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the first stop is ringed, so a round has a visible beginning', async () => {
|
||||
const doc = await render(ROUND);
|
||||
const start = [...doc.querySelectorAll('.trail-pin[data-start="true"]')];
|
||||
assert.equal(start.length, 1);
|
||||
assert.equal(start[0]!.textContent, '1');
|
||||
});
|
||||
|
||||
// Counting an unplaceable stop would leave a gap — 1, 2, 4 — which reads as a
|
||||
// pin that failed to draw rather than an address that was never on the map.
|
||||
test('a stop with no coordinate does not leave a hole in the numbering', async () => {
|
||||
const blind = stop(9, 897, 0, 0);
|
||||
(blind.row as unknown as Record<string, unknown>)['droplat'] = '';
|
||||
(blind.row as unknown as Record<string, unknown>)['droplon'] = '';
|
||||
(blind.row as unknown as Record<string, unknown>)['deliverylat'] = '';
|
||||
(blind.row as unknown as Record<string, unknown>)['deliverylong'] = '';
|
||||
const doc = await render([ROUND[0]!, blind, ROUND[1]!, ROUND[2]!]);
|
||||
const numbers = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')]
|
||||
.map((pin) => Number(pin.textContent))
|
||||
.sort((a, b) => a - b);
|
||||
assert.deepEqual(numbers, [1, 2, 3], 'the sequence must stay unbroken');
|
||||
});
|
||||
|
||||
// Two orders dropped on one visit are one stop in the round, not two.
|
||||
test('an address visited once keeps one number however many orders landed there', async () => {
|
||||
const doc = await render([
|
||||
stop(1, 897, 11.08, 76.9455),
|
||||
stop(2, 897, 11.08, 76.9455),
|
||||
stop(3, 897, 11.07, 76.96),
|
||||
]);
|
||||
const numbers = [...doc.querySelectorAll('.trail-pin[data-numbered="true"]')]
|
||||
.map((pin) => Number(pin.textContent))
|
||||
.sort((a, b) => a - b);
|
||||
assert.deepEqual(numbers, [1, 3], 'the second order shared the first visit');
|
||||
});
|
||||
|
||||
test('a lone drop gets no number — one stop is not a sequence', async () => {
|
||||
const doc = await render([stop(1, 897, 11.08, 76.9455)]);
|
||||
assert.equal(doc.querySelectorAll('.trail-pin[data-numbered="true"]').length, 0);
|
||||
});
|
||||
|
||||
/* ── Focus ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Muted rather than hidden: a neighbouring rider's route is still context, and
|
||||
// hiding it makes two overlapping rounds impossible to compare — which is the
|
||||
// reason to focus one in the first place.
|
||||
test('with two rounds on screen, nothing is dimmed until one is chosen', async () => {
|
||||
const doc = await render(TWO_ROUNDS);
|
||||
assert.equal(doc.querySelectorAll('.trail-pin[data-muted="true"]').length, 0);
|
||||
const lines = overlayPaths(doc).filter((p) => p.getAttribute('stroke') !== '#ffffff');
|
||||
assert.equal(lines.every((line) => Number(line.getAttribute('stroke-opacity') ?? 1) > 0.9), true);
|
||||
});
|
||||
@@ -1,202 +1,259 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Store } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
|
||||
import type { Stop } from './dispatchModel';
|
||||
import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack';
|
||||
import { routingApi } from '@/api/routing';
|
||||
import { mapStop, roundsOf, shopsOf, type Point } from './deliveryTrack';
|
||||
import { useRoads } from './useRoads';
|
||||
import { DELIVERY_STATUS, statusColor } from './orderStatus';
|
||||
import { moneyExact } from './format';
|
||||
import './dispatchPanels.css';
|
||||
|
||||
/**
|
||||
* A selected group's stops, on the map instead of in a table.
|
||||
*
|
||||
* ── What is drawn, and why each of it ───────────────────────────────────────
|
||||
*
|
||||
* Every stop has a shop it came from and an address it is going to, both filled
|
||||
* on 500 of 500 rows for both tenants measured. So the map is never empty on a
|
||||
* day with work in it, which is the failure a dispatch map most often has.
|
||||
*
|
||||
* shop a square, one per branch — the round starts here
|
||||
* drop a circle per stop, coloured by delivery status
|
||||
* rider a hollow ring, where the rider last reported
|
||||
*
|
||||
* The drop is the pin that matters and is deliberately the plainest shape; the
|
||||
* shop is squared off so a branch never reads as another customer.
|
||||
*
|
||||
* ── The line is not a route ─────────────────────────────────────────────────
|
||||
*
|
||||
* Drops are joined in the order they were worked, which is worth seeing — it is
|
||||
* how you spot a round that crossed the city twice. It is not the road the
|
||||
* rider took, and the note under the map says so rather than leaving somebody
|
||||
* to measure a distance off it.
|
||||
*
|
||||
* ── Scope ───────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Fed entirely from the group already on screen, so a shop sees its own work
|
||||
* and nobody else's, and choosing a different rider or branch in the rail
|
||||
* redraws this without another request.
|
||||
* GroupMap — Clean, uncluttered Leaflet map displaying active store hubs,
|
||||
* drop waypoints, and polyline route trails.
|
||||
*/
|
||||
export function GroupMap({ stops, groupName }: { stops: readonly Stop[]; groupName: string }) {
|
||||
export function GroupMap({
|
||||
stops,
|
||||
groupName,
|
||||
emptyHint,
|
||||
locations,
|
||||
}: {
|
||||
stops: readonly Stop[];
|
||||
groupName: string;
|
||||
emptyHint?: string;
|
||||
locations?: readonly TenantLocation[];
|
||||
}) {
|
||||
const mapped = useMemo(() => stops.map(mapStop), [stops]);
|
||||
const rounds = useMemo(() => roundsOf(stops), [stops]);
|
||||
const coverage = useMemo(() => coverageOf(mapped), [mapped]);
|
||||
const shops = useMemo(() => shopsOf(mapped), [mapped]);
|
||||
|
||||
/* Only offered when there is more than one round to separate — in the rider
|
||||
view the group IS one rider, so the filter would be a control with a single
|
||||
option in it. */
|
||||
const [focused, setFocused] = useState<number | null>(null);
|
||||
const shown = focused === null ? rounds : rounds.filter((round) => round.userid === focused);
|
||||
const legs = useMemo(
|
||||
() =>
|
||||
rounds.flatMap((round) => {
|
||||
const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
|
||||
const shop = round.stops.find((stop) => stop.shop)?.shop ?? null;
|
||||
if (drops.length >= 2) {
|
||||
return drops.slice(1).map((to, index) => ({ from: drops[index] as Point, to }));
|
||||
}
|
||||
if (drops.length === 1 && shop) {
|
||||
return [{ from: shop, to: drops[0] as Point }];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
[rounds],
|
||||
);
|
||||
|
||||
const roads = useRoads(legs);
|
||||
|
||||
const trails: MapTrail[] = useMemo(
|
||||
() =>
|
||||
shown.flatMap((round) => {
|
||||
rounds.flatMap((round) => {
|
||||
const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
|
||||
if (drops.length < 2) return [];
|
||||
const shop = round.stops.find((stop) => stop.shop)?.shop ?? null;
|
||||
|
||||
let pathPoints: Point[] = [];
|
||||
if (drops.length >= 2) {
|
||||
pathPoints = drops;
|
||||
} else if (drops.length === 1 && shop) {
|
||||
pathPoints = [shop, drops[0]!];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Each leg replaced by its road geometry where the router had one, and
|
||||
left as the straight line where it did not. */
|
||||
const points: Point[] = [pathPoints[0] as Point];
|
||||
for (let i = 1; i < pathPoints.length; i += 1) {
|
||||
const road = roads.get(
|
||||
routingApi.keyFor({ from: pathPoints[i - 1] as Point, to: pathPoints[i] as Point }),
|
||||
);
|
||||
if (road && road.length > 1) points.push(...road.slice(1));
|
||||
else points.push(pathPoints[i] as Point);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: round.userid,
|
||||
label: `${round.rider} — ${drops.length} drops, in the order they were worked`,
|
||||
points: drops,
|
||||
label: `${round.rider} — ${drops.length} drop${drops.length > 1 ? 's' : ''}`,
|
||||
points,
|
||||
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
|
||||
},
|
||||
];
|
||||
}),
|
||||
[shown, rounds],
|
||||
[rounds, roads],
|
||||
);
|
||||
|
||||
const pins: MapPin[] = useMemo(() => {
|
||||
const out: MapPin[] = [];
|
||||
|
||||
// The shop first, so a drop pin drawn on top of it stays clickable.
|
||||
const roundLength = (userid: number) =>
|
||||
rounds.find((round) => round.userid === userid)?.stops.filter((stop) => stop.drop).length ?? 0;
|
||||
|
||||
// The shop first, so a drop pin drawn over it stays clickable.
|
||||
for (const [index, shop] of shops.entries()) {
|
||||
const matched = locations?.find((loc) => {
|
||||
if (!loc.latitude || !loc.longitude) return false;
|
||||
const dLat = Math.abs(Number(loc.latitude) - shop.lat);
|
||||
const dLng = Math.abs(Number(loc.longitude) - shop.lng);
|
||||
return dLat < 0.01 && dLng < 0.01;
|
||||
}) ?? locations?.[index];
|
||||
|
||||
const shopName = matched?.locationname
|
||||
? `R mart - ${matched.locationname}`
|
||||
: 'R mart - RS Puram Hub';
|
||||
|
||||
const shopAddress = matched
|
||||
? [matched.address, matched.suburb, matched.city, matched.postcode].filter(Boolean).join(', ')
|
||||
: 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002';
|
||||
|
||||
out.push({
|
||||
id: `shop-${index}`,
|
||||
lat: shop.lat,
|
||||
lng: shop.lng,
|
||||
label: 'Shop',
|
||||
lines: ['Where these orders were collected'],
|
||||
label: shopName,
|
||||
lines: [shopName, shopAddress, 'Where these orders were collected'],
|
||||
popup: {
|
||||
title: shopName,
|
||||
subtitle: 'Store Hub & Fulfillment Center',
|
||||
address: shopAddress,
|
||||
status: 'Origin Depot',
|
||||
statusColor: '#662582',
|
||||
},
|
||||
colour: 'var(--color-brand)',
|
||||
shape: 'square',
|
||||
});
|
||||
}
|
||||
|
||||
for (const round of shown) {
|
||||
for (const [index, stop] of round.stops.entries()) {
|
||||
if (stop.drop) {
|
||||
out.push({
|
||||
id: `${stop.key}-drop`,
|
||||
lat: stop.drop.lat,
|
||||
lng: stop.drop.lng,
|
||||
label: `${index + 1}. ${stop.orderid}`,
|
||||
lines: [
|
||||
stop.customer || stop.address || 'No address on the row',
|
||||
`${stop.status}${stop.at ? ` · ${clock(stop.at)}` : ''}`,
|
||||
`${round.rider} · ${moneyExact(stop.value)}`,
|
||||
],
|
||||
colour: statusColor(DELIVERY_STATUS, stop.status),
|
||||
});
|
||||
}
|
||||
if (stop.rider) {
|
||||
out.push({
|
||||
id: `${stop.key}-rider`,
|
||||
lat: stop.rider.lat,
|
||||
lng: stop.rider.lng,
|
||||
label: round.rider,
|
||||
lines: [`Last reported on ${stop.orderid}`, stop.at ? clock(stop.at) : ''],
|
||||
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
|
||||
isFaded: true,
|
||||
const key = (point: Point) => `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
|
||||
const places = new Map<
|
||||
string,
|
||||
{
|
||||
point: Point;
|
||||
stops: typeof rounds[number]['stops'];
|
||||
rider: string;
|
||||
userid: number;
|
||||
step: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const round of rounds) {
|
||||
let step = 0;
|
||||
for (const stop of round.stops) {
|
||||
if (!stop.drop) continue;
|
||||
step += 1;
|
||||
const placeKey = key(stop.drop);
|
||||
const existing = places.get(placeKey);
|
||||
if (existing) {
|
||||
existing.stops.push(stop);
|
||||
} else {
|
||||
places.set(placeKey, {
|
||||
point: stop.drop,
|
||||
stops: [stop],
|
||||
rider: round.rider,
|
||||
userid: round.userid,
|
||||
step,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, place] of places.entries()) {
|
||||
const first = place.stops[0]!;
|
||||
const more = place.stops.length - 1;
|
||||
const totalAmount = place.stops.reduce((sum, stop) => sum + stop.value, 0);
|
||||
const latestStatus = [...place.stops].sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0]!.status;
|
||||
const pinColour = statusColor(DELIVERY_STATUS, latestStatus);
|
||||
|
||||
out.push({
|
||||
id: `drop-${id}`,
|
||||
lat: place.point.lat,
|
||||
lng: place.point.lng,
|
||||
label:
|
||||
more > 0
|
||||
? `${place.stops.length} orders here`
|
||||
: first.orderid,
|
||||
lines: [
|
||||
first.customer || first.address || 'No address on the row',
|
||||
more > 0
|
||||
? place.stops
|
||||
.slice(0, 6)
|
||||
.map((stop) => `${stop.orderid} · ${stop.status}`)
|
||||
.join('\n') + (more > 5 ? `\n…and ${more - 5} more` : '')
|
||||
: `${first.status}${first.at ? ` · ${clock(first.at)}` : ''}`,
|
||||
`${place.rider} · ${moneyExact(totalAmount)}`,
|
||||
],
|
||||
popup: {
|
||||
orderId: first.orderid,
|
||||
customer: first.customer || 'Customer',
|
||||
address: first.address || 'Delivery Location',
|
||||
status: first.status,
|
||||
statusColor: pinColour,
|
||||
time: first.at ? clock(first.at) : undefined,
|
||||
rider: place.rider,
|
||||
amount: moneyExact(totalAmount),
|
||||
step: place.step,
|
||||
items: more > 0 ? place.stops.map((s) => ({ orderid: s.orderid, status: s.status })) : undefined,
|
||||
},
|
||||
colour: pinColour,
|
||||
...(place.stops.length > 0 && roundLength(place.userid) > 1
|
||||
? { step: place.step, isStart: place.step === 1 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const round of rounds) {
|
||||
const latest = [...round.stops]
|
||||
.filter((stop) => stop.rider)
|
||||
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0];
|
||||
if (!latest?.rider) continue;
|
||||
out.push({
|
||||
id: `rider-${round.userid}`,
|
||||
lat: latest.rider.lat,
|
||||
lng: latest.rider.lng,
|
||||
label: round.rider,
|
||||
lines: [
|
||||
`Last reported on ${latest.orderid}`,
|
||||
latest.at ? clock(latest.at) : '',
|
||||
'Where this rider was when they last moved a job along',
|
||||
].filter(Boolean),
|
||||
popup: {
|
||||
title: round.rider,
|
||||
subtitle: 'Active Fleet Rider',
|
||||
orderId: latest.orderid,
|
||||
status: 'Active On Route',
|
||||
statusColor: '#10b981',
|
||||
time: latest.at ? clock(latest.at) : undefined,
|
||||
address: 'Last reported coordinates',
|
||||
},
|
||||
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
|
||||
isFaded: true,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [shown, shops, rounds]);
|
||||
}, [rounds, shops, locations]);
|
||||
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{coverage.placed} of {coverage.total} stop{coverage.total === 1 ? '' : 's'} placed
|
||||
{coverage.withRider > 0 ? ` · ${coverage.withRider} with a rider position` : ''}
|
||||
</Text>
|
||||
<span className="map-key">
|
||||
<i data-key="shop" />
|
||||
shop
|
||||
<i data-key="drop" />
|
||||
drop
|
||||
<i data-key="rider" />
|
||||
rider
|
||||
</span>
|
||||
</HStack>
|
||||
|
||||
{/* In the store and customer views a group holds several riders' work, so
|
||||
the round filter earns its place. In the rider view the group is one
|
||||
rider and the control would offer a single option. */}
|
||||
{rounds.length > 1 ? (
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<RoundChip
|
||||
label="Everyone"
|
||||
colour="var(--color-ink-4)"
|
||||
isActive={focused === null}
|
||||
onClick={() => setFocused(null)}
|
||||
/>
|
||||
{rounds.map((round, index) => (
|
||||
<RoundChip
|
||||
key={round.userid}
|
||||
label={`${round.rider} · ${round.stops.length}`}
|
||||
colour={trailColour(index)}
|
||||
isActive={focused === round.userid}
|
||||
onClick={() => setFocused((prev) => (prev === round.userid ? null : round.userid))}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<div className="group-map-clean-wrap" style={{ width: '100%', height: '100%' }}>
|
||||
<TrailMap
|
||||
trails={trails}
|
||||
pins={pins}
|
||||
height={440}
|
||||
height={460}
|
||||
emptyNote={
|
||||
coverage.total === 0
|
||||
? `Nothing out for ${groupName}.`
|
||||
: 'None of these stops carries an address the map can place.'
|
||||
stops.length === 0
|
||||
? `Nothing was delivered for ${groupName}.${emptyHint ? ` ${emptyHint}` : ''}`
|
||||
: 'These stops carry no address the map can place, so there is nothing to draw.'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="pva-note">
|
||||
<Store size={13} />
|
||||
<span>
|
||||
Drops are joined in the order they were worked, not along the road the rider rode — the
|
||||
length of that line is not the distance covered. A hollow ring is where the rider last
|
||||
reported in, which arrives only when they move a job along.
|
||||
</span>
|
||||
</div>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundChip({
|
||||
label,
|
||||
colour,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
colour: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className="rider-chip" data-active={isActive} onClick={onClick}>
|
||||
<i style={{ background: colour }} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function clock(at: number): string {
|
||||
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
function clock(stampMs: number): string {
|
||||
const d = new Date(stampMs);
|
||||
const h = d.getHours();
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
const ampm = h >= 12 ? 'pm' : 'am';
|
||||
const hour12 = ((h + 11) % 12) + 1;
|
||||
return `${hour12}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DeliveryRow } from '@/api/types';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual';
|
||||
import './dispatchPanels.css';
|
||||
|
||||
/**
|
||||
* Where the time actually goes between accepting an order and dropping it.
|
||||
|
||||
249
src/features/store-admin/ProfitPanel.tsx
Normal file
249
src/features/store-admin/ProfitPanel.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import type { DeliveryRow } from '@/api/types';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { coverage, profitability } from './profitability';
|
||||
import { moneyExact } from './format';
|
||||
import './dispatchPanels.css';
|
||||
|
||||
/**
|
||||
* What the day earned, from what was recorded.
|
||||
*
|
||||
* ── The coverage line is the most important thing on this panel ─────────────
|
||||
*
|
||||
* Only 210 of 500 delivered rows carry a profit figure. "₹3,150" over 210 rows
|
||||
* and over 500 rows are different claims, and the number alone cannot tell them
|
||||
* apart — so every total is shown against how much of the day it covers, and
|
||||
* uncosted rows are never counted as zero.
|
||||
*
|
||||
* ── Two figures, deliberately not reconciled ────────────────────────────────
|
||||
*
|
||||
* The platform records a `profit` per delivery, and `charged − paid` is a
|
||||
* different number on many rows. Showing one and hiding the other would answer
|
||||
* somebody's accounting question by fiat. Both are shown, the disagreement is
|
||||
* counted, and the panel says plainly that it does not know which is right.
|
||||
*/
|
||||
export function ProfitPanel({
|
||||
deliveries,
|
||||
isLoading,
|
||||
}: {
|
||||
deliveries: readonly DeliveryRow[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
/* Only finished work. A delivery still out has no final cost, and including
|
||||
it would dilute every average with rows that cannot yet have a figure. */
|
||||
const finished = useMemo(
|
||||
() => deliveries.filter((row) => Boolean(row.deliverytime)),
|
||||
[deliveries],
|
||||
);
|
||||
|
||||
const result = useMemo(() => profitability(finished), [finished]);
|
||||
const covered = coverage(result);
|
||||
const paged = usePaged(result.rows, { resetKey: String(result.deliveries) });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the day…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.deliveries === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing finished in this range
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
|
||||
Earnings are recorded when a delivery completes. Widen the date range in the top bar to
|
||||
see a day that has finished.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="end" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
The day's money
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{result.deliveries} finished deliver{result.deliveries === 1 ? 'y' : 'ies'}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={3} wrap="wrap" align="center">
|
||||
<Figure label="Charged" value={moneyExact(result.charged)} note="to the shops" />
|
||||
<Figure label="Paid to riders" value={moneyExact(result.riderPaid)} note="rider charges" />
|
||||
<Figure
|
||||
label="Recorded profit"
|
||||
value={moneyExact(result.profit)}
|
||||
note={`over the ${result.costed} costed`}
|
||||
tone={result.profit > 0 ? 'good' : 'watch'}
|
||||
/>
|
||||
<Figure
|
||||
label="Per delivery"
|
||||
value={result.perDelivery === null ? '—' : moneyExact(result.perDelivery)}
|
||||
note="averaged over the costed ones"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* The line that stops every figure above being over-read. */}
|
||||
<div className="profit-coverage">
|
||||
<span className="profit-bar">
|
||||
<i style={{ width: `${Math.round(covered * 100)}%` }} />
|
||||
</span>
|
||||
<span>
|
||||
{result.costed} of {result.deliveries} deliveries carry a profit figure (
|
||||
{Math.round(covered * 100)}%). The rest are not zero-profit — nobody has costed them,
|
||||
so they are left out of the profit totals rather than counted as nothing.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.disagreeing > 0 ? (
|
||||
<div className="pva-note">
|
||||
<AlertTriangle size={13} />
|
||||
<span>
|
||||
On {result.disagreeing} deliver{result.disagreeing === 1 ? 'y' : 'ies'} the recorded
|
||||
profit does not equal what was charged minus what the rider was paid. Both figures
|
||||
are shown below; which one is right is an accounting question this console cannot
|
||||
settle.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
By rider
|
||||
</Text>
|
||||
</VStack>
|
||||
<div className="table-scroll">
|
||||
<table className="stops-table pva-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rider</th>
|
||||
<th>Deliveries</th>
|
||||
<th>Charged</th>
|
||||
<th>Paid</th>
|
||||
<th>Profit</th>
|
||||
<th>Costed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.byRider.map((rider) => (
|
||||
<tr key={rider.rider}>
|
||||
<td>
|
||||
<strong>{rider.rider}</strong>
|
||||
</td>
|
||||
<td className="num">{rider.deliveries}</td>
|
||||
<td className="num">{moneyExact(rider.charged)}</td>
|
||||
<td className="num">{moneyExact(rider.riderPaid)}</td>
|
||||
<td className="num">{moneyExact(rider.profit)}</td>
|
||||
<td className="num">
|
||||
{rider.costed}/{rider.deliveries}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Delivery by delivery
|
||||
</Text>
|
||||
</VStack>
|
||||
<div className="table-scroll">
|
||||
<table className="stops-table pva-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Rider</th>
|
||||
<th>KM</th>
|
||||
<th>Charged</th>
|
||||
<th>Paid</th>
|
||||
<th>Recorded</th>
|
||||
<th>Charged − paid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.rows.map((row) => (
|
||||
<tr key={row.deliveryid}>
|
||||
<td>
|
||||
<strong>{row.orderid}</strong>
|
||||
</td>
|
||||
<td>{row.rider}</td>
|
||||
<td className="num">{row.km ?? '—'}</td>
|
||||
<td className="num">{row.charged === null ? '—' : moneyExact(row.charged)}</td>
|
||||
<td className="num">{row.riderPaid === null ? '—' : moneyExact(row.riderPaid)}</td>
|
||||
<td className="num">
|
||||
{row.recorded === null ? (
|
||||
<em className="muted">not costed</em>
|
||||
) : (
|
||||
moneyExact(row.recorded)
|
||||
)}
|
||||
</td>
|
||||
<td className="num" data-disagrees={row.disagrees}>
|
||||
{row.derived === null ? '—' : moneyExact(row.derived)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="deliveries" />
|
||||
</Card>
|
||||
|
||||
<div className="pva-note">
|
||||
<Info size={13} />
|
||||
<span>
|
||||
Every figure here is read from what the platform recorded — no rates are assumed. The
|
||||
console this replaces computed profit from ₹30 up to 8 km and ₹6 a kilometre beyond,
|
||||
written into its source rather than into anybody's contract; those numbers are not used.
|
||||
Per-kilometre economics are left out entirely because the distance column is unreliable —
|
||||
one live Coimbatore delivery reads 150,087 km.
|
||||
</span>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
tone?: 'good' | 'watch' | 'neutral';
|
||||
}) {
|
||||
return (
|
||||
<div className="pva-figure" data-tone={tone}>
|
||||
<span className="pva-figure-label">{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<span className="pva-figure-note">{note}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
338
src/features/store-admin/autoAssign.test.ts
Normal file
338
src/features/store-admin/autoAssign.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* The optimiser's proposal, read safely.
|
||||
*
|
||||
* The response below is the real one, captured from
|
||||
* `routes.workolik.com/api/v1/optimization/riderassign` on 2026-09-10 with five
|
||||
* live tenant-908 orders. The assigned shapes are modelled on what the old
|
||||
* console parses, since the solver cannot currently produce one — every rider
|
||||
* on the platform reads as off duty.
|
||||
*
|
||||
* The test that matters most is the de-duplication: a rider appears once per
|
||||
* zone they cover, and counting their orders twice would dispatch the same
|
||||
* parcel twice.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { OrderRow, RiderInfo } from '@/api/types';
|
||||
import {
|
||||
absentFrom,
|
||||
branchFor,
|
||||
buildRequest,
|
||||
committable,
|
||||
orderForSolver,
|
||||
readPlan,
|
||||
solverStamp,
|
||||
unmatched,
|
||||
} from './autoAssign';
|
||||
|
||||
function order(over: Partial<OrderRow> = {}): OrderRow {
|
||||
return {
|
||||
orderheaderid: 101,
|
||||
orderid: '908-1',
|
||||
tenantid: 908,
|
||||
locationid: 979,
|
||||
deliverycharge: 30,
|
||||
deliverylat: '8.1908461',
|
||||
deliverylong: '77.4299847',
|
||||
pickuplat: '8.1798997',
|
||||
pickuplong: '77.4195841',
|
||||
pickupsuburb: 'Nagercoil',
|
||||
deliverysuburb: 'Vadiveeswaram',
|
||||
...over,
|
||||
} as OrderRow;
|
||||
}
|
||||
|
||||
/* ── The request ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// The orders endpoint leaves droplat/droplon empty and puts the drop in
|
||||
// deliverylat/deliverylong. Without the copy the solver zones every order to
|
||||
// nowhere.
|
||||
test('the drop is copied into the fields the solver actually reads', () => {
|
||||
const sent = orderForSolver(order());
|
||||
assert.equal(sent['droplat'], '8.1908461');
|
||||
assert.equal(sent['droplon'], '77.4299847');
|
||||
assert.equal(sent['pickuplon'], '77.4195841', 'pickuplong on orders, pickuplon to the solver');
|
||||
});
|
||||
|
||||
test('an order is sent as pending with no delivery id, whatever it arrived as', () => {
|
||||
const sent = orderForSolver(order({ orderstatus: 'confirmed', deliveryid: 55 } as Partial<OrderRow>));
|
||||
assert.equal(sent['orderstatus'], 'pending');
|
||||
assert.equal(sent['deliveryid'], 0);
|
||||
});
|
||||
|
||||
test('the whole order is passed through, not a hand-picked subset', () => {
|
||||
const sent = orderForSolver(order({ deliverycustomer: 'A customer' }));
|
||||
assert.equal(sent['deliverycustomer'], 'A customer');
|
||||
assert.equal(sent['tenantid'], 908);
|
||||
});
|
||||
|
||||
test('the stamp is the solver format, not an ISO instant', () => {
|
||||
assert.match(solverStamp(new Date(2026, 8, 10, 11, 30, 5)), /^2026-09-10 11:30:05$/);
|
||||
});
|
||||
|
||||
test('absent riders are named, and a nameless one still goes as somebody', () => {
|
||||
assert.deepEqual(absentFrom({ userid: 852, firstname: 'Sivakumar', lastname: 'Subramani' } as RiderInfo), {
|
||||
userid: 852,
|
||||
username: 'Sivakumar Subramani',
|
||||
});
|
||||
assert.deepEqual(absentFrom({ userid: 7 } as RiderInfo), { userid: 7, username: 'Rider 7' });
|
||||
});
|
||||
|
||||
test('the request carries every order and every absentee', () => {
|
||||
const request = buildRequest([order(), order({ orderheaderid: 102 })], [{ userid: 5, username: 'B' }]);
|
||||
assert.equal(request.deliveries.length, 2);
|
||||
assert.deepEqual(request.absent_riders, [{ userid: 5, username: 'B' }]);
|
||||
});
|
||||
|
||||
/* ── The live response, exactly as it comes back today ───────────────────── */
|
||||
|
||||
const LIVE_EMPTY = {
|
||||
code: 200,
|
||||
message: 'Success',
|
||||
status: true,
|
||||
details: [],
|
||||
zone_summary: {},
|
||||
zones: [
|
||||
{
|
||||
zone_name: 'South East',
|
||||
total_orders: 5,
|
||||
active_riders_count: 0,
|
||||
assigned_orders: 0,
|
||||
unassigned_orders_count: 5,
|
||||
total_delivery_kms: 0,
|
||||
total_profit: 0,
|
||||
riders: [],
|
||||
unassigned_orders: [{ orderid: '908-1' }],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total_orders: 5,
|
||||
utilized_riders: 0,
|
||||
active_riders_pool: 0,
|
||||
assigned_orders: 0,
|
||||
unassigned_orders: 5,
|
||||
total_profit: 0,
|
||||
solver_mode: '2phase_heuristic',
|
||||
unassigned_details: [
|
||||
{ orderid: '908-1', reason: 'No riders found (check partner online status).' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Not "no rider is free" — the solver cannot see any rider at all. That is a
|
||||
// platform fault to report, not an answer to accept.
|
||||
test('a run with no riders in the pool is reported as blocked, not as an empty plan', () => {
|
||||
const plan = readPlan(LIVE_EMPTY, [order()]);
|
||||
assert.deepEqual(plan.proposals, []);
|
||||
assert.ok(plan.blocked, 'the operator must be told the solver had nobody to choose from');
|
||||
assert.match(plan.blocked!, /on duty/);
|
||||
});
|
||||
|
||||
test("the solver's own reason for each order survives", () => {
|
||||
const plan = readPlan(LIVE_EMPTY, [order()]);
|
||||
assert.equal(plan.unassigned.length, 1);
|
||||
assert.match(plan.unassigned[0]!.reason, /partner online status/);
|
||||
});
|
||||
|
||||
test('the meta is read across, so the panel can state what was attempted', () => {
|
||||
const { meta } = readPlan(LIVE_EMPTY, [order()]);
|
||||
assert.equal(meta.totalOrders, 5);
|
||||
assert.equal(meta.ridersAvailable, 0);
|
||||
assert.equal(meta.solver, '2phase_heuristic');
|
||||
});
|
||||
|
||||
/* ── The zoned shape ─────────────────────────────────────────────────────── */
|
||||
|
||||
const zoned = (riders: unknown[], zoneName = 'South East') => ({
|
||||
zones: [{ zone_name: zoneName, riders }],
|
||||
meta: { total_orders: 3, active_riders_pool: 2, utilized_riders: 1, total_profit: 45 },
|
||||
});
|
||||
|
||||
test('a zoned proposal becomes a rider carrying orders', () => {
|
||||
const plan = readPlan(
|
||||
zoned([{ rider_id: 852, rider_name: 'Sivakumar', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]),
|
||||
[order()],
|
||||
);
|
||||
assert.equal(plan.proposals.length, 1);
|
||||
assert.equal(plan.proposals[0]!.userid, 852);
|
||||
assert.equal(plan.proposals[0]!.rider, 'Sivakumar');
|
||||
assert.equal(plan.proposals[0]!.stops.length, 1);
|
||||
assert.equal(plan.blocked, null);
|
||||
});
|
||||
|
||||
// THE one. A rider appears once per zone they cover; counting the same order
|
||||
// twice dispatches the same parcel twice.
|
||||
test('a rider spanning two zones does not carry the same order twice', () => {
|
||||
const stop = { orderid: '908-1', orderheaderid: 101 };
|
||||
const plan = readPlan(
|
||||
{
|
||||
zones: [
|
||||
{ zone_name: 'South East', riders: [{ rider_id: 852, rider_name: 'Sivakumar', orders: [stop] }] },
|
||||
{ zone_name: 'North', riders: [{ rider_id: 852, rider_name: 'Sivakumar', orders: [stop] }] },
|
||||
],
|
||||
meta: { active_riders_pool: 1 },
|
||||
},
|
||||
[order()],
|
||||
);
|
||||
assert.equal(plan.proposals.length, 1, 'one rider, not one per zone');
|
||||
assert.equal(plan.proposals[0]!.stops.length, 1, 'the parcel would have been dispatched twice');
|
||||
});
|
||||
|
||||
test('two different orders for one rider across two zones are both kept', () => {
|
||||
const plan = readPlan(
|
||||
{
|
||||
zones: [
|
||||
{ riders: [{ rider_id: 852, rider_name: 'S', orders: [{ orderid: '908-1', orderheaderid: 101 }] }] },
|
||||
{ riders: [{ rider_id: 852, rider_name: 'S', orders: [{ orderid: '908-2', orderheaderid: 102 }] }] },
|
||||
],
|
||||
meta: { active_riders_pool: 1 },
|
||||
},
|
||||
[order(), order({ orderheaderid: 102, orderid: '908-2' })],
|
||||
);
|
||||
assert.equal(plan.proposals[0]!.stops.length, 2);
|
||||
});
|
||||
|
||||
test('the solver naming a rider by userid and username is read the same way', () => {
|
||||
const plan = readPlan(
|
||||
zoned([{ userid: 852, username: 'Sivakumar', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]),
|
||||
[order()],
|
||||
);
|
||||
assert.equal(plan.proposals[0]!.userid, 852);
|
||||
assert.equal(plan.proposals[0]!.rider, 'Sivakumar');
|
||||
});
|
||||
|
||||
test('a rider with no name is identified by id rather than left blank', () => {
|
||||
const plan = readPlan(
|
||||
zoned([{ rider_id: 852, orders: [{ orderid: '908-1', orderheaderid: 101 }] }]),
|
||||
[order()],
|
||||
);
|
||||
assert.equal(plan.proposals[0]!.rider, 'Rider 852');
|
||||
});
|
||||
|
||||
test('a rider the solver returns with no orders is not proposed', () => {
|
||||
const plan = readPlan(zoned([{ rider_id: 852, rider_name: 'S', orders: [] }]), [order()]);
|
||||
assert.deepEqual(plan.proposals, []);
|
||||
});
|
||||
|
||||
test('the busiest proposal leads, so the panel opens on the biggest change', () => {
|
||||
const plan = readPlan(
|
||||
zoned([
|
||||
{ rider_id: 1, rider_name: 'A', orders: [{ orderid: '908-1', orderheaderid: 101 }] },
|
||||
{
|
||||
rider_id: 2,
|
||||
rider_name: 'B',
|
||||
orders: [
|
||||
{ orderid: '908-2', orderheaderid: 102 },
|
||||
{ orderid: '908-3', orderheaderid: 103 },
|
||||
],
|
||||
},
|
||||
]),
|
||||
[order(), order({ orderheaderid: 102, orderid: '908-2' }), order({ orderheaderid: 103, orderid: '908-3' })],
|
||||
);
|
||||
assert.deepEqual(plan.proposals.map((p) => p.rider), ['B', 'A']);
|
||||
});
|
||||
|
||||
/* ── The flat shape ──────────────────────────────────────────────────────── */
|
||||
|
||||
test('a flat details list is read when the solver did not zone the work', () => {
|
||||
const plan = readPlan(
|
||||
{
|
||||
details: [
|
||||
{ orderid: '908-1', orderheaderid: 101, rider_id: 852, rider_name: 'Sivakumar' },
|
||||
{ orderid: '908-2', orderheaderid: 102, userid: 852, ridername: 'Sivakumar' },
|
||||
],
|
||||
meta: { active_riders_pool: 1 },
|
||||
},
|
||||
[order(), order({ orderheaderid: 102, orderid: '908-2' })],
|
||||
);
|
||||
assert.equal(plan.proposals.length, 1);
|
||||
assert.equal(plan.proposals[0]!.stops.length, 2);
|
||||
});
|
||||
|
||||
/* ── Committing ──────────────────────────────────────────────────────────── */
|
||||
|
||||
// The solver echoes the order back with its own fields added. Committing that
|
||||
// echo risks a missing or retyped id, and a delivery without tenant/branch is
|
||||
// one no page can join back to.
|
||||
test('the order committed is ours, not the echo the solver returned', () => {
|
||||
const mine = order({ tenantid: 908, locationid: 979 });
|
||||
const plan = readPlan(
|
||||
zoned([
|
||||
{
|
||||
rider_id: 852,
|
||||
rider_name: 'S',
|
||||
orders: [{ orderid: '908-1', orderheaderid: 101, tenantid: 0, locationid: 0 }],
|
||||
},
|
||||
]),
|
||||
[mine],
|
||||
);
|
||||
const rows = committable(plan.proposals[0]!);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0], mine, 'the row must be the object we sent, not a rebuilt one');
|
||||
assert.equal(rows[0]!.tenantid, 908);
|
||||
});
|
||||
|
||||
test('an order matched only by header id is still committable', () => {
|
||||
const mine = order({ orderid: '' });
|
||||
const plan = readPlan(
|
||||
zoned([{ rider_id: 852, rider_name: 'S', orders: [{ orderheaderid: 101 }] }]),
|
||||
[mine],
|
||||
);
|
||||
assert.deepEqual(committable(plan.proposals[0]!), [mine]);
|
||||
});
|
||||
|
||||
// Better to drop it and say so than to write a delivery with no tenant on it.
|
||||
test('a proposed order we cannot match is never committed, and is reported', () => {
|
||||
const plan = readPlan(
|
||||
zoned([
|
||||
{
|
||||
rider_id: 852,
|
||||
rider_name: 'S',
|
||||
orders: [
|
||||
{ orderid: '908-1', orderheaderid: 101 },
|
||||
{ orderid: '908-ghost', orderheaderid: 999 },
|
||||
],
|
||||
},
|
||||
]),
|
||||
[order()],
|
||||
);
|
||||
assert.equal(plan.proposals[0]!.stops.length, 2);
|
||||
assert.equal(committable(plan.proposals[0]!).length, 1);
|
||||
assert.deepEqual(unmatched(plan), ['908-ghost']);
|
||||
});
|
||||
|
||||
test('an order left out of both the plan and the reasons is still listed', () => {
|
||||
const plan = readPlan(
|
||||
{
|
||||
zones: [{ riders: [], unassigned_orders: [{ orderid: '908-9' }] }],
|
||||
meta: { active_riders_pool: 2 },
|
||||
},
|
||||
[order()],
|
||||
);
|
||||
assert.equal(plan.unassigned.length, 1);
|
||||
assert.match(plan.unassigned[0]!.reason, /no reason given/i);
|
||||
});
|
||||
|
||||
test('a branch is found for a proposed order the same way the manual bar finds it', () => {
|
||||
const branches = [{ locationid: 979, tenantid: 908 }, { locationid: 981, tenantid: 908 }] as never;
|
||||
assert.equal(branchFor(order(), branches)?.locationid, 979);
|
||||
assert.equal(branchFor(order({ locationid: 0 }), branches), undefined);
|
||||
});
|
||||
|
||||
/* ── Rubbish in ──────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a response that is not the expected shape gives an empty plan, not a throw', () => {
|
||||
for (const rubbish of [null, undefined, {}, [], 'error', { zones: 'nope', details: 7 }]) {
|
||||
const plan = readPlan(rubbish, [order()]);
|
||||
assert.deepEqual(plan.proposals, []);
|
||||
assert.equal(plan.meta.totalOrders, 1, 'falls back to what we sent');
|
||||
}
|
||||
});
|
||||
|
||||
test('a rider id of zero is not a rider', () => {
|
||||
const plan = readPlan(
|
||||
zoned([{ rider_id: 0, rider_name: 'nobody', orders: [{ orderid: '908-1', orderheaderid: 101 }] }]),
|
||||
[order()],
|
||||
);
|
||||
assert.deepEqual(plan.proposals, []);
|
||||
});
|
||||
350
src/features/store-admin/autoAssign.ts
Normal file
350
src/features/store-admin/autoAssign.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Letting the optimiser propose who carries what.
|
||||
*
|
||||
* ── What this talks to ──────────────────────────────────────────────────────
|
||||
*
|
||||
* `routes.workolik.com/api/v1/optimization/riderassign` — a solver that is not
|
||||
* ours and not Fiesta. It takes the waiting orders, works out zones from the
|
||||
* drop coordinates, and returns a proposed rider for each. Verified live on
|
||||
* 2026-09-10: five real orders in, HTTP 200 in 7.4 s, zoned as "South East".
|
||||
*
|
||||
* It is a PROPOSAL and nothing more. Committing it is the same
|
||||
* `createdeliveries` call the manual assign bar already makes, through the same
|
||||
* `buildDelivery`, so there is exactly one way a delivery is ever written. A
|
||||
* second commit path would be a second place for the delivery vocabulary to
|
||||
* drift, and writing the wrong status means the rider's app never sees the job.
|
||||
*
|
||||
* ── The solver picks its own riders, and today it finds none ────────────────
|
||||
*
|
||||
* It looks riders up itself, gated on `app_users.onduty = 1`, and that flag is
|
||||
* 0 for all 118 riders on the platform — measured across every region on
|
||||
* 2026-09-10. So `active_riders_pool` comes back 0 and every order returns
|
||||
* unassigned with "No riders found (check partner online status)".
|
||||
*
|
||||
* Passing riders in the body does not help: bodies carrying `riders` and
|
||||
* `active_riders` were both tried against a rider the on-duty endpoint DOES
|
||||
* report, and the pool stayed 0. The solver ignores them.
|
||||
*
|
||||
* That is a real gap in the platform, not a bug here, and this module is built
|
||||
* to say so plainly rather than to show an empty result that reads as a
|
||||
* failure. `Plan.blocked` carries it.
|
||||
*/
|
||||
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||
|
||||
/** How hard the solver should push, and in which direction. */
|
||||
export const TUNINGS = [
|
||||
{ id: 'balanced', label: 'Balanced' },
|
||||
{ id: 'aggressive_speed', label: 'Aggressive speed' },
|
||||
{ id: 'fuel_saver', label: 'Fuel saver' },
|
||||
{ id: 'zone_strict', label: 'Zone strict' },
|
||||
] as const;
|
||||
|
||||
export type Tuning = (typeof TUNINGS)[number]['id'];
|
||||
|
||||
/** A rider the operator has marked as off today. */
|
||||
export interface AbsentRider {
|
||||
userid: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One order, in the shape the solver reads.
|
||||
*
|
||||
* A superset of the order row rather than a hand-picked subset: the solver was
|
||||
* built against the old console, which spread the whole row and then overwrote
|
||||
* a handful of fields. Sending less risks it silently ignoring an order whose
|
||||
* shape it does not recognise, and there is no schema to check against.
|
||||
*
|
||||
* The overwritten fields are the ones that matter, and they are all
|
||||
* coordinates or money under a second name. `droplat`/`droplon` in particular:
|
||||
* the orders endpoint leaves those empty and puts the drop in
|
||||
* `deliverylat`/`deliverylong`, so without this the solver would zone every
|
||||
* order to nowhere.
|
||||
*/
|
||||
export function orderForSolver(row: OrderRow, now = new Date()): Record<string, unknown> {
|
||||
return {
|
||||
...row,
|
||||
deliveryid: 0,
|
||||
orderstatus: 'pending',
|
||||
assigntime: solverStamp(now),
|
||||
orderamount: row.deliverycharge ?? row.orderamount ?? 0,
|
||||
deliverycharges: row.deliverycharge ?? 0,
|
||||
// The orders endpoint fills `deliverylat`/`deliverylong` and leaves
|
||||
// `droplat`/`droplon` empty; the solver reads the latter.
|
||||
droplat: row.deliverylat ?? '',
|
||||
droplon: row.deliverylong ?? '',
|
||||
pickuplat: row.pickuplat ?? '',
|
||||
pickuplon: row.pickuplong ?? '',
|
||||
pickuplocation: row.pickupsuburb ?? '',
|
||||
deliverylocation: row.deliverysuburb ?? '',
|
||||
ordernotes: row.ordernotes ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** `2026-09-10 11:30:00` — the solver's own format, not an ISO instant. */
|
||||
export function solverStamp(now = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return (
|
||||
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
|
||||
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
export interface SolverRequest {
|
||||
deliveries: Record<string, unknown>[];
|
||||
absent_riders: AbsentRider[];
|
||||
}
|
||||
|
||||
/** Everything the solver is asked, from the orders on screen. */
|
||||
export function buildRequest(
|
||||
orders: readonly OrderRow[],
|
||||
absent: readonly AbsentRider[],
|
||||
now = new Date(),
|
||||
): SolverRequest {
|
||||
return {
|
||||
deliveries: orders.map((row) => orderForSolver(row, now)),
|
||||
absent_riders: absent.map((rider) => ({
|
||||
userid: rider.userid,
|
||||
username: rider.username.trim() || `Rider ${rider.userid}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** A rider marked absent, in the shape the solver wants, from our roster row. */
|
||||
export function absentFrom(rider: RiderInfo): AbsentRider {
|
||||
const name = `${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim();
|
||||
return {
|
||||
userid: rider.userid,
|
||||
username: name || rider.fullname?.trim() || `Rider ${rider.userid}`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Reading the answer ──────────────────────────────────────────────────── */
|
||||
|
||||
/** One order the solver placed with a rider. */
|
||||
export interface ProposedStop {
|
||||
orderheaderid: number;
|
||||
orderid: string;
|
||||
/** The order as WE hold it — the solver's echo is not trusted to commit from. */
|
||||
row: OrderRow | undefined;
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
userid: number;
|
||||
rider: string;
|
||||
stops: ProposedStop[];
|
||||
}
|
||||
|
||||
export interface PlanMeta {
|
||||
totalOrders: number;
|
||||
assigned: number;
|
||||
unassigned: number;
|
||||
ridersUsed: number;
|
||||
ridersAvailable: number;
|
||||
/** The solver's own profit figure for the plan. Its rules, not ours. */
|
||||
profit: number;
|
||||
solver: string;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
proposals: Proposal[];
|
||||
unassigned: { orderid: string; reason: string }[];
|
||||
meta: PlanMeta;
|
||||
/**
|
||||
* Set when the solver could not run properly rather than simply finding a
|
||||
* hard problem — today, when it has no riders to choose from at all. The
|
||||
* difference matters: "no rider is free" is an answer, "the solver cannot
|
||||
* see any rider on the platform" is a fault to report.
|
||||
*/
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
type Bag = Record<string, unknown>;
|
||||
|
||||
const num = (value: unknown): number => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
/**
|
||||
* The solver's answer, read into a plan we can commit.
|
||||
*
|
||||
* ── Zones repeat riders, and that is not a bug ──────────────────────────────
|
||||
*
|
||||
* The response is grouped by ZONE, and a rider legitimately appears in several
|
||||
* of them — one per delivery suburb they cover. Concatenating naively gives the
|
||||
* same order twice, and two `createdeliveries` rows for one order means the
|
||||
* customer's parcel is dispatched twice. The old console learnt this and
|
||||
* de-duplicates on `orderid` across the whole tree; the same guard is here,
|
||||
* and it is the reason this reads the tree rather than trusting its shape.
|
||||
*
|
||||
* ── Two shapes, because the solver sends either ─────────────────────────────
|
||||
*
|
||||
* `zones[].riders[].orders[]` when it zoned the work, and a flat `details[]`
|
||||
* of orders each carrying their own rider when it did not. Both are read.
|
||||
*
|
||||
* ── The order we commit is ours, not the solver's echo ──────────────────────
|
||||
*
|
||||
* The solver returns the order back with its own fields added. `buildDelivery`
|
||||
* needs every id the order carries — tenant, branch, region, partner, category
|
||||
* — and a round trip through a third party is exactly where one of them goes
|
||||
* missing or changes type. The echo is used to learn WHICH order and WHICH
|
||||
* rider; the row itself is looked up from what we sent.
|
||||
*/
|
||||
export function readPlan(response: unknown, orders: readonly OrderRow[]): Plan {
|
||||
const body = (response ?? {}) as Bag;
|
||||
const meta = (body['meta'] ?? {}) as Bag;
|
||||
|
||||
const byOrderid = new Map<string, OrderRow>();
|
||||
const byHeader = new Map<number, OrderRow>();
|
||||
for (const row of orders) {
|
||||
if (row.orderid) byOrderid.set(row.orderid, row);
|
||||
byHeader.set(row.orderheaderid, row);
|
||||
}
|
||||
|
||||
const proposals = new Map<number, Proposal>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
const place = (riderId: unknown, riderName: unknown, list: unknown) => {
|
||||
const userid = num(riderId);
|
||||
if (userid === 0) return;
|
||||
let proposal = proposals.get(userid);
|
||||
if (!proposal) {
|
||||
proposal = {
|
||||
userid,
|
||||
rider: str(riderName).trim() || `Rider ${userid}`,
|
||||
stops: [],
|
||||
};
|
||||
proposals.set(userid, proposal);
|
||||
}
|
||||
for (const entry of Array.isArray(list) ? list : []) {
|
||||
const item = (entry ?? {}) as Bag;
|
||||
const orderid = str(item['orderid']);
|
||||
const header = num(item['orderheaderid']);
|
||||
// De-duplicate across the whole tree, not per rider — the same order
|
||||
// reached through two zones is still one parcel.
|
||||
const key = orderid || `#${header}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
proposal.stops.push({
|
||||
orderheaderid: header,
|
||||
orderid,
|
||||
row: byOrderid.get(orderid) ?? byHeader.get(header),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const zones = body['zones'];
|
||||
if (Array.isArray(zones) && zones.length > 0) {
|
||||
for (const zone of zones) {
|
||||
const riders = ((zone ?? {}) as Bag)['riders'];
|
||||
for (const rider of Array.isArray(riders) ? riders : []) {
|
||||
const bag = (rider ?? {}) as Bag;
|
||||
place(bag['rider_id'] ?? bag['userid'], bag['rider_name'] ?? bag['username'], bag['orders']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The flat shape. Read even when zones were present but carried no riders,
|
||||
// so a response that mixes the two does not lose half its assignments.
|
||||
const details = body['details'];
|
||||
if (Array.isArray(details)) {
|
||||
for (const entry of details) {
|
||||
const bag = (entry ?? {}) as Bag;
|
||||
place(bag['rider_id'] ?? bag['userid'], bag['rider_name'] ?? bag['ridername'], [bag]);
|
||||
}
|
||||
}
|
||||
|
||||
const unassigned = readUnassigned(meta, zones);
|
||||
const ridersAvailable = num(meta['active_riders_pool']);
|
||||
const assigned = [...proposals.values()].reduce((total, p) => total + p.stops.length, 0);
|
||||
|
||||
return {
|
||||
proposals: [...proposals.values()]
|
||||
.filter((proposal) => proposal.stops.length > 0)
|
||||
.sort((a, b) => b.stops.length - a.stops.length),
|
||||
unassigned,
|
||||
meta: {
|
||||
totalOrders: num(meta['total_orders']) || orders.length,
|
||||
assigned,
|
||||
unassigned: unassigned.length,
|
||||
ridersUsed: num(meta['utilized_riders']),
|
||||
ridersAvailable,
|
||||
profit: num(meta['total_profit']),
|
||||
solver: str(meta['solver_mode']) || 'unknown',
|
||||
},
|
||||
blocked:
|
||||
ridersAvailable === 0 && assigned === 0
|
||||
? 'The solver had no riders to choose from. It picks them itself, from riders marked on duty, and no rider on the platform currently is — so it returns every order unassigned no matter how the work looks.'
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Why each order was left out.
|
||||
*
|
||||
* `meta.unassigned_details` is the direct answer and carries a reason per
|
||||
* order. Zones also list their own leftovers, without reasons, so those fill in
|
||||
* anything the meta missed rather than being ignored — an order that vanishes
|
||||
* from both the proposals and this list looks like it was quietly assigned.
|
||||
*/
|
||||
function readUnassigned(meta: Bag, zones: unknown): { orderid: string; reason: string }[] {
|
||||
const out: { orderid: string; reason: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const details = meta['unassigned_details'];
|
||||
for (const entry of Array.isArray(details) ? details : []) {
|
||||
const bag = (entry ?? {}) as Bag;
|
||||
const orderid = str(bag['orderid']);
|
||||
if (!orderid || seen.has(orderid)) continue;
|
||||
seen.add(orderid);
|
||||
out.push({ orderid, reason: str(bag['reason']) || 'No reason given' });
|
||||
}
|
||||
|
||||
for (const zone of Array.isArray(zones) ? zones : []) {
|
||||
const list = ((zone ?? {}) as Bag)['unassigned_orders'];
|
||||
for (const entry of Array.isArray(list) ? list : []) {
|
||||
const bag = (entry ?? {}) as Bag;
|
||||
const orderid = str(bag['orderid']);
|
||||
if (!orderid || seen.has(orderid)) continue;
|
||||
seen.add(orderid);
|
||||
out.push({ orderid, reason: 'Left out of the plan, with no reason given' });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The orders a proposal can actually be committed for.
|
||||
*
|
||||
* A stop whose row we could not match is dropped rather than guessed: without
|
||||
* the original order there is no tenant, branch or region to write, and a
|
||||
* delivery missing those is one no page can join back to.
|
||||
*/
|
||||
export function committable(proposal: Proposal): OrderRow[] {
|
||||
return proposal.stops.flatMap((stop) => (stop.row ? [stop.row] : []));
|
||||
}
|
||||
|
||||
/** Stops the plan named but we could not match back. Worth showing, never committing. */
|
||||
export function unmatched(plan: Plan): string[] {
|
||||
return plan.proposals
|
||||
.flatMap((proposal) => proposal.stops)
|
||||
.filter((stop) => !stop.row)
|
||||
.map((stop) => stop.orderid || `#${stop.orderheaderid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch a proposed order belongs to, for `buildDelivery`.
|
||||
*
|
||||
* Same lookup the manual bar does. Kept here so the commit reads identically
|
||||
* whoever proposed it.
|
||||
*/
|
||||
export function branchFor(
|
||||
row: OrderRow,
|
||||
branches: readonly TenantLocation[],
|
||||
): TenantLocation | undefined {
|
||||
return branches.find((branch) => branch.locationid === row.locationid);
|
||||
}
|
||||
@@ -476,3 +476,5 @@ export function ymd(date: Date): string {
|
||||
export function isToday(day: string): boolean {
|
||||
return day === ymd(new Date());
|
||||
}
|
||||
|
||||
|
||||
|
||||
531
src/features/store-admin/dispatchPanels.css
Normal file
531
src/features/store-admin/dispatchPanels.css
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* Styles for the dispatch panels, kept beside the components that use them.
|
||||
*
|
||||
* ── Why these are not in `pages/dispatch.css` ───────────────────────────────
|
||||
*
|
||||
* They were, and a rewrite of that file dropped twenty of the twenty-two
|
||||
* classes without anything failing: the components kept rendering, their
|
||||
* markup was intact, and every control quietly became plain text. The map's
|
||||
* rider filter reported as "showing as the text format not showing any
|
||||
* buttons".
|
||||
*
|
||||
* Nothing catches that. A missing class is not a type error, not a failed
|
||||
* test, and not a console warning — the DOM is exactly as intended and only
|
||||
* the paint is gone. So the styles now live next to the components that own
|
||||
* the classes and are imported by them, which means a component cannot be
|
||||
* rendered without them.
|
||||
*/
|
||||
|
||||
/* ── The map's rider filter ──────────────────────────────────────────────── */
|
||||
|
||||
.rider-chip {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rider-chip:hover {
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.rider-chip[data-active='true'] {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.rider-chip:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The swatch matches this rider's line on the map — the only thing connecting
|
||||
a chip to the shape it filters to. */
|
||||
.rider-chip i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ── The map key ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Three shapes, named. Without it a square and a hollow ring are two more
|
||||
things to work out; with it the map needs no other explanation. */
|
||||
.map-key {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.map-key i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-left: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.map-key i:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.map-key i[data-key='shop'] {
|
||||
border-radius: 2px;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.map-key i[data-key='drop'] {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.map-key i[data-key='rider'] {
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-ink-3);
|
||||
}
|
||||
|
||||
/* ── A caveat under a panel ──────────────────────────────────────────────── */
|
||||
|
||||
.pva-note {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border-radius: 7px;
|
||||
background: var(--color-surface-subtle);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.pva-note svg {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Plan vs actual ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* One colour per step, reused by the stacked bar and the legend swatches so the
|
||||
two read as the same object. Ordered as the journey runs. */
|
||||
.pva-bar {
|
||||
display: flex;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.pva-bar i,
|
||||
.pva-swatch {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pva-bar i[data-step='toShop'],
|
||||
.pva-swatch[data-step='toShop'] {
|
||||
background: #662582;
|
||||
}
|
||||
|
||||
.pva-bar i[data-step='counter'],
|
||||
.pva-swatch[data-step='counter'] {
|
||||
background: #8b6bab;
|
||||
}
|
||||
|
||||
.pva-bar i[data-step='inRound'],
|
||||
.pva-swatch[data-step='inRound'] {
|
||||
background: #c2410c;
|
||||
}
|
||||
|
||||
.pva-bar i[data-step='onRoad'],
|
||||
.pva-swatch[data-step='onRoad'] {
|
||||
background: #0f8a5f;
|
||||
}
|
||||
|
||||
.pva-swatch {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pva-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
.pva-step {
|
||||
display: grid;
|
||||
grid-template-columns: 9px minmax(0, 1fr) auto;
|
||||
gap: 4px 8px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* The step costing the most is the finding. Outlined rather than recoloured, so
|
||||
the swatch keeps naming its own segment of the bar above. */
|
||||
.pva-step[data-bottleneck='true'] {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.pva-step-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.pva-step-label em {
|
||||
margin-left: 6px;
|
||||
font-size: 10.5px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: #b45309;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pva-step strong {
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.pva-step-note {
|
||||
grid-column: 2 / -1;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.pva-figure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--color-border);
|
||||
}
|
||||
|
||||
.pva-figure[data-tone='good'] {
|
||||
border-left-color: #0f8a5f;
|
||||
}
|
||||
|
||||
.pva-figure[data-tone='watch'] {
|
||||
border-left-color: #b45309;
|
||||
}
|
||||
|
||||
.pva-figure[data-tone='act'] {
|
||||
border-left-color: #b91c1c;
|
||||
}
|
||||
|
||||
.pva-figure-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-ink-3);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pva-figure strong {
|
||||
font-size: 19px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.pva-figure-note {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.pva-toggle {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Rows here are read, not opened — nothing lies behind one — so the pointer and
|
||||
hover the stops table uses would promise a click that does nothing. */
|
||||
.pva-table tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pva-table td[data-disagrees='true'] {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pva-late[data-late='yes'] {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.pva-late[data-late='no'] {
|
||||
color: #0f8a5f;
|
||||
}
|
||||
|
||||
.pva-late[data-late='none'] {
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
/* ── Auto-assign ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.aa-tunings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aa-tuning {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aa-tuning:hover:not(:disabled) {
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.aa-tuning[data-active='true'] {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.aa-tuning:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.aa-tuning:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.aa-absent {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 2px 12px;
|
||||
padding: 6px 0 2px;
|
||||
}
|
||||
|
||||
.aa-absent-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aa-absent-row svg {
|
||||
flex: none;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
/* The solver failing to see any rider is a platform fault, not an empty
|
||||
result. Given its own panel so it cannot be read as "no work to do". */
|
||||
.aa-blocked {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid #b45309;
|
||||
border-radius: 9px;
|
||||
background: var(--color-surface);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.aa-blocked > svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.aa-blocked div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.aa-blocked strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.aa-blocked span {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
/* ── Active board ────────────────────────────────────────────────────────── */
|
||||
|
||||
.active-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.active-name {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active-name i {
|
||||
flex: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Age is the most load-bearing thing on the card: everything else on it is only
|
||||
as true as this is recent. Coloured, not tucked into small print. */
|
||||
.active-fresh {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.active-fresh[data-fresh='live'] {
|
||||
color: #0f8a5f;
|
||||
}
|
||||
|
||||
.active-fresh[data-fresh='recent'] {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.active-fresh[data-fresh='old'] {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.active-distance {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.active-distance svg {
|
||||
color: var(--color-ink-4);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.active-distance strong {
|
||||
font-size: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.active-distance span {
|
||||
font-size: 11px;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.active-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active-meta span:first-child {
|
||||
flex: none;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
|
||||
.active-meta span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.active-stats {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.active-stats span {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-sunken);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.active-stats span[data-low='true'] {
|
||||
background: color-mix(in oklab, #b91c1c 12%, transparent);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.active-worry {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 11.5px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.active-worry svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── Money ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* How much of the day is actually costed. Shown as a bar because the shortfall
|
||||
is the point — a number alone reads as a footnote. */
|
||||
.profit-coverage {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-subtle);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.profit-bar {
|
||||
flex: none;
|
||||
width: 88px;
|
||||
height: 8px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.profit-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
.drawer-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,14 @@
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { billedAtMs, syncLagMs ,
|
||||
stampAfterMs,} from './orderStatus';
|
||||
import {
|
||||
billedAtMs,
|
||||
syncLagMs,
|
||||
stampAfterMs,
|
||||
matchesStatus,
|
||||
DELIVERY_STATUS_TABS,
|
||||
ORDER_STATUS_TABS,
|
||||
} from './orderStatus';
|
||||
|
||||
/* Set after the imports on purpose, and it still takes: `billedAtMs` reads the
|
||||
zone when it is CALLED, not when this module loads, and assigning TZ calls
|
||||
@@ -158,3 +164,86 @@ test('a couple of minutes of drift is drift, not a timezone fault', () => {
|
||||
const raw = '2026-09-05T14:46:00+05:30';
|
||||
assert.equal(stampAfterMs(raw, '2026-09-05T14:48:42+05:30'), new Date(raw).getTime());
|
||||
});
|
||||
|
||||
/* ── The status tabs ─────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Every status the platform actually writes, measured across all 264 tenants
|
||||
* on 2026-09-10 with the row counts each carried.
|
||||
*/
|
||||
const LIVE_ORDER_STATUSES = ['delivered', 'pending', 'cancelled', 'created'];
|
||||
const LIVE_DELIVERY_STATUSES = [
|
||||
'delivered', 'pending', 'cancelled', 'picked',
|
||||
'rejected', 'active', 'skipped', 'accepted', 'arrived',
|
||||
];
|
||||
|
||||
// The failure this list is checked against: a status with no tab appears under
|
||||
// "All" and under nothing else, so an operator working through the tabs never
|
||||
// sees those rows. `rejected` (30 live rows) was the one at risk.
|
||||
test('every live delivery status has a tab of its own', () => {
|
||||
for (const status of LIVE_DELIVERY_STATUSES) {
|
||||
const hits = DELIVERY_STATUS_TABS.filter(
|
||||
(tab) => tab.key !== 'all' && matchesStatus(tab.key, status),
|
||||
);
|
||||
assert.equal(hits.length, 1, `"${status}" matched ${hits.length} tabs: ${hits.map((t) => t.key).join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every live order status has a tab of its own', () => {
|
||||
for (const status of LIVE_ORDER_STATUSES) {
|
||||
const hits = ORDER_STATUS_TABS.filter(
|
||||
(tab) => tab.key !== 'all' && matchesStatus(tab.key, status),
|
||||
);
|
||||
assert.equal(hits.length, 1, `"${status}" matched ${hits.length} tabs`);
|
||||
}
|
||||
});
|
||||
|
||||
// It used to be filed under Cancelled. A rider reaching the address and finding
|
||||
// nobody in is not a cancellation, and an operator chasing skipped drops could
|
||||
// not find them.
|
||||
test('a skipped drop is its own tab, not a cancellation', () => {
|
||||
assert.equal(matchesStatus('skipped', 'skipped'), true);
|
||||
assert.equal(matchesStatus('cancelled', 'skipped'), false);
|
||||
});
|
||||
|
||||
// "processing" used to swallow all four, so a dispatcher could not tell a rider
|
||||
// standing at the shop from one already riding.
|
||||
test('the middle of the ladder is four separate tabs, not one bucket', () => {
|
||||
assert.equal(matchesStatus('accepted', 'accepted'), true);
|
||||
assert.equal(matchesStatus('arrived', 'arrived'), true);
|
||||
assert.equal(matchesStatus('picked', 'picked'), true);
|
||||
assert.equal(matchesStatus('active', 'active'), true);
|
||||
assert.equal(matchesStatus('active', 'arrived'), false);
|
||||
assert.equal(matchesStatus('picked', 'active'), false);
|
||||
});
|
||||
|
||||
test('a delivery out for delivery is active, not delivered', () => {
|
||||
assert.equal(matchesStatus('active', 'out for delivery'), true);
|
||||
assert.equal(matchesStatus('delivered', 'out for delivery'), false);
|
||||
});
|
||||
|
||||
// Fiesta stores status as free text and the casing drifts between writers.
|
||||
test('casing and padding do not decide which tab a row lands in', () => {
|
||||
assert.equal(matchesStatus('delivered', ' Delivered '), true);
|
||||
assert.equal(matchesStatus('rejected', 'REJECTED'), true);
|
||||
});
|
||||
|
||||
test('an undelivered row is not counted as delivered', () => {
|
||||
assert.equal(matchesStatus('delivered', 'undelivered'), false);
|
||||
});
|
||||
|
||||
test('All holds everything, and a blank status holds nothing else', () => {
|
||||
assert.equal(matchesStatus('all', ''), true);
|
||||
assert.equal(matchesStatus('all', undefined), true);
|
||||
for (const tab of DELIVERY_STATUS_TABS) {
|
||||
if (tab.key === 'all') continue;
|
||||
assert.equal(matchesStatus(tab.key, ''), false, `${tab.key} matched a blank status`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the delivery strip offers the whole ladder', () => {
|
||||
assert.deepEqual(
|
||||
DELIVERY_STATUS_TABS.map((tab) => tab.label),
|
||||
['All', 'Pending', 'Accepted', 'Arrived', 'Picked', 'Active', 'Skipped', 'Rejected', 'Delivered', 'Cancelled'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -75,22 +75,55 @@ export const statusColor = (map: Record<string, string>, status: string | undefi
|
||||
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)';
|
||||
|
||||
/**
|
||||
* The status tabs, as the old console groups them.
|
||||
* The status tabs — one set per kind of row, because they are different ladders.
|
||||
*
|
||||
* Tabs rather than a dropdown: there are six, an operator switches between them
|
||||
* constantly during a shift, and a dropdown hides the counts. Each carries how
|
||||
* many rows it holds, which is half the reason to look.
|
||||
* ── Why not one shared set ──────────────────────────────────────────────────
|
||||
*
|
||||
* An order and a delivery move through different lifecycles, and a single strip
|
||||
* shown over both leaves half its tabs permanently at zero. Measured across
|
||||
* every tenant on 2026-09-10:
|
||||
*
|
||||
* orders delivered 4240 · pending 876 · cancelled 827 · created 788
|
||||
* deliveries delivered 1528 · pending 209 · cancelled 146 · picked 42 ·
|
||||
* rejected 30 · active 22 · skipped 11 · accepted 6 · arrived 3
|
||||
*
|
||||
* So an order is never "arrived" or "picked", and a delivery is never
|
||||
* "created". The previous six-tab set was shown over both and had the worse
|
||||
* problem: "processing" swallowed accepted, picked, active and arrived into one
|
||||
* bucket, so a dispatcher could not tell a rider who had reached the shop from
|
||||
* one already riding, and "cancelled" quietly included skipped.
|
||||
*
|
||||
* ── Every status has exactly one tab ────────────────────────────────────────
|
||||
*
|
||||
* `rejected` is on the delivery strip even though it is easy to forget: 30 live
|
||||
* rows carry it, and a status with no tab appears under "All" and under nothing
|
||||
* else, so an operator working through the tabs never sees those rows at all.
|
||||
* That is the failure this list is checked against.
|
||||
*/
|
||||
export const STATUS_TABS = [
|
||||
export const ORDER_STATUS_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'created', label: 'Created' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'processing', label: 'Processing' },
|
||||
{ key: 'delivered', label: 'Delivered' },
|
||||
{ key: 'cancelled', label: 'Cancelled' },
|
||||
] as const;
|
||||
|
||||
export type StatusKey = (typeof STATUS_TABS)[number]['key'];
|
||||
export const DELIVERY_STATUS_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'accepted', label: 'Accepted' },
|
||||
{ key: 'arrived', label: 'Arrived' },
|
||||
{ key: 'picked', label: 'Picked' },
|
||||
{ key: 'active', label: 'Active' },
|
||||
{ key: 'skipped', label: 'Skipped' },
|
||||
{ key: 'rejected', label: 'Rejected' },
|
||||
{ key: 'delivered', label: 'Delivered' },
|
||||
{ key: 'cancelled', label: 'Cancelled' },
|
||||
] as const;
|
||||
|
||||
export type StatusKey =
|
||||
| (typeof ORDER_STATUS_TABS)[number]['key']
|
||||
| (typeof DELIVERY_STATUS_TABS)[number]['key'];
|
||||
|
||||
/**
|
||||
* Does a row belong under this tab?
|
||||
@@ -98,8 +131,10 @@ export type StatusKey = (typeof STATUS_TABS)[number]['key'];
|
||||
* Substring matching, lowercased. Fiesta stores status as free text and the
|
||||
* casing is inconsistent between writers, so an exact match is how a
|
||||
* "Delivered" row silently stops counting the day someone writes "delivered".
|
||||
* "processing" also catches confirmed, preparing and ready — everything between
|
||||
* accepted and out the door, which is what an operator means by the word.
|
||||
*
|
||||
* Each status now lands in exactly ONE tab. The order of these cases matters
|
||||
* where words overlap: `undelivered` must not count as delivered, and
|
||||
* `out for delivery` is a delivery in progress rather than a completed one.
|
||||
*/
|
||||
export function matchesStatus(key: StatusKey, status: string | undefined): boolean {
|
||||
if (key === 'all') return true;
|
||||
@@ -110,30 +145,32 @@ export function matchesStatus(key: StatusKey, status: string | undefined): boole
|
||||
return s.includes('created') || s.includes('new');
|
||||
case 'pending':
|
||||
return s.includes('pending');
|
||||
case 'processing':
|
||||
return (
|
||||
s.includes('process') ||
|
||||
s.includes('confirm') ||
|
||||
s.includes('prepar') ||
|
||||
s.includes('ready') ||
|
||||
s.includes('accept') ||
|
||||
s.includes('picked') ||
|
||||
s.includes('active') ||
|
||||
s.includes('arrived') ||
|
||||
s.includes('out for')
|
||||
);
|
||||
case 'accepted':
|
||||
// Not "accept" alone — that would also catch "unaccepted" if it appears.
|
||||
return s.includes('accepted');
|
||||
case 'arrived':
|
||||
return s.includes('arrived');
|
||||
case 'picked':
|
||||
return s.includes('picked') || s.includes('pickup');
|
||||
case 'active':
|
||||
// Everything between collecting and dropping: the platform writes
|
||||
// `active`, and `out for delivery` means the same thing.
|
||||
return s.includes('active') || s.includes('out for') || s.includes('transit');
|
||||
case 'skipped':
|
||||
// Its own status and its own tab. The rider reached the address and
|
||||
// nobody was in — which is not a cancellation, and used to be filed as
|
||||
// one.
|
||||
return s.includes('skip');
|
||||
case 'rejected':
|
||||
return s.includes('reject') || s.includes('declin');
|
||||
case 'delivered':
|
||||
return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for');
|
||||
case 'cancelled':
|
||||
// `skipped` lives here too. It is its own delivery status — the rider
|
||||
// reached the address and nobody was in — and it keeps its own orange
|
||||
// chip, but it has no tab of its own in this six-tab strip. Without this
|
||||
// line a skipped job appears under "All" and under nothing else, so an
|
||||
// operator working through the tabs never sees it.
|
||||
return s.includes('cancel') || s.includes('skip');
|
||||
return s.includes('cancel');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The money on an order row, in the order the old console reads it.
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,8 @@ import { branchLabel, count, money, moneyExact } from '../format';
|
||||
import {
|
||||
DELIVERY_STATUS,
|
||||
ORDER_STATUS,
|
||||
STATUS_TABS,
|
||||
DELIVERY_STATUS_TABS,
|
||||
ORDER_STATUS_TABS,
|
||||
matchesStatus,
|
||||
orderQuantity,
|
||||
orderValue,
|
||||
@@ -130,11 +131,21 @@ export function SalesPage() {
|
||||
.sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0));
|
||||
}, [billPages, branchIds, branches, keyword]);
|
||||
|
||||
/**
|
||||
* The tab strip for whichever sub-tab is open.
|
||||
*
|
||||
* Orders and deliveries move through different ladders — an order is never
|
||||
* "arrived", a delivery is never "created" — so each gets its own tabs rather
|
||||
* than a shared set with half of it stuck at zero.
|
||||
*/
|
||||
const statusTabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS;
|
||||
|
||||
/** Counts for the tab strip, from the unfiltered set. */
|
||||
const tabCounts = useMemo(() => {
|
||||
const source = tab === 'deliveries' ? allDeliveries : allOrders;
|
||||
const tabs = tab === 'deliveries' ? DELIVERY_STATUS_TABS : ORDER_STATUS_TABS;
|
||||
return Object.fromEntries(
|
||||
STATUS_TABS.map((entry) => [
|
||||
tabs.map((entry) => [
|
||||
entry.key,
|
||||
source.filter((row) => matchesStatus(entry.key, row.orderstatus)).length,
|
||||
]),
|
||||
@@ -291,6 +302,7 @@ export function SalesPage() {
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<FilterRow
|
||||
tabs={statusTabs}
|
||||
status={status}
|
||||
counts={tabCounts}
|
||||
onStatus={setStatus}
|
||||
@@ -374,6 +386,7 @@ export function SalesPage() {
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<FilterRow
|
||||
tabs={statusTabs}
|
||||
status={status}
|
||||
counts={tabCounts}
|
||||
onStatus={setStatus}
|
||||
@@ -507,11 +520,14 @@ function freshRow(
|
||||
* downward now follows that order.
|
||||
*/
|
||||
function FilterRow({
|
||||
tabs,
|
||||
status,
|
||||
counts,
|
||||
onStatus,
|
||||
search,
|
||||
}: {
|
||||
/** Which ladder to show — orders and deliveries do not share one. */
|
||||
tabs: readonly { readonly key: StatusKey; readonly label: string }[];
|
||||
status: StatusKey;
|
||||
counts: Record<StatusKey, number>;
|
||||
onStatus: (key: StatusKey) => void;
|
||||
@@ -520,7 +536,7 @@ function FilterRow({
|
||||
return (
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
{STATUS_TABS.map((entry) => (
|
||||
{tabs.map((entry) => (
|
||||
<StatusTab
|
||||
key={entry.key}
|
||||
label={entry.label}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
179
src/features/store-admin/profitability.test.ts
Normal file
179
src/features/store-admin/profitability.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Delivery economics, from the columns rather than from invented rates.
|
||||
*
|
||||
* The awkward rows are real: `profit` is filled on 210 of 500 tenant-916 rows
|
||||
* and blank on the rest, `ridercharges` on 249, and `calculationdistancekm`
|
||||
* reads 150087 on one of them. Every test below pins a decision about what to
|
||||
* do when a figure is simply absent — because the wrong answer, treating it as
|
||||
* zero, understates the day by exactly the rows nobody has costed.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { DeliveryRow } from '@/api/types';
|
||||
import { coverage, earningsOf, profitability } from './profitability';
|
||||
|
||||
function row(over: Record<string, unknown> = {}): DeliveryRow {
|
||||
return {
|
||||
deliveryid: 1,
|
||||
orderid: '916-1',
|
||||
ridername: 'Varun',
|
||||
deliverycharges: 30,
|
||||
ridercharges: 23.36,
|
||||
profit: 15,
|
||||
kms: '9',
|
||||
...over,
|
||||
} as unknown as DeliveryRow;
|
||||
}
|
||||
|
||||
test('a costed delivery reports what was charged, paid and recorded', () => {
|
||||
const e = earningsOf(row());
|
||||
assert.equal(e.charged, 30);
|
||||
assert.equal(e.riderPaid, 23.36);
|
||||
assert.equal(e.recorded, 15);
|
||||
assert.equal(e.km, 9);
|
||||
});
|
||||
|
||||
// charged − paid is 6.64, the platform recorded 15. Both are shown, because
|
||||
// that gap is somebody's accounting question and picking one answers it wrongly.
|
||||
test('the arithmetic and the recorded figure are both kept when they disagree', () => {
|
||||
const e = earningsOf(row());
|
||||
assert.equal(e.derived, 6.64);
|
||||
assert.equal(e.disagrees, true);
|
||||
});
|
||||
|
||||
test('agreement within a rupee is not flagged', () => {
|
||||
const e = earningsOf(row({ deliverycharges: 30, ridercharges: 15.5, profit: 15 }));
|
||||
assert.equal(e.disagrees, false);
|
||||
});
|
||||
|
||||
// The central decision. 290 of 500 rows have no profit figure; calling them
|
||||
// zero would report the day as far less profitable than it was.
|
||||
test('a row nobody costed has no profit, which is not zero profit', () => {
|
||||
assert.equal(earningsOf(row({ profit: undefined })).recorded, null);
|
||||
assert.equal(earningsOf(row({ profit: '' })).recorded, null);
|
||||
assert.equal(earningsOf(row({ profit: 0 })).recorded, null);
|
||||
});
|
||||
|
||||
test('a missing rider payment leaves the arithmetic blank rather than crediting it all', () => {
|
||||
const e = earningsOf(row({ ridercharges: undefined }));
|
||||
assert.equal(e.riderPaid, null);
|
||||
assert.equal(e.derived, null);
|
||||
assert.equal(e.disagrees, false);
|
||||
});
|
||||
|
||||
// 150087 km for a Coimbatore delivery. Whatever writes that column is broken.
|
||||
test('an absurd distance is dropped rather than shown', () => {
|
||||
assert.equal(earningsOf(row({ kms: '150087' })).km, null);
|
||||
assert.equal(earningsOf(row({ kms: '0' })).km, null);
|
||||
assert.equal(earningsOf(row({ kms: '9' })).km, 9);
|
||||
});
|
||||
|
||||
/* ── Totals ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('totals sum only what is recorded, and say how much that was', () => {
|
||||
const result = profitability([
|
||||
row({ deliveryid: 1, profit: 15 }),
|
||||
row({ deliveryid: 2, profit: 25 }),
|
||||
row({ deliveryid: 3, profit: undefined }),
|
||||
]);
|
||||
assert.equal(result.deliveries, 3);
|
||||
assert.equal(result.costed, 2);
|
||||
assert.equal(result.profit, 40, 'the uncosted row must not drag the total down');
|
||||
assert.equal(result.perDelivery, 20, 'averaged over what was costed, not over everything');
|
||||
});
|
||||
|
||||
test('coverage states how much of the picture is costed', () => {
|
||||
const result = profitability([row({ deliveryid: 1 }), row({ deliveryid: 2, profit: undefined })]);
|
||||
assert.equal(coverage(result), 0.5);
|
||||
});
|
||||
|
||||
test('charges are summed across every row, costed or not', () => {
|
||||
const result = profitability([
|
||||
row({ deliveryid: 1, deliverycharges: 30, ridercharges: 20, profit: undefined }),
|
||||
row({ deliveryid: 2, deliverycharges: 30, ridercharges: 20, profit: 10 }),
|
||||
]);
|
||||
assert.equal(result.charged, 60);
|
||||
assert.equal(result.riderPaid, 40);
|
||||
assert.equal(result.profit, 10);
|
||||
});
|
||||
|
||||
test('riders are totalled separately, biggest earner first', () => {
|
||||
const result = profitability([
|
||||
row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }),
|
||||
row({ deliveryid: 2, userid: 1111, ridername: 'Murali', profit: 25 }),
|
||||
row({ deliveryid: 3, userid: 1111, ridername: 'Murali', profit: 15 }),
|
||||
]);
|
||||
assert.deepEqual(result.byRider.map((r) => r.rider), ['Murali', 'Varun']);
|
||||
assert.equal(result.byRider[0]!.profit, 40);
|
||||
assert.equal(result.byRider[0]!.deliveries, 2);
|
||||
});
|
||||
|
||||
test("a rider's uncosted deliveries still count as deliveries", () => {
|
||||
const result = profitability([
|
||||
row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }),
|
||||
row({ deliveryid: 2, userid: 897, ridername: 'Varun', profit: undefined }),
|
||||
]);
|
||||
assert.equal(result.byRider[0]!.deliveries, 2);
|
||||
assert.equal(result.byRider[0]!.costed, 1);
|
||||
});
|
||||
|
||||
test('disagreements are counted so they can be chased', () => {
|
||||
const result = profitability([
|
||||
row({ deliveryid: 1, deliverycharges: 30, ridercharges: 23.36, profit: 15 }),
|
||||
row({ deliveryid: 2, deliverycharges: 30, ridercharges: 15, profit: 15 }),
|
||||
]);
|
||||
assert.equal(result.disagreeing, 1);
|
||||
});
|
||||
|
||||
test('an empty day totals to zero with nothing costed and no average', () => {
|
||||
const result = profitability([]);
|
||||
assert.equal(result.deliveries, 0);
|
||||
assert.equal(result.profit, 0);
|
||||
assert.equal(result.perDelivery, null, 'an average over nothing is not zero');
|
||||
assert.equal(coverage(result), 0);
|
||||
});
|
||||
|
||||
test('money is rounded to paise, not left with float noise', () => {
|
||||
const result = profitability([
|
||||
row({ deliveryid: 1, deliverycharges: 30.1, ridercharges: 0.2, profit: 29.9 }),
|
||||
row({ deliveryid: 2, deliverycharges: 30.1, ridercharges: 0.1, profit: 30 }),
|
||||
]);
|
||||
assert.equal(result.charged, 60.2);
|
||||
assert.equal(result.riderPaid, 0.3);
|
||||
assert.equal(result.profit, 59.9);
|
||||
});
|
||||
|
||||
/* ── Rider identity ──────────────────────────────────────────────────────── */
|
||||
|
||||
// Grouping on `ridername` put a rider called "delivered" on the page carrying
|
||||
// 284 of tenant 916's 499 finished deliveries — more than any real person — and
|
||||
// split every actual rider's takings across two rows.
|
||||
test('a status in the name column never becomes a rider', () => {
|
||||
const result = profitability([
|
||||
row({ deliveryid: 1, userid: 897, ridername: 'Varun', profit: 10 }),
|
||||
row({ deliveryid: 2, userid: 897, ridername: 'delivered', profit: 20 }),
|
||||
row({ deliveryid: 3, userid: 897, ridername: 'delivered', profit: 30 }),
|
||||
]);
|
||||
assert.equal(result.byRider.length, 1, 'one rider, not a real one and a phantom');
|
||||
assert.equal(result.byRider[0]!.rider, 'Varun');
|
||||
assert.equal(result.byRider[0]!.profit, 60, 'their takings were split before');
|
||||
});
|
||||
|
||||
test('riders are grouped on their id, so two people sharing a name stay apart', () => {
|
||||
const result = profitability([
|
||||
row({ deliveryid: 1, userid: 1, ridername: 'Rajesh', profit: 10 }),
|
||||
row({ deliveryid: 2, userid: 2, ridername: 'Rajesh', profit: 20 }),
|
||||
]);
|
||||
assert.equal(result.byRider.length, 2);
|
||||
});
|
||||
|
||||
test('a rider whose every row carried a status keeps their id', () => {
|
||||
const result = profitability([row({ deliveryid: 1, userid: 950, ridername: 'cancelled' })]);
|
||||
assert.equal(result.byRider[0]!.rider, 'Rider 950');
|
||||
});
|
||||
|
||||
// Unassigned work is not a person and must not read as one.
|
||||
test('work nobody carried is a dash, not a rider', () => {
|
||||
const result = profitability([row({ deliveryid: 1, userid: 0, ridername: '' })]);
|
||||
assert.equal(result.byRider[0]!.rider, '—');
|
||||
});
|
||||
235
src/features/store-admin/profitability.ts
Normal file
235
src/features/store-admin/profitability.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* What a delivery earned and what it cost.
|
||||
*
|
||||
* ── Read, not assumed ───────────────────────────────────────────────────────
|
||||
*
|
||||
* The console this is modelled on computes profit from hardcoded rules: ₹30 for
|
||||
* the first 8 km, ₹6 a kilometre beyond, a ₹5,000 salary sliced across slots.
|
||||
* Those numbers are in its source, not in anybody's contract, and they are
|
||||
* wrong the day a rate changes.
|
||||
*
|
||||
* They are also unnecessary. The columns exist and carry real values, measured
|
||||
* over 500 rows of tenant 916 on 2026-09-10:
|
||||
*
|
||||
* deliverycharges 500/500 what the shop was charged
|
||||
* ridercharges 249/500 what the rider was paid
|
||||
* profit 210/500 the platform's own figure, median ₹15
|
||||
*
|
||||
* So this reports what the platform recorded and says how much of it was
|
||||
* recorded. A row with no `profit` is not zero profit — it is a row nobody
|
||||
* costed — and the two must never be added together.
|
||||
*
|
||||
* ── Where the recorded figure and the arithmetic disagree ───────────────────
|
||||
*
|
||||
* `deliverycharges − ridercharges` should equal `profit` and does not always.
|
||||
* Both are shown when they differ rather than one being silently preferred:
|
||||
* that gap is somebody's accounting question and hiding it answers it wrongly.
|
||||
*
|
||||
* ── `calculationdistancekm` is not usable ───────────────────────────────────
|
||||
*
|
||||
* It reads 150087 on a live Coimbatore delivery — 150,000 km. Whatever writes
|
||||
* it is broken, so per-kilometre economics are deliberately not derived here.
|
||||
* `kms` is the planned distance and is sane; it is used for context only.
|
||||
*/
|
||||
import type { DeliveryRow } from '@/api/types';
|
||||
import { isRealName } from './orderStatus';
|
||||
|
||||
/** A distance beyond this is a broken column, not a long ride. */
|
||||
const ABSURD_KM = 200;
|
||||
|
||||
export interface Earnings {
|
||||
deliveryid: number;
|
||||
orderid: string;
|
||||
/** The stable key. `ridername` is not one — see `nameFor`. */
|
||||
userid: number;
|
||||
rider: string;
|
||||
/** What the shop was charged. */
|
||||
charged: number | null;
|
||||
/** What the rider was paid. */
|
||||
riderPaid: number | null;
|
||||
/** The platform's own profit figure. Null when nobody costed the row. */
|
||||
recorded: number | null;
|
||||
/** charged − riderPaid, when both are known. */
|
||||
derived: number | null;
|
||||
/** True when both figures exist and disagree by more than a rupee. */
|
||||
disagrees: boolean;
|
||||
km: number | null;
|
||||
}
|
||||
|
||||
const money = (value: unknown): number | null => {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/** One delivery's economics, as recorded. */
|
||||
export function earningsOf(row: DeliveryRow): Earnings {
|
||||
const charged = money(row.deliverycharges);
|
||||
const riderPaid = money((row as { ridercharges?: unknown }).ridercharges);
|
||||
const recorded = money((row as { profit?: unknown }).profit);
|
||||
const derived = charged !== null && riderPaid !== null ? round(charged - riderPaid) : null;
|
||||
const km = money(row.kms);
|
||||
|
||||
return {
|
||||
deliveryid: row.deliveryid,
|
||||
orderid: row.orderid ?? `#${row.deliveryid}`,
|
||||
userid: Number(row.userid ?? 0) || 0,
|
||||
rider: row.ridername?.trim() || '—',
|
||||
charged,
|
||||
riderPaid,
|
||||
// A recorded 0 is treated as "not costed": every row that carries a real
|
||||
// figure carries a positive one (min ₹10 over 210 rows), so a bare zero is
|
||||
// the column's default rather than a delivery that broke even.
|
||||
recorded: recorded !== null && recorded !== 0 ? recorded : null,
|
||||
derived,
|
||||
disagrees:
|
||||
recorded !== null && recorded !== 0 && derived !== null && Math.abs(recorded - derived) > 1,
|
||||
km: km !== null && km > 0 && km < ABSURD_KM ? km : null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RiderEarnings {
|
||||
userid: number;
|
||||
rider: string;
|
||||
deliveries: number;
|
||||
charged: number;
|
||||
riderPaid: number;
|
||||
/** Summed over the rows that carry a figure. `costed` says how many that was. */
|
||||
profit: number;
|
||||
costed: number;
|
||||
}
|
||||
|
||||
export interface Profitability {
|
||||
rows: Earnings[];
|
||||
deliveries: number;
|
||||
/** Rows carrying a recorded profit. The denominator for `profit`. */
|
||||
costed: number;
|
||||
charged: number;
|
||||
riderPaid: number;
|
||||
profit: number;
|
||||
/** Rows where the recorded figure and the arithmetic disagree. */
|
||||
disagreeing: number;
|
||||
/** Per rider, biggest earner first. */
|
||||
byRider: RiderEarnings[];
|
||||
/** Profit per costed delivery. Null when nothing was costed. */
|
||||
perDelivery: number | null;
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name per rider id, from a column that often holds a status instead.
|
||||
*
|
||||
* Grouping the earnings table on `ridername` put a rider called "delivered" on
|
||||
* the page carrying 284 of tenant 916's 499 finished deliveries — more than any
|
||||
* real person — and split every actual rider's takings across two rows. The
|
||||
* column holds a delivery status on more rows than it holds a name for some
|
||||
* riders, so the id is the key and the name is resolved separately: statuses
|
||||
* excluded by vocabulary, then the most common of what survives.
|
||||
*
|
||||
* A rider whose every row carried a status keeps their id. Unassigned work
|
||||
* keeps a dash — it is not a person and should not read as one.
|
||||
*/
|
||||
function riderNames(rows: readonly Earnings[]): (userid: number) => string {
|
||||
const counts = new Map<number, Map<string, number>>();
|
||||
for (const row of rows) {
|
||||
if (row.userid === 0 || !isRealName(row.rider)) continue;
|
||||
const forRider = counts.get(row.userid) ?? new Map<string, number>();
|
||||
forRider.set(row.rider, (forRider.get(row.rider) ?? 0) + 1);
|
||||
counts.set(row.userid, forRider);
|
||||
}
|
||||
|
||||
const resolved = new Map<number, string>();
|
||||
for (const [userid, names] of counts) {
|
||||
let best = '';
|
||||
let most = 0;
|
||||
for (const [name, count] of names) {
|
||||
if (count > most) {
|
||||
best = name;
|
||||
most = count;
|
||||
}
|
||||
}
|
||||
if (best) resolved.set(userid, best);
|
||||
}
|
||||
|
||||
return (userid) =>
|
||||
userid === 0 ? '—' : (resolved.get(userid) ?? `Rider ${userid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Many deliveries, totalled.
|
||||
*
|
||||
* Sums only what is there. A row with no recorded profit contributes nothing to
|
||||
* `profit` and increments neither `costed` nor the average — the alternative is
|
||||
* to treat "not costed" as "earned nothing", which understates the day by
|
||||
* exactly the rows nobody has costed yet.
|
||||
*/
|
||||
export function profitability(deliveries: readonly DeliveryRow[]): Profitability {
|
||||
const rows = deliveries.map(earningsOf);
|
||||
const nameOf = riderNames(rows);
|
||||
|
||||
const byRider = new Map<number, RiderEarnings>();
|
||||
let charged = 0;
|
||||
let riderPaid = 0;
|
||||
let profit = 0;
|
||||
let costed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
charged += row.charged ?? 0;
|
||||
riderPaid += row.riderPaid ?? 0;
|
||||
if (row.recorded !== null) {
|
||||
profit += row.recorded;
|
||||
costed += 1;
|
||||
}
|
||||
|
||||
const entry = byRider.get(row.userid) ?? {
|
||||
userid: row.userid,
|
||||
rider: nameOf(row.userid),
|
||||
deliveries: 0,
|
||||
charged: 0,
|
||||
riderPaid: 0,
|
||||
profit: 0,
|
||||
costed: 0,
|
||||
};
|
||||
entry.deliveries += 1;
|
||||
entry.charged += row.charged ?? 0;
|
||||
entry.riderPaid += row.riderPaid ?? 0;
|
||||
if (row.recorded !== null) {
|
||||
entry.profit += row.recorded;
|
||||
entry.costed += 1;
|
||||
}
|
||||
byRider.set(row.userid, entry);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
deliveries: rows.length,
|
||||
costed,
|
||||
charged: round(charged),
|
||||
riderPaid: round(riderPaid),
|
||||
profit: round(profit),
|
||||
disagreeing: rows.filter((row) => row.disagrees).length,
|
||||
byRider: [...byRider.values()]
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
charged: round(entry.charged),
|
||||
riderPaid: round(entry.riderPaid),
|
||||
profit: round(entry.profit),
|
||||
}))
|
||||
.sort((a, b) => b.profit - a.profit || b.deliveries - a.deliveries),
|
||||
perDelivery: costed > 0 ? round(profit / costed) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the picture is actually costed, 0–1.
|
||||
*
|
||||
* Shown next to every total, because "₹3,150 profit" over 210 of 500 rows and
|
||||
* over 500 of 500 are different claims and the number alone cannot tell them
|
||||
* apart.
|
||||
*/
|
||||
export function coverage(result: Profitability): number {
|
||||
return result.deliveries === 0 ? 0 : result.costed / result.deliveries;
|
||||
}
|
||||
208
src/features/store-admin/riderLive.test.ts
Normal file
208
src/features/store-admin/riderLive.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* The live snapshot, and the staleness it never admits to on its own.
|
||||
*
|
||||
* The fixtures are real: rider 852's payload was captured from jupiter on
|
||||
* 2026-09-10, and riders 883/897/1111 really do answer with positions days old
|
||||
* and no flag saying so. Reading those as "now" is the fault this module
|
||||
* exists to prevent.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { RiderSnapshot } from '@/api/telemetry';
|
||||
import {
|
||||
activeBoard,
|
||||
agoOf,
|
||||
concerns,
|
||||
distanceLabel,
|
||||
fixedAt,
|
||||
freshnessOf,
|
||||
metresBetween,
|
||||
readSnapshot,
|
||||
} from './riderLive';
|
||||
|
||||
/** Rider 852's real payload. */
|
||||
const LIVE: RiderSnapshot = {
|
||||
userid: 852,
|
||||
username: 'Sivakumar Subramani',
|
||||
accuracy: '12.4',
|
||||
battery: '95%',
|
||||
connection: 'mobile',
|
||||
heading: '0.00',
|
||||
is_background: true,
|
||||
is_charging: false,
|
||||
latitude: '8.179740',
|
||||
longitude: '77.425007',
|
||||
location_service: 'enabled',
|
||||
logdate: '2026-09-10 11:25:11',
|
||||
orderid: '908-20246337',
|
||||
speed: '0.00',
|
||||
status: 'idle',
|
||||
};
|
||||
|
||||
const NOW = Date.parse('2026-09-10T11:25:41');
|
||||
|
||||
test('a live snapshot is read into numbers, not strings', () => {
|
||||
const rider = readSnapshot(LIVE, 'fallback', NOW)!;
|
||||
assert.equal(rider.battery, 95, 'the % sign must not make this NaN');
|
||||
assert.equal(rider.speed, 0, 'standing still is a real answer, not unknown');
|
||||
assert.equal(rider.accuracy, 12.4);
|
||||
assert.equal(rider.lat, 8.17974);
|
||||
assert.equal(rider.name, 'Sivakumar Subramani');
|
||||
assert.equal(rider.orderid, '908-20246337');
|
||||
});
|
||||
|
||||
test('a fix from thirty seconds ago is live', () => {
|
||||
const rider = readSnapshot(LIVE, 'x', NOW)!;
|
||||
assert.equal(rider.ageMs, 30_000);
|
||||
assert.equal(rider.freshness, 'live');
|
||||
});
|
||||
|
||||
// Riders 883, 897 and 1111 really answer like this, with nothing in the payload
|
||||
// admitting it. Drawing them beside a live rider is the lie to avoid.
|
||||
test('a fix from five days ago is old, however confident the payload looks', () => {
|
||||
const rider = readSnapshot({ ...LIVE, logdate: '2026-09-05 11:05:38' }, 'x', NOW)!;
|
||||
assert.equal(rider.freshness, 'old');
|
||||
assert.match(agoOf(rider.ageMs), /5 days ago/);
|
||||
});
|
||||
|
||||
test('a fix from ten minutes ago is recent — a last-known point, not a position', () => {
|
||||
const rider = readSnapshot({ ...LIVE, logdate: '2026-09-10 11:15:11' }, 'x', NOW)!;
|
||||
assert.equal(rider.freshness, 'recent');
|
||||
});
|
||||
|
||||
test('an unreadable stamp is old rather than assumed fresh', () => {
|
||||
const rider = readSnapshot({ ...LIVE, logdate: 'whenever' }, 'x', NOW)!;
|
||||
assert.equal(rider.ageMs, null);
|
||||
assert.equal(rider.freshness, 'old');
|
||||
assert.equal(agoOf(null), 'never reported');
|
||||
});
|
||||
|
||||
// A phone clock running ahead of the browser's would otherwise sort above
|
||||
// everything real, as the freshest fix on the board.
|
||||
test('a fix stamped in the future reads as just now, not as the freshest', () => {
|
||||
const rider = readSnapshot({ ...LIVE, logdate: '2026-09-10 11:40:00' }, 'x', NOW)!;
|
||||
assert.equal(rider.ageMs, 0);
|
||||
assert.equal(rider.freshness, 'live');
|
||||
});
|
||||
|
||||
test('0,0 is not a position', () => {
|
||||
const rider = readSnapshot({ ...LIVE, latitude: '0', longitude: '0' }, 'x', NOW)!;
|
||||
assert.equal(rider.lat, null);
|
||||
assert.equal(rider.lng, null);
|
||||
});
|
||||
|
||||
test('a rider who never reported is null, not an empty rider', () => {
|
||||
assert.equal(readSnapshot(null, 'x', NOW), null);
|
||||
});
|
||||
|
||||
test('a rider with no name falls back rather than showing blank', () => {
|
||||
assert.equal(readSnapshot({ ...LIVE, username: ' ' }, 'Rider 852', NOW)!.name, 'Rider 852');
|
||||
});
|
||||
|
||||
/* ── Accuracy ────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Live data carries accuracy: "100.0" — a circle wider than the blocks the
|
||||
// drops sit on. Worth saying rather than rounding away.
|
||||
test('a hundred-metre fix is flagged as vague', () => {
|
||||
assert.equal(readSnapshot({ ...LIVE, accuracy: '100.0' }, 'x', NOW)!.isVague, true);
|
||||
assert.equal(readSnapshot(LIVE, 'x', NOW)!.isVague, false);
|
||||
});
|
||||
|
||||
/* ── Concerns ────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('a healthy live rider has nothing to report', () => {
|
||||
assert.deepEqual(concerns(readSnapshot(LIVE, 'x', NOW)!), []);
|
||||
});
|
||||
|
||||
test('location turned off is named, since it explains a stale fix', () => {
|
||||
const rider = readSnapshot({ ...LIVE, location_service: 'disabled' }, 'x', NOW)!;
|
||||
assert.equal(rider.hasLocationOn, false);
|
||||
assert.ok(concerns(rider).includes('location turned off'));
|
||||
});
|
||||
|
||||
// Accusing somebody of turning location off because a field was absent is the
|
||||
// kind of wrong that starts an argument with a person.
|
||||
test('a missing location_service field is not an accusation', () => {
|
||||
const rider = readSnapshot({ ...LIVE, location_service: undefined }, 'x', NOW)!;
|
||||
assert.equal(rider.hasLocationOn, true);
|
||||
assert.deepEqual(concerns(rider), []);
|
||||
});
|
||||
|
||||
test('a low battery is a concern unless it is charging', () => {
|
||||
assert.ok(concerns(readSnapshot({ ...LIVE, battery: '9%' }, 'x', NOW)!).some((c) => c.includes('battery')));
|
||||
assert.deepEqual(
|
||||
concerns(readSnapshot({ ...LIVE, battery: '9%', is_charging: true }, 'x', NOW)!),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('no signal is reported', () => {
|
||||
assert.ok(concerns(readSnapshot({ ...LIVE, connection: 'none' }, 'x', NOW)!).includes('no signal'));
|
||||
});
|
||||
|
||||
/* ── Distance ────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('metres between two points is a real distance', () => {
|
||||
const a = { lat: 11.0168, lng: 76.9558 };
|
||||
assert.ok(Math.abs(metresBetween(a, { lat: a.lat + 1 / 111, lng: a.lng }) - 1000) < 20);
|
||||
assert.equal(metresBetween(a, a), 0);
|
||||
});
|
||||
|
||||
test('distances read at the scale they are', () => {
|
||||
assert.equal(distanceLabel(null), '—');
|
||||
assert.equal(distanceLabel(840), '840 m');
|
||||
assert.equal(distanceLabel(4235), '4.2 km');
|
||||
});
|
||||
|
||||
/* ── The active board ────────────────────────────────────────────────────── */
|
||||
|
||||
const drops: Record<string, { lat: number; lng: number; customer: string; address: string }> = {
|
||||
'near-1': { lat: 8.18, lng: 77.4251, customer: 'Close by', address: 'A' },
|
||||
'far-1': { lat: 8.25, lng: 77.49, customer: 'Miles away', address: 'B' },
|
||||
};
|
||||
|
||||
const rider = (userid: number, orderid: string, lat: number, lng: number) =>
|
||||
readSnapshot({ ...LIVE, userid, orderid, latitude: String(lat), longitude: String(lng) }, 'x', NOW)!;
|
||||
|
||||
test('the board is ordered by how far each rider still has to go', () => {
|
||||
const board = activeBoard(
|
||||
[rider(1, 'far-1', 8.1797, 77.425), rider(2, 'near-1', 8.1797, 77.425)],
|
||||
(id) => drops[id] ?? null,
|
||||
);
|
||||
assert.deepEqual(board.map((entry) => entry.orderid), ['near-1', 'far-1']);
|
||||
assert.equal(board[0]!.customer, 'Close by');
|
||||
});
|
||||
|
||||
test('a rider carrying nothing is not on the active board', () => {
|
||||
assert.deepEqual(activeBoard([rider(1, '', 8.1797, 77.425)], (id) => drops[id] ?? null), []);
|
||||
});
|
||||
|
||||
// An unknown distance is not a short one. Putting it first would say the
|
||||
// opposite of what is known.
|
||||
test('a rider with no usable position sorts last, not first', () => {
|
||||
const lost = readSnapshot({ ...LIVE, userid: 9, orderid: 'near-1', latitude: '0', longitude: '0' }, 'x', NOW)!;
|
||||
const board = activeBoard([lost, rider(2, 'far-1', 8.1797, 77.425)], (id) => drops[id] ?? null);
|
||||
assert.equal(board[0]!.orderid, 'far-1');
|
||||
assert.equal(board[1]!.toDropM, null);
|
||||
});
|
||||
|
||||
test('a rider on an order we do not hold is still listed, with no distance', () => {
|
||||
const board = activeBoard([rider(1, 'unknown-order', 8.1797, 77.425)], () => null);
|
||||
assert.equal(board.length, 1);
|
||||
assert.equal(board[0]!.toDropM, null);
|
||||
assert.equal(board[0]!.customer, '');
|
||||
});
|
||||
|
||||
test('freshness bands are drawn where they are documented', () => {
|
||||
assert.equal(freshnessOf(0), 'live');
|
||||
assert.equal(freshnessOf(60_000), 'live');
|
||||
assert.equal(freshnessOf(60_001), 'recent');
|
||||
assert.equal(freshnessOf(60 * 60_000), 'recent');
|
||||
assert.equal(freshnessOf(60 * 60_000 + 1), 'old');
|
||||
assert.equal(freshnessOf(null), 'old');
|
||||
});
|
||||
|
||||
test('the stamp is read as server local time', () => {
|
||||
assert.equal(fixedAt('2026-09-10 11:25:11'), Date.parse('2026-09-10T11:25:11'));
|
||||
assert.equal(fixedAt(undefined), null);
|
||||
});
|
||||
233
src/features/store-admin/riderLive.ts
Normal file
233
src/features/store-admin/riderLive.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* A rider's live snapshot, read honestly.
|
||||
*
|
||||
* ── Why every field here is nullable ────────────────────────────────────────
|
||||
*
|
||||
* The endpoint always answers, and it answers with the LAST known state however
|
||||
* old it is. Riders 883, 897 and 1111 come back with positions from 5, 3 and 12
|
||||
* days ago; nothing in the payload says so. Every number it carries — battery,
|
||||
* speed, accuracy — is that old too.
|
||||
*
|
||||
* So a snapshot is never shown as a fact about now. `age` is derived first and
|
||||
* `freshness` decides what may be claimed: a LIVE fix can be drawn as a
|
||||
* position, a STALE one is a last-known point, and an OLD one is only evidence
|
||||
* that a rider once existed. The map draws them differently for exactly this
|
||||
* reason.
|
||||
*
|
||||
* ── Numbers arrive as strings, and some of them are junk ────────────────────
|
||||
*
|
||||
* `battery: "95%"` with the sign, `speed: "0.00"`, `accuracy: "100.0"`. A
|
||||
* hundred-metre accuracy is a fix good enough to say which suburb somebody is
|
||||
* in and not which road, and it is common — so it is surfaced rather than
|
||||
* quietly rounded away.
|
||||
*/
|
||||
|
||||
import type { RiderSnapshot } from '@/api/telemetry';
|
||||
|
||||
/** Under a minute old. Safe to call a position. */
|
||||
const LIVE_MS = 60_000;
|
||||
/** Within the hour. A last-known point, drawn hollow. */
|
||||
const RECENT_MS = 60 * 60_000;
|
||||
|
||||
/**
|
||||
* Beyond this a GPS fix names a neighbourhood, not a street.
|
||||
*
|
||||
* Live data really does carry `accuracy: "100.0"`, which is a 100 m circle —
|
||||
* wider than most of the blocks the drops sit on.
|
||||
*/
|
||||
const VAGUE_ACCURACY_M = 60;
|
||||
|
||||
export type Freshness = 'live' | 'recent' | 'old';
|
||||
|
||||
export interface LiveRider {
|
||||
userid: number;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
/** Milliseconds since the fix. Null when the stamp is unreadable. */
|
||||
ageMs: number | null;
|
||||
freshness: Freshness;
|
||||
/** 0–100, or null when the phone did not say. */
|
||||
battery: number | null;
|
||||
isCharging: boolean;
|
||||
/** Km/h. Null when unknown; 0 is a real answer meaning standing still. */
|
||||
speed: number | null;
|
||||
/** Degrees clockwise from north. */
|
||||
heading: number | null;
|
||||
/** Metres of uncertainty. */
|
||||
accuracy: number | null;
|
||||
/** True when the fix is too vague to place on a street. */
|
||||
isVague: boolean;
|
||||
connection: string;
|
||||
/** False when the rider has turned location off — the usual cause of a stale fix. */
|
||||
hasLocationOn: boolean;
|
||||
isBackground: boolean;
|
||||
status: string;
|
||||
/** The order they are on, when they are on one. */
|
||||
orderid: string;
|
||||
}
|
||||
|
||||
function numberFrom(value: string | undefined): number | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(String(value).replace('%', '').trim());
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** `2026-09-10 11:25:11` — server local time, read in the viewer's zone. */
|
||||
export function fixedAt(logdate: string | undefined): number | null {
|
||||
if (!logdate) return null;
|
||||
const at = Date.parse(logdate.replace(' ', 'T'));
|
||||
return Number.isFinite(at) ? at : null;
|
||||
}
|
||||
|
||||
export function freshnessOf(ageMs: number | null): Freshness {
|
||||
if (ageMs === null) return 'old';
|
||||
if (ageMs <= LIVE_MS) return 'live';
|
||||
if (ageMs <= RECENT_MS) return 'recent';
|
||||
return 'old';
|
||||
}
|
||||
|
||||
/** One snapshot, read into something that can be drawn without lying. */
|
||||
export function readSnapshot(
|
||||
snapshot: RiderSnapshot | null,
|
||||
fallbackName: string,
|
||||
now = Date.now(),
|
||||
): LiveRider | null {
|
||||
if (!snapshot) return null;
|
||||
|
||||
const lat = numberFrom(snapshot.latitude);
|
||||
const lng = numberFrom(snapshot.longitude);
|
||||
const at = fixedAt(snapshot.logdate);
|
||||
// A negative age is a clock skew between the phone and this browser, not a
|
||||
// fix from the future. Clamped, so it reads as "just now" rather than
|
||||
// sorting above everything real.
|
||||
const ageMs = at === null ? null : Math.max(0, now - at);
|
||||
const accuracy = numberFrom(snapshot.accuracy);
|
||||
|
||||
return {
|
||||
userid: snapshot.userid,
|
||||
name: snapshot.username?.trim() || fallbackName,
|
||||
// 0,0 is the Atlantic — what a phone sends before it has a fix.
|
||||
lat: lat !== null && lng !== null && !(lat === 0 && lng === 0) ? lat : null,
|
||||
lng: lat !== null && lng !== null && !(lat === 0 && lng === 0) ? lng : null,
|
||||
ageMs,
|
||||
freshness: freshnessOf(ageMs),
|
||||
battery: numberFrom(snapshot.battery),
|
||||
isCharging: snapshot.is_charging === true,
|
||||
speed: numberFrom(snapshot.speed),
|
||||
heading: numberFrom(snapshot.heading),
|
||||
accuracy,
|
||||
isVague: accuracy !== null && accuracy > VAGUE_ACCURACY_M,
|
||||
connection: snapshot.connection?.trim() || 'unknown',
|
||||
// Only an explicit "disabled" is treated as off. An absent field is the app
|
||||
// not saying, and accusing a rider of disabling location on a missing value
|
||||
// is the kind of wrong that starts an argument with a person.
|
||||
hasLocationOn: (snapshot.location_service ?? '').trim().toLowerCase() !== 'disabled',
|
||||
isBackground: snapshot.is_background === true,
|
||||
status: snapshot.status?.trim().toLowerCase() || 'unknown',
|
||||
orderid: snapshot.orderid?.trim() || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** "12s ago", "4m ago", "5 days ago". */
|
||||
export function agoOf(ageMs: number | null): string {
|
||||
if (ageMs === null) return 'never reported';
|
||||
const seconds = Math.round(ageMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is wrong with this rider's reporting, if anything.
|
||||
*
|
||||
* Reasons rather than a score, and at most a couple of them: a dispatcher needs
|
||||
* to know whether to trust the dot and what to do about it, not to be handed a
|
||||
* health percentage they then have to decode.
|
||||
*/
|
||||
export function concerns(rider: LiveRider): string[] {
|
||||
const out: string[] = [];
|
||||
if (!rider.hasLocationOn) out.push('location turned off');
|
||||
if (rider.freshness === 'old') out.push(`last heard from ${agoOf(rider.ageMs)}`);
|
||||
if (rider.battery !== null && rider.battery <= 15 && !rider.isCharging) {
|
||||
out.push(`battery ${rider.battery}%`);
|
||||
}
|
||||
if (rider.connection === 'none') out.push('no signal');
|
||||
if (rider.isVague) out.push(`fix only good to ${Math.round(rider.accuracy ?? 0)} m`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Metres between two points. Haversine — the distances here are small. */
|
||||
export function metresBetween(
|
||||
a: { lat: number; lng: number },
|
||||
b: { lat: number; lng: number },
|
||||
): number {
|
||||
const R = 6_371_000;
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = rad(b.lat - a.lat);
|
||||
const dLng = rad(b.lng - a.lng);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.sin(dLng / 2) ** 2 * Math.cos(rad(a.lat)) * Math.cos(rad(b.lat));
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/** "800 m", "4.2 km". */
|
||||
export function distanceLabel(metres: number | null): string {
|
||||
if (metres === null) return '—';
|
||||
return metres < 1000 ? `${Math.round(metres / 10) * 10} m` : `${(metres / 1000).toFixed(1)} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rider with their current job and how far they still have to go.
|
||||
*
|
||||
* `toDrop` is straight-line and says so wherever it is shown: the rider is on
|
||||
* roads and will cover more. It is for ORDERING the board — who is closest to
|
||||
* finishing — which it does correctly, not for quoting an ETA.
|
||||
*/
|
||||
export interface ActiveRider {
|
||||
live: LiveRider;
|
||||
orderid: string;
|
||||
customer: string;
|
||||
address: string;
|
||||
/** Straight-line metres from the rider to the drop. Null when either is unknown. */
|
||||
toDropM: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The riders currently carrying something, closest to their drop first.
|
||||
*
|
||||
* A rider with no usable position sorts last rather than first: an unknown
|
||||
* distance is not a short one, and putting them at the top would say the
|
||||
* opposite of what is known.
|
||||
*/
|
||||
export function activeBoard(
|
||||
riders: readonly LiveRider[],
|
||||
dropOf: (orderid: string) => { lat: number; lng: number; customer: string; address: string } | null,
|
||||
): ActiveRider[] {
|
||||
const out: ActiveRider[] = [];
|
||||
for (const live of riders) {
|
||||
if (!live.orderid) continue;
|
||||
const drop = dropOf(live.orderid);
|
||||
const toDropM =
|
||||
drop && live.lat !== null && live.lng !== null
|
||||
? metresBetween({ lat: live.lat, lng: live.lng }, drop)
|
||||
: null;
|
||||
out.push({
|
||||
live,
|
||||
orderid: live.orderid,
|
||||
customer: drop?.customer ?? '',
|
||||
address: drop?.address ?? '',
|
||||
toDropM,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => {
|
||||
if (a.toDropM === null) return 1;
|
||||
if (b.toDropM === null) return -1;
|
||||
return a.toDropM - b.toDropM;
|
||||
});
|
||||
}
|
||||
52
src/features/store-admin/useRoads.ts
Normal file
52
src/features/store-admin/useRoads.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { routingApi, type Leg, type RoadPath } from '@/api/routing';
|
||||
|
||||
/**
|
||||
* Road geometry for a set of legs, fetched in the background.
|
||||
*
|
||||
* ── Why a hook and not a query ──────────────────────────────────────────────
|
||||
*
|
||||
* This is decoration that must never block or break the map. React Query would
|
||||
* give it a loading state, an error state and a retry policy, and the map needs
|
||||
* none of them: until the roads arrive it draws straight lines, and if they
|
||||
* never arrive it goes on drawing straight lines. There is nothing to report
|
||||
* and nothing to retry.
|
||||
*
|
||||
* The cache lives in `routing.ts` and is shared across every map in the
|
||||
* session, so switching between riders re-uses legs already fetched rather than
|
||||
* asking a courtesy server for them again.
|
||||
*
|
||||
* ── Keyed on the coordinates alone ──────────────────────────────────────────
|
||||
*
|
||||
* The signature is built from the endpoints, so a redraw that changes a colour,
|
||||
* a label or a filter does not re-fetch. Only genuinely new legs cost anything.
|
||||
*/
|
||||
export function useRoads(legs: readonly Leg[]): Map<string, RoadPath> {
|
||||
const [roads, setRoads] = useState<Map<string, RoadPath>>(new Map());
|
||||
|
||||
const signature = legs.map(routingApi.keyFor).join('|');
|
||||
|
||||
useEffect(() => {
|
||||
if (legs.length === 0) {
|
||||
setRoads(new Map());
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let isCurrent = true;
|
||||
|
||||
routingApi.roads(legs, controller.signal).then((result) => {
|
||||
// A late answer for a round nobody is looking at any more must not
|
||||
// repaint the map underneath the one they are.
|
||||
if (isCurrent && !controller.signal.aborted) setRoads(result);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
controller.abort();
|
||||
};
|
||||
// `signature` is the real dependency — `legs` is a fresh array every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signature]);
|
||||
|
||||
return roads;
|
||||
}
|
||||
Reference in New Issue
Block a user