5483 lines
270 KiB
JavaScript
5483 lines
270 KiB
JavaScript
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,
|
||
MdTrendingDown,
|
||
MdAccountBalanceWallet,
|
||
MdStraighten,
|
||
MdLocationOn,
|
||
MdMarkunreadMailbox,
|
||
MdMoveToInbox,
|
||
MdPlace,
|
||
MdTwoWheeler,
|
||
MdNotes,
|
||
MdSwapHoriz,
|
||
MdExpandMore,
|
||
MdInfoOutline,
|
||
MdBatteryFull,
|
||
MdSignalCellularAlt,
|
||
MdMyLocation,
|
||
MdSpeed,
|
||
MdExplore,
|
||
MdAccessTime,
|
||
MdGpsFixed,
|
||
MdPower,
|
||
MdSearch,
|
||
MdChevronLeft,
|
||
MdChevronRight,
|
||
MdLocalMall,
|
||
MdCheckCircle,
|
||
MdErrorOutline,
|
||
MdWarning,
|
||
MdClose,
|
||
MdFormatListBulleted,
|
||
MdTimer,
|
||
MdCalendarToday,
|
||
MdInsights,
|
||
MdRefresh
|
||
} from 'react-icons/md';
|
||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
|
||
import {
|
||
STATUS_STYLES,
|
||
getStatusStyle,
|
||
FINAL_STATUSES,
|
||
SKIPPED_STATUSES,
|
||
STEP_PALETTE,
|
||
stepColor
|
||
} from './dispatchShared';
|
||
import CompareDataPanel from './CompareDataPanel';
|
||
import './Dispatch.css';
|
||
import logger from '../../../utils/logger';
|
||
|
||
// Combined-mode rail colors. The per-step palette (STEP_PALETTE) is great for
|
||
// "which step is this" but useless for "is this line planned or actual" when
|
||
// both layers overlay. In Combined view only we drop the step palette on
|
||
// polylines and use fixed, high-contrast colors: indigo for the dispatched
|
||
// plan (matches the "Compare" button + the prior "Planned Route" label) and
|
||
// emerald for the actual GPS trail (signals "live / real" data). Per-step
|
||
// distinction in Combined view is carried by the numbered drop pins, which
|
||
// keep STEP_PALETTE so the timeline link to a specific delivery survives.
|
||
const COMBINED_PLANNED_COLOR = '#6366f1';
|
||
const COMBINED_ACTUAL_COLOR = '#10b981';
|
||
|
||
const toNum = (v) => {
|
||
const n = parseFloat(v);
|
||
return Number.isFinite(n) ? n : NaN;
|
||
};
|
||
|
||
// Long delivery addresses come in two shapes:
|
||
// 1. Comma-separated: "Room No:C-4, Second Floor, ..., Vetrilaikara St,
|
||
// Peelamedu" — we keep the last two segments (typically street + area).
|
||
// 2. Free-form / space-separated: "Vistara Homes 71 & 72 ... Uppilipalayam
|
||
// post Coimbatore - 641 015 Opposite ..." — Indian addresses often run
|
||
// everything into one comma-less string. There's no reliable way to
|
||
// pick the locality token, so we hard-cap to the last 6 words and trim
|
||
// to ~40 chars; the full address still lives in the row's title tooltip.
|
||
const extractArea = (addr) => {
|
||
if (!addr) return '';
|
||
const str = String(addr).trim();
|
||
if (!str) return '';
|
||
if (str.includes(',')) {
|
||
const parts = str.split(',').map((s) => s.trim()).filter(Boolean);
|
||
if (parts.length === 0) return str;
|
||
if (parts.length <= 2) return parts.join(', ');
|
||
return parts.slice(-2).join(', ');
|
||
}
|
||
const words = str.split(/\s+/).filter(Boolean);
|
||
const tail = words.length > 6 ? words.slice(-6).join(' ') : str;
|
||
return tail.length > 40 ? `${tail.slice(0, 40).trim()}…` : tail;
|
||
};
|
||
|
||
const hasValidDrop = (o) => Number.isFinite(toNum(o.droplat || o.deliverylat)) && Number.isFinite(toNum(o.droplon || o.deliverylong));
|
||
// Try multiple field-name variants — the live delivery API may return pickuplatitude/picklongitude
|
||
// or pickuplongitude instead of the shorter pickuplat/pickuplong used in the static data.
|
||
const pickupLat = (o) => o.pickuplat || o.pickuplatitude || o.pickup_lat;
|
||
const pickupLon = (o) => o.pickuplong || o.pickuplongitude || o.picklongitude || o.pickup_lon;
|
||
const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isFinite(toNum(pickupLon(o)));
|
||
|
||
// Named delivery batches — operator's mental model of the day's waves.
|
||
// Each entry covers a half-open range [startHour, endHour) measured in
|
||
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
|
||
// Three named batches, bucketed by assigntime per spec:
|
||
// • Morning Batch: before 8 AM (00:00 → 08:00)
|
||
// • Afternoon Batch: 9 AM → 12:30 PM (09:00 → 12:30)
|
||
// • Evening Batch: 4 PM → 7 PM (16:00 → 19:00)
|
||
// Gaps (8–9 AM, 12:30 PM–4 PM, 7 PM+) intentionally fall outside every batch.
|
||
const BATCHES_DEFAULT_RAW = [
|
||
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
|
||
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
|
||
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
|
||
];
|
||
|
||
// v8: afternoon batch extended to 12:30 PM. Bumping from v7 wipes the
|
||
// cached layouts that still hold the old endHour: 12 value.
|
||
const SLOTS_STORAGE_KEY = 'dispatch.slots.v8';
|
||
|
||
// Every prior storage key. Wiped once on mount so stale layouts
|
||
// from earlier code versions can't reappear on the next page load.
|
||
const LEGACY_SLOTS_STORAGE_KEYS = [
|
||
'dispatch.slots.v1',
|
||
'dispatch.slots.v2',
|
||
'dispatch.slots.v3',
|
||
'dispatch.slots.v4',
|
||
'dispatch.slots.v5',
|
||
'dispatch.slots.v6',
|
||
'dispatch.slots.v7'
|
||
];
|
||
|
||
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
|
||
// fractional startHour (24h, half-hour steps). Mirrors the human-readable
|
||
// form the defaults use, so user-edited slots still look consistent.
|
||
const formatSlotLabel = (idx, startHour) => {
|
||
return `Slot ${idx + 1} · ${formatHourLabel(startHour)}`;
|
||
};
|
||
|
||
// Render a fractional hour as a human-readable clock label. Whole hours
|
||
// render as "8 AM"; half-hour values render as "12:30 PM". Other fractions
|
||
// round to the nearest minute (covers any future extension to quarter-hours
|
||
// without losing precision in the label).
|
||
const formatHourLabel = (h) => {
|
||
const wholeHour = Math.floor(h);
|
||
const minutes = Math.round((h - wholeHour) * 60);
|
||
const hr = ((wholeHour + 11) % 12) + 1;
|
||
const ampm = wholeHour >= 12 && wholeHour < 24 ? 'PM' : 'AM';
|
||
if (minutes === 0) return `${hr} ${ampm}`;
|
||
const mm = String(minutes).padStart(2, '0');
|
||
return `${hr}:${mm} ${ampm}`;
|
||
};
|
||
|
||
const formatSlotRange = (startHour, endHour) => {
|
||
if (endHour >= 24) return `After ${formatHourLabel(startHour)}`;
|
||
return `${formatHourLabel(startHour)}–${formatHourLabel(endHour)}`;
|
||
};
|
||
|
||
// Derive the operator-facing label/range strings from BATCHES_DEFAULT_RAW.
|
||
// Doing it through the formatters (instead of hardcoding "Slot 2 · 12:30 PM"
|
||
// etc.) guarantees user-edited slots and default slots render the same way
|
||
// — no chance of drift between the two paths.
|
||
// Prefer the explicit `name` (e.g. "Morning Batch") when provided; fall back
|
||
// to the auto-generated "Slot N · 8 AM" label for any user-added slot that
|
||
// doesn't carry a name.
|
||
const BATCHES_DEFAULT = BATCHES_DEFAULT_RAW.map((s, i) => ({
|
||
...s,
|
||
label: s.name || formatSlotLabel(i, s.startHour),
|
||
range: formatSlotRange(s.startHour, s.endHour)
|
||
}));
|
||
|
||
const getBatchForHour = (h, batches) => {
|
||
for (const b of batches) {
|
||
if (h >= b.startHour && h < b.endHour) return b.id;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Time fields the operator can pick from to drive slot bucketing. Each
|
||
// option maps to a column on the delivery row; the chosen one becomes the
|
||
// timestamp `getRowBatch` reads. "Delivery" defaults to actual deliverytime
|
||
// with a fallback to expecteddeliverytime so undelivered orders still bucket.
|
||
const TIME_FIELDS = [
|
||
{ id: 'delivered', label: 'Delivered', keys: ['deliverytime'] },
|
||
{ id: 'pending', label: 'Pending', keys: ['expecteddeliverytime'] },
|
||
{ id: 'assigned', label: 'Assigned', keys: ['assigntime'] },
|
||
{ id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] },
|
||
{ id: 'started', label: 'Started', keys: ['starttime'] },
|
||
{ id: 'arrived', label: 'Arrived', keys: ['arrivaltime'] },
|
||
{ id: 'pickup', label: 'Pickup', keys: ['pickuptime'] },
|
||
{ id: 'all', label: 'All', keys: ['deliverytime', 'expecteddeliverytime', 'assigntime', 'acceptedtime', 'arrivaltime', 'pickuptime', 'starttime'] }
|
||
];
|
||
|
||
const getTimeFieldValue = (r, fieldId) => {
|
||
const field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0];
|
||
for (const k of field.keys) {
|
||
if (r?.[k]) return r[k];
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => {
|
||
const t = getTimeFieldValue(r, fieldId);
|
||
if (!t) return null;
|
||
const str = String(t).trim();
|
||
// Skip bare date strings — no time component, would always parse to midnight.
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
||
const d = dayjs(t);
|
||
if (!d.isValid()) return null;
|
||
// Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which
|
||
// starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would
|
||
// truncate to 12 and mis-bucket the back half of every hour.
|
||
return getBatchForHour(d.hour() + d.minute() / 60, batches);
|
||
};
|
||
|
||
// Sits inside the Compare MapContainer and unpins any pinned popup whenever
|
||
// the operator clicks empty map space. Markers' click events do NOT bubble
|
||
// to the map, so this only fires on background clicks (which is what we
|
||
// want — clicking elsewhere should release the pin).
|
||
function CompareMapClickUnpin({ onUnpin }) {
|
||
useMapEvents({ click: () => onUnpin() });
|
||
return null;
|
||
}
|
||
|
||
// Captures the Leaflet map instance for the parent component via a ref. Kept
|
||
// available even after the two-map Compare layout was unified into one map,
|
||
// since future per-step imperative zoom logic still needs a handle on the
|
||
// planned map instance.
|
||
function CaptureMap({ targetRef }) {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
targetRef.current = map;
|
||
return () => { targetRef.current = null; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [map]);
|
||
return null;
|
||
}
|
||
|
||
// Haversine distance between two [lat, lng] points in kilometers. Good to
|
||
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
|
||
// polyline so the Compare delta panel can show "actual km" without depending
|
||
// on the backend's actualkms field (which can be stale or missing).
|
||
function haversineKm(a, b) {
|
||
const R = 6371; // km
|
||
const toRad = (d) => (d * Math.PI) / 180;
|
||
const lat1 = toRad(a[0]);
|
||
const lat2 = toRad(b[0]);
|
||
const dLat = toRad(b[0] - a[0]);
|
||
const dLon = toRad(b[1] - a[1]);
|
||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||
}
|
||
|
||
function polylineLengthKm(points) {
|
||
if (!Array.isArray(points) || points.length < 2) return 0;
|
||
let total = 0;
|
||
for (let i = 1; i < points.length; i++) {
|
||
total += haversineKm(points[i - 1], points[i]);
|
||
}
|
||
return total;
|
||
}
|
||
|
||
// ─── Kalman filter for GPS pings ─────────────────────────────────────────
|
||
//
|
||
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
|
||
// chronologically sorted list of GPS pings. Per-axis state: [position,
|
||
// velocity]. Constant-velocity dynamics with random acceleration as process
|
||
// noise; measurement model H = [1, 0] (we measure position only).
|
||
//
|
||
// Why a Kalman filter here:
|
||
// Raw /getdeliverylogs pings contain jitter (multi-path in dense urban
|
||
// areas), brief stationary noise (rider parked at the drop, GPS still
|
||
// wandering), and occasional outliers (cold-start fix). The Kalman pass
|
||
// fuses each ping with the predicted trajectory from prior pings, weighted
|
||
// by their relative uncertainty. The output is a smooth polyline that
|
||
// tracks the rider's real path without the zig-zags and bunched-up dots
|
||
// near drops, and it costs O(N) — runs once per delivery on fetch.
|
||
//
|
||
// Tuning:
|
||
// processNoise (q) — random-acceleration variance (deg²/s²). Lower = a
|
||
// smoother result but slower to follow sharp turns.
|
||
// measurementNoise (r) — GPS-fix variance (deg²). Higher = trust pings
|
||
// less, lean on the predicted state more.
|
||
// The defaults below correspond loosely to ~5m GPS accuracy and gentle
|
||
// urban acceleration. Bump q if smoothing eats genuine turns; bump r if
|
||
// the line still wiggles between pings.
|
||
function kalmanSmoothGps(pings, options = {}) {
|
||
if (!Array.isArray(pings) || pings.length === 0) return [];
|
||
if (pings.length === 1) {
|
||
return [{ lat: pings[0].lat, lng: pings[0].lng, logdate: pings[0].logdate }];
|
||
}
|
||
|
||
const processNoise =
|
||
options.processNoise != null ? options.processNoise : 1e-9;
|
||
const measurementNoise =
|
||
options.measurementNoise != null ? options.measurementNoise : 1e-7;
|
||
|
||
const tsOf = (p) =>
|
||
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
|
||
|
||
// Run a 1D Kalman over one axis (lat or lng). State: [pos, vel]; cov: 2x2.
|
||
const smoothAxis = (axisKey) => {
|
||
let x = pings[0][axisKey]; // position estimate
|
||
let v = 0; // velocity estimate
|
||
// Initial covariance — large enough that the first few measurements
|
||
// dominate over the initial state.
|
||
let p00 = 1, p01 = 0, p10 = 0, p11 = 1;
|
||
const out = [x];
|
||
let prevTs = tsOf(pings[0]);
|
||
|
||
for (let i = 1; i < pings.length; i++) {
|
||
const ts = tsOf(pings[i]) || prevTs + 1000;
|
||
const dt = Math.max(0.1, (ts - prevTs) / 1000);
|
||
prevTs = ts;
|
||
|
||
// ─── Predict ───
|
||
// x' = F x where F = [[1, dt], [0, 1]]
|
||
const xPred = x + v * dt;
|
||
const vPred = v;
|
||
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
|
||
const dt2 = dt * dt;
|
||
const dt3 = dt2 * dt;
|
||
const dt4 = dt3 * dt;
|
||
const np00 = p00 + dt * (p01 + p10) + dt2 * p11 + (dt4 / 4) * processNoise;
|
||
const np01 = p01 + dt * p11 + (dt3 / 2) * processNoise;
|
||
const np10 = p10 + dt * p11 + (dt3 / 2) * processNoise;
|
||
const np11 = p11 + dt2 * processNoise;
|
||
|
||
// ─── Update ───
|
||
// y = z − Hx' (innovation, measurement vs prediction)
|
||
const z = pings[i][axisKey];
|
||
const y = z - xPred;
|
||
// S = H P' H^T + R (innovation covariance)
|
||
const S = np00 + measurementNoise;
|
||
// K = P' H^T / S (Kalman gain)
|
||
const K0 = np00 / S;
|
||
const K1 = np10 / S;
|
||
// x = x' + K y
|
||
x = xPred + K0 * y;
|
||
v = vPred + K1 * y;
|
||
// P = (I − K H) P'
|
||
p00 = (1 - K0) * np00;
|
||
p01 = (1 - K0) * np01;
|
||
p10 = np10 - K1 * np00;
|
||
p11 = np11 - K1 * np01;
|
||
|
||
out.push(x);
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const lats = smoothAxis('lat');
|
||
const lngs = smoothAxis('lng');
|
||
return pings.map((p, i) => ({
|
||
lat: lats[i],
|
||
lng: lngs[i],
|
||
logdate: p.logdate,
|
||
_ts: p._ts
|
||
}));
|
||
}
|
||
|
||
// Splits a routed OSRM polyline into per-step segments by finding the
|
||
// polyline index closest to each drop waypoint. Returns an array of
|
||
// segments where segments[i] runs from the previous waypoint (or polyline
|
||
// start when i===0) to drops[i]. Adjacent segments share their endpoint
|
||
// so the rendered polylines visually touch with no gap.
|
||
//
|
||
// Used by renderRoutes in Compare mode to recolor the planned-route
|
||
// polyline per step, so the left map's step colors match the right map's
|
||
// per-step palette + the timeline strip. Without per-segment splitting
|
||
// the planned polyline would be a single rider-colored line that doesn't
|
||
// visually link to step N's actual GPS polyline.
|
||
function splitPolylineByDrops(polyline, drops) {
|
||
if (!Array.isArray(polyline) || polyline.length < 2 || !drops || !drops.length) {
|
||
return [];
|
||
}
|
||
const findClosestIndex = (target) => {
|
||
let best = 0;
|
||
let bestD = Infinity;
|
||
for (let i = 0; i < polyline.length; i++) {
|
||
const dy = polyline[i][0] - target[0];
|
||
const dx = polyline[i][1] - target[1];
|
||
const d = dy * dy + dx * dx;
|
||
if (d < bestD) { bestD = d; best = i; }
|
||
}
|
||
return best;
|
||
};
|
||
const cutIndices = drops.map(findClosestIndex);
|
||
// Force monotonically non-decreasing — in pathological route shapes the
|
||
// raw "closest" indices can briefly regress (OSRM doubles back near a
|
||
// waypoint), which would produce empty/negative slices below.
|
||
for (let i = 1; i < cutIndices.length; i++) {
|
||
if (cutIndices[i] < cutIndices[i - 1]) cutIndices[i] = cutIndices[i - 1];
|
||
}
|
||
const segments = [];
|
||
let prev = 0;
|
||
cutIndices.forEach((idx) => {
|
||
const end = Math.max(idx, prev);
|
||
segments.push(polyline.slice(prev, end + 1));
|
||
prev = end;
|
||
});
|
||
return segments;
|
||
}
|
||
|
||
|
||
// --- Marker popup helpers ---
|
||
// Strip the date portion from an API timestamp — operators viewing today's
|
||
// orders only care about the wall-clock time. Falls back to the raw string
|
||
// if dayjs can't parse the value.
|
||
const formatTimeOnly = (t) => {
|
||
if (!t) return null;
|
||
const d = dayjs(t);
|
||
if (!d.isValid()) return String(t);
|
||
return d.format('HH:mm:ss');
|
||
};
|
||
|
||
// Stages the popup walks through, top → bottom, in real-world delivery order.
|
||
// Each row only renders when the corresponding API field is populated, so the
|
||
// timeline visually shrinks for orders that haven't reached later stages yet.
|
||
const POPUP_TIMELINE = [
|
||
{ key: 'assigntime', label: 'Assigned' },
|
||
{ key: 'acceptedtime', label: 'Accepted' },
|
||
{ key: 'arrivaltime', label: 'Arrived' },
|
||
{ key: 'pickuptime', label: 'Pickup' },
|
||
{ key: 'starttime', label: 'Started' },
|
||
{ key: 'deliverytime', label: 'Delivered', final: true }
|
||
];
|
||
|
||
// Build a polyline-ready point list for a sorted trip:
|
||
// - drop NaN drops
|
||
// - prepend the first valid pickup we can find (so the line starts at the kitchen)
|
||
const buildTripPoints = (sorted) => {
|
||
const valid = sorted.filter(hasValidDrop);
|
||
if (!valid.length) return [];
|
||
const pickupSrc = sorted.find(hasValidPickup);
|
||
const pts = [];
|
||
if (pickupSrc) pts.push([toNum(pickupLat(pickupSrc)), toNum(pickupLon(pickupSrc))]);
|
||
valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
|
||
return pts;
|
||
};
|
||
|
||
// Fix for default leaflet marker icons
|
||
delete L.Icon.Default.prototype._getIconUrl;
|
||
L.Icon.Default.mergeOptions({
|
||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||
});
|
||
|
||
const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000'];
|
||
|
||
// Deterministic rider-id → palette slot mapping. The main `riders` array
|
||
// assigns colors by iteration order (see useMemo around line 1308), which
|
||
// means the same rider can shuffle to a different color across live-data
|
||
// refetches. The Rider Info list needs a fixed color per rider so the dot
|
||
// next to "Rajan A" doesn't flip from blue to green on the next poll, so
|
||
// it uses this hash-based lookup instead of `getRiderColor(id)`.
|
||
const getStableRiderColor = (id) => {
|
||
const s = String(id ?? '');
|
||
if (!s) return RIDER_COLORS[0];
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i++) {
|
||
h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||
}
|
||
return RIDER_COLORS[h % RIDER_COLORS.length];
|
||
};
|
||
|
||
// STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES,
|
||
// STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the
|
||
// extracted CompareDataPanel component can import them without forcing
|
||
// a circular dependency on Dispatch.js.
|
||
|
||
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => {
|
||
const map = useMap();
|
||
// Last fit signature. We only call fitBounds when this changes — otherwise
|
||
// every parent render (data refetch, sidebar tick, etc.) would refit the
|
||
// map and snap it back mid-drag, which felt like the map was un-draggable.
|
||
const lastFitKeyRef = useRef('');
|
||
|
||
// Stable identifier for the current focus target. Uses ids where possible
|
||
// so it doesn't churn when the underlying objects get rebuilt by useMemo.
|
||
// `locationKey` is included everywhere so switching hubs (Coimbatore →
|
||
// Nagercoil etc.) always triggers a refit onto the new region, even when
|
||
// the new hub happens to have the same kitchen/order count.
|
||
//
|
||
// We also append a coarse centroid (lat/lon rounded to 1 decimal) of the
|
||
// current dataset. This is what fixes the Nagercoil case: when only the
|
||
// hub id changes but the kitchen/order array is briefly the previous
|
||
// hub's stale data (or empty during refetch), the fit only happens once
|
||
// truly-new data arrives — at which point the centroid signature flips
|
||
// (e.g. 11.0,77.0 → 8.2,77.4) and we refit to the new region.
|
||
const fitKey = useMemo(() => {
|
||
const loc = locationKey != null ? `loc:${locationKey}|` : '';
|
||
const centroidSig = (pairs) => {
|
||
let lat = 0;
|
||
let lon = 0;
|
||
let n = 0;
|
||
for (const p of pairs) {
|
||
if (Number.isFinite(p[0]) && Number.isFinite(p[1])) {
|
||
lat += p[0];
|
||
lon += p[1];
|
||
n += 1;
|
||
}
|
||
}
|
||
return n === 0 ? '0' : `${(lat / n).toFixed(1)},${(lon / n).toFixed(1)}`;
|
||
};
|
||
if (focusedItem) {
|
||
const id =
|
||
focusedItem.id ??
|
||
focusedItem.kitchenName ??
|
||
focusedItem.name ??
|
||
(focusedItem.lat != null ? `${focusedItem.lat},${focusedItem.lon}` : 'item');
|
||
const n = focusedItem.orders ? focusedItem.orders.length : 0;
|
||
return `${loc}f|${id}|${n}`;
|
||
}
|
||
const kPairs = (kitchens || []).map((k) => [k.lat, k.lon]);
|
||
const kSig = centroidSig(kPairs);
|
||
if (viewMode === 'kitchens') {
|
||
const n = kPairs.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1])).length;
|
||
return `${loc}k|${n}|${kSig}`;
|
||
}
|
||
if (viewMode === 'all') {
|
||
const oPairs = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
||
return `${loc}a|${oPairs.length}|${centroidSig(oPairs)}`;
|
||
}
|
||
return `${loc}m|${viewMode || ''}|${kPairs.length}|${kSig}`;
|
||
}, [focusedItem, viewMode, orders, kitchens, locationKey]);
|
||
|
||
useEffect(() => {
|
||
if (lastFitKeyRef.current === fitKey) return;
|
||
|
||
let pts = [];
|
||
if (focusedItem) {
|
||
if (focusedItem.orders) {
|
||
pts = focusedItem.orders.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
||
focusedItem.orders.forEach((o) => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))]));
|
||
} else {
|
||
pts = [[focusedItem.lat, focusedItem.lon]];
|
||
}
|
||
} else if (viewMode === 'kitchens') {
|
||
pts = (kitchens || [])
|
||
.filter((k) => Number.isFinite(k.lat) && Number.isFinite(k.lon))
|
||
.map((k) => [k.lat, k.lon]);
|
||
if (pts.length === 0) {
|
||
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
||
}
|
||
} else if (viewMode === 'all') {
|
||
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
||
} else {
|
||
// No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the
|
||
// current hub's footprint so switching from Coimbatore → Nagercoil
|
||
// (or any hub change) recenters the map onto the new region instead
|
||
// of leaving it on the previous city.
|
||
pts = (kitchens || [])
|
||
.filter((k) => Number.isFinite(k.lat) && Number.isFinite(k.lon))
|
||
.map((k) => [k.lat, k.lon]);
|
||
if (pts.length === 0) {
|
||
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
||
}
|
||
}
|
||
|
||
const filtered = pts.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
|
||
if (filtered.length > 0) {
|
||
const bounds = L.latLngBounds(filtered);
|
||
if (bounds.isValid()) {
|
||
// Zoom into a single-point bounds (focused rider with one drop, or
|
||
// a single kitchen) instead of using maxZoom defaults which would
|
||
// leave the map zoomed out.
|
||
const isSinglePoint = filtered.length === 1 || bounds.getNorthEast().equals(bounds.getSouthWest());
|
||
if (isSinglePoint) {
|
||
map.setView(filtered[0], 15, { animate: true, duration: 0.6 });
|
||
} else {
|
||
map.flyToBounds(bounds, { padding: [60, 60], duration: 0.6, maxZoom: 16 });
|
||
}
|
||
lastFitKeyRef.current = fitKey;
|
||
}
|
||
return;
|
||
}
|
||
// No data to fit to yet — hub switch is mid-flight, or this hub has no
|
||
// orders today. Do NOT lock the fit key: when real hub data arrives the
|
||
// fitKey will change (its centroid signature flips, e.g. 11.0,77.0 →
|
||
// 8.2,77.4) and this effect will re-run with proper coordinates.
|
||
//
|
||
// Also do NOT call setView([11.022, 76.982], 12) here — that was the
|
||
// bug that left Nagercoil (and every non-Coimbatore hub) stuck on the
|
||
// Coimbatore default during the brief window between picking the hub
|
||
// and its data arriving.
|
||
}, [fitKey, focusedItem, viewMode, orders, kitchens, map]);
|
||
|
||
return null;
|
||
};
|
||
|
||
// Inline-icon wrapper used wherever a Material icon precedes some text — keeps the
|
||
// SVG vertically centered with the adjacent text and inherits the parent color.
|
||
const Ico = ({ children }) => (
|
||
<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 tied to the focused rider's orders — used to drive the
|
||
// batched per-delivery GPS log fetch for Compare mode. Deduped; ignores rows
|
||
// without a deliveryid (e.g. unaccepted orders) since /getdeliverylogs needs
|
||
// a real id.
|
||
const focusedRiderDeliveryIds = useMemo(() => {
|
||
if (!focusedRider) return [];
|
||
const set = new Set();
|
||
focusedRider.orders.forEach((o) => {
|
||
if (o.deliveryid != null && o.deliveryid !== '' && o.deliveryid !== 0) set.add(String(o.deliveryid));
|
||
});
|
||
return Array.from(set);
|
||
}, [focusedRider]);
|
||
|
||
// Fan-out per-delivery GPS log queries in parallel. Each result is cached by
|
||
// deliveryid, so toggling Compare off/on for the same rider is instant.
|
||
// We only enable the queries while Compare is open AND a rider is focused —
|
||
// otherwise the fetches would fire on idle dispatch viewing and waste
|
||
// bandwidth.
|
||
const deliveryLogQueries = useQueries({
|
||
queries: focusedRiderDeliveryIds.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);
|
||
},
|
||
enabled: compareOpen && focusedRider != null,
|
||
staleTime: 5 * 60 * 1000,
|
||
refetchOnWindowFocus: false,
|
||
retry: 1
|
||
}))
|
||
});
|
||
|
||
// 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 idx = focusedRiderDeliveryIds.indexOf(String(o.deliveryid));
|
||
const q = idx >= 0 ? deliveryLogQueries[idx] : null;
|
||
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: q?.isLoading || q?.isFetching,
|
||
isError: q?.isError,
|
||
coords: q?.data || []
|
||
};
|
||
});
|
||
}, [focusedRider, focusedRiderDeliveryIds, deliveryLogQueries]);
|
||
|
||
|
||
// 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.
|
||
// Only runs while Compare is open so idle dispatch viewing doesn't hit OSRM.
|
||
useEffect(() => {
|
||
if (!compareOpen || !focusedRider) return;
|
||
riderActualTracks.forEach((t) => {
|
||
if (!t.deliveryid || t.coords.length < 2) return;
|
||
const pts = t.coords.map((c) => [c.lat, c.lng]);
|
||
fetchTrackRoute(t.deliveryid, pts);
|
||
});
|
||
}, [riderActualTracks, compareOpen, focusedRider, 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>
|
||
)}
|
||
<span><Ico><MdAccountBalanceWallet /></Ico>₹{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</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.kms != null || o.actualkms != null || (!isDelivered && o.riderkms != null) || estMeters !== null) && (
|
||
<div className="pu-distance-row">
|
||
{o.kms != null && o.kms !== '' && (
|
||
<div className="pu-distance-chip">
|
||
<span className="pu-distance-icon"><MdStraighten /></span>
|
||
<span className="pu-distance-label">Planned</span>
|
||
<span className="pu-distance-value">{o.kms} km</span>
|
||
</div>
|
||
)}
|
||
{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));
|
||
|
||
// 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;
|
||
|
||
const isKitchenView = (viewMode === 'kitchens' || focusedKitchen);
|
||
const opacity = isActive ? 1.0 : 0.1;
|
||
const weight = isKitchenView ? 7 : 6;
|
||
// 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.
|
||
const isCompareTarget =
|
||
compareOpen && focusedRider && r.id === focusedRider.id;
|
||
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
|
||
}
|
||
|
||
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 id="dispatch-top-tabs" className="dtt-inline">
|
||
<button
|
||
type="button"
|
||
className={`dtt-tab ${topView === 'live' ? 'active' : ''}`}
|
||
onClick={() => setTopView('live')}
|
||
>
|
||
<span className="dtt-icon"><MdMap /></span>
|
||
Live
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`dtt-tab ${topView === 'analysis' ? 'active' : ''}`}
|
||
onClick={() => setTopView('analysis')}
|
||
>
|
||
<span className="dtt-icon"><MdInsights /></span>
|
||
Analysis
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker.
|
||
Sits to the LEFT of the running clock so the operator sees fleet
|
||
health + current wave size + selected date together in one row. */}
|
||
<div className="hdr-stats">
|
||
{(() => {
|
||
const isLoss = activeStats.profit < 0;
|
||
const amount = Math.abs(activeStats.profit);
|
||
return (
|
||
<span
|
||
className={`strat-stat ${isLoss ? 'strat-stat-loss' : 'strat-stat-profit'}`}
|
||
title={`${isLoss ? 'Loss' : 'Profit'} (${activeStats.label})`}
|
||
>
|
||
<span className="strat-stat-icon">{isLoss ? <MdTrendingDown /> : <MdTrendingUp />}</span>
|
||
<span className="strat-stat-label">{isLoss ? 'Loss' : 'Profit'}</span>
|
||
<span className="strat-stat-value">{isLoss ? '-' : ''}₹{amount.toFixed(0)}</span>
|
||
</span>
|
||
);
|
||
})()}
|
||
|
||
{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 0–24 (24h clock). Half-hour steps allowed (e.g. 12.5 = 12:30). Start < 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='© 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/Profit 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 className="sc"><div className="sc-lbl">Profit</div><div className="sc-val g">₹{activeStats.profit.toFixed(0)}</div><div className="sc-sub">Earned</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);
|
||
const totalProfit = focusedRider.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0);
|
||
const isLoss = totalProfit < 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 className={`rd-stat rd-stat-profit ${isLoss ? 'is-loss' : 'is-gain'}`}>
|
||
<div className="rd-stat-icon">{isLoss ? <MdTrendingDown /> : <MdTrendingUp />}</div>
|
||
<div className="rd-stat-value">
|
||
{isLoss ? '-' : ''}₹{Math.abs(totalProfit).toFixed(0)}
|
||
</div>
|
||
<div className="rd-stat-label">{isLoss ? 'Loss' : 'Profit'}</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 profit = parseFloat(o.profit || 0);
|
||
const isLoss = profit < 0;
|
||
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>
|
||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||
</span>
|
||
{o.deliverycharge != null && (
|
||
<span className="zone-order-chip" title="Delivery charge">
|
||
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
|
||
</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 profit = parseFloat(o.profit || 0);
|
||
const isLoss = profit < 0;
|
||
|
||
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>
|
||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||
</span>
|
||
{o.deliverycharge != null && (
|
||
<span className="zone-order-chip" title="Delivery charge">
|
||
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
|
||
</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 profit = parseFloat(o.profit || 0);
|
||
const isLoss = profit < 0;
|
||
|
||
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>
|
||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||
</span>
|
||
{o.deliverycharge != null && (
|
||
<span className="zone-order-chip" title="Delivery charge">
|
||
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
|
||
</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;
|
||
const profitNeg = z.totalProfit < 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>
|
||
<span className={`zone-stat-pill ${profitNeg ? 'profit-negative' : 'profit-positive'}`} title="Total profit">
|
||
<span className="zone-stat-icon"><MdAccountBalanceWallet /></span>
|
||
<span className="zone-stat-value">
|
||
{profitNeg ? `-₹${Math.abs(z.totalProfit).toFixed(0)}` : `₹${z.totalProfit.toFixed(0)}`}
|
||
</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} riders · <Ico><MdAccountBalanceWallet /></Ico>₹{k.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</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='© 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>
|
||
{/* Compare planned vs. actual: when no rider is focused yet we
|
||
auto-pick the first rider with orders, so the button always
|
||
does something visible. The comparison itself is per-rider. */}
|
||
<button
|
||
type="button"
|
||
className={`sbt ${compareOpen ? 'active' : ''}`}
|
||
onClick={() => {
|
||
if (compareOpen) { setCompareOpen(false); return; }
|
||
if (!focusedRider) {
|
||
// Prefer a rider whose orders have deliveryids — those are
|
||
// the only ones /getdeliverylogs/ can return tracks for, so
|
||
// picking such a rider guarantees the comparison map has
|
||
// something to draw.
|
||
const withDelivery = riders.find((r) =>
|
||
(r.orders || []).some((o) => o.deliveryid != null && o.deliveryid !== '' && o.deliveryid !== 0)
|
||
);
|
||
const fallback = riders.find((r) => r.orders && r.orders.length > 0);
|
||
const pick = withDelivery || fallback;
|
||
if (pick) {
|
||
handleRiderFocus(pick);
|
||
// In controlled mode focusedRider won't update until the parent
|
||
// re-renders with the new selectedRiderId, so defer the open.
|
||
if (isControlled) {
|
||
pendingCompareRef.current = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
setCompareOpen(true);
|
||
}}
|
||
title={focusedRider
|
||
? `Compare planned vs. actual route for ${focusedRider.riderName}`
|
||
: 'Compare planned vs. actual route (will focus the first rider)'}
|
||
style={{
|
||
boxShadow: 'var(--shadow-lg)',
|
||
background: compareOpen
|
||
? 'linear-gradient(135deg, #6366f1, #3b82f6)'
|
||
: '#fff',
|
||
marginLeft: 8,
|
||
color: compareOpen ? '#fff' : undefined
|
||
}}
|
||
>
|
||
<span className="sbt-icon"><MdSwapHoriz /></span>
|
||
{compareOpen ? 'Exit Compare' : 'Compare'}
|
||
</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, profit), 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), the delivery time
|
||
vs expected, and the order profit. 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;
|