Files
dailygrubs_console/src/pages/nearle/dispatch/Dispatch.js

5446 lines
267 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (89 AM, 12:30 PM4 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 }) => (
<span className="ico-inline" style={{ display: 'inline-flex', alignItems: 'center', verticalAlign: '-2px', marginRight: 4 }}>
{children}
</span>
);
// 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 <Popup> (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 <Popup>: 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: `<div class="kitchen-mark${focused ? ' is-focused' : ''}">${(name || 'K').charAt(0).toUpperCase()}</div>`
});
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 (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div>
<div className="rcard-info">
<div className="rcard-name">{r.riderName}</div>
<div className="rcard-zone">{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips</div>
</div>
<div
className={`rcard-badge ${isDone ? 'is-done' : ''}`}
style={isDone ? undefined : { background: `${r.color}18`, color: r.color }}
title={`${delivered} delivered of ${total} total`}
>
{delivered}/{total}
</div>
</div>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (total / 15) * 100)}%`, background: r.color }}></div></div>
<div className="rcard-meta">
<span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
{estMeters !== null && (
<span className="rcard-est-meters" title="Estimated distance to next drop location">
<Ico><MdMyLocation /></Ico>{formatMeters(estMeters)} to drop
</span>
)}
</div>
<div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div>
</div>
);
};
// 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 (
<div style={{ height: '100%', width: '100%' }}>
<div className="pu-header">
<div className="pu-header-top">
<div className="pu-id">ORDER #{o.orderid}</div>
{o.orderstatus && (
<span className="pu-status-chip" style={{ background: statusStyle.bg, color: statusStyle.fg }}>
{statusStyle.label}
</span>
)}
</div>
<div className="pu-rider">
<MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span>
</div>
{o.deliveryid != null && (
<div className="pu-delivery-id">Delivery #{o.deliveryid}</div>
)}
</div>
<div className="pu-body">
{POPUP_TIMELINE.some((t) => o[t.key]) && (
<div className="pu-section">
<div className="pu-section-label">Timeline</div>
<div className="pu-timeline">
{POPUP_TIMELINE.map((t) => {
const time = formatTimeOnly(o[t.key]);
if (!time) return null;
return (
<div key={t.key} className={`pu-tl-row ${t.final ? 'delivered' : ''}`}>
<span className="pu-tl-dot" />
<span className="pu-tl-label">{t.label}</span>
<span className="pu-tl-time">{time}</span>
</div>
);
})}
</div>
</div>
)}
<div className="pu-section">
<div className="pu-section-label">Details</div>
<div className="pu-details-grid">
{(o.pickupcustomer || o.locationname || o.pickuplocation) && (
<div className="pu-detail">
<div className="pu-detail-icon"><MdRestaurant /></div>
<div className="pu-detail-body">
<div className="pu-detail-label">Pickup</div>
<div
className="pu-detail-value"
title={o.pickupcustomer || o.locationname || o.pickuplocation}
>
{o.pickupcustomer || o.locationname || o.pickuplocation}
</div>
</div>
</div>
)}
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="pu-detail">
<div className="pu-detail-icon"><MdPlace /></div>
<div className="pu-detail-body">
<div className="pu-detail-label">Drop</div>
<div
className="pu-detail-value"
title={o.deliveryaddress || o.deliverysuburb}
>
{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
</div>
</div>
)}
{o.zone_name && (
<div className="pu-detail">
<div className="pu-detail-icon"><MdMap /></div>
<div className="pu-detail-body">
<div className="pu-detail-label">Zone</div>
<div className="pu-detail-value" title={o.zone_name}>{o.zone_name}</div>
</div>
</div>
)}
{(o.rider_id || o.userid) && (
<div className="pu-detail">
<div className="pu-detail-icon"><MdTwoWheeler /></div>
<div className="pu-detail-body">
<div className="pu-detail-label">Rider ID</div>
<div className="pu-detail-value">#{o.rider_id || o.userid}</div>
</div>
</div>
)}
</div>
{(o.actualkms != null || (!isDelivered && o.riderkms != null) || estMeters !== null) && (
<div className="pu-distance-row">
{o.actualkms != null && o.actualkms !== '' && (
<div className="pu-distance-chip">
<span className="pu-distance-icon"><MdStraighten /></span>
<span className="pu-distance-label">Actual</span>
<span className="pu-distance-value">{o.actualkms} km</span>
</div>
)}
{!isDelivered && o.riderkms != null && o.riderkms !== '' && (
<div className="pu-distance-chip">
<span className="pu-distance-icon"><MdDirectionsBike /></span>
<span className="pu-distance-label">Rider</span>
<span className="pu-distance-value">{parseFloat(o.riderkms).toFixed(2)} km</span>
</div>
)}
{estMeters !== null && (
<div className="pu-distance-chip pu-est-meters" title="Estimated distance to drop location">
<span className="pu-distance-icon"><MdMyLocation /></span>
<span className="pu-distance-label">Est. to Drop</span>
<span className="pu-distance-value">{formatMeters(estMeters)}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
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
? `<svg class="cmark-flag" viewBox="0 0 18 22" xmlns="http://www.w3.org/2000/svg">
<line x1="1.5" y1="0" x2="1.5" y2="22" stroke="#0f172a" stroke-width="1.6" stroke-linecap="round"/>
<polygon points="2,1 17,1 13.5,5.5 17,10 2,10" fill="${statusStyle.bg}" stroke="#0f172a" stroke-width="0.6" stroke-linejoin="round"/>
${isDelivered ? '<polyline points="5,5.5 7,7.5 11,3.5" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>' : ''}
</svg>`
: '';
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: `<div class="cmark is-rider-focused${isPulsing ? ' pulse' : ''}" style="background:${color};width:${sz}px;height:${sz}px;font-size:${seq > 9 ? 12 : 14}px;opacity:${active ? 1 : 0.75}">${seq > 0 ? seq : ''}${flagSvg}</div>`
});
})()
: L.divIcon({
className: '',
iconSize: [24, 30],
iconAnchor: [2, 30],
popupAnchor: [10, -25], // Lift popup above the flag, not just the larger 32px marker
html: `<div class="cmark${isPulsing ? ' pulse' : ''}" style="width:24px;height:30px;opacity:${active ? 1 : 0.75}">${flagSvg}</div>`
});
return (
<Marker
key={`${o.orderid}-${showNumbers ? 'num' : 'flag'}`}
position={[parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]}
icon={icon}
zIndexOffset={rid ? 100 : 0}
ref={(inst) => {
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) => (
<Polyline key={i} positions={[s.from, s.to]} pathOptions={{ color: s.color, weight: 6, opacity: 0.9, lineJoin: 'round', lineCap: 'round' }} />
));
}
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(
<React.Fragment key={`${r.id}-${tNum}-actual`}>
<Polyline
positions={actualPoints}
pathOptions={{ color: '#ffffff', weight: weight + 4, opacity: opacity * 0.5, lineJoin: 'round', lineCap: 'round' }}
/>
<Polyline
positions={actualPoints}
pathOptions={{ color: r.color, weight, opacity, lineJoin: 'round', lineCap: 'round' }}
/>
</React.Fragment>
);
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(
<Polyline
key={`${r.id}-${tNum}-halo`}
positions={finalPoints}
pathOptions={{
color: '#ffffff',
weight: weight + 4,
opacity: opacity * 0.5,
lineJoin: 'round',
lineCap: 'round',
offset: plannedOffset
}}
/>
);
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(
<Polyline
key={`${r.id}-${tNum}-step-${i}-${order?.deliveryid || i}`}
positions={seg}
pathOptions={{
color,
weight: segWeight,
opacity: segOpacity,
lineJoin: 'round',
lineCap: 'round',
dashArray,
offset: plannedOffset
}}
/>
);
});
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(
<React.Fragment key={`${r.id}-${tNum}`}>
<Polyline positions={finalPoints} pathOptions={{ color: '#ffffff', weight: weight + 4, opacity: opacity * 0.5, lineJoin: 'round', lineCap: 'round' }} />
<Polyline positions={finalPoints} pathOptions={{ color: r.color, weight, opacity, lineJoin: 'round', lineCap: 'round', dashArray }} />
</React.Fragment>
);
});
});
return routes;
};
const toggleRider = (rid) => {
const newActive = new Set(activeRiders);
if (newActive.has(rid)) newActive.delete(rid);
else newActive.add(rid);
setActiveRiders(newActive);
};
return (
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
{!embedded && (
<div id="hdr">
<div className="logo">
<div className="logo-badge">D</div>
<div className="logo-name">Dispatch</div>
{appLocations && appLocations.length > 0 && (
<div className="logo-city-wrap" ref={locationMenuRef}>
<button
type="button"
className={`logo-city ${locationMenuOpen ? 'open' : ''}`}
onClick={() => setLocationMenuOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={locationMenuOpen}
title="Switch hub"
>
<MdPlace />
<span className="logo-city-text">{locationName || 'All locations'}</span>
<MdExpandMore className="logo-city-caret" />
</button>
{locationMenuOpen && (
<div className="logo-city-menu" role="listbox">
{appLocations.map((loc) => {
const isActive = String(loc.applocationid) === String(selectedAppLocationId);
return (
<button
key={loc.applocationid}
type="button"
role="option"
aria-selected={isActive}
className={`logo-city-option ${isActive ? 'active' : ''}`}
onClick={() => handleLocationPick(loc.applocationid)}
>
<MdPlace className="logo-city-option-icon" />
<span>{loc.locationname}</span>
{isActive && <span className="logo-city-option-check"></span>}
</button>
);
})}
</div>
)}
</div>
)}
</div>
{/* 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. */}
<div className="hdr-stats">
{shouldFetchLive && (
<>
{liveIsFetching && (
<span className="live-status">
<span className="live-dot" /> Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''}
</span>
)}
{!liveIsFetching && !liveIsError && (
<span className="live-status live-status-ready">
<span className="live-dot ready" /> {filteredLiveRows.length} orders
<span className="live-status-sub"> / {liveRows.length} total</span>
</span>
)}
{liveIsError && (
<span className="live-status live-status-error">
<span className="live-dot error" /> Failed to load
</span>
)}
{(() => {
// Date-picker chip + custom calendar popover. Replaces the
// OS-native <input type="date"> 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 (
<div className={`date-chip${isToday ? ' is-today' : ''}${datePickerOpen ? ' is-open' : ''}`} ref={datePickerRef}>
<button
type="button"
className="date-chip-nav"
onClick={goPrevDay}
aria-label="Previous day"
title="Previous day"
>
<MdChevronLeft />
</button>
<button
type="button"
className="date-chip-main"
onClick={() => setDatePickerOpen((o) => !o)}
aria-haspopup="dialog"
aria-expanded={datePickerOpen}
>
<span className="date-chip-icon" aria-hidden="true">
<MdCalendarToday />
</span>
<span className="date-chip-text">
<span className="date-chip-label">
Date{isToday && <span className="date-chip-today-pill">Today</span>}
</span>
<span className="date-chip-value">
{picked.isValid() ? picked.format('ddd, MMM D, YYYY') : '—'}
</span>
</span>
<span className={`date-chip-chevron${datePickerOpen ? ' is-open' : ''}`} aria-hidden="true">
<MdExpandMore />
</span>
</button>
<button
type="button"
className="date-chip-nav"
onClick={goNextDay}
disabled={isToday || isFuture}
aria-label="Next day"
title={isToday ? "You're viewing today" : 'Next day'}
>
<MdChevronRight />
</button>
{datePickerOpen && (
<div className="date-cal-popover" role="dialog" aria-label="Pick a date">
{/* 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. */}
<div className="date-cal-header">
<button
type="button"
className="date-cal-nav"
onClick={prevMonth}
aria-label="Previous month"
>
<MdChevronLeft />
</button>
<div className="date-cal-title">
{calViewMonth.format('MMMM YYYY')}
</div>
<button
type="button"
className="date-cal-nav"
onClick={nextMonth}
disabled={!canGoNextMonth}
aria-label="Next month"
>
<MdChevronRight />
</button>
</div>
<div className="date-cal-weekdays">
{WEEKDAYS.map((w) => (
<div key={w} className="date-cal-weekday">{w}</div>
))}
</div>
<div className="date-cal-grid">
{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 (
<button
key={d.format('YYYY-MM-DD')}
type="button"
className={cls}
disabled={disabled}
onClick={() => commitDate(d)}
aria-current={isTodayCell ? 'date' : undefined}
aria-pressed={isSel}
>
{d.date()}
</button>
);
})}
</div>
{/* Quick presets — the three dates ops scrub to
most often. Saves a month-nav + a day-click for
the common cases. */}
<div className="date-cal-presets">
<button
type="button"
className="date-cal-preset"
onClick={() => commitDate(today)}
>
Today
</button>
<button
type="button"
className="date-cal-preset"
onClick={() => commitDate(today.subtract(1, 'day'))}
>
Yesterday
</button>
<button
type="button"
className="date-cal-preset"
onClick={() => commitDate(today.subtract(7, 'day'))}
>
7 days
</button>
</div>
</div>
)}
</div>
);
})()}
</>
)}
</div>
<div id="clock">{clock}</div>
</div>
)}
{(embedded || topView === 'live') && (<>
<div id="strat-row">
<button className={`sbt ${viewMode === 'kitchens' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Location'); setViewMode('kitchens'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPlace /></span> By Location</button>
<button
className={`sbt ${viewMode === 'zones' ? 'active' : ''}`}
onClick={() => { logger.info('View mode changed: By Zone'); setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
><span className="sbt-icon"><MdMap /></span> By Zone</button>
<button className={`sbt ${viewMode === 'riders' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Rider'); setViewMode('riders'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdDirectionsBike /></span> By Rider</button>
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: All Routes'); setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span> All Routes</button>
<button
type="button"
className={`sbt sbt-rider-info ${viewMode === 'rider-info' ? 'active' : ''}`}
onClick={() => {
setViewMode('rider-info');
handleRiderFocus(null);
setFocusedKitchen(null);
setFocusedZone(null);
}}
title="Live GPS / battery / status snapshot for a rider"
>
<span className="sbt-icon"><MdInfoOutline /></span> Rider Info
</button>
</div>
{shouldFetchLive && viewMode !== 'rider-info' && (
<div id="batch-row">
<span className="batch-label">Batch</span>
{/* 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.
<div className="time-field-wrap" ref={timeFieldMenuRef}>
<button
type="button"
className={`time-field-btn ${timeFieldMenuOpen ? 'open' : ''}`}
onClick={() => setTimeFieldMenuOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={timeFieldMenuOpen}
title="Bucket slots by this timestamp"
>
<MdAccessTime />
<span className="time-field-text">{TIME_FIELDS.find((f) => f.id === selectedTimeField)?.label || 'Delivered'}</span>
<MdExpandMore className="time-field-caret" />
</button>
{timeFieldMenuOpen && (
<div className="time-field-menu" role="listbox">
{TIME_FIELDS.map((f) => {
const isActive = f.id === selectedTimeField;
return (
<button
key={f.id}
type="button"
role="option"
aria-selected={isActive}
className={`time-field-option ${isActive ? 'active' : ''}`}
onClick={() => {
setSelectedTimeField(f.id);
setTimeFieldMenuOpen(false);
}}
>
<MdAccessTime className="time-field-option-icon" />
<span>{f.label}</span>
{isActive && <span className="time-field-option-check">✓</span>}
</button>
);
})}
</div>
)}
</div>
*/}
{/* 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.
<div className="slot-edit-wrap" ref={slotEditRef}>
<button
type="button"
className={`slot-edit-btn ${slotEditOpen ? 'open' : ''}`}
onClick={() => setSlotEditOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={slotEditOpen}
title="Adjust slot timings"
>
<MdAccessTime />
<span>Edit slots</span>
</button>
{slotEditOpen && (
<div className="slot-edit-panel" role="dialog" aria-label="Edit slot timings">
<div className="slot-edit-head">
<div className="slot-edit-title">Slot timings</div>
<div className="slot-edit-sub">Hours are 024 (24h clock). Half-hour steps allowed (e.g. 12.5 = 12:30). Start &lt; End.</div>
</div>
<div className="slot-edit-list">
{slotsConfig.map((s, idx) => (
<div key={s.id} className="slot-edit-row">
<span className="slot-edit-idx">{idx + 1}</span>
<label className="slot-edit-field">
<span className="slot-edit-field-label">Start</span>
<input
type="number"
min={0}
max={23.5}
step={0.5}
value={s.startHour}
onChange={(e) => {
const raw = parseFloat(e.target.value);
const snapped = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0;
const v = Math.max(0, Math.min(23.5, snapped));
setSlotsConfig((cur) => cur.map((row, i) =>
i === idx
? { ...row, startHour: v, label: formatSlotLabel(i, v), range: formatSlotRange(v, row.endHour) }
: row
));
}}
/>
</label>
<label className="slot-edit-field">
<span className="slot-edit-field-label">End</span>
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={s.endHour}
onChange={(e) => {
const raw = parseFloat(e.target.value);
const snapped = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0.5;
const v = Math.max(0.5, Math.min(24, snapped));
setSlotsConfig((cur) => cur.map((row, i) =>
i === idx
? { ...row, endHour: v, range: formatSlotRange(row.startHour, v) }
: row
));
}}
/>
</label>
<span className="slot-edit-preview" title={`${formatSlotLabel(idx, s.startHour)} — ${formatSlotRange(s.startHour, s.endHour)}`}>
{formatSlotRange(s.startHour, s.endHour)}
</span>
<button
type="button"
className="slot-edit-remove"
onClick={() => setSlotsConfig((cur) => cur.filter((_, i) => i !== idx).map((row, i) => ({
...row,
id: `slot-${i + 1}`,
label: formatSlotLabel(i, row.startHour)
})))}
disabled={slotsConfig.length <= 1}
title={slotsConfig.length <= 1 ? 'Keep at least one slot' : 'Remove this slot'}
>
×
</button>
</div>
))}
</div>
<div className="slot-edit-actions">
<button
type="button"
className="slot-edit-add"
onClick={() => setSlotsConfig((cur) => {
const last = cur[cur.length - 1];
const start = Math.min(23, (last?.endHour ?? 0));
const end = Math.min(24, start + 1);
const i = cur.length;
return [
...cur,
{
id: `slot-${i + 1}`,
startHour: start,
endHour: end,
label: formatSlotLabel(i, start),
range: formatSlotRange(start, end)
}
];
})}
>
+ Add slot
</button>
<button
type="button"
className="slot-edit-reset"
onClick={() => setSlotsConfig(BATCHES_DEFAULT)}
>
Reset to defaults
</button>
</div>
</div>
)}
</div>
*/}
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
horizontally when it overflows. */}
<div className="batch-scroll">
{BATCHES.map((b) => {
const isActive = selectedBatch === b.id;
return (
<button
key={b.id}
ref={isActive ? activeBatchRef : null}
className={`batch-btn batch-slot ${isActive ? 'active' : ''}`}
onClick={() => {
setSelectedBatch(b.id);
handleRiderFocus(null);
setFocusedKitchen(null);
setFocusedZone(null);
}}
title={`${b.label} (${b.range})`}
>
<span className="batch-btn-label">{b.label}</span>
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
</button>
);
})}
</div>
</div>
)}
{viewMode === 'rider-info' ? (
<div className="rider-info-mode">
<div className="ri-sidebar">
<div className="ri-sb-head">
<div className="ri-sb-title">Riders</div>
<div className="ri-sb-sub">{ridersAllDay.length} {ridersAllDay.length === 1 ? 'rider' : 'riders'} today</div>
</div>
<div className="ri-search">
<MdSearch className="ri-search-icon" />
<input
type="text"
className="ri-search-input"
placeholder="Search by name or ID"
value={riderInfoSearch}
onChange={(e) => setRiderInfoSearch(e.target.value)}
/>
</div>
{(() => {
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 <div className="ri-empty">{riderInfoSearch ? `No riders match "${riderInfoSearch}"` : 'No riders have orders today'}</div>;
}
return (
<div className="ri-rider-list">
{matched.map((r) => {
const isActive = String(riderInfoUserid) === String(r.id);
return (
<button
key={r.id}
type="button"
className={`ri-rider-item ${isActive ? 'active' : ''}`}
onClick={() => setRiderInfoUserid(r.id)}
>
<span className="ri-rider-dot" style={{ background: getStableRiderColor(r.id) }} />
<span className="ri-rider-info-block">
<span className="ri-rider-name">{r.riderName}</span>
<span className="ri-rider-meta">#{r.id}</span>
</span>
<span className="ri-rider-arrow"></span>
</button>
);
})}
</div>
);
})()}
</div>
<div className="ri-main">
{riderInfoUserid == null ? (
<div className="ri-placeholder">
<div className="ri-placeholder-icon"><MdInfoOutline /></div>
<div className="ri-placeholder-title">Pick a rider</div>
<div className="ri-placeholder-sub">
Select a rider from the list on the left to see their live GPS,
battery, connection, and current order snapshot.
</div>
</div>
) : (
<>
{riderInfoFetching && !riderInfoData && (
<div className="ri-loading">Loading rider snapshot</div>
)}
{riderInfoIsError && (
<div className="ri-error">
Couldn't load this rider's log. {riderInfoError?.message || ''}
</div>
)}
{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 (
<div className="ri-snapshot">
<div className="ri-snap-head">
<div className="ri-snap-name">{d.username || `Rider #${d.userid}`}</div>
<div className="ri-snap-meta">
<span>#{d.userid}</span>
{d.status && (
<span className={`ri-status ri-status-${statusKey}`}>{d.status}</span>
)}
<span className={`ri-live ${riderInfoFetching ? 'is-refetching' : ''}`}>
<span className="ri-live-dot" />
{riderInfoFetching ? 'Updating…' : 'Live'}
</span>
</div>
{d.logdate && (
<div className="ri-snap-time">
<MdAccessTime /> Last seen {d.logdate}
</div>
)}
</div>
<div className="ri-snap-grid">
<div className={`ri-stat ${batteryLow ? 'ri-stat-warn' : ''}`}>
<div className="ri-stat-icon"><MdBatteryFull /></div>
<div className="ri-stat-body">
<div className="ri-stat-label">Battery</div>
<div className="ri-stat-value">
{d.battery || '—'}
{d.is_charging && <span className="ri-stat-tag">Charging</span>}
</div>
</div>
</div>
<div className="ri-stat">
<div className="ri-stat-icon"><MdSignalCellularAlt /></div>
<div className="ri-stat-body">
<div className="ri-stat-label">Connection</div>
<div className="ri-stat-value">{d.connection || '—'}</div>
</div>
</div>
<div className="ri-stat">
<div className="ri-stat-icon"><MdMyLocation /></div>
<div className="ri-stat-body">
<div className="ri-stat-label">GPS Accuracy</div>
<div className="ri-stat-value">{d.accuracy ? `${d.accuracy} m` : '—'}</div>
</div>
</div>
<div className="ri-stat">
<div className="ri-stat-icon"><MdGpsFixed /></div>
<div className="ri-stat-body">
<div className="ri-stat-label">Location Service</div>
<div className="ri-stat-value">{d.location_service || '—'}</div>
</div>
</div>
<div className="ri-stat">
<div className="ri-stat-icon"><MdSpeed /></div>
<div className="ri-stat-body">
<div className="ri-stat-label">Speed</div>
<div className="ri-stat-value">
{Number.isFinite(speedNum) ? `${speedNum.toFixed(2)} km/h` : '—'}
</div>
</div>
</div>
<div className="ri-stat">
<div className="ri-stat-icon"><MdExplore /></div>
<div className="ri-stat-body">
<div className="ri-stat-label">Heading</div>
<div className="ri-stat-value">{d.heading != null ? `${d.heading}°` : '—'}</div>
</div>
</div>
<div className="ri-stat">
<div className="ri-stat-icon"><MdPower /></div>
<div className="ri-stat-body">
<div className="ri-stat-label">App State</div>
<div className="ri-stat-value">{d.is_background ? 'Background' : 'Foreground'}</div>
</div>
</div>
<div className="ri-stat">
<div className="ri-stat-icon"><MdInventory2 /></div>
<div className="ri-stat-body">
<div className="ri-stat-label">Current Order</div>
<div className="ri-stat-value">{d.orderid || '—'}</div>
</div>
</div>
</div>
{hasCoords && (
<div className="ri-map-section">
<div className="ri-coords-label">
<MdLocationOn /> {lat.toFixed(6)}, {lon.toFixed(6)}
</div>
<div className="ri-map">
{/* `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). */}
<MapContainer
key={`${d.userid}-${lat}-${lon}`}
center={[lat, lon]}
zoom={13}
scrollWheelZoom={false}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
<Marker position={[lat, lon]}>
{/* 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. */}
<Tooltip
direction="top"
offset={[0, -10]}
permanent
className="ri-area-banner"
>
{riderInfoArea?.area || 'Locating area…'}
</Tooltip>
<Popup>
<div style={{ fontWeight: 700, marginBottom: 2 }}>{d.username || `Rider #${d.userid}`}</div>
{riderInfoArea?.area && (
<div style={{ fontSize: 12, color: '#0f172a', marginBottom: 4 }}>
{riderInfoArea.area}
</div>
)}
<div style={{ fontSize: 11, color: '#64748b' }}>
{d.logdate ? `Last seen ${d.logdate}` : `${lat.toFixed(6)}, ${lon.toFixed(6)}`}
</div>
</Popup>
</Marker>
</MapContainer>
</div>
</div>
)}
</div>
);
})()}
</>
)}
</div>
</div>
) : (
<div id="body" className={`${sidebarCollapsed ? 'sidebar-collapsed' : ''} ${compareOpen ? 'compare-mode' : ''} ${compareOpen && compareDataCollapsed ? 'compare-data-collapsed' : ''}`.trim()}>
<button
type="button"
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
onClick={() => setSidebarCollapsed((v) => !v)}
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
aria-label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
>
{sidebarCollapsed ? <MdChevronRight /> : <MdChevronLeft />}
</button>
{compareOpen && focusedRider && (
<button
type="button"
className={`compare-data-toggle-tab${compareDataCollapsed ? ' is-collapsed' : ''}`}
onClick={() => setCompareDataCollapsed((v) => !v)}
title={compareDataCollapsed ? 'Show details panel' : 'Hide details panel'}
aria-label={compareDataCollapsed ? 'Show details panel' : 'Hide details panel'}
>
{compareDataCollapsed ? <MdChevronLeft /> : <MdChevronRight />}
</button>
)}
<div id="sidebar">
{/* Sidebar header — replaces the top-bar meta line. Hidden when a specific
rider is focused, since the focused-rider view already shows that rider's
stats prominently (name + Orders/Distance tiles). */}
{!focusedRider && (
<div className="sb-header">
<div className="sb-header-top">
<div className="sb-header-title">
<span className="sb-title-bar" aria-hidden="true" />
<span className="sb-title-text">RIDER DISPATCH</span>
</div>
<span className="sb-header-scope" title={activeStats.label}>
<span className="sb-scope-dot" />
{activeStats.label}
</span>
</div>
<div className="sb-header-tiles">
<div className="sb-tile sb-tile-orders">
<span className="sb-tile-icon"><MdInventory2 /></span>
<div className="sb-tile-body">
<div className="sb-tile-value">{activeStats.orders}</div>
<div className="sb-tile-label">{activeStats.orders === 1 ? 'Order' : 'Orders'}</div>
</div>
</div>
<div className="sb-tile sb-tile-riders">
<span className="sb-tile-icon"><MdTwoWheeler /></span>
<div className="sb-tile-body">
<div className="sb-tile-value">{activeStats.riders}</div>
<div className="sb-tile-label">{activeStats.riders === 1 ? 'Rider' : 'Riders'}</div>
</div>
</div>
</div>
</div>
)}
{/* Stats strip hidden for now — restore by removing this comment wrapper.
<div id="stats-strip">
<div className="sc"><div className="sc-lbl">Orders</div><div className="sc-val g">{activeStats.orders}</div><div className="sc-sub">{activeStats.label}</div></div>
<div className="sc"><div className="sc-lbl">Riders</div><div className="sc-val">{activeStats.riders}</div><div className="sc-sub">Active</div></div>
<div className="sc"><div className="sc-lbl">Distance</div><div className="sc-val">{activeStats.km.toFixed(1)} km</div><div className="sc-sub">Kilometers</div></div>
</div>
*/}
{(focusedRider || focusedKitchen) ? (
<div id="route-detail">
<button className="rd-back" onClick={() => { handleRiderFocus(null); setFocusedKitchen(null); }}> Back to {focusedZone ? focusedZone.name : 'list'}</button>
{focusedRider ? (
<>
<div className="rd-rider-name" style={{ color: focusedRider.color }}>{focusedRider.riderName}</div>
{(() => {
const totalKm = focusedRider.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
return (
<div className="rd-stats-grid">
<div className="rd-stat rd-stat-orders">
<div className="rd-stat-icon"><MdInventory2 /></div>
<div className="rd-stat-value">{focusedRider.orders.length}</div>
<div className="rd-stat-label">Orders</div>
</div>
<div className="rd-stat rd-stat-distance">
<div className="rd-stat-icon"><MdStraighten /></div>
<div className="rd-stat-value">{totalKm.toFixed(1)}<span className="rd-stat-unit">km</span></div>
<div className="rd-stat-label">Distance</div>
</div>
</div>
);
})()}
{(() => {
const trips = {};
focusedRider.orders.forEach(o => {
const t = o.trip_number || 1;
if (!trips[t]) trips[t] = [];
trips[t].push(o);
});
// Identify the rider's currently-going-on order — first non-final,
// non-skipped stop in (trip, step) order. Highlighted in light green
// so users see at a glance which delivery is in progress.
const sortedAll = [...focusedRider.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);
});
const activeOrder = sortedAll.find((o) => {
const s = String(o.orderstatus || '').toLowerCase();
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
const activeOrderId = activeOrder ? activeOrder.orderid : null;
let prevKitchenKey = null;
return Object.entries(trips)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([tNum, tOrders]) => (
<div key={tNum} className="trip-block">
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
<span className="trip-stats">
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
<span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</span>
</div>
<div className="zone-order-grid">
{tOrders.map((o, idx) => {
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey;
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
const isGoingOn = activeOrderId && o.orderid === activeOrderId;
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const statusStyle = getStatusStyle(o.orderstatus);
const estMeters = calculateEstMeters(focusedRider.id, o);
return (
<React.Fragment key={o.orderid}>
{showTransition && (
<div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
)}
<div
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
>
<div className="zone-order-card-head">
<div className="zone-order-num">{o.step || idx + 1}</div>
<div className="zone-order-id-block">
<div className="zone-order-id">Order #{o.orderid}</div>
</div>
{(() => {
// Stack the status pill and delivery time vertically on
// the right of the header so the operator sees the order
// outcome and the wall-clock time it landed at a glance.
const actual = formatTimeOnly(o.deliverytime);
const expected = formatTimeOnly(o.expecteddeliverytime);
const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase());
const showEstDrop = !isDelivered && estMeters !== null;
if (!o.orderstatus && !actual && !expected && !showEstDrop) return null;
return (
<div className="zone-order-status-stack">
{o.orderstatus && (
<span
className="zone-order-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{(actual || expected) && (
<span
className={`zone-order-time ${actual ? '' : 'is-expected'}`}
title={actual ? `Delivered at ${actual}` : `Expected at ${expected}`}
>
<MdAccessTime />{actual || expected}
</span>
)}
{showEstDrop && (
<span
className="zone-order-est-drop"
title="Estimated distance to drop location"
>
<MdMyLocation />{formatMeters(estMeters)}
</span>
)}
</div>
);
})()}
{onChangeRider && (
<button
type="button"
className="zone-order-change-rider"
title="Change rider"
onClick={(e) => {
e.stopPropagation();
onChangeRider(o, focusedRider);
}}
>
<MdSwapHoriz />
</button>
)}
</div>
<div className="zone-order-customer">
<Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || ''}
</div>
{o.pickupcustomer && (
<div className="zone-order-line" title={`Kitchen: ${o.pickupcustomer}`}>
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
</div>
)}
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
)}
{o.ordernotes && (
<div className="zone-order-line zone-order-notes" title={o.ordernotes}>
<Ico><MdNotes /></Ico>{o.ordernotes}
</div>
)}
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
</span>
{o.ordertype && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
</span>
)}
<span className="zone-order-chip zone-order-trip">
T{o.trip_number || tNum} · S{o.step || idx + 1}
</span>
</div>
</div>
</React.Fragment>
);
})}
</div>
</div>
));
})()}
</>
) : (
<>
<div className="rd-rider-name" style={{ color: '#f59e0b' }}>{focusedKitchen.kitchenName}</div>
<div className="rd-rider-sub">
<span><Ico><MdInventory2 /></Ico>{focusedKitchen.orders.length} orders</span>
<span><Ico><MdTwoWheeler /></Ico>{focusedKitchen.riders.size} riders</span>
</div>
{/* Render the kitchen's orders with the same zone-order-card layout
used by the focused-zone view, so By Location, By Zone, and By
Rider all look consistent. Kitchen name is omitted from each
card because the focused kitchen header already provides it. */}
<div className="zone-detail-section">
<div className="zone-section-label">Orders <span className="section-count">({focusedKitchen.orders.length})</span></div>
{focusedKitchen.orders.length === 0 ? (
<div className="zone-suburb-panel-empty">No orders for this kitchen.</div>
) : (
<div className="zone-order-grid">
{focusedKitchen.orders.map((o, idx) => {
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
const statusStyle = getStatusStyle(o.orderstatus);
const orderRiderId = o.rider_id || o.userid;
const riderForOrder = orderRiderId ? riders.find(r => String(r.id) === String(orderRiderId)) : null;
const activeOrderId = (() => {
if (!riderForOrder) return null;
const sortedAll = [...riderForOrder.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);
});
const active = sortedAll.find((x) => {
const s = String(x.orderstatus || '').toLowerCase();
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
return active ? active.orderid : null;
})();
const isGoingOn = activeOrderId && o.orderid === activeOrderId;
const estMeters = orderRiderId ? calculateEstMeters(orderRiderId, o) : null;
return (
<div
key={o.orderid}
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
>
<div className="zone-order-card-head">
<div className="zone-order-num">{o.step || idx + 1}</div>
<div className="zone-order-id-block">
<div className="zone-order-id">Order #{o.orderid}</div>
<div className="zone-order-rider">
<Ico><MdTwoWheeler /></Ico>{o.rider_name || o.ridername || 'Unassigned'}
</div>
</div>
{(() => {
const actual = formatTimeOnly(o.deliverytime);
const expected = formatTimeOnly(o.expecteddeliverytime);
const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase());
const showEstDrop = !isDelivered && estMeters !== null;
if (!o.orderstatus && !actual && !expected && !showEstDrop) return null;
return (
<div className="zone-order-status-stack">
{o.orderstatus && (
<span
className="zone-order-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{(actual || expected) && (
<span
className={`zone-order-time ${actual ? '' : 'is-expected'}`}
title={actual ? `Delivered at ${actual}` : `Expected at ${expected}`}
>
<MdAccessTime />{actual || expected}
</span>
)}
{showEstDrop && (
<span
className="zone-order-est-drop"
title="Estimated distance to drop location"
>
<MdMyLocation />{formatMeters(estMeters)}
</span>
)}
</div>
);
})()}
</div>
<div className="zone-order-customer">
<Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || ''}
</div>
{o.pickupcustomer && (
<div className="zone-order-line" title={`Kitchen: ${o.pickupcustomer}`}>
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
</div>
)}
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
)}
{o.ordernotes && (
<div className="zone-order-line zone-order-notes" title={o.ordernotes}>
<Ico><MdNotes /></Ico>{o.ordernotes}
</div>
)}
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
</span>
{o.ordertype && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
</span>
)}
<span className="zone-order-chip zone-order-trip">
T{o.trip_number || '-'} · S{o.step || idx + 1}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
</div>
) : focusedZone ? (
<div id="route-detail">
<button className="rd-back" onClick={() => setFocusedZone(null)}> Back to zones</button>
<div className="rd-rider-name" style={{ color: '#3b82f6' }}>{focusedZone.name}</div>
<div className="rd-rider-sub">
<span><Ico><MdInventory2 /></Ico>{focusedZone.totalOrders} {focusedZone.totalOrders === 1 ? 'order' : 'orders'}</span>
</div>
<div className="zone-detail-section">
<div className="zone-section-label">Orders <span className="section-count">({focusedZone.orders.length})</span></div>
{focusedZone.orders.length === 0 ? (
<div className="zone-suburb-panel-empty">No orders in this zone.</div>
) : (
<div className="zone-order-grid">
{focusedZone.orders.map((o, idx) => {
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
const statusStyle = getStatusStyle(o.orderstatus);
const orderRiderId = o.rider_id || o.userid;
const riderForOrder = orderRiderId ? riders.find(r => String(r.id) === String(orderRiderId)) : null;
const activeOrderId = (() => {
if (!riderForOrder) return null;
const sortedAll = [...riderForOrder.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);
});
const active = sortedAll.find((x) => {
const s = String(x.orderstatus || '').toLowerCase();
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
return active ? active.orderid : null;
})();
const isGoingOn = activeOrderId && o.orderid === activeOrderId;
const estMeters = orderRiderId ? calculateEstMeters(orderRiderId, o) : null;
return (
<div
key={o.orderid}
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
>
<div className="zone-order-card-head">
<div className="zone-order-num">
{o.step || idx + 1}
</div>
<div className="zone-order-id-block">
<div className="zone-order-id">Order #{o.orderid}</div>
<div className="zone-order-rider">
<Ico><MdTwoWheeler /></Ico>{o.rider_name || o.ridername || 'Unassigned'}
</div>
</div>
{(() => {
const actual = formatTimeOnly(o.deliverytime);
const expected = formatTimeOnly(o.expecteddeliverytime);
const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase());
const showEstDrop = !isDelivered && estMeters !== null;
if (!o.orderstatus && !actual && !expected && !showEstDrop) return null;
return (
<div className="zone-order-status-stack">
{o.orderstatus && (
<span
className="zone-order-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{(actual || expected) && (
<span
className={`zone-order-time ${actual ? '' : 'is-expected'}`}
title={actual ? `Delivered at ${actual}` : `Expected at ${expected}`}
>
<MdAccessTime />{actual || expected}
</span>
)}
{showEstDrop && (
<span
className="zone-order-est-drop"
title="Estimated distance to drop location"
>
<MdMyLocation />{formatMeters(estMeters)}
</span>
)}
</div>
);
})()}
</div>
<div className="zone-order-customer">
<Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || ''}
</div>
{o.pickupcustomer && (
<div className="zone-order-line" title={`Kitchen: ${o.pickupcustomer}`}>
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
</div>
)}
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
)}
{o.ordernotes && (
<div className="zone-order-line zone-order-notes" title={o.ordernotes}>
<Ico><MdNotes /></Ico>{o.ordernotes}
</div>
)}
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
</span>
{o.ordertype && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
</span>
)}
<span className="zone-order-chip zone-order-trip">
T{o.trip_number || '-'} · S{o.step || idx + 1}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
) : (
<div id="riders-panel">
<div className="ph">{
viewMode === 'zones' ? 'Zone dispatch' :
viewMode === 'kitchens' ? 'Kitchen dispatch' :
'Rider dispatch'
}</div>
<div id="rider-cards">
{allOrders.length === 0 && !liveIsFetching ? (
(() => {
const slotLabel = BATCHES.find(b => b.id === selectedBatch)?.label;
const hasDayData = shouldFetchLive && liveRows.length > 0;
return (
<div className="empty-slot">
<div className="empty-slot-icon">
<MdInventory2 />
</div>
<div className="empty-slot-title">
{slotLabel ? `No orders in ${slotLabel}` : 'No orders'}
</div>
<div className="empty-slot-sub">
{hasDayData
? `${liveRows.length} order${liveRows.length === 1 ? '' : 's'} exist in other slots today`
: 'No deliveries found for this date'}
</div>
</div>
);
})()
) : viewMode === 'zones' ? (
zoneCards.map((z, i) => {
const delivered = z.statusCounts.delivered || 0;
return (
<div key={z.id} className="rcard zone-card" onClick={() => setFocusedZone(z)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="zone-card-header">
<div className="zone-card-emoji"><MdMap /></div>
<div className="zone-card-titles">
<div className="zone-card-name">{z.name}</div>
<div className="zone-card-sub">
{z.activeRidersCount} {z.activeRidersCount === 1 ? 'rider' : 'riders'} · {z.totalOrders} {z.totalOrders === 1 ? 'order' : 'orders'}
</div>
</div>
<span className="zone-card-arrow" aria-hidden="true"></span>
</div>
{/* Status segments + delivered counter */}
{z.totalOrders > 0 && (
<div className="zone-progress-row">
<div
className="zone-status-bar"
title={Object.entries(z.statusCounts)
.map(([k, v]) => `${getStatusStyle(k).label}: ${v}`)
.join(' · ')}
>
{Object.entries(z.statusCounts).map(([status, count]) => {
const style = getStatusStyle(status);
const pct = (count / z.totalOrders) * 100;
return (
<div
key={status}
className="zone-status-seg"
style={{ width: `${pct}%`, background: style.bg }}
/>
);
})}
</div>
<div className="zone-progress-label">
{delivered}/{z.totalOrders}
</div>
</div>
)}
{/* Stat pills */}
<div className="zone-stat-pills">
<span className="zone-stat-pill" title="Areas covered">
<span className="zone-stat-icon"><MdLocationOn /></span>
<span className="zone-stat-value">{z.suburbs.length}</span>
<span className="zone-stat-label">{z.suburbs.length === 1 ? 'area' : 'areas'}</span>
</span>
<span className="zone-stat-pill" title="Total distance">
<span className="zone-stat-icon"><MdStraighten /></span>
<span className="zone-stat-value">{z.totalKms.toFixed(1)}</span>
<span className="zone-stat-label">km</span>
</span>
<span className="zone-stat-pill" title="Kitchens">
<span className="zone-stat-icon"><MdRestaurant /></span>
<span className="zone-stat-value">{z.kitchens.length}</span>
<span className="zone-stat-label">{z.kitchens.length === 1 ? 'kitchen' : 'kitchens'}</span>
</span>
</div>
{z.suburbs.length > 0 && (
<div className="zone-card-suburbs">
<span className="zone-card-suburbs-text">
{z.suburbs.slice(0, 3).map((s) => s.name).join(' · ')}
</span>
{z.suburbs.length > 3 && (
<span className="zone-card-suburbs-more">+{z.suburbs.length - 3}</span>
)}
</div>
)}
</div>
);
})
) : viewMode === 'kitchens' ? (
kitchens.map((k, i) => (
<div key={k.id} className="rcard" onClick={() => setFocusedKitchen(k)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: '#f59e0b18', borderColor: '#f59e0b50', color: '#f59e0b' }}><MdRestaurant /></div>
<div className="rcard-info">
<div className="rcard-name">{k.kitchenName}</div>
<div className="rcard-zone">{k.riders.size} {k.riders.size === 1 ? 'rider' : 'riders'}</div>
</div>
<div className="rcard-badge" style={{ background: '#f59e0b18', color: '#f59e0b' }}>{k.orders.length}</div>
</div>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (k.orders.length / 20) * 100)}%`, background: '#f59e0b' }}></div></div>
<div className="rcard-meta"><span><Ico><MdStraighten /></Ico>{k.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span><span>{k.riders.size} riders</span></div>
<div className="step-ids">
{Array.from(k.riders).slice(0, 10).map(rid => (
<span key={rid} className="step-id" style={{ color: getRiderColor(rid) }}>{riders.find(r => r.id === rid)?.riderName.split(' ')[0]}</span>
))}
</div>
</div>
))
) : (
riders.map(renderRiderCard)
)}
</div>
</div>
)}
</div>
<div id="map-wrap" className={`${viewMode === 'kitchens' ? 'view-mode-kitchens' : ''} ${compareOpen ? 'compare-split' : ''}`.trim()}>
<MapContainer
center={[11.022, 76.982]}
zoom={12}
scrollWheelZoom
style={{ height: '100%', width: '100%' }}
zoomControl={false}
renderer={plannedMapRendererRef.current}
inertia
inertiaDeceleration={2400}
inertiaMaxSpeed={2000}
wheelDebounceTime={20}
wheelPxPerZoomLevel={80}
zoomSnap={0.25}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" />
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} kitchens={kitchens} locationKey={selectedAppLocationId} />
{kitchens
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
.filter(k => !focusedRider || k.riders.has(focusedRider.id))
.map((k, i) => (
<Marker
key={`k-${i}`}
position={[k.lat, k.lon]}
icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id)}
zIndexOffset={focusedKitchen?.id === k.id ? 4000 : 2000}
eventHandlers={{
click: () => setFocusedKitchen(k),
mouseover: (e) => e.target.openPopup(),
mouseout: (e) => e.target.closePopup()
}}
>
<Popup className="kitchen-popup" maxWidth={220} minWidth={200} autoPan={true} autoPanPadding={[20, 20]}>
<div className="kp-header">KITCHEN</div>
<div className="kp-name">{k.kitchenName}</div>
<div className="kp-stat">
<span className="kp-stat-lbl">Orders</span>
<span className="kp-stat-val">{k.orders.length}</span>
</div>
</Popup>
</Marker>
))
}
{renderMarkers()}
{renderRoutes()}
{/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the
Reports → Riders Logs map: green pin when the rider's last log
row is `active`, red otherwise, with the rider's username as a
label. Scoped to riders who actually have orders in the
currently selected slot — `riders` is derived from
filteredLiveRows so it already reflects the slot filter. A
rider with zero orders in the current slot is hidden, even if
getriderlogs still returns their GPS row. When a specific
rider is focused, only that one is shown. */}
{liveRiderLocations
.filter((r) => riders.some((rd) => String(rd.id) === String(r.id)))
.filter((r) => !focusedRider || String(focusedRider.id) === String(r.id))
.map((r) => {
const isActive = r.status === 'active';
const pinColor = isActive ? '#16a34a' : '#dc2626';
// Look up the rider's in-progress order so the popup can show
// where they're heading next (drop customer/area + originating
// kitchen). Falls back to nothing when every order is final.
const matchingRider = riders.find((rd) => String(rd.id) === String(r.id));
const nextOrder = matchingRider?.orders
?.slice()
.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);
})
.find((o) => {
const s = String(o.orderstatus || '').toLowerCase();
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
const nextDropArea = nextOrder
? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress))
: null;
const liveIcon = L.divIcon({
className: '',
iconSize: [140, 56],
iconAnchor: [12, 41],
popupAnchor: [58, -40],
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
<div class="live-rider-pin-marker"></div>
<div class="live-rider-pin-label">${(r.username || '').replace(/[<>&"']/g, '')}${r.orderid ? ` <span>#${String(r.orderid).replace(/[<>&"']/g, '')}</span>` : ''}</div>
</div>`
});
return (
<Marker
key={`live-${r.id}`}
position={[r.lat, r.lon]}
icon={liveIcon}
zIndexOffset={2500}
eventHandlers={{
click: (e) => {
const idStr = String(r.id);
if (pinnedLivePopupsRef.current.has(idStr)) {
pinnedLivePopupsRef.current.delete(idStr);
e.target.closePopup();
} else {
pinnedLivePopupsRef.current.add(idStr);
e.target.openPopup();
}
const match = riders.find((rd) => String(rd.id) === idStr);
if (match) handleRiderFocus(match);
},
popupclose: () => {
pinnedLivePopupsRef.current.delete(String(r.id));
}
}}
>
<Popup maxWidth={260} autoPan={true} autoPanPadding={[20, 20]} className="dispatch-popup live-rider-popup">
<div className="pu-hdr-live">
<div className="pu-hdr-left">
<span className="pu-live-indicator" style={{ '--pulse-color': pinColor }}>
<span className="pu-live-dot"></span>
</span>
<span className="pu-hdr-title">LIVE GPS</span>
</div>
</div>
<div className="pu-rider-profile">
<div className="pu-avatar" style={{ backgroundColor: `${pinColor}12`, color: pinColor }}>
<MdDirectionsBike />
</div>
<div className="pu-rider-info-text">
<div className="pu-rider-name-row">
<span className="pu-rider-name">{r.username || `Rider #${r.id}`}</span>
{r.status && (
<span className={`pu-status-badge ${r.status.toLowerCase() === 'active' ? 'active' : 'idle'}`}>
{r.status}
</span>
)}
</div>
<div className="pu-rider-meta">Rider ID: #{r.id}</div>
</div>
</div>
<div className="pu-body-content">
{r.orderid && (
<div className="pu-info-row">
<span className="pu-info-label">Active Order</span>
<span className="pu-info-value pu-order-badge">#{r.orderid}</span>
</div>
)}
{nextOrder && (
<div className="pu-info-row">
<span className="pu-info-label">Next Stop</span>
<span className="pu-info-value" style={{ color: '#4f46e5' }}>
#{nextOrder.step || '?'} · {nextOrder.deliverycustomer || '—'}
</span>
</div>
)}
{nextDropArea && (
<div className="pu-info-row">
<span className="pu-info-label">Next Location</span>
<span
className="pu-info-value"
title={nextOrder.deliveryaddress || nextDropArea}
>
{nextDropArea}
</span>
</div>
)}
{nextOrder?.pickupcustomer && (
<div className="pu-info-row">
<span className="pu-info-label">Pickup</span>
<span className="pu-info-value" title={nextOrder.pickupcustomer}>
{nextOrder.pickupcustomer}
</span>
</div>
)}
{r.contactno && (
<div className="pu-info-row">
<span className="pu-info-label">Phone</span>
<a href={`tel:${r.contactno}`} className="pu-info-value pu-phone-link">
{r.contactno}
</a>
</div>
)}
{r.logdate && (
<div className="pu-info-row">
<span className="pu-info-label">Last Seen</span>
<span className="pu-info-value pu-time-stamp">
<MdAccessTime className="inline-icon" />{' '}
{dayjs(r.logdate).isValid() ? dayjs(r.logdate).format('hh:mm:ss A') : r.logdate}
</span>
</div>
)}
<div className="pu-info-row">
<span className="pu-info-label">Position</span>
<span className="pu-info-value pu-coordinates">
{r.lat.toFixed(5)}, {r.lon.toFixed(5)}
</span>
</div>
</div>
</Popup>
</Marker>
);
})}
{/* Compare mode — actual GPS tracks for the focused rider, drawn
on the same MapContainer as the planned route. Gated by
compareViewMode so the segmented control on the map's top-left
can hide them ("Planned only") or hide the planned polylines
instead ("Actual only"). In Combined mode both render and the
planned polylines switch to a dashed stroke (see renderRoutes)
so the operator can read overlap at a glance. */}
{compareOpen && focusedRider && compareViewMode !== 'planned' && (riderActualTracks.map((t, i) => {
if (t.coords.length === 0) return null;
// `color` drives the drop pin, start pin, and tooltip header so
// those keep their per-step palette identity (the same colors
// the timeline uses). `polylineColor` is what the GPS line
// itself draws with — collapsed to a single emerald in Combined
// view so the actual layer reads as one cohesive trail next to
// the indigo planned rail; Actual-only mode keeps step palette
// on the polyline since there's no second layer to confuse with.
const color = stepColor(i);
const polylineColor = compareViewMode === 'combined'
? COMBINED_ACTUAL_COLOR
: color;
const startPos = [t.coords[0].lat, t.coords[0].lng];
const endPos = [t.coords[t.coords.length - 1].lat, t.coords[t.coords.length - 1].lng];
const snapped = osrmTrackRoutes[t.deliveryid];
const hasRoad = Array.isArray(snapped) && snapped.length >= 2;
const fullPositions = hasRoad
? snapped
: t.coords.map((p) => [p.lat, p.lng]);
let positions = fullPositions;
let drawPolyline = true;
if (isAnimating) {
const progress = animatedActualProgress[t.sequenceStep] || 0;
if (progress < 2) {
drawPolyline = false;
} else {
positions = fullPositions.slice(0, Math.min(progress, fullPositions.length));
}
}
const isFocusedStep = focusedCompareStep === t.sequenceStep;
const statusLow = String(t.orderstatus || '').toLowerCase();
const isDelivered = FINAL_STATUSES.has(statusLow);
const isSkipped = SKIPPED_STATUSES.has(statusLow);
const delta = compareDeltas.find((d) => d.sequenceStep === t.sequenceStep);
const isAnomaly = !!delta?.anomaly;
// Look up the corresponding order in the focused rider's set so
// the actual-track drop pin can render the same rich popup the
// planned-route number marker uses (Timeline, Details, KM chips).
// Matched by deliveryid since order.orderid is not in the track
// payload but deliveryid is the join key for /getdeliverylogs.
const orderForTrack = focusedRider?.orders?.find(
(o) => o.deliveryid != null && String(o.deliveryid) === String(t.deliveryid)
);
const statusStyle = getStatusStyle(t.orderstatus);
const flagSvg = t.orderstatus
? `<svg class="cmark-flag" viewBox="0 0 18 22" xmlns="http://www.w3.org/2000/svg">
<line x1="1.5" y1="0" x2="1.5" y2="22" stroke="#0f172a" stroke-width="1.6" stroke-linecap="round"/>
<polygon points="2,1 17,1 13.5,5.5 17,10 2,10" fill="${statusStyle.bg}" stroke="#0f172a" stroke-width="0.6" stroke-linejoin="round"/>
${isDelivered ? '<polyline points="5,5.5 7,7.5 11,3.5" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>' : ''}
</svg>`
: '';
const dropClasses = ['compare-step-pin'];
if (isFocusedStep) dropClasses.push('is-focused');
if (isDelivered) dropClasses.push('is-delivered');
if (isSkipped) dropClasses.push('is-skipped');
if (isAnomaly) dropClasses.push('is-anomaly');
const dropPinHtml =
`<div class="${dropClasses.join(' ')}" style="--pin-color:${color}">` +
`<span class="compare-step-pin-num">${t.sequenceStep}</span>` +
(isDelivered
? '<svg class="compare-step-pin-check" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<path d="M2.5 6.5 L5 9 L9.5 3.5" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>'
: '') +
'</div>';
const sequenceIcon = L.divIcon({
className: '',
iconSize: [36, 36],
iconAnchor: [18, 18],
// Lift the popup ~22px above the pin so its tip clears the
// marker icon. Without this the popup centers on the marker,
// its body overlaps the pin, and the cursor moves onto the
// popup → fires mouseout on the marker → popup snaps shut.
popupAnchor: [0, -22],
html: dropPinHtml
});
const showStartMarker = t.sequenceStep === 1;
const startIconEl = showStartMarker
? L.divIcon({
className: '',
iconSize: [40, 40],
iconAnchor: [20, 20],
html:
`<div class="compare-start-pin" style="--pin-color:${color}">` +
'<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<path d="M19 6h-2c0-2.76-2.24-5-5-5S7 3.24 7 6H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-7-3c1.66 0 3 1.34 3 3H9c0-1.66 1.34-3 3-3zm0 10c-2.76 0-5-2.24-5-5h2c0 1.66 1.34 3 3 3s3-1.34 3-3h2c0 2.76-2.24 5-5 5z" fill="currentColor"/>' +
'</svg></div>'
})
: null;
const handleStepClick = (e) => {
if (e.originalEvent) e.originalEvent.stopPropagation();
setFocusedCompareStep((prev) =>
prev === t.sequenceStep ? null : t.sequenceStep
);
};
// Drop pin click — toggles step focus AND opens the rich order
// popup, so the actual / combined view answers the same "tell me
// about this delivery" question the planned-route number marker
// answers. The pickup (start) pin keeps the focus-only handler;
// there's no per-delivery order modal attached to step 1's
// origin since it's the rider's pickup point, not the drop.
const handleEndMarkerClick = (e) => {
if (e.originalEvent) e.originalEvent.stopPropagation();
setFocusedCompareStep((prev) =>
prev === t.sequenceStep ? null : t.sequenceStep
);
if (orderForTrack && e.target && typeof e.target.openPopup === 'function') {
e.target.openPopup();
}
};
// Combined view rail-offset (negative side): mirror image of
// the +5px shift applied to the planned polyline in
// renderRoutes. With planned at +5 and actual at -5, the two
// layers sit as parallel rails ~10px apart when they share
// a road, so the operator can read both even on tight match.
// Actual-only and Planned-only modes leave offset = 0 since
// there's only one layer drawing on the map.
const actualOffset = compareViewMode === 'combined' ? -5 : 0;
return (
<React.Fragment key={`actual-${t.deliveryid}`}>
{drawPolyline && (
<Polyline
positions={positions}
pathOptions={{
color: '#ffffff',
weight: isFocusedStep ? 11 : 9,
opacity: isFocusedStep ? 0.75 : 0.55,
lineJoin: 'round',
lineCap: 'round',
offset: actualOffset
}}
/>
)}
{drawPolyline && (
<Polyline
positions={positions}
pathOptions={{
color: polylineColor,
weight: isFocusedStep ? 6.5 : 5,
opacity: isFocusedStep ? 1 : focusedCompareStep ? 0.55 : 0.95,
lineJoin: 'round',
lineCap: 'round',
offset: actualOffset
}}
/>
)}
{showStartMarker && (
<Marker
position={startPos}
icon={startIconEl}
zIndexOffset={isFocusedStep ? 900 : 100}
eventHandlers={{ click: handleStepClick }}
>
<Tooltip
direction="top"
offset={[0, -12]}
opacity={1}
className="compare-tooltip"
>
<div className="cmp-tip">
<div className="cmp-tip-header">
<span className="cmp-tip-step" style={{ background: color }}>
<MdLocalMall />
</span>
<div className="cmp-tip-title-stack">
<div className="cmp-tip-title">
{t.pickupcustomer || 'Pickup'}
</div>
<div className="cmp-tip-sub">
{t.coords[0]?.logdate
? `Picked up · ${dayjs(t.coords[0].logdate).format('hh:mm A')}`
: 'Rider trip origin'}
</div>
</div>
</div>
<div className="cmp-tip-action">Click for step 1 details</div>
</div>
</Tooltip>
</Marker>
)}
<Marker
position={endPos}
icon={sequenceIcon}
zIndexOffset={isFocusedStep ? 1000 : 200}
eventHandlers={
orderForTrack
? {
// Match the planned-route marker UX: hover surfaces
// the rich order card in the centered overlay. The
// ~200ms grace timer on mouseout lets the cursor
// travel onto the overlay without flicker. Pinning
// is implicit while focusedCompareStep === this
// step, so the card stays put while the user is
// inspecting this delivery.
mouseover: () => {
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = null;
}
setCenterPopupOrder(orderForTrack);
},
mouseout: () => {
if (focusedCompareStep === t.sequenceStep) return;
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
}
popupHoverTimerRef.current = setTimeout(() => {
setCenterPopupOrder((cur) =>
cur && String(cur.orderid) === String(orderForTrack.orderid) ? null : cur
);
popupHoverTimerRef.current = null;
}, 200);
},
click: handleEndMarkerClick
}
: { click: handleEndMarkerClick }
}
>
{!orderForTrack && (
<Tooltip
direction="top"
offset={[0, -20]}
opacity={1}
className="compare-tooltip"
>
{(() => {
const ss = getStatusStyle(t.orderstatus);
return (
<div className="cmp-tip">
<div className="cmp-tip-header">
<span className="cmp-tip-step" style={{ background: color }}>
{t.sequenceStep}
</span>
<div className="cmp-tip-title-stack">
<div className="cmp-tip-title">
{t.deliverycustomer || `Step ${t.sequenceStep}`}
</div>
<div className="cmp-tip-sub">
{t.deliverytime
? `Delivered ${dayjs(t.deliverytime).format('hh:mm A')}`
: `${t.coords.length} GPS pings`}
</div>
</div>
{t.orderstatus && (
<span
className="cmp-tip-tag"
style={{ background: ss.bg, color: ss.fg }}
>
{ss.label}
</span>
)}
</div>
{isAnomaly && (
<div className="cmp-tip-anomaly">
Deviation flagged see details below
</div>
)}
<div className="cmp-tip-action">
{isFocusedStep ? 'Click to deselect' : 'Click for details'}
</div>
</div>
);
})()}
</Tooltip>
)}
</Marker>
</React.Fragment>
);
}))}
</MapContainer>
{compareOpen && focusedRider && (
<div className="compare-view-switcher" role="group" aria-label="Compare view layer">
<button
type="button"
className={compareViewMode === 'actual' ? 'is-active' : ''}
onClick={() => setCompareViewMode('actual')}
title="Show only the rider's actual GPS trail"
>
Actual
</button>
<button
type="button"
className={compareViewMode === 'planned' ? 'is-active' : ''}
onClick={() => setCompareViewMode('planned')}
title="Show only the dispatched planned route"
>
Planned
</button>
<button
type="button"
className={compareViewMode === 'combined' ? 'is-active' : ''}
onClick={() => setCompareViewMode('combined')}
title="Overlay planned (dashed) and actual (solid) on one map"
>
Combined
</button>
</div>
)}
<div id="ov-tl">
{/* <div className="ov-card">
<div className="ov-stats">
<div><div className="osv g">{activeStats.orders}</div><div className="osl">Orders</div></div>
<div><div className="osv">{activeStats.riders}</div><div className="osl">Riders</div></div>
<div><div className="osv g">₹{activeStats.profit.toFixed(0)}</div><div className="osl">Profit</div></div>
</div>
</div> */}
</div>
{/* Right-corner rider/kitchen legend hidden per spec — the same
delivered/total count now renders inside the left sidebar's
rider card badge (see renderRiderCard). Restore this block to
bring back the floating top-right chip list.
<div id="ov-tr">
{viewMode === 'kitchens' ? (
kitchens.slice(0, 10).map(k => {
const total = k.orders.length;
const delivered = k.orders.filter((o) =>
FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase())
).length;
const isDone = total > 0 && delivered >= total;
return (
<div key={k.id} className={`rchip ${focusedKitchen?.id === k.id ? 'active' : ''}`} onClick={() => setFocusedKitchen(k)} title={`${delivered} delivered of ${total} total`}>
<div className="rchip-dot" style={{ background: '#f59e0b' }}></div>
<span style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{k.kitchenName}</span>
<span className={`rchip-n ${isDone ? 'is-done' : ''}`}>{delivered}/{total}</span>
</div>
);
})
) : (
riders.slice(0, 10).map(r => {
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;
return (
<div key={r.id} className={`rchip ${focusedRider?.id === r.id ? 'active' : ''}`} onClick={() => handleRiderFocus(r)} title={`${delivered} delivered of ${total} total`}>
<div className="rchip-dot" style={{ background: r.color }}></div>
<span style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.riderName}</span>
<span className={`rchip-n ${isDone ? 'is-done' : ''}`}>{delivered}/{total}</span>
</div>
);
})
)}
</div>
*/}
<div id="ov-br">
<button className={`sbt ${isAnimating ? 'active' : ''}`} onClick={startAnimation} style={{ boxShadow: 'var(--shadow-lg)', background: isAnimating ? 'var(--accent)' : '#fff' }}>
<span>{isAnimating ? '⏹' : '▶'}</span> {isAnimating ? 'Stop' : 'Animate Routes'}
</button>
</div>
</div>
{/* Compare-mode second pane — actual GPS tracks per delivery for the
focused rider. Fans out /deliveries/getdeliverylogs/?deliveryid=X
(one query per order, cached by deliveryid) and overlays each
track as a colored polyline + start/end markers. Renders alongside
#map-wrap when compareOpen so the operator can eyeball planned vs.
actual side by side. */}
{compareOpen && focusedRider && (
<div id="compare-map-wrap">
{(() => {
const total = riderActualTracks.length;
const loaded = riderActualTracks.filter((t) => t.coords.length > 0).length;
const loading = riderActualTracks.filter((t) => t.isLoading).length;
const pct = total > 0 ? Math.round((loaded / total) * 100) : 0;
const isDone = total > 0 && loaded === total && loading === 0;
const focusedDelta =
focusedCompareStep != null
? compareDeltas.find((d) => d.sequenceStep === focusedCompareStep)
: null;
return (
<div className="compare-header-v2">
<div className="compare-header-row">
<div className="compare-title">
<span className="compare-title-dot" style={{ background: focusedRider.color }} />
<span className="compare-title-name">{focusedRider.riderName}</span>
<span className="compare-title-badge">ACTUAL vs PLANNED</span>
</div>
<div className="compare-header-tools">
{focusedCompareStep != null && (
<button
type="button"
className="compare-overall-btn"
onClick={() => setFocusedCompareStep(null)}
title="Zoom out to the whole day"
>
<MdPublic /> Overall
</button>
)}
<button
type="button"
className={`compare-timeline-toggle${compareTimelineOpen ? ' is-open' : ''}`}
onClick={() => setCompareTimelineOpen((v) => !v)}
title={compareTimelineOpen
? 'Hide planned/actual timeline'
: 'Show planned/actual timeline'}
aria-expanded={compareTimelineOpen}
>
<MdExpandMore />
</button>
</div>
</div>
{/* Step timeline — every delivery as a tappable dot in chronological
order. The operator can drill into any step to scrutinize that
single delivery on both maps. Filled = delivered, ring = pending,
dim = cancelled/skipped, ring with spinner = GPS still loading.
Whole strip (timeline + progress + legend) collapses via the
header chevron — open by default. */}
{compareTimelineOpen && (
<>
<div className="compare-timeline-wrap">
<div className="compare-timeline-container">
<div className="compare-timeline-labels">
<div className="compare-timeline-label">Planned</div>
<div className="compare-timeline-label">Actual</div>
</div>
<div className="compare-timeline-scrollable">
{/* Planned Row */}
<div className="compare-timeline-track is-planned">
{plannedOrdered.map((d, i) => {
const statusLow = String(d.orderstatus || '').toLowerCase();
const isDelivered = FINAL_STATUSES.has(statusLow);
const isSkipped = SKIPPED_STATUSES.has(statusLow);
const isFocused = focusedCompareStep === d.sequenceStep;
const isLoading = d.isLoading && d.coordsCount === 0;
const isNoData = !d.isLoading && d.coordsCount === 0;
const plannedStepNum = d.order?.step || d.sequenceStep;
const color = stepColor(plannedStepNum - 1);
const cls = [
'compare-step',
isFocused && 'is-focused',
isDelivered && 'is-delivered',
isSkipped && 'is-skipped',
!isDelivered && !isSkipped && 'is-pending',
isLoading && 'is-loading',
isNoData && 'is-no-data',
d.anomaly && 'is-anomaly'
].filter(Boolean).join(' ');
return (
<React.Fragment key={`step-p-${d.deliveryid}`}>
{i > 0 && <span className="compare-step-spacer" />}
<button
type="button"
className={cls}
style={{ '--step-color': color }}
onClick={() =>
setFocusedCompareStep((prev) =>
prev === d.sequenceStep ? null : d.sequenceStep
)
}
title={
`Planned Step ${plannedStepNum}` +
(d.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
(d.expectedTs ? ` · ${d.expectedTs.format('hh:mm A')}` : '') +
(d.anomaly ? ' · deviation flagged' : '')
}
>
<span className="compare-step-circle">
{isLoading ? <span className="compare-step-spin" /> : plannedStepNum}
</span>
{d.expectedTs && (
<span className="compare-step-tick">
{d.expectedTs.format('HH:mm')}
</span>
)}
</button>
</React.Fragment>
);
})}
</div>
{/* Actual Row */}
<div className="compare-timeline-track is-actual">
{actualOrdered.map((d, i) => {
const statusLow = String(d.orderstatus || '').toLowerCase();
const isDelivered = FINAL_STATUSES.has(statusLow);
const isSkipped = SKIPPED_STATUSES.has(statusLow);
const isFocused = focusedCompareStep === d.sequenceStep;
const isLoading = d.isLoading && d.coordsCount === 0;
const isNoData = !d.isLoading && d.coordsCount === 0;
const plannedStepNum = d.order?.step || d.sequenceStep;
const color = stepColor(plannedStepNum - 1);
const cls = [
'compare-step',
isFocused && 'is-focused',
isDelivered && 'is-delivered',
isSkipped && 'is-skipped',
!isDelivered && !isSkipped && 'is-pending',
isLoading && 'is-loading',
isNoData && 'is-no-data',
d.anomaly && 'is-anomaly'
].filter(Boolean).join(' ');
return (
<React.Fragment key={`step-a-${d.deliveryid}`}>
{i > 0 && <span className="compare-step-spacer" />}
<button
type="button"
className={cls}
style={{ '--step-color': color }}
onClick={() =>
setFocusedCompareStep((prev) =>
prev === d.sequenceStep ? null : d.sequenceStep
)
}
title={
`Actual Visit ${i + 1} (Planned Step ${plannedStepNum})` +
(d.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
(d.actualTs ? ` · ${d.actualTs.format('hh:mm A')}` : '') +
(d.anomaly ? ' · deviation flagged' : '')
}
>
<span className="compare-step-circle">
{isLoading ? <span className="compare-step-spin" /> : plannedStepNum}
</span>
{d.actualTs && (
<span className="compare-step-tick">
{d.actualTs.format('HH:mm')}
</span>
)}
{d.anomaly && <span className="compare-step-flag" title="Deviation flagged" />}
</button>
</React.Fragment>
);
})}
</div>
</div>
</div>
<div className="compare-progress-strip">
<div className="compare-progress-bar-wrap">
<div
className={`compare-progress-bar-fill${isDone ? ' is-done' : ''}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="compare-progress-text">
{loading > 0 ? `Loading GPS… ${loaded}/${total}` : `${loaded}/${total} tracks`}
</span>
</div>
</div>
{/* Legend strip — one line of icons so the operator instantly knows
what each line/marker means. Lives in the header so it doesn't
compete with the map for vertical real estate. */}
{(() => {
// Two color stories depending on view mode:
// • Combined: polylines collapse to fixed indigo (planned)
// and emerald (actual) so the two overlaid layers can be
// told apart at a glance. Legend swatches mirror this.
// • Planned-only / Actual-only: single layer on the map,
// so polylines keep STEP_PALETTE and the swatch shows
// the focused step's color or a step-gradient strip
// (signals "varies by step").
const isCombined = compareViewMode === 'combined';
const stepSwatchBg = focusedDelta
? stepColor(focusedDelta.sequenceStep - 1)
: `linear-gradient(90deg, ${STEP_PALETTE.slice(0, 6).join(', ')})`;
const plannedSwatchBg = isCombined ? COMBINED_PLANNED_COLOR : stepSwatchBg;
const actualSwatchBg = isCombined ? COMBINED_ACTUAL_COLOR : stepSwatchBg;
return (
<div className="compare-legend">
<span className="compare-legend-item">
<span
className="compare-legend-swatch is-step-color is-dashed"
style={{ background: plannedSwatchBg }}
/>
Planned (dashed)
</span>
<span className="compare-legend-item">
<span
className="compare-legend-swatch is-step-color"
style={{ background: actualSwatchBg }}
/>
Actual GPS (solid)
</span>
<span className="compare-legend-note">
Kalman-smoothed GPS · OSRM road-snapped
</span>
</div>
);
})()}
</>
)}
</div>
);
})()}
</div>
)}
{/* Right-side data panel — full-height column in Compare mode. Renders
day-overview tiles (distance, deviation, on-time), the
focused-step delta when a step is selected, a deviations list of
anomaly steps, and a per-step list showing whether each step was
followed correctly (delivered + within plan) and the delivery time
vs expected. Clicking any step row mirrors the timeline click —
sets focusedCompareStep so both maps zoom onto that delivery. */}
{compareOpen && focusedRider && (
<CompareDataPanel
focusedRider={focusedRider}
compareDeltas={compareDeltas}
compareSummary={compareSummary}
actualOrdered={actualOrdered}
focusedCompareStep={focusedCompareStep}
setFocusedCompareStep={setFocusedCompareStep}
sequenceOpen={sequenceOpen}
setSequenceOpen={setSequenceOpen}
expandedSeqGroups={expandedSeqGroups}
setExpandedSeqGroups={setExpandedSeqGroups}
onClose={() => setCompareOpen(false)}
/>
)}
</div>
)}
{/* Centered order popup — sibling of the map (NOT inside leaflet's
transformed panes) so position: fixed actually pins it to the
viewport. Replaces the marker-attached leaflet Popup so the rich
order card stays fully visible at the screen center on small
laptop displays. */}
{centerPopupOrder && (
<div
className="dispatch-popup-center"
role="dialog"
aria-label={`Order ${centerPopupOrder.orderid} details`}
onMouseEnter={() => {
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = null;
}
}}
onMouseLeave={() => {
if (isOrderPopupPinned(centerPopupOrder)) return;
if (popupHoverTimerRef.current) clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = setTimeout(() => {
setCenterPopupOrder(null);
popupHoverTimerRef.current = null;
}, 200);
}}
>
<div className="dispatch-popup-card dispatch-popup">
<button
type="button"
className="dispatch-popup-center-close"
aria-label="Close order details"
onClick={() => {
pinnedPopupsRef.current.delete(String(centerPopupOrder.orderid));
setCenterPopupOrder(null);
}}
>
×
</button>
{renderOrderPopupContent(centerPopupOrder)}
</div>
</div>
)}
</>)}
{!embedded && topView === 'analysis' && (
<div id="dispatch-analysis">
<div className="da-picker-row">
{ANALYSIS_BATCH_WINDOWS.map((b) => {
const result = analysisResults[b.key];
const isLoading = analysisLoadingWindow === b.key;
const hasError = result?.data?.success === false;
const statusBg = hasError ? '#fee2e2' : result ? `${b.color}22` : '#f1f5f9';
const statusFg = hasError ? '#dc2626' : result ? b.color : '#64748b';
const statusLabel = isLoading
? 'Loading…'
: hasError
? '! Failed'
: result
? `${result.fetchedAt}`
: 'Fetch';
const isActive = activeBatchKey === b.key;
return (
<button
key={b.key}
type="button"
className={`da-picker ${result ? 'has-data' : ''} ${isLoading ? 'is-loading' : ''} ${isActive ? 'is-active' : ''}`}
onClick={() => !isLoading && handleFetchAnalysisBatch(b.key)}
style={{
borderColor: isActive ? b.color : hasError ? '#fecaca' : result ? b.border : '#e2e8f0',
background: hasError ? '#fef2f2' : result ? b.bg : '#ffffff',
boxShadow: isActive ? `0 0 0 2px ${b.color}33` : undefined
}}
disabled={isLoading}
>
<div className="da-picker-head">
<div
className="da-picker-badge"
style={{ background: `${b.color}22`, color: b.color }}
>
{b.label[0]}
</div>
<div className="da-picker-meta">
<div className="da-picker-name">{b.label}</div>
<div className="da-picker-range">{b.timeRange}</div>
</div>
<span
className="da-picker-status"
style={{ background: statusBg, color: statusFg }}
>
{statusLabel}
</span>
</div>
<div className="da-picker-sub">{b.sub}</div>
</button>
);
})}
</div>
{(() => {
if (!activeBatchKey) {
return (
<div className="da-empty">
Pick a batch above to view its efficiency analysis.
</div>
);
}
const activeMeta = ANALYSIS_BATCH_WINDOWS.find((b) => b.key === activeBatchKey);
const cached = analysisResults[activeBatchKey];
const isLoading = analysisLoadingWindow === activeBatchKey;
if (isLoading && !cached) {
return (
<div className="da-empty">Loading {activeMeta.label} batch</div>
);
}
if (!cached) return null;
const raw = cached.data || {};
if (raw.success === false) {
return (
<div
className="da-result-card da-result-card-error"
style={{ borderColor: '#fecaca', borderTopColor: '#ef4444' }}
>
<div className="da-result-head">
<div>
<div className="da-result-title">{activeMeta.label} Batch</div>
<div className="da-result-sub">
{activeMeta.timeRange} · Fetched at {cached.fetchedAt}
</div>
</div>
<button
type="button"
className="da-result-refresh"
title="Retry"
onClick={() => handleFetchAnalysisBatch(activeBatchKey)}
disabled={isLoading}
style={{ background: '#fee2e2', color: '#dc2626' }}
>
<MdRefresh />
</button>
</div>
<div className="da-error">
<div className="da-error-title">
<MdErrorOutline />
<span>{raw?.error?.code || 'Request failed'}</span>
</div>
<div className="da-error-msg">
{raw?.error?.message || 'The server returned an error.'}
</div>
{raw?.request_id && (
<div className="da-error-meta">request_id: {raw.request_id}</div>
)}
</div>
</div>
);
}
const fleet = raw.fleet_summary || {};
const riders = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : [];
const subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : [];
const rec = raw.top_recommendation;
const hasRecRider = !!(rec && (rec.idle_rider_name || rec.idle_rider_id));
const hasRec = !!(rec && rec.action && rec.action !== 'none' && hasRecRider);
const win = raw.window || {};
const fleetMetrics = [
{ label: 'Total Orders', value: analysisFormatNum(fleet.total_orders) },
{ label: 'Total Riders', value: analysisFormatNum(fleet.total_riders) },
{ label: 'Avg Orders/Rider', value: fleet.orders_per_rider_avg ?? '—' },
{ label: 'Fleet Start', value: fleet.fleet_start || '—' },
{ label: 'Fleet Done', value: fleet.fleet_done || '—' },
{ label: 'Duration', value: fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min` : '—' }
];
return (
<div className="da-detail">
<div
className="da-detail-head"
style={{ borderTopColor: activeMeta.color, background: activeMeta.bg }}
>
<div>
<div className="da-detail-title">
{activeMeta.label} Batch
<span className="da-detail-sub-inline">
{raw.date ? ` · ${raw.date}` : ''}
{win.from && win.to ? ` · ${win.from} ${win.to}` : ''}
</span>
</div>
<div className="da-detail-sub">
Fetched at {cached.fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'}
</div>
</div>
<button
type="button"
className="da-result-refresh"
title="Refresh"
onClick={() => {
// Force refetch (bypass cache)
setAnalysisResults((prev) => {
const next = { ...prev };
delete next[activeBatchKey];
return next;
});
batchEfficiencyMutation.mutate({
batch: activeBatchKey,
tenantId: ANALYSIS_TENANT_ID
});
}}
disabled={isLoading}
style={{ background: `${activeMeta.color}22`, color: activeMeta.color }}
>
<MdRefresh />
</button>
</div>
<div className="da-section">
<div className="da-section-label">Fleet Summary</div>
<div className="da-metric-grid da-metric-grid-3">
{fleetMetrics.map((m) => (
<div key={m.label} className="da-metric">
<div className="da-metric-label">{m.label}</div>
<div className="da-metric-value">{m.value}</div>
</div>
))}
</div>
</div>
{hasRec ? (
<div className="da-section">
<div className="da-section-label">Top Recommendation</div>
<div className="da-rec">
<div className="da-rec-head">
<div className="da-rec-action">
<MdInsights />
<span>{(rec.action || 'recommendation').replaceAll('_', ' ')}</span>
</div>
{rec.fleet_improvement_minutes != null && (
<span
className="da-rec-improve"
style={
rec.fleet_improvement_minutes > 0
? { background: '#dcfce7', color: '#166534' }
: { background: '#f1f5f9', color: '#475569' }
}
>
{rec.fleet_improvement_minutes > 0 ? '↑' : '•'} Fleet improves by {rec.fleet_improvement_minutes} min
</span>
)}
</div>
<div className="da-rec-line">
<strong>{rec.idle_rider_name || `Rider ${rec.idle_rider_id}`}</strong>
{rec.primary_kitchen && (
<> · primary kitchen <strong>{rec.primary_kitchen}</strong></>
)}
{rec.second_kitchen && (
<> also serve <strong>{rec.second_kitchen}</strong> after {rec.second_kitchen_dispatch_after || '—'}</>
)}
</div>
{rec.description && (
<div className="da-rec-desc">{rec.description}</div>
)}
{rec.activate_when?.rules?.length > 0 && (
<div className="da-rec-rules">
<div className="da-rec-rules-head">
Activate when ({rec.activate_when.condition || 'AND'}):
</div>
{rec.activate_when.rules.map((rule, i) => (
<div key={i} className="da-rec-rule">
<code>{rule.field} {rule.operator} {rule.value}</code>
{rule.reason && <span className="da-rec-rule-why"> {rule.reason}</span>}
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className="da-section">
<div className="da-section-label">Top Recommendation</div>
<div className="da-rec da-rec-empty">
<div className="da-rec-action">
<MdInsights />
<span>Fleet is balanced, no reassignment needed right now.</span>
</div>
</div>
</div>
)}
{riders.length > 0 && (
<div className="da-section">
<div className="da-section-label">
Rider Timelines <span className="da-section-count">({riders.length})</span>
</div>
<div className="da-timeline-list">
{riders.map((r) => {
const isActive = String(r.status || '').toLowerCase() === 'active';
return (
<div key={r.userid} className="da-timeline-card">
<div className="da-timeline-top">
<div className="da-timeline-name">
<MdTwoWheeler style={{ color: activeMeta.color }} />
<span>{r.name}</span>
<span className="da-timeline-id">#{r.userid}</span>
</div>
<span
className={`da-pill ${isActive ? 'is-active' : 'is-idle'}`}
>
{r.status}
</span>
</div>
<div className="da-timeline-mid">
{r.kitchen && (
<span className="da-chip">
<MdRestaurant /> {r.kitchen}
</span>
)}
<span className="da-chip">
<MdInventory2 /> {r.order_count} orders
</span>
<span className="da-chip">
<MdAccessTime /> {r.started_at} {r.finished_at}
</span>
<span
className="da-chip"
style={
r.idle_minutes > 30
? { background: '#fef3c7', color: '#92400e' }
: undefined
}
>
<MdTimer /> {r.idle_minutes} min idle
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{subs.length > 0 && (
<div className="da-section">
<div className="da-section-label">
Substitution Opportunities <span className="da-section-count">({subs.length})</span>
</div>
<div className="da-sub-list">
{subs.map((s, i) => {
const idle = s.idle_rider || {};
const relieved = s.most_relieved_rider || {};
const improved = s.fleet_improvement_minutes ?? 0;
return (
<div key={i} className="da-sub-card">
<div className="da-sub-head">
<div className="da-sub-title">
<strong>{idle.name || `Rider ${idle.userid}`}</strong>{' '}
covers <strong>{s.target_kitchen}</strong>
</div>
<span
className="da-sub-improve"
style={
improved > 0
? { background: '#dcfce7', color: '#166534' }
: { background: '#f1f5f9', color: '#475569' }
}
>
Fleet {improved > 0 ? '↑' : '•'} {improved} min
</span>
</div>
<div className="da-sub-meta">
<span className="da-chip">
<MdStraighten /> {s.travel_to_kitchen_km} km
</span>
<span className="da-chip">
<MdTimer /> {s.travel_to_kitchen_minutes} min travel
</span>
<span className="da-chip">
<MdAccessTime /> arrives {s.arrive_at_kitchen}
</span>
<span className="da-chip">
<MdInventory2 /> {s.total_orders_transferred} orders
</span>
<span className="da-chip">
<MdStraighten /> +{s.extra_km_for_idle_rider} km for idle rider
</span>
</div>
{relieved.name && (
<div className="da-sub-relieved">
<MdTrendingUp />
Most relieved: <strong>{relieved.name}</strong>{' '}
({relieved.original_finish} {relieved.new_finish}, saves{' '}
{relieved.time_saved_minutes} min)
</div>
)}
{Array.isArray(s.orders_to_transfer) && s.orders_to_transfer.length > 0 && (
<div className="da-sub-transfers">
<div className="da-sub-transfers-head">Orders transferred</div>
{s.orders_to_transfer.map((o) => {
const imp = o.improvement_minutes ?? 0;
return (
<div key={o.deliveryid} className="da-transfer-row">
<span className="da-transfer-id">#{o.deliveryid}</span>
<span className="da-transfer-from">
from {o.from_rider_name}
</span>
<span className="da-transfer-time">
{o.original_delivery_time} {o.estimated_delivery_time}
</span>
<span
className="da-transfer-imp"
style={
imp > 0
? { background: '#dcfce7', color: '#166534' }
: imp < 0
? { background: '#fee2e2', color: '#991b1b' }
: { background: '#f1f5f9', color: '#475569' }
}
>
{imp > 0 ? '+' : ''}{imp} min
</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
})()}
</div>
)}
</div>
);
};
export default Dispatch;