import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip, useMap, useMapEvents, ZoomControl } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; // Side-effect import: patches L.Polyline so pathOptions.offset (in screen px) // renders the line perpendicular-shifted from its actual geometry. Used by // Compare → Combined mode to render planned + actual as parallel rails when // they share the same road geometry (otherwise they'd stack and read as one). import '../../../utils/leafletPolylineOffset'; import dayjs from 'dayjs'; import { useInfiniteQuery, useQueries, useQuery, useMutation } from '@tanstack/react-query'; import axios from 'axios'; import { MdMap, MdDirectionsBike, MdRestaurant, MdPublic, MdInventory2, MdTrendingUp, MdStraighten, MdLocationOn, MdMarkunreadMailbox, MdMoveToInbox, MdPlace, MdTwoWheeler, MdNotes, MdSwapHoriz, MdExpandMore, MdInfoOutline, MdBatteryFull, MdSignalCellularAlt, MdMyLocation, MdSpeed, MdExplore, MdAccessTime, MdGpsFixed, MdPower, MdSearch, MdChevronLeft, MdChevronRight, MdLocalMall, MdCheckCircle, MdErrorOutline, MdWarning, MdClose, MdFormatListBulleted, MdTimer, MdCalendarToday, MdInsights, MdRefresh } from 'react-icons/md'; import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../api/api'; import { STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES, STEP_PALETTE, stepColor } from './dispatchShared'; import CompareDataPanel from './CompareDataPanel'; import './Dispatch.css'; import logger from '../../../utils/logger'; // Combined-mode rail colors. The per-step palette (STEP_PALETTE) is great for // "which step is this" but useless for "is this line planned or actual" when // both layers overlay. In Combined view only we drop the step palette on // polylines and use fixed, high-contrast colors: indigo for the dispatched // plan (matches the "Compare" button + the prior "Planned Route" label) and // emerald for the actual GPS trail (signals "live / real" data). Per-step // distinction in Combined view is carried by the numbered drop pins, which // keep STEP_PALETTE so the timeline link to a specific delivery survives. const COMBINED_PLANNED_COLOR = '#6366f1'; const COMBINED_ACTUAL_COLOR = '#10b981'; const toNum = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? n : NaN; }; // Long delivery addresses come in two shapes: // 1. Comma-separated: "Room No:C-4, Second Floor, ..., Vetrilaikara St, // Peelamedu" — we keep the last two segments (typically street + area). // 2. Free-form / space-separated: "Vistara Homes 71 & 72 ... Uppilipalayam // post Coimbatore - 641 015 Opposite ..." — Indian addresses often run // everything into one comma-less string. There's no reliable way to // pick the locality token, so we hard-cap to the last 6 words and trim // to ~40 chars; the full address still lives in the row's title tooltip. const extractArea = (addr) => { if (!addr) return ''; const str = String(addr).trim(); if (!str) return ''; if (str.includes(',')) { const parts = str.split(',').map((s) => s.trim()).filter(Boolean); if (parts.length === 0) return str; if (parts.length <= 2) return parts.join(', '); return parts.slice(-2).join(', '); } const words = str.split(/\s+/).filter(Boolean); const tail = words.length > 6 ? words.slice(-6).join(' ') : str; return tail.length > 40 ? `${tail.slice(0, 40).trim()}…` : tail; }; const hasValidDrop = (o) => Number.isFinite(toNum(o.droplat || o.deliverylat)) && Number.isFinite(toNum(o.droplon || o.deliverylong)); // Try multiple field-name variants — the live delivery API may return pickuplatitude/picklongitude // or pickuplongitude instead of the shorter pickuplat/pickuplong used in the static data. const pickupLat = (o) => o.pickuplat || o.pickuplatitude || o.pickup_lat; const pickupLon = (o) => o.pickuplong || o.pickuplongitude || o.picklongitude || o.pickup_lon; const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isFinite(toNum(pickupLon(o))); // Named delivery batches — operator's mental model of the day's waves. // Each entry covers a half-open range [startHour, endHour) measured in // FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported. // Three named batches, bucketed by assigntime per spec: // • Morning Batch: before 8 AM (00:00 → 08:00) // • Afternoon Batch: 9 AM → 12:30 PM (09:00 → 12:30) // • Evening Batch: 4 PM → 7 PM (16:00 → 19:00) // Gaps (8–9 AM, 12:30 PM–4 PM, 7 PM+) intentionally fall outside every batch. const BATCHES_DEFAULT_RAW = [ { id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 }, { id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 }, { id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 } ]; // v8: afternoon batch extended to 12:30 PM. Bumping from v7 wipes the // cached layouts that still hold the old endHour: 12 value. const SLOTS_STORAGE_KEY = 'dispatch.slots.v8'; // Every prior storage key. Wiped once on mount so stale layouts // from earlier code versions can't reappear on the next page load. const LEGACY_SLOTS_STORAGE_KEYS = [ 'dispatch.slots.v1', 'dispatch.slots.v2', 'dispatch.slots.v3', 'dispatch.slots.v4', 'dispatch.slots.v5', 'dispatch.slots.v6', 'dispatch.slots.v7' ]; // Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a // fractional startHour (24h, half-hour steps). Mirrors the human-readable // form the defaults use, so user-edited slots still look consistent. const formatSlotLabel = (idx, startHour) => { return `Slot ${idx + 1} · ${formatHourLabel(startHour)}`; }; // Render a fractional hour as a human-readable clock label. Whole hours // render as "8 AM"; half-hour values render as "12:30 PM". Other fractions // round to the nearest minute (covers any future extension to quarter-hours // without losing precision in the label). const formatHourLabel = (h) => { const wholeHour = Math.floor(h); const minutes = Math.round((h - wholeHour) * 60); const hr = ((wholeHour + 11) % 12) + 1; const ampm = wholeHour >= 12 && wholeHour < 24 ? 'PM' : 'AM'; if (minutes === 0) return `${hr} ${ampm}`; const mm = String(minutes).padStart(2, '0'); return `${hr}:${mm} ${ampm}`; }; const formatSlotRange = (startHour, endHour) => { if (endHour >= 24) return `After ${formatHourLabel(startHour)}`; return `${formatHourLabel(startHour)}–${formatHourLabel(endHour)}`; }; // Derive the operator-facing label/range strings from BATCHES_DEFAULT_RAW. // Doing it through the formatters (instead of hardcoding "Slot 2 · 12:30 PM" // etc.) guarantees user-edited slots and default slots render the same way // — no chance of drift between the two paths. // Prefer the explicit `name` (e.g. "Morning Batch") when provided; fall back // to the auto-generated "Slot N · 8 AM" label for any user-added slot that // doesn't carry a name. const BATCHES_DEFAULT = BATCHES_DEFAULT_RAW.map((s, i) => ({ ...s, label: s.name || formatSlotLabel(i, s.startHour), range: formatSlotRange(s.startHour, s.endHour) })); const getBatchForHour = (h, batches) => { for (const b of batches) { if (h >= b.startHour && h < b.endHour) return b.id; } return null; }; // Time fields the operator can pick from to drive slot bucketing. Each // option maps to a column on the delivery row; the chosen one becomes the // timestamp `getRowBatch` reads. "Delivery" defaults to actual deliverytime // with a fallback to expecteddeliverytime so undelivered orders still bucket. const TIME_FIELDS = [ { id: 'delivered', label: 'Delivered', keys: ['deliverytime'] }, { id: 'pending', label: 'Pending', keys: ['expecteddeliverytime'] }, { id: 'assigned', label: 'Assigned', keys: ['assigntime'] }, { id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] }, { id: 'started', label: 'Started', keys: ['starttime'] }, { id: 'arrived', label: 'Arrived', keys: ['arrivaltime'] }, { id: 'pickup', label: 'Pickup', keys: ['pickuptime'] }, { id: 'all', label: 'All', keys: ['deliverytime', 'expecteddeliverytime', 'assigntime', 'acceptedtime', 'arrivaltime', 'pickuptime', 'starttime'] } ]; const getTimeFieldValue = (r, fieldId) => { const field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0]; for (const k of field.keys) { if (r?.[k]) return r[k]; } return null; }; const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => { const t = getTimeFieldValue(r, fieldId); if (!t) return null; const str = String(t).trim(); // Skip bare date strings — no time component, would always parse to midnight. if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null; const d = dayjs(t); if (!d.isValid()) return null; // Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which // starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would // truncate to 12 and mis-bucket the back half of every hour. return getBatchForHour(d.hour() + d.minute() / 60, batches); }; // Sits inside the Compare MapContainer and unpins any pinned popup whenever // the operator clicks empty map space. Markers' click events do NOT bubble // to the map, so this only fires on background clicks (which is what we // want — clicking elsewhere should release the pin). function CompareMapClickUnpin({ onUnpin }) { useMapEvents({ click: () => onUnpin() }); return null; } // Captures the Leaflet map instance for the parent component via a ref. Kept // available even after the two-map Compare layout was unified into one map, // since future per-step imperative zoom logic still needs a handle on the // planned map instance. function CaptureMap({ targetRef }) { const map = useMap(); useEffect(() => { targetRef.current = map; return () => { targetRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); return null; } // Haversine distance between two [lat, lng] points in kilometers. Good to // ~0.1% across city scales; we use it to sum the length of an OSRM-snapped // polyline so the Compare delta panel can show "actual km" without depending // on the backend's actualkms field (which can be stale or missing). function haversineKm(a, b) { const R = 6371; // km const toRad = (d) => (d * Math.PI) / 180; const lat1 = toRad(a[0]); const lat2 = toRad(b[0]); const dLat = toRad(b[0] - a[0]); const dLon = toRad(b[1] - a[1]); const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.min(1, Math.sqrt(s))); } function polylineLengthKm(points) { if (!Array.isArray(points) || points.length < 2) return 0; let total = 0; for (let i = 1; i < points.length; i++) { total += haversineKm(points[i - 1], points[i]); } return total; } // ─── Kalman filter for GPS pings ───────────────────────────────────────── // // Two independent 1D Kalman filters (one for lat, one for lng) applied to a // chronologically sorted list of GPS pings. Per-axis state: [position, // velocity]. Constant-velocity dynamics with random acceleration as process // noise; measurement model H = [1, 0] (we measure position only). // // Why a Kalman filter here: // Raw /getdeliverylogs pings contain jitter (multi-path in dense urban // areas), brief stationary noise (rider parked at the drop, GPS still // wandering), and occasional outliers (cold-start fix). The Kalman pass // fuses each ping with the predicted trajectory from prior pings, weighted // by their relative uncertainty. The output is a smooth polyline that // tracks the rider's real path without the zig-zags and bunched-up dots // near drops, and it costs O(N) — runs once per delivery on fetch. // // Tuning: // processNoise (q) — random-acceleration variance (deg²/s²). Lower = a // smoother result but slower to follow sharp turns. // measurementNoise (r) — GPS-fix variance (deg²). Higher = trust pings // less, lean on the predicted state more. // The defaults below correspond loosely to ~5m GPS accuracy and gentle // urban acceleration. Bump q if smoothing eats genuine turns; bump r if // the line still wiggles between pings. function kalmanSmoothGps(pings, options = {}) { if (!Array.isArray(pings) || pings.length === 0) return []; if (pings.length === 1) { return [{ lat: pings[0].lat, lng: pings[0].lng, logdate: pings[0].logdate }]; } const processNoise = options.processNoise != null ? options.processNoise : 1e-9; const measurementNoise = options.measurementNoise != null ? options.measurementNoise : 1e-7; const tsOf = (p) => p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0); // Run a 1D Kalman over one axis (lat or lng). State: [pos, vel]; cov: 2x2. const smoothAxis = (axisKey) => { let x = pings[0][axisKey]; // position estimate let v = 0; // velocity estimate // Initial covariance — large enough that the first few measurements // dominate over the initial state. let p00 = 1, p01 = 0, p10 = 0, p11 = 1; const out = [x]; let prevTs = tsOf(pings[0]); for (let i = 1; i < pings.length; i++) { const ts = tsOf(pings[i]) || prevTs + 1000; const dt = Math.max(0.1, (ts - prevTs) / 1000); prevTs = ts; // ─── Predict ─── // x' = F x where F = [[1, dt], [0, 1]] const xPred = x + v * dt; const vPred = v; // P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]] const dt2 = dt * dt; const dt3 = dt2 * dt; const dt4 = dt3 * dt; const np00 = p00 + dt * (p01 + p10) + dt2 * p11 + (dt4 / 4) * processNoise; const np01 = p01 + dt * p11 + (dt3 / 2) * processNoise; const np10 = p10 + dt * p11 + (dt3 / 2) * processNoise; const np11 = p11 + dt2 * processNoise; // ─── Update ─── // y = z − Hx' (innovation, measurement vs prediction) const z = pings[i][axisKey]; const y = z - xPred; // S = H P' H^T + R (innovation covariance) const S = np00 + measurementNoise; // K = P' H^T / S (Kalman gain) const K0 = np00 / S; const K1 = np10 / S; // x = x' + K y x = xPred + K0 * y; v = vPred + K1 * y; // P = (I − K H) P' p00 = (1 - K0) * np00; p01 = (1 - K0) * np01; p10 = np10 - K1 * np00; p11 = np11 - K1 * np01; out.push(x); } return out; }; const lats = smoothAxis('lat'); const lngs = smoothAxis('lng'); return pings.map((p, i) => ({ lat: lats[i], lng: lngs[i], logdate: p.logdate, _ts: p._ts })); } // Splits a routed OSRM polyline into per-step segments by finding the // polyline index closest to each drop waypoint. Returns an array of // segments where segments[i] runs from the previous waypoint (or polyline // start when i===0) to drops[i]. Adjacent segments share their endpoint // so the rendered polylines visually touch with no gap. // // Used by renderRoutes in Compare mode to recolor the planned-route // polyline per step, so the left map's step colors match the right map's // per-step palette + the timeline strip. Without per-segment splitting // the planned polyline would be a single rider-colored line that doesn't // visually link to step N's actual GPS polyline. function splitPolylineByDrops(polyline, drops) { if (!Array.isArray(polyline) || polyline.length < 2 || !drops || !drops.length) { return []; } const findClosestIndex = (target) => { let best = 0; let bestD = Infinity; for (let i = 0; i < polyline.length; i++) { const dy = polyline[i][0] - target[0]; const dx = polyline[i][1] - target[1]; const d = dy * dy + dx * dx; if (d < bestD) { bestD = d; best = i; } } return best; }; const cutIndices = drops.map(findClosestIndex); // Force monotonically non-decreasing — in pathological route shapes the // raw "closest" indices can briefly regress (OSRM doubles back near a // waypoint), which would produce empty/negative slices below. for (let i = 1; i < cutIndices.length; i++) { if (cutIndices[i] < cutIndices[i - 1]) cutIndices[i] = cutIndices[i - 1]; } const segments = []; let prev = 0; cutIndices.forEach((idx) => { const end = Math.max(idx, prev); segments.push(polyline.slice(prev, end + 1)); prev = end; }); return segments; } // --- Marker popup helpers --- // Strip the date portion from an API timestamp — operators viewing today's // orders only care about the wall-clock time. Falls back to the raw string // if dayjs can't parse the value. const formatTimeOnly = (t) => { if (!t) return null; const d = dayjs(t); if (!d.isValid()) return String(t); return d.format('HH:mm:ss'); }; // Stages the popup walks through, top → bottom, in real-world delivery order. // Each row only renders when the corresponding API field is populated, so the // timeline visually shrinks for orders that haven't reached later stages yet. const POPUP_TIMELINE = [ { key: 'assigntime', label: 'Assigned' }, { key: 'acceptedtime', label: 'Accepted' }, { key: 'arrivaltime', label: 'Arrived' }, { key: 'pickuptime', label: 'Pickup' }, { key: 'starttime', label: 'Started' }, { key: 'deliverytime', label: 'Delivered', final: true } ]; // Build a polyline-ready point list for a sorted trip: // - drop NaN drops // - prepend the first valid pickup we can find (so the line starts at the kitchen) const buildTripPoints = (sorted) => { const valid = sorted.filter(hasValidDrop); if (!valid.length) return []; const pickupSrc = sorted.find(hasValidPickup); const pts = []; if (pickupSrc) pts.push([toNum(pickupLat(pickupSrc)), toNum(pickupLon(pickupSrc))]); valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)])); return pts; }; // Fix for default leaflet marker icons delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', }); const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000']; // Deterministic rider-id → palette slot mapping. The main `riders` array // assigns colors by iteration order (see useMemo around line 1308), which // means the same rider can shuffle to a different color across live-data // refetches. The Rider Info list needs a fixed color per rider so the dot // next to "Rajan A" doesn't flip from blue to green on the next poll, so // it uses this hash-based lookup instead of `getRiderColor(id)`. const getStableRiderColor = (id) => { const s = String(id ?? ''); if (!s) return RIDER_COLORS[0]; let h = 0; for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) >>> 0; } return RIDER_COLORS[h % RIDER_COLORS.length]; }; // STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES, // STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the // extracted CompareDataPanel component can import them without forcing // a circular dependency on Dispatch.js. const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => { const map = useMap(); // Last fit signature. We only call fitBounds when this changes — otherwise // every parent render (data refetch, sidebar tick, etc.) would refit the // map and snap it back mid-drag, which felt like the map was un-draggable. const lastFitKeyRef = useRef(''); // Stable identifier for the current focus target. Uses ids where possible // so it doesn't churn when the underlying objects get rebuilt by useMemo. // `locationKey` is included everywhere so switching hubs (Coimbatore → // Nagercoil etc.) always triggers a refit onto the new region, even when // the new hub happens to have the same kitchen/order count. // // We also append a coarse centroid (lat/lon rounded to 1 decimal) of the // current dataset. This is what fixes the Nagercoil case: when only the // hub id changes but the kitchen/order array is briefly the previous // hub's stale data (or empty during refetch), the fit only happens once // truly-new data arrives — at which point the centroid signature flips // (e.g. 11.0,77.0 → 8.2,77.4) and we refit to the new region. const fitKey = useMemo(() => { const loc = locationKey != null ? `loc:${locationKey}|` : ''; const centroidSig = (pairs) => { let lat = 0; let lon = 0; let n = 0; for (const p of pairs) { if (Number.isFinite(p[0]) && Number.isFinite(p[1])) { lat += p[0]; lon += p[1]; n += 1; } } return n === 0 ? '0' : `${(lat / n).toFixed(1)},${(lon / n).toFixed(1)}`; }; if (focusedItem) { const id = focusedItem.id ?? focusedItem.kitchenName ?? focusedItem.name ?? (focusedItem.lat != null ? `${focusedItem.lat},${focusedItem.lon}` : 'item'); const n = focusedItem.orders ? focusedItem.orders.length : 0; return `${loc}f|${id}|${n}`; } const kPairs = (kitchens || []).map((k) => [k.lat, k.lon]); const kSig = centroidSig(kPairs); if (viewMode === 'kitchens') { const n = kPairs.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1])).length; return `${loc}k|${n}|${kSig}`; } if (viewMode === 'all') { const oPairs = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); return `${loc}a|${oPairs.length}|${centroidSig(oPairs)}`; } return `${loc}m|${viewMode || ''}|${kPairs.length}|${kSig}`; }, [focusedItem, viewMode, orders, kitchens, locationKey]); useEffect(() => { if (lastFitKeyRef.current === fitKey) return; let pts = []; if (focusedItem) { if (focusedItem.orders) { pts = focusedItem.orders.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); focusedItem.orders.forEach((o) => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))])); } else { pts = [[focusedItem.lat, focusedItem.lon]]; } } else if (viewMode === 'kitchens') { pts = (kitchens || []) .filter((k) => Number.isFinite(k.lat) && Number.isFinite(k.lon)) .map((k) => [k.lat, k.lon]); if (pts.length === 0) { pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); } } else if (viewMode === 'all') { pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); } else { // No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the // current hub's footprint so switching from Coimbatore → Nagercoil // (or any hub change) recenters the map onto the new region instead // of leaving it on the previous city. pts = (kitchens || []) .filter((k) => Number.isFinite(k.lat) && Number.isFinite(k.lon)) .map((k) => [k.lat, k.lon]); if (pts.length === 0) { pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); } } const filtered = pts.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1])); if (filtered.length > 0) { const bounds = L.latLngBounds(filtered); if (bounds.isValid()) { // Zoom into a single-point bounds (focused rider with one drop, or // a single kitchen) instead of using maxZoom defaults which would // leave the map zoomed out. const isSinglePoint = filtered.length === 1 || bounds.getNorthEast().equals(bounds.getSouthWest()); if (isSinglePoint) { map.setView(filtered[0], 15, { animate: true, duration: 0.6 }); } else { map.flyToBounds(bounds, { padding: [60, 60], duration: 0.6, maxZoom: 16 }); } lastFitKeyRef.current = fitKey; } return; } // No data to fit to yet — hub switch is mid-flight, or this hub has no // orders today. Do NOT lock the fit key: when real hub data arrives the // fitKey will change (its centroid signature flips, e.g. 11.0,77.0 → // 8.2,77.4) and this effect will re-run with proper coordinates. // // Also do NOT call setView([11.022, 76.982], 12) here — that was the // bug that left Nagercoil (and every non-Coimbatore hub) stuck on the // Coimbatore default during the brief window between picking the hub // and its data arriving. }, [fitKey, focusedItem, viewMode, orders, kitchens, map]); return null; }; // Inline-icon wrapper used wherever a Material icon precedes some text — keeps the // SVG vertically centered with the adjacent text and inherits the parent color. const Ico = ({ children }) => ( {children} ); // Batch windows for the standalone Dispatch Analysis view. Maps the UI label // to the X-Batch-Window header value the backend expects. const ANALYSIS_BATCH_WINDOWS = [ { key: 'morning', label: 'Morning', timeRange: '12:00 AM – 8:00 AM', sub: 'Early shift orders', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' }, { key: 'afternoon', label: 'Noon', timeRange: '9:00 AM – 12:30 PM', sub: 'Lunch rush window', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' }, { key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' } ]; // Tolerant field-name lookup so the Analysis card still renders cleanly even // if the API response uses slightly different keys than expected. const analysisPick = (obj, keys) => { for (const k of keys) { if (obj && obj[k] != null && obj[k] !== '') return obj[k]; } return null; }; const analysisFormatNum = (v) => { if (v == null) return '—'; if (typeof v === 'number') return v.toLocaleString('en-IN'); const n = parseFloat(v); if (Number.isFinite(n)) return n.toLocaleString('en-IN'); return String(v); }; const analysisFormatKm = (v) => (v == null ? '—' : `${parseFloat(v).toFixed(1)} km`); const analysisFormatRupees = (v) => (v == null ? '—' : `₹${parseFloat(v).toFixed(0)}`); const analysisFormatPct = (v) => { if (v == null) return '—'; const n = parseFloat(v); if (!Number.isFinite(n)) return '—'; return `${n > 1 ? n.toFixed(1) : (n * 100).toFixed(1)}%`; }; const Dispatch = ({ data, embedded = false, // Controlled focus: when selectedRiderId is defined, the focused rider is derived from prop // and clicks inside Dispatch only fire onRiderSelect (parent owns the state). When undefined, // Dispatch falls back to its internal focusedRider state (standalone /dispatch behavior). selectedRiderId, onRiderSelect, // Highlight a single marker (e.g. on table-row hover). Adds a `.pulse` class to that cmark. pulseOrderId, // Optional. When provided, focused-rider order cards render a small "change rider" // icon in their header. Receiving callsite owns the rider-picker dialog. Standalone // /dispatch usage leaves this undefined so the icon never appears there. onChangeRider }) => { // Default to "By Zone" when the caller passes pre-zoned data (AI preview); fall back to // "By Rider" for the standalone live page where zones are synthesized but riders are primary. const initialViewMode = data?.zones && data.zones.length > 0 ? 'zones' : 'riders'; const [viewMode, setViewMode] = useState(initialViewMode); // Top-level page mode for the standalone Dispatch screen: 'live' (existing // map + sidebar UI) or 'analysis' (batch efficiency panel). Embedded use // (Preview) never reaches the Analysis switcher, so default doesn't matter // there. const [topView, setTopView] = useState('live'); const [analysisResults, setAnalysisResults] = useState({}); const [analysisLoadingWindow, setAnalysisLoadingWindow] = useState(null); const [activeBatchKey, setActiveBatchKey] = useState(null); // TODO: wire to real tenant context once the standalone Dispatch screen // surfaces it. 916 matches the example tenant in the API spec. const ANALYSIS_TENANT_ID = 916; const batchEfficiencyMutation = useMutation({ mutationFn: fetchBatchEfficiency, onMutate: (vars) => setAnalysisLoadingWindow(vars.batch), onSuccess: (resp, vars) => { setAnalysisResults((prev) => ({ ...prev, [vars.batch]: { data: resp, fetchedAt: dayjs().format('HH:mm:ss') } })); }, onSettled: () => setAnalysisLoadingWindow(null) }); const handleFetchAnalysisBatch = (windowKey) => { setActiveBatchKey(windowKey); // Re-use cached non-error response if already loaded const cached = analysisResults[windowKey]; if (cached && cached.data && cached.data.success !== false) return; batchEfficiencyMutation.mutate({ batch: windowKey, tenantId: ANALYSIS_TENANT_ID }); }; const [activeRiders, setActiveRiders] = useState(new Set()); const [internalFocusedRider, setInternalFocusedRider] = useState(null); const [focusedKitchen, setFocusedKitchen] = useState(null); const [focusedZone, setFocusedZone] = useState(null); // Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map. const [focusedStop, setFocusedStop] = useState(null); // Holds leaflet marker instances keyed by orderid so we can imperatively open // their popups when the user clicks a step in the focused-rider sidebar. const orderMarkerRefs = useRef({}); // Orderids whose popup was explicitly pinned open by a click. mouseout skips // the close for these so the popup stays visible after the cursor leaves the // marker; clicking the marker again unpins it. Stored in a ref because this // only drives imperative leaflet calls — no re-render needed. const pinnedPopupsRef = useRef(new Set()); // Rider ids whose LIVE GPS marker popup was pinned open by a click. Same // semantics as pinnedPopupsRef but tracked separately because live-GPS // popups use leaflet's marker-attached (openPopup/closePopup) rather // than the centered overlay used for order popups. const pinnedLivePopupsRef = useRef(new Set()); // Short-lived close timer for the general map order/marker popups. // Gives the cursor a ~200ms window to travel from the marker onto the popup // or vice versa without immediately triggering a close. const activePopupMarkerRef = useRef(null); const popupHoverTimerRef = useRef(null); // Order shown in the centered popup overlay. Rendered outside the leaflet // map (see `dispatch-popup-center` overlay near the bottom of the JSX) so // `position: fixed` actually centers on the viewport. Drives behavior that // used to flow through leaflet's marker-attached : hover opens it, // mouseout closes it after a ~200ms grace window (unless pinned), click // toggles a pinned state stored in pinnedPopupsRef. const [centerPopupOrder, setCenterPopupOrder] = useState(null); const isControlled = selectedRiderId !== undefined; const [clock, setClock] = useState(''); // Fetch all hubs/locations the logged-in user has access to. The list is // rendered as a dropdown next to the Dispatch title so the operator can // switch the active hub without going back to login. const { data: appLocations } = useQuery({ queryKey: ['appLocations'], queryFn: fetchAppLocations, staleTime: 5 * 60 * 1000 }); // The user's last-picked location is mirrored into localStorage so it // survives reloads and stays in sync with other pages that read it. const initialAppLocationId = typeof window !== 'undefined' ? localStorage.getItem('applocationid') : null; const [selectedAppLocationId, setSelectedAppLocationId] = useState( initialAppLocationId != null ? Number(initialAppLocationId) : 0 ); const [locationMenuOpen, setLocationMenuOpen] = useState(false); const locationMenuRef = useRef(null); // Which timestamp column drives slot bucketing. Default = assigntime so // orders bucket into Morning/Afternoon/Evening by when they were assigned, // per current spec. The status-wise time-field dropdown is hidden for now // (see commented-out block in JSX), so this stays fixed at 'assigned'. const [selectedTimeField, setSelectedTimeField] = useState('assigned'); const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false); const timeFieldMenuRef = useRef(null); // Operator-editable slot configuration. Seeded from localStorage so edits // survive reloads; falls back to BATCHES_DEFAULT otherwise. Each entry has // id/label/range/startHour/endHour just like the default list — the rest // of the file reads BATCHES (derived below) without caring whether the // values came from defaults or from operator edits. const [slotsConfig, setSlotsConfig] = useState(() => { if (typeof window === 'undefined') return BATCHES_DEFAULT; try { const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY); if (!raw) return BATCHES_DEFAULT; const parsed = JSON.parse(raw); // If the parsed slots length does not match BATCHES_DEFAULT_RAW, the data // is stale (e.g. written from the older 5-slot layout). Discard it and // load the default 3-batch layout. if (!Array.isArray(parsed) || parsed.length !== BATCHES_DEFAULT_RAW.length) return BATCHES_DEFAULT; // Re-derive label + range from the saved hours so any UI tweaks to the // formatter (e.g. AM/PM style) flow through to old persisted slots. If // the saved id matches a default batch, prefer that batch's friendly // name ("Morning Batch", etc.) over the generated "Slot N · time" label. return parsed.map((s, i) => { const id = s.id || `slot-${i + 1}`; const startHour = Number(s.startHour) || 0; const endHour = Number(s.endHour) || 24; const defaultMatch = BATCHES_DEFAULT.find((b) => b.id === id); return { id, startHour, endHour, label: defaultMatch?.name || formatSlotLabel(i, startHour), range: formatSlotRange(startHour, endHour) }; }); } catch (e) { return BATCHES_DEFAULT; } }); const BATCHES = slotsConfig; const [slotEditOpen, setSlotEditOpen] = useState(false); const slotEditRef = useRef(null); // One-shot housekeeping: drop every prior slot-storage key so a stale // layout written under an older schema version (e.g. v1/v2/v3 with five // slots) can never resurface. Runs once per mount because deps are []. useEffect(() => { if (typeof window === 'undefined') return; try { LEGACY_SLOTS_STORAGE_KEYS.forEach((k) => window.localStorage.removeItem(k)); } catch (e) { /* private-mode / quota — ignore */ } }, []); // Persist edits whenever slotsConfig changes (skip the first render — the // initializer already loaded from storage). const slotsInitMountedRef = useRef(false); useEffect(() => { if (!slotsInitMountedRef.current) { slotsInitMountedRef.current = true; return; } if (typeof window === 'undefined') return; try { window.localStorage.setItem(SLOTS_STORAGE_KEY, JSON.stringify( slotsConfig.map(({ id, startHour, endHour }) => ({ id, startHour, endHour })) )); } catch (e) { /* quota / private-mode — ignore */ } }, [slotsConfig]); // Close the location dropdown on any click outside its wrapper. useEffect(() => { if (!locationMenuOpen) return; const onDocClick = (e) => { if (locationMenuRef.current && !locationMenuRef.current.contains(e.target)) { setLocationMenuOpen(false); } }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [locationMenuOpen]); // Same click-outside behavior for the slot-time-field dropdown. useEffect(() => { if (!timeFieldMenuOpen) return; const onDocClick = (e) => { if (timeFieldMenuRef.current && !timeFieldMenuRef.current.contains(e.target)) { setTimeFieldMenuOpen(false); } }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [timeFieldMenuOpen]); // Click-outside dismissal for the slot-edit popover. useEffect(() => { if (!slotEditOpen) return; const onDocClick = (e) => { if (slotEditRef.current && !slotEditRef.current.contains(e.target)) { setSlotEditOpen(false); } }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [slotEditOpen]); // Rider Info view — operator picks a rider in the sidebar, the main panel // shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode // ('rider-info') so it follows the same toggle pattern as the other modes. // Importantly, the rider list here is built from the FULL day's rows // (`liveRows`) — NOT the slot-filtered `filteredLiveRows`. Operators using // Rider Info want to look up any rider regardless of which slot they were // active in. const [riderInfoUserid, setRiderInfoUserid] = useState(null); const [riderInfoSearch, setRiderInfoSearch] = useState(''); const { data: riderInfoData, isFetching: riderInfoFetching, isError: riderInfoIsError, error: riderInfoError } = useQuery({ queryKey: ['riderPeriodicLog', riderInfoUserid], queryFn: () => getRiderPeriodicLogs(riderInfoUserid), enabled: viewMode === 'rider-info' && riderInfoUserid != null, // Auto-refresh the rider snapshot every 15s while the view is open and a // rider is selected. Don't poll while the tab is hidden so we don't burn // requests on background tabs. refetchInterval: viewMode === 'rider-info' && riderInfoUserid != null ? 15_000 : false, refetchIntervalInBackground: false, staleTime: 5 * 1000, refetchOnWindowFocus: false }); // Reverse-geocode the selected rider's GPS so we can show a small banner // above the map pin telling the operator which suburb/area the rider is in. // Nominatim is rate-limited (1 req/sec public), so we round coords to 4 // decimals (~11 m) — that turns the query key into a stable cache slot and // stops jittery GPS fixes from re-firing the request every poll cycle. const riderInfoCoordsKey = useMemo(() => { const lat = parseFloat(riderInfoData?.latitude); const lon = parseFloat(riderInfoData?.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; return { lat: lat.toFixed(4), lon: lon.toFixed(4) }; }, [riderInfoData?.latitude, riderInfoData?.longitude]); const { data: riderInfoArea } = useQuery({ queryKey: ['reverseGeocode', riderInfoCoordsKey?.lat, riderInfoCoordsKey?.lon], queryFn: async () => { const res = await fetch( `https://nominatim.openstreetmap.org/reverse?lat=${riderInfoCoordsKey.lat}&lon=${riderInfoCoordsKey.lon}&format=json&zoom=16&addressdetails=1`, { headers: { Accept: 'application/json' } } ); if (!res.ok) return null; const j = await res.json(); const a = j?.address || {}; // Prefer the most specific locality name available; the bigger admin // levels (city/county/state) are kept only as last-resort fallbacks. const area = a.suburb || a.neighbourhood || a.village || a.hamlet || a.city_district || a.town || a.city || a.county || a.state || ''; return { area, display: j?.display_name || '' }; }, enabled: viewMode === 'rider-info' && !!riderInfoCoordsKey, staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, retry: 1 }); const locationName = useMemo(() => { if (!appLocations) return null; const match = appLocations.find((l) => String(l.applocationid) === String(selectedAppLocationId)); return match?.locationname || null; }, [appLocations, selectedAppLocationId]); const handleLocationPick = (id) => { logger.info('Switching hub/location ID:', id); setSelectedAppLocationId(Number(id)); setLocationMenuOpen(false); if (typeof window !== 'undefined') { try { localStorage.setItem('applocationid', String(id)); } catch (e) { /* ignore quota errors */ } } // Switching hubs invalidates the current focused rider/kitchen/zone since // they belong to the previous hub's data. handleRiderFocus is declared // further down the component but the closure is only called after click, // so the reference resolves cleanly. handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }; const [osrmRoutes, setOsrmRoutes] = useState({}); // Mirror of osrmRoutes held in a ref so fetchRoute can check the cache without // being listed in useCallback deps (which caused a render-loop: fetch → state // update → new fetchRoute → effect re-runs → repeat). const osrmRoutesRef = useRef({}); // Road-snapped polylines for each delivery's actual GPS trace (Compare map). // Keyed by deliveryid. Same null/false/array semantics as osrmRoutes. const [osrmTrackRoutes, setOsrmTrackRoutes] = useState({}); const osrmTrackRoutesRef = useRef({}); const [isAnimating, setIsAnimating] = useState(false); const [animatedSegments, setAnimatedSegments] = useState([]); // Per-step polyline progress for the Compare actual-GPS map. Keyed by // sequenceStep, value is the index up to which the step's polyline should // be rendered (positions.slice(0, value)). Lets each step's polyline DRAW // progressively across the map — matches the planned animation's // pair-by-pair drawing style instead of the older "whole step pops in" // behavior. Cleared when Animate stops. const [animatedActualProgress, setAnimatedActualProgress] = useState({}); // Mirror of isAnimating accessible inside the rAF loop. The loop needs to // bail mid-animation if the user clicks Stop, but it can't read isAnimating // directly (closure captures the value at scheduling time). const isAnimatingRef = useRef(false); const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); // Custom date-picker popover state. The visible month can drift from the // selected date (operator browses months without committing), so we keep // the "viewing" month separately. Resets to the selected date's month // every time the popover opens so the user never has to navigate back // from wherever they last browsed. const [datePickerOpen, setDatePickerOpen] = useState(false); const [calViewMonth, setCalViewMonth] = useState(() => dayjs(selectedDate).isValid() ? dayjs(selectedDate).startOf('month') : dayjs().startOf('month') ); const datePickerRef = useRef(null); // Close the date-picker on outside click or Escape. Mounted only while the // popover is open so we're not leaving global listeners around when it's // not needed. useEffect(() => { if (!datePickerOpen) return undefined; const onDocClick = (e) => { if (!datePickerRef.current) return; if (!datePickerRef.current.contains(e.target)) setDatePickerOpen(false); }; const onKey = (e) => { if (e.key === 'Escape') setDatePickerOpen(false); }; document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [datePickerOpen]); // When the popover opens, snap the visible month back to whichever month // contains the currently-selected date. Otherwise the user could open it // expecting to see "May 2026" and find themselves still parked in January // from their last browsing session. useEffect(() => { if (datePickerOpen) { const d = dayjs(selectedDate); if (d.isValid()) setCalViewMonth(d.startOf('month')); } }, [datePickerOpen, selectedDate]); // Compare mode — when ON, the body splits 50/50: left half is the regular // dispatch map (planned routes), right half is a second map that overlays // the focused rider's ACTUAL GPS tracks per delivery, pulled from the same // /deliveries/getdeliverylogs/?deliveryid=X endpoint the Order Details map // uses. Lets the operator visually verify whether the rider stuck to the // dispatched plan. Disabled when no rider is focused (the comparison is // per-rider). Kept as a single state flag so we don't entangle it with // viewMode/focused* logic. const [compareOpen, setCompareOpen] = useState(false); // Which layer the unified compare map renders. // 'combined' = planned (dashed) + actual GPS (solid) overlaid on one map // 'planned' = planned route only // 'actual' = actual GPS tracks only // Driven by the segmented control overlaid on the compare map's top-left. const [compareViewMode, setCompareViewMode] = useState('combined'); // Default-open toggle for the "Route sequence" section in the compare data // panel — shows planned vs actual visit order with out-of-order steps flagged. const [sequenceOpen, setSequenceOpen] = useState(true); // Default-open toggle for the planned/actual timeline strip above the // compare actual-map. Lets the operator collapse the dual step rows when // they want maximum map real estate. const [compareTimelineOpen, setCompareTimelineOpen] = useState(true); // Set of route-sequence "diff group" indices that are currently expanded. // Cascade-aware: when N consecutive steps share the same shift amount, // they're collapsed into one summary row by default; clicking it adds the // run's index to this set so the individual steps reveal. const [expandedSeqGroups, setExpandedSeqGroups] = useState(() => new Set()); // In controlled mode the parent owns selectedRiderId, so handleRiderFocus only // fires onRiderSelect — focusedRider won't update until the parent re-renders. // This ref lets us defer setCompareOpen(true) until focusedRider is confirmed. const pendingCompareRef = useRef(false); // (Compare popup-pin state removed — the marker tooltips now open on hover // automatically and click drives focusedCompareStep, so there's nothing left // to "pin". See the timeline + delta panel for the persistent detail surface.) // Sidebar collapse — hides the 400px sidebar (slides to width 0) so the // two maps in Compare mode can use the full body width. Auto-collapses when // Compare opens and restores the prior state when Compare closes; a peek // tab on the left edge of the body lets the operator override at any time. const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const preCompareCollapsedRef = useRef(false); const prevCompareOpenRef = useRef(false); // Compare-data-panel collapse — mirrors the left sidebar peek tab on the // opposite edge so the operator can hide the right rail and let the map // claim the full width during compare. Resets to expanded each time // Compare opens fresh. const [compareDataCollapsed, setCompareDataCollapsed] = useState(false); // Compare UI — step focus on the unified compare map. // focusedCompareStep: null = "overall" (whole day); 1..N = drill into // that single delivery. The unified map zooms to that step's bounds // and the data panel switches from day-summary to per-step delta. // leftMapRef: Leaflet map instance captured via CaptureMap — kept so // future per-step zoom logic can drive the map imperatively. const [focusedCompareStep, setFocusedCompareStep] = useState(null); const leftMapRef = useRef(null); // Stable Canvas renderer for the planned (left) map. Leaflet's default // canvas (used when `preferCanvas` is true) only draws ~10% beyond the // viewport — the moment a drag carries a polyline edge outside that // padding, the off-area pixels are blank until `moveend` triggers a // redraw, which makes polylines look "broken" mid-drag. Bumping padding // to 1.5 makes the canvas ~4× viewport area, so dragging within a screen // of travel keeps every polyline drawn without re-raster gaps. The // actual (right) map's renderer is created later, after focusedRider is // in scope, since it remounts per rider. const plannedMapRendererRef = useRef(null); if (!plannedMapRendererRef.current) { plannedMapRendererRef.current = L.canvas({ padding: 1.5, tolerance: 5 }); } // Pull the partners/getriderlogs feed for the currently selected hub + date. // This endpoint returns the exact live GPS position for every rider at the // hub (latitude/longitude/logdate/status). We render those positions as // markers on the main dispatch map so the operator sees where each rider // actually is — matching the Reports → Riders Logs page. const { data: ridersLocationLogs } = useQuery({ queryKey: [selectedAppLocationId, selectedDate, ''], queryFn: fetchRidersLogs, refetchInterval: 15_000, refetchIntervalInBackground: false, staleTime: 5 * 1000, refetchOnWindowFocus: false }); // Normalize the feed into map-ready rider points. Drop entries without a // usable lat/lon — those would crash the Leaflet Marker. const liveRiderLocations = useMemo(() => { return (ridersLocationLogs || []) .map((r) => { const lat = parseFloat(r?.latitude); const lon = parseFloat(r?.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; return { id: String(r.userid ?? ''), userid: r.userid, username: r.username || `Rider #${r.userid}`, status: String(r.status || '').toLowerCase(), contactno: r.contactno, orderid: r.orderid, logdate: r.logdate, lat, lon }; }) .filter(Boolean); }, [ridersLocationLogs]); // Default to the slot containing the current wall-clock time. Use a // fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If // the current time falls outside every slot window (e.g. before 8 AM) // fall back to the first slot. const [selectedBatch, setSelectedBatch] = useState(() => { const now = dayjs(); return getBatchForHour(now.hour() + now.minute() / 60, BATCHES_DEFAULT) || BATCHES_DEFAULT[0].id; }); // If the operator deletes the slot currently selected, fall back to the // first remaining slot so the page doesn't show an empty bucket. useEffect(() => { if (selectedBatch === 'all') return; if (!BATCHES.some((b) => b.id === selectedBatch)) { setSelectedBatch(BATCHES[0]?.id || 'all'); } }, [BATCHES, selectedBatch]); const activeBatchRef = useRef(null); // Live deliveries query — runs only when no `data` prop is passed (i.e., standalone page). const shouldFetchLive = !data; const liveUserid = typeof window !== 'undefined' ? localStorage.getItem('userid') || 0 : 0; const { data: livePagesData, isFetching: liveIsFetching, isError: liveIsError, fetchNextPage: liveFetchNextPage, hasNextPage: liveHasNextPage, isFetchingNextPage: liveIsFetchingNextPage } = useInfiniteQuery({ queryKey: ['dispatchDeliveries', selectedAppLocationId, liveUserid, 'all', selectedDate, selectedDate, 50, '', 0, 0, 0], queryFn: fetchDeliveries, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, enabled: shouldFetchLive }); // Auto-page through all results for the selected date. useEffect(() => { if (!shouldFetchLive) return; if (liveHasNextPage && !liveIsFetchingNextPage) liveFetchNextPage(); }, [shouldFetchLive, liveHasNextPage, liveIsFetchingNextPage, liveFetchNextPage]); const liveRows = useMemo(() => { // Flatten infinite-query pages, then dedupe by orderid. The deliveries API // can return the same orderid more than once (e.g. when page bookkeeping // overlaps), which would otherwise inflate counts like the header's // "N orders today" pill. First occurrence wins. const all = (livePagesData?.pages || []).flatMap((p) => p.rows || []); const seen = new Set(); const out = []; for (const r of all) { const key = r.orderid != null ? String(r.orderid) : null; if (key && seen.has(key)) continue; if (key) seen.add(key); out.push(r); } return out; }, [livePagesData]); // Distinct riders across the WHOLE day's rows — NOT slot-filtered. Used by // the Rider Info view so the operator can pick any rider regardless of // whether they had orders in the currently selected slot. const ridersAllDay = useMemo(() => { const map = new Map(); liveRows.forEach((r) => { const id = String(r.userid || r.rider_id || ''); if (!id || id === 'unassigned' || id === '0') return; if (!map.has(id)) { map.set(id, { id, riderName: r.ridername || r.rider_name || r.username || `Rider ${id}` }); } }); return Array.from(map.values()).sort((a, b) => String(a.riderName).localeCompare(String(b.riderName)) ); }, [liveRows]); // Per-batch counts shown on the batch selector pills (uses unfiltered rows so counts stay // visible even when a single batch is active). Recomputes whenever the operator // picks a different timestamp column to bucket on, or edits the slot ranges. const batchCounts = useMemo(() => { const counts = { all: liveRows.length }; BATCHES.forEach((b) => { counts[b.id] = 0; }); liveRows.forEach((r) => { const b = getRowBatch(r, selectedTimeField, BATCHES); if (b) counts[b] = (counts[b] || 0) + 1; }); return counts; }, [liveRows, selectedTimeField, BATCHES]); // Apply the batch filter before grouping so zones/riders/bikes all reflect the chosen wave. const filteredLiveRows = useMemo(() => { if (selectedBatch === 'all') return liveRows; return liveRows.filter((r) => getRowBatch(r, selectedTimeField, BATCHES) === selectedBatch); }, [liveRows, selectedBatch, selectedTimeField, BATCHES]); // Reshape flat delivery rows into the zones/riders/orders structure Dispatch consumes. const liveData = useMemo(() => { if (!shouldFetchLive) return null; if (!filteredLiveRows.length) return { code: 200, zone_summary: [], zones: [] }; // Bucket by delivery area (suburb) so each zone card represents a real place like // "Uppilipalayam" — the operator-facing concept of a zone. Suburb strings from the // API have inconsistent whitespace and casing, so we key on the normalized form // (trimmed + lowercased) and keep the first-seen original casing as the display label. const normSuburb = (v) => String(v || '').trim(); const zoneBuckets = {}; filteredLiveRows.forEach((r) => { const rawZone = normSuburb(r.deliverysuburb) || normSuburb(r.locationsuburb) || normSuburb(r.zone_name); const displayName = rawZone || 'Unzoned'; const zoneKey = displayName.toLowerCase(); const riderKey = String(r.userid || r.rider_id || 'unassigned'); const riderName = r.ridername || r.rider_name || r.username || (riderKey === 'unassigned' ? 'Unassigned' : `Rider ${riderKey}`); if (!zoneBuckets[zoneKey]) zoneBuckets[zoneKey] = { zone_name: displayName, riders: {} }; if (!zoneBuckets[zoneKey].riders[riderKey]) { zoneBuckets[zoneKey].riders[riderKey] = { rider_id: riderKey, rider_name: riderName, orders: [] }; } zoneBuckets[zoneKey].riders[riderKey].orders.push(r); }); const zones = Object.values(zoneBuckets).map((z) => { const riders = Object.values(z.riders).map((rd) => { const sorted = [...rd.orders].sort((a, b) => dayjs(a.deliverydate || a.assigntime || 0).valueOf() - dayjs(b.deliverydate || b.assigntime || 0).valueOf() ); return { ...rd, orders: sorted.map((o, idx) => ({ ...o, trip_number: o.trip_number || 1, step: o.step || idx + 1 })) }; }); return { zone_name: z.zone_name, riders }; }); const zone_summary = zones.map((z) => { const allOrds = z.riders.flatMap((r) => r.orders); const assigned = allOrds.filter((o) => o.userid || o.rider_id).length; return { zone_name: z.zone_name, total_orders: allOrds.length, assigned_orders: assigned, unassigned_orders_count: allOrds.length - assigned, active_riders_count: z.riders.filter((r) => r.rider_id !== 'unassigned').length, total_delivery_kms: allOrds.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0), total_profit: allOrds.reduce((s, o) => s + parseFloat(o.profit || 0), 0) }; }); return { code: 200, zone_summary, zones }; }, [shouldFetchLive, filteredLiveRows]); // Merge each zone's per-rider data with its summary metrics for sidebar rendering. // Also derive aggregates the AI response doesn't pre-compute: which suburbs the zone // delivers to, which kitchens it picks up from, and the order-status breakdown. const zoneCards = useMemo(() => { const source = data || liveData || { zones: [], zone_summary: [] }; const zonesArr = source.zones || []; const summaryByName = {}; (source.zone_summary || []).forEach((s) => { summaryByName[s.zone_name] = s; }); const tally = (arr, keyFn) => { const m = {}; arr.forEach((o) => { const k = keyFn(o); if (!k) return; m[k] = (m[k] || 0) + 1; }); return Object.entries(m) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count); }; return zonesArr.map((z) => { const summary = summaryByName[z.zone_name] || {}; const allOrders = (z.riders || []).flatMap((r) => r.orders || []); const activeRiderCount = (z.riders || []).filter((r) => r.rider_id && r.rider_id !== 'unassigned').length; const suburbs = tally(allOrders, (o) => o.deliverysuburb || o.locationsuburb); const kitchens = tally(allOrders, (o) => o.pickupcustomer || o.kitchen_key); const statusCounts = {}; allOrders.forEach((o) => { const s = String(o.orderstatus || 'unknown').toLowerCase(); statusCounts[s] = (statusCounts[s] || 0) + 1; }); return { id: z.zone_name, name: z.zone_name, riders: z.riders || [], orders: allOrders, totalOrders: summary.total_orders ?? allOrders.length, activeRidersCount: summary.active_riders_count ?? activeRiderCount, totalKms: summary.total_delivery_kms ?? allOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0), totalProfit: summary.total_profit ?? allOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0), suburbs, kitchens, statusCounts }; }); }, [data, liveData]); // Data processing logic const { riders, kitchens, allOrders, stats } = useMemo(() => { const source = data || liveData || { zones: [], zone_summary: [] }; const orders = []; (source.zones || []).forEach(z => { (z.riders || []).forEach(r => { r.orders.forEach(o => { orders.push({ ...o, zone_name: z.zone_name, rider_name: r.rider_name, rider_id: r.rider_id }); }); }); }); const riderMap = {}; orders.forEach(o => { const key = o.rider_id || o.userid || 'unknown'; if (!riderMap[key]) { riderMap[key] = { id: key, riderName: o.rider_name || o.username || o.rider || key, orders: [], color: RIDER_COLORS[Object.keys(riderMap).length % RIDER_COLORS.length] }; } riderMap[key].orders.push(o); }); const kitchenMap = {}; orders.forEach(o => { const name = o.pickupcustomer || o.kitchen_key || 'Unknown'; const key = name.toLowerCase().trim(); if (!kitchenMap[key]) { kitchenMap[key] = { id: key, kitchenName: name, lat: toNum(pickupLat(o)), lon: toNum(pickupLon(o)), orders: [], riders: new Set() }; } else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) { // Upgrade to first valid pickup coords we see for this kitchen kitchenMap[key].lat = toNum(pickupLat(o)); kitchenMap[key].lon = toNum(pickupLon(o)); } kitchenMap[key].orders.push(o); if (o.rider_id) kitchenMap[key].riders.add(o.rider_id); }); const totalKms = orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0); const totalProfit = orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0); // Sort each rider's orders by (trip_number, step) so every downstream view — // sidebar step list, step-id badges on the card, OSRM trip points, animation — // sees them in delivery order 1→N. const sortedRiders = Object.values(riderMap).map((r) => ({ ...r, orders: [...r.orders].sort((a, b) => { const tA = a.trip_number || 1; const tB = b.trip_number || 1; if (tA !== tB) return tA - tB; return (a.step || 0) - (b.step || 0); }) })); return { riders: sortedRiders.sort((a, b) => b.orders.length - a.orders.length), kitchens: Object.values(kitchenMap).sort((a, b) => b.orders.length - a.orders.length), allOrders: orders, stats: { totalOrders: orders.length, totalKms, totalProfit, totalRiders: Object.keys(riderMap).length } }; }, [data, liveData]); // Resolve focusedRider: prop-derived when controlled, local state otherwise. const focusedRider = isControlled ? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null) : internalFocusedRider; // Per-rider canvas renderer for the actual (right) map in Compare mode. // Single setter used by every interactive site in the UI. In uncontrolled mode it // updates local state; in controlled mode it only notifies the parent. const handleRiderFocus = useCallback( (r) => { if (onRiderSelect) onRiderSelect(r ? r.id : null); if (!isControlled) setInternalFocusedRider(r); setFocusedStop(null); }, [isControlled, onRiderSelect] ); // ─── Logger Effects for Rider & Order Updates ─────────────────────── // Log when the active focused rider changes (uncontrolled click or controlled prop change) const prevFocusedRiderIdRef = useRef(null); useEffect(() => { const activeId = focusedRider ? focusedRider.id : null; if (activeId !== prevFocusedRiderIdRef.current) { if (focusedRider) { logger.info(`Focused rider changed to: ${focusedRider.riderName} (${focusedRider.orders.length} orders)`); } else { logger.info('Focused rider reset to: None'); } prevFocusedRiderIdRef.current = activeId; } }, [focusedRider]); // Log when selected focused stop (individual order) changes useEffect(() => { if (focusedStop) { logger.info(`Focused order updated: ID ${focusedStop.orderid}`); } else { logger.debug('Focused order selection cleared'); } }, [focusedStop]); // Log when periodic query refetch completes and updates the overall orders list const prevOrdersCountRef = useRef(0); useEffect(() => { if (allOrders) { if (allOrders.length !== prevOrdersCountRef.current) { logger.info(`Orders database updated: ${allOrders.length} orders actively tracked`); prevOrdersCountRef.current = allOrders.length; } } }, [allOrders]); // Log when periodic query refetch completes and updates the live rider locations const prevRidersCountRef = useRef(0); useEffect(() => { if (liveRiderLocations) { if (liveRiderLocations.length !== prevRidersCountRef.current) { logger.info(`Live riders list updated: ${liveRiderLocations.length} active riders mapped`); prevRidersCountRef.current = liveRiderLocations.length; } } }, [liveRiderLocations]); const activeStats = useMemo(() => { if (focusedRider) { return { orders: focusedRider.orders.length, riders: 1, km: focusedRider.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0), profit: focusedRider.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0), label: 'Focused Rider' }; } if (focusedKitchen) { return { orders: focusedKitchen.orders.length, riders: focusedKitchen.riders.size, km: focusedKitchen.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0), profit: focusedKitchen.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0), label: 'Focused Kitchen' }; } return { orders: stats.totalOrders, riders: stats.totalRiders, km: stats.totalKms, profit: stats.totalProfit, label: 'Total Fleet' }; }, [focusedRider, focusedKitchen, stats]); // List of deliveryids we want GPS logs for. Drives two pipelines: // • renderRoutes() — actual-route polylines on the main map for // every visible rider/trip // • riderActualTracks (Compare) — per-step deltas + the right-map drop pins // Scoped to currently-visible orders (zone/kitchen/rider focus) so the // default "see all orders" view doesn't fan-out N requests for orders // that aren't on screen. Deduped; ignores rows without a deliveryid // (e.g. unaccepted orders) since /getdeliverylogs needs a real id. const visibleDeliveryIds = useMemo(() => { let scope; if (focusedRider) scope = focusedRider.orders; else if (focusedKitchen) scope = focusedKitchen.orders; else if (focusedZone) scope = focusedZone.orders; else scope = allOrders; const set = new Set(); (scope || []).forEach((o) => { if (o.deliveryid != null && o.deliveryid !== '' && o.deliveryid !== 0) { set.add(String(o.deliveryid)); } }); return Array.from(set); }, [allOrders, focusedRider, focusedKitchen, focusedZone]); // Fan-out per-delivery GPS log queries in parallel. Each result is cached by // deliveryid in React Query, so navigating between focus modes / opening // Compare for a previously-loaded rider is instant. Queries are driven by // `visibleDeliveryIds` (currently rendered orders) so we only fetch what // the map will actually draw. const deliveryLogQueries = useQueries({ queries: visibleDeliveryIds.map((deliveryid) => ({ queryKey: ['deliveryLogs', deliveryid], queryFn: async () => { const res = await axios.get( `${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${deliveryid}` ); // Accept several possible response shapes — the live API has shipped // {details:[…]}, plain arrays, and {data:[…]} variants over time, and // any of those should produce a polyline. Pick the first non-empty // array-like found. const candidates = [res?.data?.details, res?.data?.data, res?.data, res]; const rows = candidates.find((c) => Array.isArray(c)) || []; // Also accept lat/lng (alternate naming) in case the endpoint ever // returns the same shape the front-end uses internally. // Sort by logdate ascending so the polyline follows the rider's // chronological path. The endpoint isn't guaranteed to return rows // in order — without this, consecutive points can be out of sequence // and the rendered track zig-zags between them. const sorted = rows .map((r) => { const ts = r?.logdate ? dayjs(r.logdate) : null; return { lat: parseFloat(r?.latitude ?? r?.lat), lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon), logdate: r?.logdate, _ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER }; }) .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng)) .sort((a, b) => a._ts - b._ts); // Kalman pass: smooths raw GPS jitter (multi-path noise, stationary // wobble at the drop, occasional cold-start outliers) before the // pings ever reach the renderer or OSRM. The filter needs the // _ts on each ping to compute per-step dt, so we run it BEFORE // stripping _ts on the way out. const smoothed = kalmanSmoothGps(sorted); return smoothed.map(({ _ts, ...p }) => p); }, // Always-on so the main map can render actual-route polylines (not just // Compare). Cached results survive view changes; nothing to gate. enabled: true, staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, retry: 1 })) }); // Index of `visibleDeliveryIds` → query state, keyed by deliveryid. Used by // renderRoutes() to draw actual-route polylines on the main map, and by // `riderActualTracks` for the Compare right map / delta panel. Keeping the // index here (instead of recomputing inside each consumer) means downstream // useMemos can depend on just this map rather than the whole query array. const actualTracksByDeliveryId = useMemo(() => { const map = new Map(); visibleDeliveryIds.forEach((did, i) => { const q = deliveryLogQueries[i]; map.set(did, { coords: q?.data || [], isLoading: !!(q?.isLoading || q?.isFetching), isError: !!q?.isError }); }); return map; }, [visibleDeliveryIds, deliveryLogQueries]); // Normalize the parallel query results into a flat array of per-delivery // tracks for the Compare map. Tracks are sorted by `deliverytime` (with // `expecteddeliverytime` and original step number as fallbacks) so the // sequence on the map mirrors the order they were actually completed. // A 1-indexed `sequenceStep` is attached to drive the numbered markers. const riderActualTracks = useMemo(() => { if (!focusedRider) return []; const tsOf = (o) => { const t = o.deliverytime || o.expecteddeliverytime; if (!t) return Number.MAX_SAFE_INTEGER; const d = dayjs(t); return d.isValid() ? d.valueOf() : Number.MAX_SAFE_INTEGER; }; const sorted = focusedRider.orders .filter((o) => o.deliveryid != null && o.deliveryid !== '' && o.deliveryid !== 0) .sort((a, b) => { const diff = tsOf(a) - tsOf(b); if (diff !== 0) return diff; return (a.step || 0) - (b.step || 0); }); return sorted.map((o, i) => { const track = actualTracksByDeliveryId.get(String(o.deliveryid)); return { sequenceStep: i + 1, orderid: o.orderid, deliveryid: o.deliveryid, deliverycustomer: o.deliverycustomer, pickupcustomer: o.pickupcustomer, step: o.step, tripNumber: o.trip_number || 1, deliverytime: o.deliverytime || o.expecteddeliverytime, kms: parseFloat(o.actualkms || o.kms || 0) || 0, profit: parseFloat(o.profit || 0) || 0, orderstatus: o.orderstatus, isLoading: !!track?.isLoading, isError: !!track?.isError, coords: track?.coords || [] }; }); }, [focusedRider, actualTracksByDeliveryId]); // Per-step planned-vs-actual deltas for the Compare delta panel. Pure // computation off the data we already have: // plannedKm — order.kms field (set when dispatch planned this trip) // actualKm — prefer the OSRM-snapped polyline length (most accurate // once snapping completes); fall back to order.actualkms // (backend-computed) and finally to the raw GPS polyline // length so the panel never shows blank while OSRM is // still in flight. // timeDeltaMin — actual deliverytime - expecteddeliverytime, in minutes. // Negative = ahead of plan, positive = late. // anomaly — true when actualKm > 1.25 * plannedKm OR // timeDeltaMin > 15 (configurable thresholds; chosen so // small noise doesn't trip the flag but a wrong-turn does). const compareDeltas = useMemo(() => { if (!focusedRider) return []; return riderActualTracks.map((t) => { const order = focusedRider.orders.find( (o) => String(o.deliveryid) === String(t.deliveryid) ); const plannedKm = parseFloat(order?.kms || 0) || 0; const snapped = osrmTrackRoutes[t.deliveryid]; let actualKm = 0; if (Array.isArray(snapped) && snapped.length >= 2) { actualKm = polylineLengthKm(snapped); } else if (order?.actualkms != null && order.actualkms !== '') { actualKm = parseFloat(order.actualkms) || 0; } else if (t.coords.length >= 2) { actualKm = polylineLengthKm(t.coords.map((p) => [p.lat, p.lng])); } const kmDelta = actualKm - plannedKm; const kmDeltaPct = plannedKm > 0 ? (kmDelta / plannedKm) * 100 : null; const expectedTs = order?.expecteddeliverytime ? dayjs(order.expecteddeliverytime) : null; const actualTs = order?.deliverytime ? dayjs(order.deliverytime) : null; const timeDeltaMin = expectedTs?.isValid() && actualTs?.isValid() ? actualTs.diff(expectedTs, 'minute') : null; const anomaly = (plannedKm > 0 && actualKm > plannedKm * 1.25) || (timeDeltaMin != null && timeDeltaMin > 15); return { sequenceStep: t.sequenceStep, deliveryid: t.deliveryid, orderid: t.orderid, order, plannedKm, actualKm, kmDelta, kmDeltaPct, expectedTs: expectedTs?.isValid() ? expectedTs : null, actualTs: actualTs?.isValid() ? actualTs : null, timeDeltaMin, anomaly, orderstatus: t.orderstatus, deliverycustomer: t.deliverycustomer, pickupcustomer: order?.pickupcustomer, isLoading: t.isLoading, coordsCount: t.coords.length }; }); }, [riderActualTracks, focusedRider, osrmTrackRoutes]); // Roll-up of the per-step deltas — used by the overall summary card shown // when no specific step is focused. Excluding loading rows from the // anomaly count prevents the "47% deviation" headline from churning while // OSRM is still snapping. const compareSummary = useMemo(() => { if (compareDeltas.length === 0) { return { plannedKm: 0, actualKm: 0, kmDeltaPct: null, anomalies: 0, late: 0, onTime: 0 }; } const ready = compareDeltas.filter((d) => !d.isLoading && d.coordsCount > 0); const plannedKm = ready.reduce((s, d) => s + d.plannedKm, 0); const actualKm = ready.reduce((s, d) => s + d.actualKm, 0); const kmDeltaPct = plannedKm > 0 ? ((actualKm - plannedKm) / plannedKm) * 100 : null; const anomalies = ready.filter((d) => d.anomaly).length; const late = ready.filter((d) => d.timeDeltaMin != null && d.timeDeltaMin > 5).length; const onTime = ready.filter((d) => d.timeDeltaMin != null && d.timeDeltaMin <= 5).length; return { plannedKm, actualKm, kmDeltaPct, anomalies, late, onTime }; }, [compareDeltas]); const plannedOrdered = useMemo(() => { return [...compareDeltas].sort( (a, b) => (a.order?.step || a.sequenceStep) - (b.order?.step || b.sequenceStep) ); }, [compareDeltas]); const actualOrdered = useMemo(() => { return [...compareDeltas].sort( (a, b) => a.sequenceStep - b.sequenceStep ); }, [compareDeltas]); // When Compare is open and a step is focused, override MapController's // focusedItem with a synthetic single-order item so the planned (left) // map zooms onto the same delivery the operator is scrutinizing. Without // this, only the actual (right) map would zoom and the two views would // disagree. const compareFocusItem = useMemo(() => { if (!compareOpen || !focusedCompareStep || !focusedRider) return null; const track = riderActualTracks.find((t) => t.sequenceStep === focusedCompareStep); if (!track) return null; const order = focusedRider.orders.find( (o) => String(o.deliveryid) === String(track.deliveryid) ); if (!order) return null; return { orders: [order], id: `cmp-step-${focusedCompareStep}-${order.orderid}` }; }, [compareOpen, focusedCompareStep, focusedRider, riderActualTracks]); // Reset focused step when leaving Compare, switching riders, or re-entering // Compare. Otherwise "step 5" leaks across riders and the next rider opens // stuck on a step that may not exist in their day. useEffect(() => { setFocusedCompareStep(null); setExpandedSeqGroups(new Set()); setCompareViewMode('combined'); }, [compareOpen, focusedRider?.id]); // When the focused rider changes, close any open comparison so the operator // doesn't see stale tracks from a previous rider. useEffect(() => { if (!focusedRider && compareOpen) setCompareOpen(false); }, [focusedRider, compareOpen]); // Controlled-mode deferred compare open: once the parent has updated // selectedRiderId and focusedRider resolves, honour the pending open request. useEffect(() => { if (pendingCompareRef.current && focusedRider) { pendingCompareRef.current = false; setCompareOpen(true); } }, [focusedRider]); // Auto-collapse the sidebar when Compare turns on (two maps need the room), // and restore the operator's prior sidebar state when Compare turns off. // Manual toggle is still available via the peek tab while compare is open. useEffect(() => { if (compareOpen && !prevCompareOpenRef.current) { preCompareCollapsedRef.current = sidebarCollapsed; setSidebarCollapsed(true); // Fresh compare-open always reveals the data panel — last-collapsed state // shouldn't carry across compare sessions. setCompareDataCollapsed(false); } else if (!compareOpen && prevCompareOpenRef.current) { setSidebarCollapsed(preCompareCollapsedRef.current); } prevCompareOpenRef.current = compareOpen; // eslint-disable-next-line react-hooks/exhaustive-deps }, [compareOpen]); const fetchRoute = useCallback(async (riderId, tripKey, points) => { const cacheKey = `${riderId}-${tripKey}`; // Use the ref (not state) for the in-flight / already-cached check so this // callback doesn't need osrmRoutes in its deps — that old pattern caused a // render loop: each resolved route updated state → recreated fetchRoute → // re-ran all route-fetching effects for every rider. if (osrmRoutesRef.current[cacheKey] !== undefined) return; if (points.length < 2) return; // Mark in-flight in both ref (immediate) and state (triggers re-render). osrmRoutesRef.current[cacheKey] = null; setOsrmRoutes(prev => ({ ...prev, [cacheKey]: null })); const coords = points.map(p => `${p[1]},${p[0]}`).join(';'); const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`; try { const res = await fetch(url); const json = await res.json(); if (json.routes && json.routes[0]) { const poly = json.routes[0].geometry.coordinates.map(c => [c[1], c[0]]); osrmRoutesRef.current[cacheKey] = poly; setOsrmRoutes(prev => ({ ...prev, [cacheKey]: poly })); } else { // OSRM responded but couldn't route — record as failed so renderRoutes // shows the aerial fallback instead of an empty gap. osrmRoutesRef.current[cacheKey] = false; setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false })); } } catch (e) { console.error('OSRM Fetch error:', e); osrmRoutesRef.current[cacheKey] = false; setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false })); } }, []); // stable — cache reads go through osrmRoutesRef, not state // Clear the OSRM route cache whenever the date or batch changes. Without this, // routes fetched for the previous day/slot linger and are shown against the new // data — especially visible when the same rider ID appears across different batches // and the cached polyline from the earlier slot is drawn over the new orders. useEffect(() => { osrmRoutesRef.current = {}; setOsrmRoutes({}); osrmTrackRoutesRef.current = {}; setOsrmTrackRoutes({}); }, [selectedDate, selectedBatch]); // Snap a raw GPS trace (lat/lng pings from /getdeliverylogs) to the road // network so the Compare map always shows a smooth road-following polyline, // never a zig-zag of aerial segments between sparse pings. // // Strategy: try OSRM's match service first (purpose-built for noisy GPS // traces). If it returns nothing useful (sparse data, off-road pings, etc.) // fall back to OSRM's route service over a subsampled set of the same // points — that still produces a smooth road polyline, just with less // fidelity. Cached per-deliveryid. const fetchTrackRoute = useCallback(async (deliveryid, points) => { if (osrmTrackRoutesRef.current[deliveryid] !== undefined) return; if (!Array.isArray(points) || points.length < 2) return; osrmTrackRoutesRef.current[deliveryid] = null; setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: null })); const storeSuccess = (poly) => { osrmTrackRoutesRef.current[deliveryid] = poly; setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: poly })); }; const storeFailure = () => { osrmTrackRoutesRef.current[deliveryid] = false; setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: false })); }; // Even subsample to <=N coordinates while keeping first + last. Public // OSRM caps match at 100 and route is slower with many waypoints, so // we pick a different N for each service. const subsample = (arr, max) => { if (arr.length <= max) return arr; const step = Math.ceil(arr.length / max); const out = arr.filter((_, i) => i % step === 0); const last = arr[arr.length - 1]; if (out[out.length - 1] !== last) out.push(last); return out; }; // Attempt 1 — map-matching (best fidelity for dense traces). try { const ptsM = subsample(points, 90); const coordsM = ptsM.map((p) => `${p[1]},${p[0]}`).join(';'); const matchUrl = `https://router.project-osrm.org/match/v1/driving/${coordsM}?overview=full&geometries=geojson&gaps=ignore&tidy=true`; const resM = await fetch(matchUrl); const jsonM = await resM.json(); if (jsonM.matchings && jsonM.matchings.length > 0) { const poly = jsonM.matchings.flatMap((m) => (m.geometry?.coordinates || []).map((c) => [c[1], c[0]]) ); if (poly.length >= 2) { storeSuccess(poly); return; } } } catch (e) { console.warn('OSRM Match error, trying route fallback:', e); } // Attempt 2 — waypoint routing through a coarser subsample. Always // returns a road polyline as long as the points are routable. try { const ptsR = subsample(points, 25); const coordsR = ptsR.map((p) => `${p[1]},${p[0]}`).join(';'); const routeUrl = `https://router.project-osrm.org/route/v1/driving/${coordsR}?overview=full&geometries=geojson`; const resR = await fetch(routeUrl); const jsonR = await resR.json(); if (jsonR.routes && jsonR.routes[0]) { const poly = jsonR.routes[0].geometry.coordinates.map((c) => [c[1], c[0]]); if (poly.length >= 2) { storeSuccess(poly); return; } } storeFailure(); } catch (e) { console.error('OSRM Route fallback error:', e); storeFailure(); } }, []); // Kick a snap-to-road request for every delivery whose GPS log just landed. // Runs for every visible delivery (not just Compare) so the main map's // actual-route polylines follow real roads. fetchTrackRoute's per-id ref // dedupes — repeated effect fires after a query resolves are no-ops. useEffect(() => { actualTracksByDeliveryId.forEach((track, did) => { if (!did || !track || track.coords.length < 2) return; const pts = track.coords.map((c) => [c.lat, c.lng]); fetchTrackRoute(did, pts); }); }, [actualTracksByDeliveryId, fetchTrackRoute]); // Mirror isAnimating into a ref so the rAF tick loop in startAnimation can // bail mid-animation when the user clicks Stop (the rAF closure captures // state at scheduling time and would otherwise keep ticking). useEffect(() => { isAnimatingRef.current = isAnimating; }, [isAnimating]); useEffect(() => { if (embedded) return undefined; const tick = () => { const n = new Date(); setClock([n.getHours(), n.getMinutes(), n.getSeconds()].map(v => String(v).padStart(2, '0')).join(':')); }; const timer = setInterval(tick, 1000); tick(); return () => clearInterval(timer); }, [embedded]); useEffect(() => { setActiveRiders(new Set(riders.map(r => r.id))); }, [riders]); useEffect(() => { riders.forEach(r => { const isActive = activeRiders.has(r.id); if (!isActive) return; if (focusedRider && focusedRider.id !== r.id) return; const trips = {}; r.orders.forEach(o => { const t = o.trip_number || 1; if (!trips[t]) trips[t] = []; trips[t].push(o); }); Object.entries(trips).forEach(([tNum, tOrders]) => { const sorted = [...tOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); const pts = buildTripPoints(sorted); if (pts.length >= 2) fetchRoute(r.id, tNum, pts); }); }); }, [riders, activeRiders, focusedRider, fetchRoute]); // Auto-advance the selected slot when the wall-clock moves into a new slot's // window — BUT only if the user is still sitting on the slot that's just been // left, so a manual pick (e.g. "let me inspect Slot 1") is never overridden. // Polls every 30s; only fires when the current hour actually changes. // prevHourRef tracks the *fractional* hour (hour + minute/60) so the // ticker notices slot boundaries that fall mid-hour — slot 1 ending at // 12:30 means the auto-advance must fire on the 11:30→12:30 crossing, // not just on the wall-clock hour change. const prevHourRef = useRef(null); useEffect(() => { if (!shouldFetchLive) return; const nowFracHour = () => { const d = dayjs(); return d.hour() + d.minute() / 60; }; if (prevHourRef.current === null) prevHourRef.current = nowFracHour(); const tick = () => { const h = nowFracHour(); const fromSlot = getBatchForHour(prevHourRef.current, BATCHES); const toSlot = getBatchForHour(h, BATCHES); prevHourRef.current = h; if (!toSlot || toSlot === fromSlot) return; setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur)); }; const id = setInterval(tick, 30 * 1000); return () => clearInterval(id); }, [shouldFetchLive, BATCHES]); // Reset focusedStop when the focused kitchen changes so a stale stop from a // previously focused kitchen doesn't linger after switching kitchens. // (For riders, handleRiderFocus already clears focusedStop.) useEffect(() => { setFocusedStop(null); }, [focusedKitchen?.id]); // Scroll the active slot chip into the visible part of the horizontal scroller // — used when the default slot is set late in the day and overflows off-screen, // or when the user clicks a chip that's only partially visible. useEffect(() => { const btn = activeBatchRef.current; if (!btn || typeof btn.scrollIntoView !== 'function') return; btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); }, [selectedBatch]); // When the user clicks a step in the focused-rider sidebar (sets focusedStop), // surface that order in the centered popup overlay so the details show up // without a second click. focusedStop is a slim { orderid, lat, lon }, so // hydrate the full order out of allOrders before passing it to the popup // (renderOrderPopupContent needs the rich timeline + status fields). // Wait ~350ms so MapController has a chance to recenter first (matches // the prior delay before openPopup was called). useEffect(() => { if (!focusedStop) return; const t = setTimeout(() => { const fullOrder = allOrders?.find?.((o) => String(o.orderid) === String(focusedStop.orderid)); if (fullOrder) setCenterPopupOrder(fullOrder); }, 350); return () => clearTimeout(t); }, [focusedStop, allOrders]); const startAnimation = () => { if (isAnimating) { setIsAnimating(false); setAnimatedSegments([]); setAnimatedActualProgress({}); return; } setIsAnimating(true); setAnimatedSegments([]); setAnimatedActualProgress({}); // Capture into locals so the setTimeout callbacks below don't read stale // state if the user toggles focus mid-animation. const isCompareFocused = compareOpen && focusedRider; const compareDeliveryToStep = isCompareFocused ? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep])) : null; const allSegs = []; riders.forEach(r => { if (!activeRiders.has(r.id)) return; if (focusedRider && focusedRider.id !== r.id) return; if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return; const trips = {}; r.orders.forEach(o => { const t = o.trip_number || 1; if (!trips[t]) trips[t] = []; trips[t].push(o); }); Object.entries(trips).forEach(([tNum, tOrders]) => { // Filter orders by focused kitchen if active const filteredTOrders = focusedKitchen ? tOrders.filter(o => (o.pickupcustomer || o.kitchen_key || 'Unknown').toLowerCase().trim() === focusedKitchen.id) : tOrders; if (filteredTOrders.length === 0) return; const cacheKey = `${r.id}-${tNum}`; const roadPath = osrmRoutes[cacheKey]; const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); // Aerial fallback — NaN-safe build const aerialPath = buildTripPoints(sorted); const isKitchenAerial = (viewMode === 'kitchens' || focusedKitchen); const path = roadPath || aerialPath; if (path.length < 2) return; // Compare mode for the focused rider: color each animation pair by // its step using STEP_PALETTE so the planned animation visually // matches the static per-step polylines + the right map's actual // GPS animation. We reuse splitPolylineByDrops to assign each // polyline-pair (path[i] → path[i+1]) to the step containing it. const isCompareRider = isCompareFocused && r.id === focusedRider.id; let pairColorAt = () => r.color; // (idx) → color if (isCompareRider) { const validDrops = sorted.filter(hasValidDrop); const dropCoords = validDrops.map((o) => [ parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong) ]); const stepSegs = roadPath ? splitPolylineByDrops(roadPath, dropCoords) : (() => { const hasPickup = aerialPath.length > dropCoords.length; const out = []; for (let i = 0; i < dropCoords.length; i++) { const a = hasPickup ? i : i - 1; const b = hasPickup ? i + 1 : i; if (a < 0 || a >= aerialPath.length || b >= aerialPath.length) { out.push([]); } else { out.push([aerialPath[a], aerialPath[b]]); } } return out; })(); // Map each path index → step index by accumulating segment lengths // (segments share endpoints so we use length-1 per segment). const pathIdxToStepIdx = []; let acc = 0; stepSegs.forEach((seg, sIdx) => { const segLen = Math.max(0, (seg?.length || 0) - 1); for (let k = 0; k < segLen; k++) pathIdxToStepIdx[acc + k] = sIdx; acc += segLen; }); pairColorAt = (idx) => { const sIdx = pathIdxToStepIdx[idx]; if (sIdx == null) return r.color; const order = sorted.filter(hasValidDrop)[sIdx]; const ss = order ? compareDeliveryToStep.get(String(order.deliveryid)) : null; return ss ? stepColor(ss - 1) : r.color; }; } for (let i = 0; i < path.length - 1; i++) { allSegs.push({ from: path[i], to: path[i + 1], color: pairColorAt(i), delay: (parseInt(r.id.slice(-3)) || 0) * 0.05 + (parseInt(tNum) * 40) + i * (isKitchenAerial ? 40 : 8) }); } }); }); allSegs.sort((a, b) => a.delay - b.delay); allSegs.forEach((s, idx) => { setTimeout(() => { setAnimatedSegments(prev => [...prev, s]); if (idx === allSegs.length - 1) { setTimeout(() => { setIsAnimating(false); setAnimatedActualProgress({}); }, 1000); } }, s.delay); }); // Compare-mode: progressively DRAW the actual-GPS polylines on the right // map — same visual style as the planned animation on the left (line // grows from start to end), but driven by a single rAF loop instead of // hundreds of per-pair setTimeouts. For each step we know: // stepStart = idx * perStep — when this step begins drawing // stepEnd = stepStart + perStep — when its polyline is fully drawn // progress = ceil(t * positions.length) where t = (now - stepStart) / perStep // The tick updates one state object containing all steps' progress so // React batches the re-render across the whole right map. if (isCompareFocused && riderActualTracks.length > 0) { const tracksSnapshot = [...riderActualTracks]; // Snapshot the resolved positions per step at animation start. Prefer // OSRM-snapped path when available so the drawing follows real roads; // fall back to Kalman-smoothed coords otherwise. const stepPositions = tracksSnapshot.map((t) => { const snap = osrmTrackRoutes[t.deliveryid]; if (Array.isArray(snap) && snap.length >= 2) return snap; return t.coords.map((p) => [p.lat, p.lng]); }); const lastDelay = allSegs.length > 0 ? allSegs[allSegs.length - 1].delay : tracksSnapshot.length * 800; const totalDuration = Math.max(lastDelay, tracksSnapshot.length * 600); const perStep = totalDuration / Math.max(1, tracksSnapshot.length); const animStartTs = Date.now(); const tick = () => { if (!isAnimatingRef.current) return; // user clicked Stop const elapsed = Date.now() - animStartTs; const next = {}; tracksSnapshot.forEach((t, idx) => { const positions = stepPositions[idx]; if (!positions || positions.length < 2) return; const stepStart = idx * perStep; const stepEnd = stepStart + perStep; if (elapsed >= stepEnd) { next[t.sequenceStep] = positions.length; } else if (elapsed >= stepStart) { const ratio = (elapsed - stepStart) / perStep; next[t.sequenceStep] = Math.max(2, Math.ceil(ratio * positions.length)); } // else: step hasn't started — leave undefined (filter will hide) }); setAnimatedActualProgress(next); if (elapsed < totalDuration + 200) { requestAnimationFrame(tick); } }; requestAnimationFrame(tick); } }; const createKitchenIcon = (name, focused = false) => L.divIcon({ className: '', iconSize: focused ? [56, 56] : [46, 46], iconAnchor: focused ? [28, 28] : [23, 23], popupAnchor: [0, focused ? -30 : -24], html: `
${(name || 'K').charAt(0).toUpperCase()}
` }); const getRiderColor = (rid) => riders.find(r => r.id === rid)?.color || '#475569'; const calculateEstMeters = (riderId, order) => { if (!riderId || !order || !hasValidDrop(order)) return null; const liveLoc = liveRiderLocations.find((l) => String(l.id) === String(riderId)); if (!liveLoc) return null; const dropLatVal = toNum(order.droplat || order.deliverylat); const dropLonVal = toNum(order.droplon || order.deliverylong); if (!Number.isFinite(dropLatVal) || !Number.isFinite(dropLonVal)) return null; const distKm = haversineKm([liveLoc.lat, liveLoc.lon], [dropLatVal, dropLonVal]); return Math.round(distKm * 1000); }; const formatMeters = (meters) => { if (meters === null || meters === undefined) return ''; return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${meters} m`; }; // Shared rider-card markup, used in the "By Rider" panel and inside the focused-zone detail. const renderRiderCard = (r, i) => { const total = r.orders.length; const delivered = r.orders.filter((o) => FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase()) ).length; const isDone = total > 0 && delivered >= total; const activeOrder = r.orders.find((o) => { const s = String(o.orderstatus || '').toLowerCase(); return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); }); const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null; return (
handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
{r.riderName}
{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips
{delivered}/{total}
{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km {estMeters !== null && ( {formatMeters(estMeters)} to drop )}
{r.orders.slice(0, 15).map(o => S{o.step})}
); }; // Returns true when the order's centered popup should stay open even after // the cursor leaves the marker: either explicitly pinned via click, or the // matching compare-step is focused (so clicking a step in the right panel // keeps the card visible while the maps recenter). const isOrderPopupPinned = (o) => { if (!o) return false; if (pinnedPopupsRef.current.has(String(o.orderid))) return true; if (compareOpen && focusedRider && o.deliveryid != null) { const track = riderActualTracks.find((t) => String(t.deliveryid) === String(o.deliveryid)); if (track && focusedCompareStep === track.sequenceStep) return true; } return false; }; // Shared order-popup body used by both the planned-route number markers // and the Compare mode actual-track drop pins. Extracted so clicking a // pin on either layer surfaces the exact same Timeline + Details + KM // chips — operators learn the layout once and trust it everywhere. // Hover behavior (keep-open while cursor is over the card) is owned by // the centered overlay wrapper, not this function. const renderOrderPopupContent = (o) => { const statusStyle = getStatusStyle(o.orderstatus); const orderRiderId = o.rider_id || o.userid; const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase()); const estMeters = isDelivered ? null : calculateEstMeters(orderRiderId, o); return (
ORDER #{o.orderid}
{o.orderstatus && ( {statusStyle.label} )}
{o.rider_name || o.ridername || 'Unassigned'}
{o.deliveryid != null && (
Delivery #{o.deliveryid}
)}
{POPUP_TIMELINE.some((t) => o[t.key]) && (
Timeline
{POPUP_TIMELINE.map((t) => { const time = formatTimeOnly(o[t.key]); if (!time) return null; return (
{t.label} {time}
); })}
)}
Details
{(o.pickupcustomer || o.locationname || o.pickuplocation) && (
Pickup
{o.pickupcustomer || o.locationname || o.pickuplocation}
)} {(o.deliverysuburb || o.deliveryaddress) && (
Drop
{o.deliverysuburb || extractArea(o.deliveryaddress)}
)} {o.zone_name && (
Zone
{o.zone_name}
)} {(o.rider_id || o.userid) && (
Rider ID
#{o.rider_id || o.userid}
)}
{(o.actualkms != null || (!isDelivered && o.riderkms != null) || estMeters !== null) && (
{o.actualkms != null && o.actualkms !== '' && (
Actual {o.actualkms} km
)} {!isDelivered && o.riderkms != null && o.riderkms !== '' && (
Rider {parseFloat(o.riderkms).toFixed(2)} km
)} {estMeters !== null && (
Est. to Drop {formatMeters(estMeters)}
)}
)}
); }; const renderMarkers = () => { // Compare "Actual GPS only" view: drop the planned order markers entirely. // The actual-track drop pins rendered later on the same map carry the // same step numbers and step-palette colors, so showing both creates // duplicate, slightly-offset pins that clutter the view. if (compareOpen && focusedRider && compareViewMode === 'actual') return null; let ordersToRender = allOrders; if (focusedZone) ordersToRender = focusedZone.orders; if (focusedKitchen) ordersToRender = focusedKitchen.orders; if (focusedRider) ordersToRender = focusedRider.orders; ordersToRender = ordersToRender.filter(hasValidDrop); // Pre-build the deliveryid → sequenceStep lookup once per render so each // marker can resolve its step palette color without an O(N) scan. const compareDeliveryToStep = compareOpen && focusedRider ? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep])) : null; return ordersToRender.map((o, idx) => { const rid = o.rider_id; const active = rid ? activeRiders.has(rid) : true; let color = getRiderColor(rid); // Compare mode: recolor and renumber the focused rider's markers by // sequenceStep (delivery-time order, same as the right map + timeline) // instead of rider color + planned step. Both maps now show the same // color + number for the same delivery — a step-3 pin on the left // planned map matches the step-3 polyline + drop pin on the right. let compareSeq; if ( compareDeliveryToStep && rid === focusedRider.id && o.deliveryid != null ) { const ss = compareDeliveryToStep.get(String(o.deliveryid)); if (ss) { color = stepColor(ss - 1); compareSeq = ss; } } const isRiderFocused = !!focusedRider; const showNumbers = isRiderFocused || !!focusedKitchen; const statusStyle = getStatusStyle(o.orderstatus); const statusLow = String(o.orderstatus || '').toLowerCase(); const isDelivered = statusLow === 'delivered'; const isPulsing = pulseOrderId && String(pulseOrderId) === String(o.orderid); // Flag SVG: pole + swallow-tail banner. A check glyph appears on the banner when delivered. const flagSvg = o.orderstatus ? ` ${isDelivered ? '' : ''} ` : ''; const icon = showNumbers ? (() => { const seq = compareSeq || o.step || (ordersToRender.indexOf(o) + 1); const sz = 32; return L.divIcon({ className: '', iconSize: [sz, sz], iconAnchor: [sz / 2, sz / 2], popupAnchor: [0, -28], html: `
${seq > 0 ? seq : ''}${flagSvg}
` }); })() : L.divIcon({ className: '', iconSize: [24, 30], iconAnchor: [2, 30], popupAnchor: [10, -25], // Lift popup above the flag, not just the larger 32px marker html: `
${flagSvg}
` }); return ( { if (inst) orderMarkerRefs.current[String(o.orderid)] = inst; else delete orderMarkerRefs.current[String(o.orderid)]; }} eventHandlers={{ click: () => { const id = String(o.orderid); if (pinnedPopupsRef.current.has(id)) { pinnedPopupsRef.current.delete(id); setCenterPopupOrder(null); } else { pinnedPopupsRef.current.add(id); setCenterPopupOrder(o); } } }} /> ); }); }; const renderRoutes = () => { // Compare "Actual GPS only" view hides EVERY planned polyline on the // unified compare map — both the static rendering AND the Animate // Routes per-pair segments. Without this gate the animation pours the // planned route on top of the actual GPS in Actual-only mode, which // is the opposite of what that view exists to show. The actual-track // polylines render their own progressive draw via animatedActualProgress // (see riderActualTracks.map inside the MapContainer), so the actual // animation still plays correctly when planned is hidden. const hidePlanned = compareOpen && focusedRider && compareViewMode === 'actual'; if (isAnimating) { if (hidePlanned) return []; return animatedSegments.map((s, i) => ( )); } const routes = []; const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null; if (hidePlanned) return routes; riders.forEach(r => { const isActive = activeRiders.has(r.id); if (focusedRider && focusedRider.id !== r.id) return; if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return; if (zoneRiderIds && !zoneRiderIds.has(String(r.id))) return; const rOrders = r.orders; const trips = {}; rOrders.forEach(o => { const t = o.trip_number || 1; if (!trips[t]) trips[t] = []; trips[t].push(o); }); Object.entries(trips).forEach(([tNum, tOrders]) => { // Filter orders by focused kitchen if active const filteredTOrders = focusedKitchen ? tOrders.filter(o => (o.pickupcustomer || o.kitchen_key || 'Unknown').toLowerCase().trim() === focusedKitchen.id) : tOrders; if (filteredTOrders.length === 0) return; const cacheKey = `${r.id}-${tNum}`; const roadPoints = osrmRoutes[cacheKey]; const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); const isKitchenView = (viewMode === 'kitchens' || focusedKitchen); const opacity = isActive ? 1.0 : 0.1; const weight = isKitchenView ? 7 : 6; // ─── ACTUAL ROUTE (primary, for non-Compare riders) ─────────────── // Prefer the rider's recorded GPS route over the dispatched plan. // We concatenate each delivery's OSRM-snapped path (or raw Kalman- // smoothed GPS when snapping hasn't finished) in step order. // Duplicate boundary points between consecutive deliveries are // dropped so the joined line reads as one continuous trip, not N // stitched segments. Compare mode for the focused rider is handled // by the dedicated planned-overlay path below, so we skip this for // that one rider when Compare is open. const isCompareTarget = compareOpen && focusedRider && r.id === focusedRider.id; if (!isCompareTarget) { const actualPoints = []; let anyActual = false; sorted.forEach((o) => { if (o.deliveryid == null || o.deliveryid === '' || o.deliveryid === 0) return; const did = String(o.deliveryid); const snapped = osrmTrackRoutes[did]; const track = actualTracksByDeliveryId.get(did); let seg = null; if (Array.isArray(snapped) && snapped.length >= 2) { seg = snapped; } else if (track && track.coords.length >= 2) { seg = track.coords.map((p) => [p.lat, p.lng]); } if (!seg) return; anyActual = true; if (actualPoints.length === 0) { actualPoints.push(...seg); } else { // Drop the first point if it duplicates the previous segment's // last point (within ~1.1m at the equator). Prevents a visible // hairline kink at delivery boundaries when the snapped paths // meet at the same drop coordinate. const last = actualPoints[actualPoints.length - 1]; const first = seg[0]; const sameJoin = Math.abs(last[0] - first[0]) < 1e-5 && Math.abs(last[1] - first[1]) < 1e-5; actualPoints.push(...(sameJoin ? seg.slice(1) : seg)); } }); if (anyActual && actualPoints.length >= 2) { routes.push( ); return; } } // ─── PLANNED ROUTE (fallback) ───────────────────────────────────── // No actual GPS yet (orders still pending, or rider hasn't moved), // so show the dispatched plan. Same waiting logic as before: don't // draw the aerial fallback until OSRM has either returned or // permanently failed — avoids the brief "aerial flash" before the // road polyline lands. // Cache values: // Array → OSRM road polyline (use it) // false → OSRM permanently failed (draw aerial fallback so user sees something) // null → request in-flight (DON'T draw anything yet — avoids the aerial flash) // undefined → not yet requested (same as in-flight, wait) const hasRoad = Array.isArray(roadPoints) && roadPoints.length >= 2; const failed = roadPoints === false; if (!hasRoad && !failed) return; // still loading — don't show aerial flash const finalPoints = hasRoad ? roadPoints : buildTripPoints(sorted); if (!finalPoints || finalPoints.length < 2) return; // Aerial fallback (OSRM permanently failed) is rendered dashed so it visually // reads as an estimate vs. an actual routed road polyline. // In Combined mode (planned + actual overlaid on a single map), the // planned polyline renders dashed so the operator can visually tell // it apart from the solid actual-GPS polyline drawn for the same step // in the same step-palette color. OSRM-failed segments stay dashed // either way — the existing aerial-fallback signal still wins. const isCompareTargetForDash = compareOpen && focusedRider && r.id === focusedRider.id; const combinedPlannedDash = isCompareTargetForDash && compareViewMode === 'combined' ? '6 5' : undefined; const dashArray = failed ? '8 6' : combinedPlannedDash; // Compare mode: recolor the planned polyline per step so the left // (planned) map's segment for delivery X uses the same STEP_PALETTE // color as the right (actual) map's GPS polyline for delivery X and // the timeline dot for X. Without this, the operator can't visually // link a step on the timeline to its planned segment on the left. // (isCompareTarget was hoisted above the actual-route attempt.) if (isCompareTarget) { const deliveryToStep = new Map( riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep]) ); const validDrops = sorted.filter(hasValidDrop); const dropCoords = validDrops.map((o) => [ parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong) ]); // hasRoad: split the OSRM polyline at each drop's nearest index. // !hasRoad: finalPoints is [pickup?, drop1, drop2, ...] — buildTripPoints // only prepends the pickup when one's available. Detect that and // align segment[i] with validDrops[i] either way. let segments; if (hasRoad) { segments = splitPolylineByDrops(finalPoints, dropCoords); } else { const hasPickup = finalPoints.length > dropCoords.length; segments = []; for (let i = 0; i < dropCoords.length; i++) { const idxA = hasPickup ? i : i - 1; const idxB = hasPickup ? i + 1 : i; if ( idxA < 0 || idxA >= finalPoints.length || idxB >= finalPoints.length ) { segments.push([]); } else { segments.push([finalPoints[idxA], finalPoints[idxB]]); } } } // Combined view rail-offset: shift the planned route +5px // perpendicular to its direction of travel so it sits *next to* // the actual GPS polyline (which gets -5px below) instead of // stacking on the same pixels. When the rider follows the // dispatched route, the two layers now read as parallel rails // hugging the same road; when they diverge, the rails fan apart // and the deviation is unmissable. Planned-only and Actual-only // modes keep offset = 0 since there's only one layer to draw. const plannedOffset = compareViewMode === 'combined' ? 5 : 0; // One halo under the whole trip so crossing roads still read as // a single planned route. Per-step segments draw on top with their // step's color. routes.push( ); segments.forEach((seg, i) => { if (!seg || seg.length < 2) return; const order = validDrops[i]; const sequenceStep = order ? deliveryToStep.get(String(order.deliveryid)) : null; // Combined view collapses the per-step palette to a single // indigo so the planned polyline reads as one layer; the // numbered drop pins (which keep STEP_PALETTE) still carry // the per-step identity. Planned-only mode keeps per-step // colors because there's no second layer to distinguish from. const color = compareViewMode === 'combined' ? COMBINED_PLANNED_COLOR : (sequenceStep ? stepColor(sequenceStep - 1) : r.color); const isFocusedThisStep = focusedCompareStep != null && focusedCompareStep === sequenceStep; // Focused segment pops; non-focused dim when *some* step is // focused so the active one stands out. When no step is focused // every segment renders at full opacity. const segWeight = isFocusedThisStep ? weight + 1.5 : weight; const segOpacity = isFocusedThisStep ? 1 : focusedCompareStep ? opacity * 0.5 : opacity; routes.push( ); }); return; // skip the default single-color render below } // Fell through both the actual-route attempt (no GPS yet) and the // Compare-target overlay (rider isn't focused in Compare). Draw the // dispatched plan so the operator sees something while GPS lands. routes.push( ); }); }); return routes; }; const toggleRider = (rid) => { const newActive = new Set(activeRiders); if (newActive.has(rid)) newActive.delete(rid); else newActive.add(rid); setActiveRiders(newActive); }; return (
{!embedded && (
D
Dispatch
{appLocations && appLocations.length > 0 && (
{locationMenuOpen && (
{appLocations.map((loc) => { const isActive = String(loc.applocationid) === String(selectedAppLocationId); return ( ); })}
)}
)}
{/* Header right-cluster: total-orders pill, date picker. Sits to the LEFT of the running clock so the operator sees current wave size + selected date together in one row. */}
{shouldFetchLive && ( <> {liveIsFetching && ( Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''} )} {!liveIsFetching && !liveIsError && ( {filteredLiveRows.length} orders / {liveRows.length} total )} {liveIsError && ( Failed to load )} {(() => { // Date-picker chip + custom calendar popover. Replaces the // OS-native dialog (which looks different // on every browser and can't pick up the design system) with // a single popover that always renders the same way. The // chip has three regions: // • prev-day arrow — one-click ±1 day scrubbing // • center card — opens the calendar popover on click // • next-day arrow — disabled when viewing today // The popover itself carries the month grid + quick presets. const today = dayjs().startOf('day'); const todayStr = today.format('YYYY-MM-DD'); const picked = dayjs(selectedDate); const isToday = selectedDate === todayStr; const isFuture = picked.isAfter(today, 'day'); const commitDate = (next) => { if (!next) return; const str = next.format('YYYY-MM-DD'); if (str === selectedDate) { setDatePickerOpen(false); return; } if (next.isAfter(today, 'day')) return; // guard future setSelectedDate(str); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); setDatePickerOpen(false); }; const goPrevDay = () => commitDate(picked.subtract(1, 'day')); const goNextDay = () => { if (isToday || isFuture) return; commitDate(picked.add(1, 'day')); }; // Build a fixed 6×7 grid of dayjs instances starting from // the Sunday before the month's first day. Days outside the // visible month render as faded "other-month" cells so the // grid never jumps in height as months change. const monthStart = calViewMonth.startOf('month'); const gridStart = monthStart.subtract(monthStart.day(), 'day'); const cells = Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day')); const prevMonth = () => setCalViewMonth((m) => m.subtract(1, 'month')); const nextMonth = () => { const candidate = calViewMonth.add(1, 'month'); // Allow navigating into the current month (so "today" can // be reached) but not into purely-future months. if (candidate.startOf('month').isAfter(today, 'month')) return; setCalViewMonth(candidate); }; const canGoNextMonth = !calViewMonth.add(1, 'month').startOf('month').isAfter(today, 'month'); const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; return (
{datePickerOpen && (
{/* Month header — month/year title flanked by prev/next arrows. The next-month arrow disables once we'd cross into a purely-future month so the operator never lands on a month they can't pick a date in. */}
{calViewMonth.format('MMMM YYYY')}
{WEEKDAYS.map((w) => (
{w}
))}
{cells.map((d) => { const inMonth = d.month() === calViewMonth.month(); const isSel = d.format('YYYY-MM-DD') === selectedDate; const isTodayCell = d.format('YYYY-MM-DD') === todayStr; const disabled = d.isAfter(today, 'day'); const cls = [ 'date-cal-day', !inMonth && 'is-other-month', isSel && 'is-selected', isTodayCell && 'is-today', disabled && 'is-disabled' ].filter(Boolean).join(' '); return ( ); })}
{/* Quick presets — the three dates ops scrub to most often. Saves a month-nav + a day-click for the common cases. */}
)}
); })()} )}
{clock}
)} {(embedded || topView === 'live') && (<>
{shouldFetchLive && viewMode !== 'rider-info' && (
Batch {/* Status-wise (time-field) filter is hidden for now per spec — bucketing is locked to `assigntime`. Restore this block to bring back the Delivered/Pending/Assigned/... dropdown.
{timeFieldMenuOpen && (
{TIME_FIELDS.map((f) => { const isActive = f.id === selectedTimeField; return ( ); })}
)}
*/} {/* Slot editor (Edit slots button + panel) is hidden for now per spec — the three batches (Morning / Afternoon / Evening) are fixed. Restore this block to bring back the operator-editable start/end hours, add-slot, and reset-to-defaults controls.
{slotEditOpen && (
Slot timings
Hours are 0–24 (24h clock). Half-hour steps allowed (e.g. 12.5 = 12:30). Start < End.
{slotsConfig.map((s, idx) => (
{idx + 1} {formatSlotRange(s.startHour, s.endHour)}
))}
)}
*/} {/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls horizontally when it overflows. */}
{BATCHES.map((b) => { const isActive = selectedBatch === b.id; return ( ); })}
)} {viewMode === 'rider-info' ? (
Riders
{ridersAllDay.length} {ridersAllDay.length === 1 ? 'rider' : 'riders'} today
setRiderInfoSearch(e.target.value)} />
{(() => { const q = riderInfoSearch.trim().toLowerCase(); const matched = ridersAllDay.filter((r) => { if (!q) return true; return String(r.riderName || '').toLowerCase().includes(q) || String(r.id).includes(q); }); if (matched.length === 0) { return
{riderInfoSearch ? `No riders match "${riderInfoSearch}"` : 'No riders have orders today'}
; } return (
{matched.map((r) => { const isActive = String(riderInfoUserid) === String(r.id); return ( ); })}
); })()}
{riderInfoUserid == null ? (
Pick a rider
Select a rider from the list on the left to see their live GPS, battery, connection, and current order snapshot.
) : ( <> {riderInfoFetching && !riderInfoData && (
Loading rider snapshot…
)} {riderInfoIsError && (
Couldn't load this rider's log. {riderInfoError?.message || ''}
)} {riderInfoData && (() => { const d = riderInfoData; const lat = parseFloat(d.latitude); const lon = parseFloat(d.longitude); const hasCoords = Number.isFinite(lat) && Number.isFinite(lon); const batteryNum = parseInt(String(d.battery || '').replace('%', ''), 10); const batteryLow = Number.isFinite(batteryNum) && batteryNum <= 20; const speedNum = parseFloat(d.speed); const statusKey = String(d.status || '').toLowerCase(); return (
{d.username || `Rider #${d.userid}`}
#{d.userid} {d.status && ( {d.status} )} {riderInfoFetching ? 'Updating…' : 'Live'}
{d.logdate && (
Last seen {d.logdate}
)}
Battery
{d.battery || '—'} {d.is_charging && Charging}
Connection
{d.connection || '—'}
GPS Accuracy
{d.accuracy ? `${d.accuracy} m` : '—'}
Location Service
{d.location_service || '—'}
Speed
{Number.isFinite(speedNum) ? `${speedNum.toFixed(2)} km/h` : '—'}
Heading
{d.heading != null ? `${d.heading}°` : '—'}
App State
{d.is_background ? 'Background' : 'Foreground'}
Current Order
{d.orderid || '—'}
{hasCoords && (
{lat.toFixed(6)}, {lon.toFixed(6)}
{/* `key` forces a remount when the rider changes so the MapContainer re-centers on the new coords (leaflet's center prop is only read on mount). */} {/* Permanent banner above the pin — Nominatim reverse-geocode tells the operator which suburb/area the rider is in. Falls back to a "Locating…" hint while the request is in flight so the pin never looks unlabeled. */} {riderInfoArea?.area || 'Locating area…'}
{d.username || `Rider #${d.userid}`}
{riderInfoArea?.area && (
{riderInfoArea.area}
)}
{d.logdate ? `Last seen ${d.logdate}` : `${lat.toFixed(6)}, ${lon.toFixed(6)}`}
)}
); })()} )}
) : (
{compareOpen && focusedRider && ( )} ); }; export default Dispatch;