updats on the dispatch page
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user