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