Files
daily_console_web/src/features/store-admin/GroupMap.tsx
2026-09-10 18:05:45 +05:30

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