dispatch map, plan vs actual, and a fleet page

Three of the features the old console had and ours did not, built on what
the data can actually support rather than on what the column names imply.

Two findings changed the shape of the work:

`riderlogs` is not a GPS trail. Every ping a rider sends carries the SAME
coordinate — one rider's 2,404 pings on 14 August all read 11.052998,
76.929958, and the same holds on every day and region checked. Distance,
speed and "time moving" cannot come from it. The Fleet page therefore
reports presence only: who was online and for how long, inferred from the
gaps between check-ins, because `login`, `logout` and `workhours` are empty
on all 320,132 August rows. It says out loud that it cannot tell a rider
parked all day from one who crossed the city.

The delivery ladder is not the order its columns are in. `starttime` is
later than `arrivaltime` on 316 of 316 rows, which looks corrupt and is not:
`starttime` is per-DROP, stamped when the rider sets off for that address
having finished the last one. Read as assign -> arrive -> pickup -> start ->
deliver, every duration is positive. On tenant 916 that shows the bottleneck
is not the riding: a median 69.5 minutes passes between handing an order to
a rider and that rider reaching the shop, against 0.6 minutes at the counter.

- Map tab on dispatch, fed by `deliveries.riderslat/lon` — the only rider
  positions that move (353 distinct across 461 rows). Tenant-scoped, so a
  shop sees its own rounds. The line joins stops in worked order and says
  it is not a route.
- Plan vs actual tab: promised against delivered, and a step breakdown of
  where the hours go. `actualkms` is excluded — it equals the planned `kms`
  to the decimal on every delivered row, so it is a copy, not a measurement.
- Fleet page in the platform console: a presence gantt and a map of where
  each rider is registered.
- `ridername` holds a delivery status on more rows than it holds a name for
  two riders in five, so names are resolved by excluding the status
  vocabulary first.
- leaflet, wrapped directly rather than via react-leaflet, lazy-loaded so
  only the pages with a map pay for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
This commit is contained in:
2026-09-09 17:02:21 +05:30
parent b760c1a078
commit 34bf7989f7
21 changed files with 3412 additions and 92 deletions

View File

@@ -6,9 +6,11 @@ import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
IndianRupee,
Map,
MapPin,
Package,
Store,
Timer,
Truck,
UserX,
Users,
@@ -27,7 +29,9 @@ import { useBranchScope } from '../BranchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
import { shortAge } from '../posStatus';
import { DispatchMap } from '../DispatchMap';
import { OrderDetailDrawer } from '../OrderDetailDrawer';
import { PlanVsActualPanel } from '../PlanVsActualPanel';
import {
dayTotals,
groupByCustomer,
@@ -89,10 +93,21 @@ import './dispatch.css';
*/
const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' };
/**
* The fourth tab is not a fourth grouping.
*
* "By rider", "by store" and "by customer" are three orderings of the same
* rows; "Plan vs actual" asks a different question of them and replaces the
* rail-and-table body entirely. Kept as a separate piece of state rather than a
* fourth `ViewMode`, so the grouping functions never have to answer for a value
* that is not a grouping.
*/
type Board = ViewMode | 'timing' | 'map';
export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope();
const dates = useDateScope();
const [mode, setMode] = useState<ViewMode>('riders');
const [mode, setMode] = useState<Board>('riders');
const [focused, setFocused] = useState<string | null>(null);
const [detail, setDetail] = useState<Stop | null>(null);
@@ -142,6 +157,7 @@ export function DispatchPage() {
);
const groups = useMemo(() => {
if (mode === 'timing' || mode === 'map') return [];
if (mode === 'stores') return groupByStore(stops, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops);
@@ -191,7 +207,7 @@ export function DispatchPage() {
[waiting, picked],
);
const changeMode = (next: ViewMode) => {
const changeMode = (next: Board) => {
setMode(next);
// A group id means nothing across groupings — a branch id is not a rider
// id — so the focus is dropped rather than carried into nonsense.
@@ -224,6 +240,18 @@ export function DispatchPage() {
isActive={mode === 'customers'}
onClick={() => changeMode('customers')}
/>
<ModeTab
label="Plan vs actual"
icon={<Timer size={14} />}
isActive={mode === 'timing'}
onClick={() => changeMode('timing')}
/>
<ModeTab
label="Map"
icon={<Map size={14} />}
isActive={mode === 'map'}
onClick={() => changeMode('map')}
/>
</HStack>
}
/>
@@ -259,77 +287,89 @@ export function DispatchPage() {
/>
</div>
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text>
<GroupList
groups={groups}
mode={mode}
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
</div>
<div className="dispatch-main">
{open ? (
<GroupDetail
group={open}
{mode === 'map' ? (
/* The one place on the platform where rider positions actually move.
Fed by the delivery rows, which are tenant-scoped, so a shop sees its
own rounds and nobody else's. */
<DispatchMap rows={deliveries.data ?? []} />
) : mode === 'timing' ? (
/* A different question of the same rows, so it replaces the board
rather than sitting beside it: promised against delivered, and where
the hours between accepting an order and dropping it actually went. */
<PlanVsActualPanel rows={deliveries.data ?? []} isLoading={deliveries.isLoading} />
) : (
<div className="dispatch-body">
<div className="dispatch-rail">
<Text type="label" size="xsm" color="secondary">
{mode === 'riders'
? isOneDay
? 'Rounds'
: 'Riders'
: mode === 'stores'
? 'Shops'
: 'Customers'}
</Text>
<GroupList
groups={groups}
mode={mode}
onOpen={setDetail}
{...(open.id === UNASSIGNED
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar:
picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null,
}
: {})}
isLoading={deliveries.isLoading || (mode === 'customers' && customers.isLoading)}
focused={focused}
onFocus={(id) => setFocused((prev) => (prev === id ? null : id))}
/>
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0
? 'Pick one to see its stops'
: isOneDay
? 'Nothing out on this day'
: 'Nothing out in this range'}
</Text>
<Text
type="body"
size="sm"
color="secondary"
style={{ textAlign: 'center', maxWidth: 380 }}
>
{groups.length > 0
? 'Every stop, in the order it was assigned, with where the rider last reported in.'
: 'Deliveries appear here once orders are assigned to a rider.'}
</Text>
</VStack>
</Card>
)}
</div>
<div className="dispatch-main">
{open ? (
<GroupDetail
group={open}
mode={mode}
onOpen={setDetail}
{...(open.id === UNASSIGNED
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
assignBar:
picked.count > 0 ? (
<AssignBar
orders={pickedOrders}
branchOf={branchOf}
assigned={assigned}
onClear={picked.clear}
onDone={picked.clear}
/>
) : null,
}
: {})}
/>
) : (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<MapPin size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
{groups.length > 0
? 'Pick one to see its stops'
: isOneDay
? 'Nothing out on this day'
: 'Nothing out in this range'}
</Text>
<Text
type="body"
size="sm"
color="secondary"
style={{ textAlign: 'center', maxWidth: 380 }}
>
{groups.length > 0
? 'Every stop, in the order it was assigned, with where the rider last reported in.'
: 'Deliveries appear here once orders are assigned to a rider.'}
</Text>
</VStack>
</Card>
)}
</div>
</div>
</div>
)}
{detail ? (
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />