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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user