261 lines
8.5 KiB
TypeScript
261 lines
8.5 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { TrailMap, trailColour, type MapPin, type MapTrail } from '@/components/TrailMap';
|
|
import type { TenantLocation } from '@/api/types';
|
|
import type { Stop } from './dispatchModel';
|
|
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';
|
|
|
|
/**
|
|
* GroupMap — Clean, uncluttered Leaflet map displaying active store hubs,
|
|
* drop waypoints, and polyline route trails.
|
|
*/
|
|
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 shops = useMemo(() => shopsOf(mapped), [mapped]);
|
|
|
|
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(
|
|
() =>
|
|
rounds.flatMap((round) => {
|
|
const drops = round.stops.flatMap((stop) => (stop.drop ? [stop.drop] : []));
|
|
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} drop${drops.length > 1 ? 's' : ''}`,
|
|
points,
|
|
colour: trailColour(rounds.findIndex((r) => r.userid === round.userid)),
|
|
},
|
|
];
|
|
}),
|
|
[rounds, roads],
|
|
);
|
|
|
|
const pins: MapPin[] = useMemo(() => {
|
|
const out: MapPin[] = [];
|
|
|
|
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: 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',
|
|
});
|
|
}
|
|
|
|
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;
|
|
}, [rounds, shops, locations]);
|
|
|
|
return (
|
|
<div className="group-map-clean-wrap" style={{ width: '100%', height: '100%' }}>
|
|
<TrailMap
|
|
trails={trails}
|
|
pins={pins}
|
|
height={460}
|
|
emptyNote={
|
|
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>
|
|
);
|
|
}
|
|
|
|
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}`;
|
|
}
|