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:
202
src/features/store-admin/GroupMap.tsx
Normal file
202
src/features/store-admin/GroupMap.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
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 { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
|
||||
import type { Stop } from './dispatchModel';
|
||||
import { coverageOf, mapStop, roundsOf, shopsOf } from './deliveryTrack';
|
||||
import { DELIVERY_STATUS, statusColor } from './orderStatus';
|
||||
import { moneyExact } from './format';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function GroupMap({ stops, groupName }: { stops: readonly Stop[]; groupName: string }) {
|
||||
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 trails: MapTrail[] = useMemo(
|
||||
() =>
|
||||
shown.flatMap((round) => {
|
||||
const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
|
||||
if (drops.length < 2) return [];
|
||||
return [
|
||||
{
|
||||
id: round.userid,
|
||||
label: `${round.rider} — ${drops.length} drops, in the order they were worked`,
|
||||
points: drops,
|
||||
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
|
||||
},
|
||||
];
|
||||
}),
|
||||
[shown, rounds],
|
||||
);
|
||||
|
||||
const pins: MapPin[] = useMemo(() => {
|
||||
const out: MapPin[] = [];
|
||||
|
||||
// The shop first, so a drop pin drawn on top of it stays clickable.
|
||||
for (const [index, shop] of shops.entries()) {
|
||||
out.push({
|
||||
id: `shop-${index}`,
|
||||
lat: shop.lat,
|
||||
lng: shop.lng,
|
||||
label: 'Shop',
|
||||
lines: ['Where these orders were collected'],
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [shown, shops, rounds]);
|
||||
|
||||
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}
|
||||
|
||||
<TrailMap
|
||||
trails={trails}
|
||||
pins={pins}
|
||||
height={440}
|
||||
emptyNote={
|
||||
coverage.total === 0
|
||||
? `Nothing out for ${groupName}.`
|
||||
: 'None of these stops carries an address the map can place.'
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
Reference in New Issue
Block a user