map the selected rider and shop, drop the map tab

The separate Map tab is gone. "By rider" and "By store" now draw the map
where the stops table was, so picking a rider from the rail shows their
round on a map and picking a branch shows its day.

A round and a shop's day are both shapes — where the work is, in what order,
how far apart — and a table of addresses shows neither. You can read twenty
rows and still not see that a rider crossed the city twice.

Two views keep the table, on purpose: the waiting queue, whose rows are
ticked to assign them (a checkbox cannot live on a map pin, and nothing
there has been worked yet), and "By customer", where one customer's drops
are all at one address.

The map draws all three places a delivery has, not just the rider:

  shop   a square, one per branch — pickuplat/pickuplon, 500/500 rows
  drop   a circle per stop, by status — droplat/droplon, 500/500 rows
  rider  a hollow ring — riderslat/riderslon, 461/500 and 324/500

Shop and drop are on every row, so the map is never empty on a day with
work in it. The rider fix is real: on delivered rows it sits a median 134 m
from the drop, a reading taken at the customer's door.

The endpoints spell the columns differently — `pickuplon` on getdeliveries
against `pickuplong` on getorders, one letter, and reading the wrong one
puts every shop off the coast of Ghana. `droplat` is filled on deliveries
and empty on orders. `coordOf` takes every spelling so a waiting order maps
the same as a delivered one.

Also fixes the rail itself: it named each group from its first stop's
`ridername`, and that column holds a delivery status on more rows than a
name for two riders in five. The board was listing a phantom rider called
"delivered" carrying 100 of the day's stops, next to the real riders.
Grouping was never wrong — that is on userid — only the label. `isRealName`
now excludes the status vocabulary and both the rail and the map take the
most common name that survives.

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:47:40 +05:30
parent 34bf7989f7
commit 35250717fc
11 changed files with 915 additions and 585 deletions

View File

