updats on the dispatch page

This commit is contained in:
2026-09-10 17:57:00 +05:30
parent 79a8f2b234
commit 8b2c4daef8
34 changed files with 6929 additions and 1059 deletions

View File

@@ -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}`;
}