@@ -6,7 +6,6 @@ import { VStack } from '@astryxdesign/core/VStack';
import {
Bike,
IndianRupee,
Map,
MapPin,
Package,
Store,
@@ -29,7 +28,7 @@ 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 { GroupMap } from '../GroupMap';
import { OrderDetailDrawer } from '../OrderDetailDrawer';
import { PlanVsActualPanel } from '../PlanVsActualPanel';
import {
@@ -74,13 +73,21 @@ import './dispatch.css';
* Deliveries tab could never answer, since it lists jobs flat with a rider
* column and no way to see a round.
*
* ── What this page deliberately is not ──────────────────────────────────────
* ── Why two of the three views draw a map instead of a table ────────────────
*
* There is no map yet. Every drop coordinate in production today sits within
* about four metres of every other — one test customer ordering repeatedly —
* so a map would render one pin with six underneath and imply a precision the
* data does not have. The grouping, the rounds and the rider positions all work
* without one, and the map is worth adding the day real drops spread out.
* A round and a shop's day are both SHAPES: where the work is, in what order,
* how far apart. A table of addresses cannot show either — you read twenty rows
* and still cannot see that a rider crossed the city twice. So picking a rider
* or a shop opens the map, and the table stays where a list is the right
* answer: the waiting queue, whose rows are ticked to assign them, and the
* customer view, where one customer's drops are all at one address.
*
* The coordinates support it. `pickuplat`/`pickuplon` and `droplat`/`droplon`
* are filled on 500 of 500 rows for both tenants measured, so the map is never
* empty on a day with work in it; the rider's own position is the sparse one,
* at 461 and 324 of 500. (An earlier note here said drops all sat within four
* metres of each other. That was one quiet test tenant: the live spread is 145
* distinct drops on tenant 916 and 341 on 908.)
*/
/**
* The delivery ladder plus the one status that is not on it.
@@ -102,7 +109,7 @@ const DISPATCH_STATUS: Record<string, string> = { ...DELIVERY_STATUS, [WAITING]:
* fourth `ViewMode`, so the grouping functions never have to answer for a value
* that is not a grouping.
*/
type Board = ViewMode | 'timing' | 'map';
type Board = ViewMode | 'timing';
export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope();
@@ -157,7 +164,7 @@ export function DispatchPage() {
);
const groups = useMemo(() => {
if (mode === 'timing' || mode === 'map') return [];
if (mode === 'timing') return [];
if (mode === 'stores') return groupByStore(stops, locations.data ?? branches);
if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName);
return groupByRider(stops);
@@ -246,12 +253,6 @@ export function DispatchPage() {
isActive={mode === 'timing'}
onClick={() => changeMode('timing')}
/>
<ModeTab
label="Map"
icon={<Map size={14} />}
isActive={mode === 'map'}
onClick={() => changeMode('map')}
/>
</HStack>
}
/>
@@ -287,12 +288,7 @@ export function DispatchPage() {
/>
</div>
{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' ? (
{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. */
@@ -491,6 +487,21 @@ function GroupDetail({
// rider's round means nothing on this one.
const paged = usePaged(group.stops, { resetKey: group.id });
/*
A round and a shop's day are both SHAPES — where the work is, in what order,
how far apart — and a table of addresses cannot show either. So those two
views draw the map instead of the list.
Never on the waiting queue. That is the only group whose rows are ticked to
assign them, a checkbox cannot live on a map pin, and nothing in it has been
worked yet so there is no shape to see. `selection` is passed only for that
group, so its presence is the test.
"By customer" keeps its table too: one customer's drops are all at one
address, so the map would be a single pin with the rest hidden underneath it.
*/
const showMap = (mode === 'riders' || mode === 'stores') && !selection;
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
@@ -512,13 +523,10 @@ function GroupDetail({
</Text>
</HStack>
{/* Rider position, only in the rider view and only when reported.
Labelled with its age and the status that produced it, because it
is a last-known point and not a live feed — drawing it as a dot on
a map would claim otherwise. */}
{/* Never on the waiting queue: nobody is carrying that work, so
{/* The rider's last reported point, but only when the map is not
drawing it. On the waiting queue nobody is carrying the work, so
"No position reported" states the obvious and reads as a fault. */}
{mode === 'riders' && group.id !== UNASSIGNED ? (
{!showMap && mode === 'riders' && group.id !== UNASSIGNED ? (
seen ? (
<div className="last-seen">
<MapPin size={14} />
@@ -532,13 +540,6 @@ function GroupDetail({
{seen.orderid ? ` · ${seen.orderid}` : ''}
</span>
</div>
<a
href={`https://www.openstreetmap.org/?mlat=${seen.lat}&mlon=${seen.lon}#map=16/${seen.lat}/${seen.lon}`}
target="_blank"
rel="noreferrer"
>
Open map
</a>
</div>
) : (
<div className="last-seen" data-empty="true">
@@ -561,98 +562,106 @@ function GroupDetail({
is free, not on a screen you have to leave the list for. */}
{assignBar}
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
{selection ? (
<th className="tick-cell">
<input
type="checkbox"
aria-label="Select every order that can be assigned"
checked={selection.allChosen}
ref={(node) => {
// Some can go and some cannot: neither ticked nor
// empty, and the box should say so rather than pick one.
if (node) node.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
{mode !== 'riders' ? <th>Rider</th> : null}
<th>KM</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{paged.rows.map((stop, rowIndex) => {
const status = stopStatus(stop);
const tint = statusColor(DISPATCH_STATUS, status);
const order = stop.kind === 'order' ? stop.row : undefined;
const verdict = order && verdictOf ? verdictOf(order) : undefined;
const pickable = Boolean(verdict?.canAssign);
return (
<tr key={stopKey(stop)} onClick={() => onOpen(stop)}>
{selection ? (
/* The click is stopped here so ticking a row does not
also open its drawer — two different intentions on the
same row, and the drawer would bury the bar. */
<td className="tick-cell" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select order ${stopOrderId(stop)}`}
checked={pickable && selection.has(order!.orderheaderid)}
disabled={!pickable}
title={pickable ? undefined : (verdict?.reason ?? 'Already with a rider')}
onChange={() => order && selection.toggle(order.orderheaderid)}
/>
</td>
) : null}
<td className="num">{paged.from + rowIndex}</td>
<td>
<strong>{stopOrderId(stop) || `#${stop.row.orderheaderid}`}</strong>
<span>{stopTime(stop) ? shortTime(stopTime(stop)) : ''}</span>
</td>
<td>
<strong>{stopCustomer(stop) || '—'}</strong>
<span>{stopAddress(stop) || stop.row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>
{stop.kind === 'delivery' && stop.row.ridername ? (
stop.row.ridername
) : (
<em className="muted">not assigned</em>
)}
</td>
) : null}
<td className="num">{stop.kind === 'delivery' ? km(stop.row) : '—'}</td>
<td className="num">{moneyExact(stopValue(stop))}</td>
<td>
<span
className="stop-status"
style={{
color: tint,
background: `color-mix(in oklab, ${tint} 12%, transparent)`,
{showMap ? (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<GroupMap stops={group.stops} groupName={group.name} />
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="stops-table">
<thead>
<tr>
{selection ? (
<th className="tick-cell">
<input
type="checkbox"
aria-label="Select every order that can be assigned"
checked={selection.allChosen}
ref={(node) => {
// Some can go and some cannot: neither ticked nor
// empty, and the box should say so rather than pick one.
if (node) node.indeterminate = selection.someChosen;
}}
>
{status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="stops" />
</Card>
onChange={selection.toggleAll}
/>
</th>
) : null}
<th>#</th>
<th>Order</th>
<th>Going to</th>
{mode !== 'riders' ? <th>Rider</th> : null}
<th>KM</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{paged.rows.map((stop, rowIndex) => {
const status = stopStatus(stop);
const tint = statusColor(DISPATCH_STATUS, status);
const order = stop.kind === 'order' ? stop.row : undefined;
const verdict = order && verdictOf ? verdictOf(order) : undefined;
const pickable = Boolean(verdict?.canAssign);
return (
<tr key={stopKey(stop)} onClick={() => onOpen(stop)}>
{selection ? (
/* The click is stopped here so ticking a row does not
also open its drawer — two different intentions on the
same row, and the drawer would bury the bar. */
<td className="tick-cell" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
aria-label={`Select order ${stopOrderId(stop)}`}
checked={pickable && selection.has(order!.orderheaderid)}
disabled={!pickable}
title={pickable ? undefined : (verdict?.reason ?? 'Already with a rider')}
onChange={() => order && selection.toggle(order.orderheaderid)}
/>
</td>
) : null}
<td className="num">{paged.from + rowIndex}</td>
<td>
<strong>{stopOrderId(stop) || `#${stop.row.orderheaderid}`}</strong>
<span>{stopTime(stop) ? shortTime(stopTime(stop)) : ''}</span>
</td>
<td>
<strong>{stopCustomer(stop) || '—'}</strong>
<span>{stopAddress(stop) || stop.row.deliverysuburb}</span>
</td>
{mode !== 'riders' ? (
<td>
{stop.kind === 'delivery' && stop.row.ridername ? (
stop.row.ridername
) : (
<em className="muted">not assigned</em>
)}
</td>
) : null}
<td className="num">{stop.kind === 'delivery' ? km(stop.row) : '—'}</td>
<td className="num">{moneyExact(stopValue(stop))}</td>
<td>
<span
className="stop-status"
style={{
color: tint,
background: `color-mix(in oklab, ${tint} 12%, transparent)`,
}}
>
{status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="stops" />
</Card>
)}
</VStack>
);
}