6704 lines
340 KiB
JavaScript
6704 lines
340 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 doormileMark from 'assets/images/doormile-mark.png';
|
||
import dayjs from 'dayjs';
|
||
import { useInfiniteQuery, useQueries, useQuery, useMutation } from '@tanstack/react-query';
|
||
import {
|
||
MdMap,
|
||
MdDirectionsBike,
|
||
MdRestaurant,
|
||
MdPublic,
|
||
MdInventory2,
|
||
MdTrendingUp,
|
||
MdTrendingDown,
|
||
MdAccountBalanceWallet,
|
||
MdStraighten,
|
||
MdLocationOn,
|
||
MdMarkunreadMailbox,
|
||
MdPlace,
|
||
MdTwoWheeler,
|
||
MdNotes,
|
||
MdSwapHoriz,
|
||
MdExpandMore,
|
||
MdInfoOutline,
|
||
MdBatteryFull,
|
||
MdSignalCellularAlt,
|
||
MdMyLocation,
|
||
MdSpeed,
|
||
MdExplore,
|
||
MdAccessTime,
|
||
MdOutlineInventory2,
|
||
MdOutlineTwoWheeler,
|
||
MdOutlineAccessTime,
|
||
MdGpsFixed,
|
||
MdPower,
|
||
MdSearch,
|
||
MdChevronLeft,
|
||
MdChevronRight,
|
||
MdLocalMall,
|
||
MdCheckCircle,
|
||
MdErrorOutline,
|
||
MdWarning,
|
||
MdClose,
|
||
MdFormatListBulleted,
|
||
MdTimer,
|
||
MdCalendarToday,
|
||
MdInsights,
|
||
MdRefresh,
|
||
MdAttachMoney
|
||
} from 'react-icons/md';
|
||
import ProfitabilitySection from './ProfitabilitySection';
|
||
import ActiveSection from './ActiveSection';
|
||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
|
||
import { BATCHES as SHARED_BATCHES } from 'utils/batchBucket';
|
||
import { getConsignmentLogs } from 'pages/api/doormileApi';
|
||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||
import {
|
||
onActivate,
|
||
getStatusStyle,
|
||
FINAL_STATUSES,
|
||
SKIPPED_STATUSES,
|
||
STEP_PALETTE,
|
||
stepColor,
|
||
isActiveDelivery,
|
||
getActiveOrder,
|
||
haversineKm,
|
||
polylineLengthKm,
|
||
kalmanSmoothGps
|
||
} 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 = '#000000';
|
||
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)));
|
||
// Kitchen/pickup-point display name. `pickupcustomer`/`kitchen_key` are the
|
||
// narrowest fields and are frequently blank, which used to fall straight
|
||
// through to a literal "Unknown" badge on the map even though the order
|
||
// carries a perfectly good name in `locationname`/`pickuplocation` (the same
|
||
// fields the order popup at `o.pickupcustomer || o.locationname ||
|
||
// o.pickuplocation` already trusts, and what orders.js/deliveries.js render
|
||
// as the pickup point elsewhere in the app). Checked by every callsite that
|
||
// needs a kitchen grouping key or label so they can't drift out of sync with
|
||
// each other (a prior copy at line ~4222 checked kitchen_key before
|
||
// pickupcustomer, the opposite order of every other copy, which could group
|
||
// the same order under two different keys depending on which code path ran).
|
||
const pickupName = (o) => o.pickupcustomer || o.kitchen_key || o.locationname || o.pickuplocation || o.pickupsuburb || null;
|
||
|
||
// 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 expected delivery time (see TIME_FIELDS):
|
||
// • 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.
|
||
// Derived from utils/batchBucket.js rather than redeclared. Three copies of
|
||
// these windows existed (here, deliveries.js, batchBucket.js) and they are the
|
||
// kind of constant that MUST agree — a row bucketed one way on Dispatch and
|
||
// another on Deliveries is the same bug twice. See that module for why the
|
||
// windows now cover the full 24 hours.
|
||
const BATCHES_DEFAULT_RAW = SHARED_BATCHES.map((b) => ({
|
||
id: b.id,
|
||
name: b.label,
|
||
startHour: b.startHour,
|
||
endHour: b.endHour
|
||
}));
|
||
|
||
// The `dispatch.slots.v1`…`v9` localStorage keys are no longer read or
|
||
// written — batch windows are constants now (see BATCHES in the component).
|
||
// Any leftover entries in an operator's browser are inert.
|
||
|
||
// 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'] },
|
||
// The batch-bucketing default. `orderdate` is the booking's `createdat` —
|
||
// when the order came in. It is immutable, and it matches the operator's
|
||
// mental model: an order created and assigned at 12:22 belongs to the
|
||
// Afternoon wave, full stop.
|
||
//
|
||
// Two fields were tried here first and both were wrong:
|
||
// • `assigntime` — api.js maps it to the booking's last-modified column,
|
||
// so it is re-stamped by any status change and orders drifted into
|
||
// whichever batch held the current clock time.
|
||
// • `expecteddeliverytime` — stable, but it is the PROMISED DELIVERY slot,
|
||
// not the wave. An order created at 12:22 whose SLA puts its ETA at
|
||
// 16:30 counted towards Evening, so Evening showed orders that had not
|
||
// been placed yet. Bucketing on creation time also means no future batch
|
||
// can ever show a count for today, which is the correct behaviour.
|
||
{ id: 'created', label: 'Created', keys: ['orderdate'] },
|
||
{ 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']
|
||
}
|
||
];
|
||
|
||
// The one time-field batch bucketing uses. Not operator-selectable — see the
|
||
// `created` entry in TIME_FIELDS for why the other candidates were rejected.
|
||
// deliveries.js's BATCH_TIME_KEYS must name the same row field.
|
||
const BATCH_TIME_FIELD = 'created';
|
||
|
||
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;
|
||
// parseDoormileTimestamp strips a false trailing Z some Doormile timestamps
|
||
// carry (see utils/doormileTimestamp.js) — bare dayjs(t) would treat that as
|
||
// a real UTC instant and shift it +5:30 (IST), potentially bucketing a
|
||
// just-assigned row into the wrong slot or out of every slot entirely.
|
||
// deliveries.js's getRowBatchId uses the same parse so both pages agree.
|
||
const d = parseDoormileTimestamp(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;
|
||
}
|
||
|
||
// Keeps Leaflet's internal canvas in sync with its container when the layout
|
||
// changes (sidebar collapse/expand, Compare open/close). A flex/width change
|
||
// doesn't fire a window resize, so without this the map's drawn area stays at
|
||
// the old size and leaves a grey gutter at the right edge until the next
|
||
// pan/zoom. We pulse invalidateSize across the sidebar's 0.32s CSS transition
|
||
// so the map grows/shrinks smoothly with the container instead of snapping.
|
||
function MapAutoResize({ trigger }) {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
const timers = [0, 60, 120, 200, 300, 380].map((t) => setTimeout(() => map.invalidateSize({ animate: false }), t));
|
||
return () => timers.forEach(clearTimeout);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [trigger, map]);
|
||
return null;
|
||
}
|
||
|
||
// haversineKm/polylineLengthKm/kalmanSmoothGps moved to dispatchShared.js so
|
||
// deliveries.js's Update Status dialog can compute the same real, GPS-based
|
||
// Actual KMs figure instead of leaving that field permanently blank.
|
||
|
||
// 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 A');
|
||
};
|
||
|
||
// 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;
|
||
};
|
||
|
||
const getTripCacheKey = (riderId, tripKey, points) => {
|
||
if (!points || points.length === 0) return `${riderId}-${tripKey}`;
|
||
const pointsSig = points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join('|');
|
||
return `${riderId}-${tripKey}-${pointsSig}`;
|
||
};
|
||
|
||
// 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];
|
||
};
|
||
|
||
// 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.
|
||
//
|
||
// getStatusStyle is now a thin adapter over themes/dt/status.js. The remaining
|
||
// call sites in this file are Leaflet divIcon HTML strings, which need a raw
|
||
// hex — normal JSX should render <StatusBadge> instead.
|
||
|
||
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;
|
||
};
|
||
|
||
// Smoothly-moving rider marker — the Swiggy/Zomato/Rapido style "bike gliding
|
||
// down the road" effect. Instead of letting react-leaflet snap the marker to
|
||
// each new GPS fix, we keep the <Marker>'s `position` prop frozen at its mount
|
||
// coordinate (a stable ref, so react-leaflet never repositions it) and drive
|
||
// every subsequent move imperatively with marker.setLatLng() inside a
|
||
// requestAnimationFrame loop. Each time `target` changes we ease from the
|
||
// marker's current on-screen latlng to the new fix, so the rider visibly
|
||
// travels between points rather than teleporting. A large jump (GPS glitch /
|
||
// first real fix after a placeholder) snaps instead of crawling across the map.
|
||
//
|
||
// ADAPTIVE GLIDE — the critical bit for "live" feel:
|
||
// We poll the GPS feed every 1s, but the backend only emits a fresh coordinate
|
||
// every ~30s, so the same fix repeats for ~30 polls and then jumps. With a
|
||
// fixed glide we'd animate for ~1s and then sit frozen for ~29s — the bike
|
||
// would look like it teleports every 30s. Instead we MEASURE the real wall-time
|
||
// between distinct fixes and stretch the glide across that whole interval
|
||
// (clamped). So when fixes are 30s apart the bike eases continuously for the
|
||
// full 30s; if the backend ever speeds up to 1s the glide tightens to 1s
|
||
// automatically. Either way motion is smooth and never freezes mid-trip.
|
||
// `duration` is only the seed used for the very first segment (no prior fix to
|
||
// measure against yet).
|
||
const MIN_GLIDE_MS = 200; // floor: don't animate faster than this even on rapid fixes
|
||
const MAX_GLIDE_MS = 2500; // ceiling: tighter ceiling for 1s update cadence
|
||
const interpolatePath = (path, t) => {
|
||
if (!path || path.length === 0) return null;
|
||
if (path.length === 1) return path[0];
|
||
if (t <= 0) return path[0];
|
||
if (t >= 1) return path[path.length - 1];
|
||
|
||
const segmentDistances = [];
|
||
let totalDistance = 0;
|
||
for (let i = 0; i < path.length - 1; i++) {
|
||
const p1 = path[i];
|
||
const p2 = path[i + 1];
|
||
const dLat = p2[0] - p1[0];
|
||
const dLng = p2[1] - p1[1];
|
||
const dist = Math.sqrt(dLat * dLat + dLng * dLng);
|
||
segmentDistances.push(dist);
|
||
totalDistance += dist;
|
||
}
|
||
|
||
if (totalDistance === 0) return path[0];
|
||
|
||
const targetDistance = t * totalDistance;
|
||
let accumulatedDistance = 0;
|
||
for (let i = 0; i < segmentDistances.length; i++) {
|
||
const nextDistance = accumulatedDistance + segmentDistances[i];
|
||
if (nextDistance >= targetDistance) {
|
||
const segT = (targetDistance - accumulatedDistance) / segmentDistances[i];
|
||
const p1 = path[i];
|
||
const p2 = path[i + 1];
|
||
return [p1[0] + (p2[0] - p1[0]) * segT, p1[1] + (p2[1] - p1[1]) * segT];
|
||
}
|
||
accumulatedDistance = nextDistance;
|
||
}
|
||
return path[path.length - 1];
|
||
};
|
||
|
||
const AnimatedRiderMarker = ({ target, icon, duration = 950, zIndexOffset, eventHandlers, children, markerRef: externalRef }) => {
|
||
const markerRef = useRef(null);
|
||
const rafRef = useRef(null);
|
||
// Frozen mount position — never handed back to react-leaflet again, so it
|
||
// can't fight the imperative animation below.
|
||
const mountPosRef = useRef(target);
|
||
// performance.now() timestamp of the last DISTINCT fix we glided to. Lets us
|
||
// measure the true inter-fix interval and size the next glide to match it.
|
||
const prevFixTsRef = useRef(null);
|
||
|
||
// CRITICAL: depend on the primitive lat/lon, NOT the `target` array. The
|
||
// parent re-renders every second (clock tick + 1s GPS poll) and hands us a
|
||
// brand-new `[lat, lon]` array each time. If the effect keyed off that array
|
||
// it would cancel + restart the glide on every render — the ease keeps
|
||
// resetting to zero velocity and the bike visibly stutters/lags. Keying off
|
||
// the numbers means the glide only (re)starts when the rider's coordinate
|
||
// genuinely changes, so each 1s segment plays out uninterrupted.
|
||
const lat = Array.isArray(target) ? Number(target[0]) : NaN;
|
||
const lon = Array.isArray(target) ? Number(target[1]) : NaN;
|
||
useEffect(() => {
|
||
const marker = markerRef.current;
|
||
if (!marker || !Number.isFinite(lat) || !Number.isFinite(lon)) return undefined;
|
||
|
||
const to = L.latLng(lat, lon);
|
||
const from = marker.getLatLng();
|
||
if (!from) {
|
||
marker.setLatLng(to);
|
||
return undefined;
|
||
}
|
||
const dLat = to.lat - from.lat;
|
||
const dLng = to.lng - from.lng;
|
||
// No meaningful move (~<0.1m) — snap and skip the rAF.
|
||
if (Math.abs(dLat) < 1e-6 && Math.abs(dLng) < 1e-6) {
|
||
marker.setLatLng(to);
|
||
return undefined;
|
||
}
|
||
// Teleport on big jumps (>2km) so a bad fix doesn't drag the icon across town.
|
||
let bigJump = false;
|
||
try {
|
||
bigJump = from.distanceTo(to) > 2000;
|
||
} catch {
|
||
bigJump = false;
|
||
}
|
||
if (bigJump) {
|
||
marker.setLatLng(to);
|
||
prevFixTsRef.current = performance.now();
|
||
return undefined;
|
||
}
|
||
|
||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||
const startTs = performance.now();
|
||
// Size this glide to the real gap since the previous fix so the bike keeps
|
||
// moving for the whole interval instead of darting then freezing. First
|
||
// segment has no prior timestamp, so fall back to the `duration` seed.
|
||
const gap = prevFixTsRef.current == null ? duration : startTs - prevFixTsRef.current;
|
||
const segMs = Math.max(MIN_GLIDE_MS, Math.min(MAX_GLIDE_MS, gap));
|
||
prevFixTsRef.current = startTs;
|
||
const startLat = from.lat;
|
||
const startLng = from.lng;
|
||
|
||
let active = true;
|
||
let osrmPath = null;
|
||
|
||
// Fetch OSRM route for this step so the bike follows the roads
|
||
const fetchOsrmPath = async () => {
|
||
const url = `https://router.project-osrm.org/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
|
||
try {
|
||
const res = await fetch(url);
|
||
const json = await res.json();
|
||
if (active && json.routes && json.routes[0]) {
|
||
osrmPath = json.routes[0].geometry.coordinates.map((c) => [c[1], c[0]]);
|
||
}
|
||
} catch (e) {
|
||
console.error('OSRM animated rider marker fetch error:', e);
|
||
}
|
||
};
|
||
fetchOsrmPath();
|
||
|
||
const step = (now) => {
|
||
if (!active) return;
|
||
const t = Math.min(1, (now - startTs) / segMs);
|
||
|
||
if (osrmPath && osrmPath.length >= 2) {
|
||
const pos = interpolatePath(osrmPath, t);
|
||
if (pos) {
|
||
marker.setLatLng(pos);
|
||
} else {
|
||
marker.setLatLng([startLat + dLat * t, startLng + dLng * t]);
|
||
}
|
||
} else {
|
||
// Fallback to linear interpolation if OSRM route isn't available
|
||
marker.setLatLng([startLat + dLat * t, startLng + dLng * t]);
|
||
}
|
||
|
||
if (t < 1) rafRef.current = requestAnimationFrame(step);
|
||
};
|
||
rafRef.current = requestAnimationFrame(step);
|
||
return () => {
|
||
active = false;
|
||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||
};
|
||
}, [lat, lon, duration]);
|
||
|
||
// Cleanup any in-flight animation on unmount.
|
||
useEffect(
|
||
() => () => {
|
||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||
},
|
||
[]
|
||
);
|
||
|
||
return (
|
||
<Marker
|
||
ref={(inst) => {
|
||
markerRef.current = inst;
|
||
if (typeof externalRef === 'function') externalRef(inst);
|
||
else if (externalRef) externalRef.current = inst;
|
||
}}
|
||
position={mountPosRef.current}
|
||
icon={icon}
|
||
zIndexOffset={zIndexOffset}
|
||
eventHandlers={eventHandlers}
|
||
>
|
||
{children}
|
||
</Marker>
|
||
);
|
||
};
|
||
|
||
// 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'
|
||
}
|
||
];
|
||
|
||
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);
|
||
};
|
||
// Parse "HH:mm:ss" or "HH:mm" → seconds since midnight. Returns null when the
|
||
// string is missing or malformed. Used to compute gantt percentages for the
|
||
// rider timelines on the Analysis page — the API ships those fields as bare
|
||
// clock strings, not full ISO datetimes, so we can't lean on dayjs.
|
||
const parseHMS = (s) => {
|
||
if (!s || typeof s !== 'string') return null;
|
||
const parts = s.split(':').map(Number);
|
||
if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return null;
|
||
const [h, m, sec = 0] = parts;
|
||
return h * 3600 + m * 60 + sec;
|
||
};
|
||
// Derive an "imbalance severity" bucket from the assessment text + stdev.
|
||
// Used to color the recommendation banner correctly when `action == 'none'` —
|
||
// the current backend returns no action but still flags real imbalance in
|
||
// `fleet_balance_assessment` (e.g. "High imbalance — 57 min spread"), and
|
||
// muting that behind a green "balanced" empty state would be misleading.
|
||
const analysisSeverity = ({ assessment, spreadMin, stdev }) => {
|
||
const text = String(assessment || '').toLowerCase();
|
||
if (/high imbalance|severe|critical/.test(text)) return 'warn';
|
||
if (/imbalance|recommend/.test(text)) return 'warn';
|
||
// Numeric fallback when the assessment string is missing / unrecognised.
|
||
if (spreadMin != null && spreadMin > 30) return 'warn';
|
||
if (stdev != null && stdev > 1.0) return 'warn';
|
||
if (/balanced|excellent|good/.test(text)) return 'success';
|
||
return 'info';
|
||
};
|
||
|
||
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);
|
||
// Position modal for rider's `last_position` in the analysis section.
|
||
// Shape: { lat, lon, name, userid, kitchen, status } | null.
|
||
// Held outside the analysis IIFE so a single MapContainer mounts at the
|
||
// top level (Leaflet is expensive to remount mid-list).
|
||
const [riderPositionModal, setRiderPositionModal] = useState(null);
|
||
useEffect(() => {
|
||
if (!riderPositionModal) return undefined;
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setRiderPositionModal(null);
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [riderPositionModal]);
|
||
|
||
// Was hardcoded to 916 (the example tenant in the API spec) — every
|
||
// operator's batch-efficiency analytics queried the same tenant regardless
|
||
// of who was actually logged in. Reads the real session tenant now, same
|
||
// as the rest of the app (login.js sets this on sign-in).
|
||
const ANALYSIS_TENANT_ID = localStorage.getItem('tenantid') || undefined;
|
||
|
||
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
|
||
});
|
||
};
|
||
|
||
// ─── Auto-refresh the active analysis batch every 15s while the
|
||
// Analysis view is open. Skips firing when:
|
||
// • the user isn't on the Analysis tab,
|
||
// • no batch is selected,
|
||
// • the previous request is still in flight (prevents queue
|
||
// stacking on slow networks),
|
||
// • the browser tab is hidden (saves API quota on routes.workolik).
|
||
// Loading state is tracked through a ref so the interval doesn't
|
||
// reset on every in-flight flip. ─────────────────────────────────
|
||
const ANALYSIS_POLL_MS = 15000;
|
||
const analysisLoadingRef = useRef(null);
|
||
useEffect(() => {
|
||
analysisLoadingRef.current = analysisLoadingWindow;
|
||
}, [analysisLoadingWindow]);
|
||
useEffect(() => {
|
||
if (topView !== 'analysis') return undefined;
|
||
if (!activeBatchKey) return undefined;
|
||
const tick = () => {
|
||
if (analysisLoadingRef.current) return;
|
||
if (typeof document !== 'undefined' && document.hidden) return;
|
||
batchEfficiencyMutation.mutate({
|
||
batch: activeBatchKey,
|
||
tenantId: ANALYSIS_TENANT_ID
|
||
});
|
||
};
|
||
const id = setInterval(tick, ANALYSIS_POLL_MS);
|
||
return () => clearInterval(id);
|
||
// batchEfficiencyMutation is stable for the component lifetime.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [topView, activeBatchKey]);
|
||
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);
|
||
// Sort mode for the focused-rider trip cards in the left sidebar.
|
||
// 'planned' (default) — orders displayed in dispatched sequence (step asc).
|
||
// 'time' — orders displayed in actual completion sequence
|
||
// (deliverytime asc, expecteddeliverytime fallback,
|
||
// step tiebreaker). Mirrors how the Compare timeline
|
||
// numbers its sequenceStep so an operator can switch
|
||
// between sidebar + Compare without re-mapping
|
||
// which delivery is "1st", "2nd", etc.
|
||
// Global per the focused rider — every trip in the rider detail shares the
|
||
// mode so the toggle in any trip-header reflects (and controls) all trips.
|
||
const [tripSortMode, setTripSortMode] = useState('planned');
|
||
// 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());
|
||
// Cache for live GPS rider Leaflet divIcon instances.
|
||
const liveIconCacheRef = useRef(new Map());
|
||
// 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 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. Fixed at creation time —
|
||
// see TIME_FIELDS. This was a useState with an operator-facing dropdown;
|
||
// the dropdown was disabled for a correctness reason (picking "Assigned"
|
||
// bucketed on a column the backend re-stamps, so orders visibly jumped
|
||
// batches) and its JSX has now been deleted rather than left commented out.
|
||
// A constant makes the lock explicit instead of relying on nothing calling
|
||
// the setter.
|
||
const selectedTimeField = BATCH_TIME_FIELD;
|
||
|
||
// Batch windows are fixed. They used to be operator-editable and persisted
|
||
// to localStorage, but the editor UI was disabled and is now deleted, so the
|
||
// state, the persist effect and the legacy-key cleanup all went with it.
|
||
//
|
||
// Dropping the localStorage READ is itself a fix: an operator who edited
|
||
// their slot hours in an older build kept getting those stale custom hours
|
||
// forever, silently disagreeing with deliveries.js, which has always used
|
||
// the constants.
|
||
const BATCHES = BATCHES_DEFAULT;
|
||
|
||
// 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]);
|
||
|
||
// 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],
|
||
// riderInfoUserid is actually a userid (it's sourced from ridersAllDay,
|
||
// which comes from booking data — assignedmileruserid, confirmed live)
|
||
// but /admin/milers/:id/* routes need milerprofileid. liveRiderLocations
|
||
// (declared below) carries both, so resolve the real id here rather than
|
||
// changing riderInfoUserid itself, which is compared against elsewhere
|
||
// in this file for sidebar row highlighting in userid-space.
|
||
queryFn: () => {
|
||
const match = liveRiderLocations.find((r) => String(r.userid) === String(riderInfoUserid));
|
||
return getRiderPeriodicLogs(match?.milerprofileid ?? 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 });
|
||
}
|
||
|
||
// Live rider position feed. Was dead for a while during the backend
|
||
// migration (fetchRidersLogs was a permanent [] stub) — now sourced from
|
||
// GET /admin/milers/summary (confirmed in jupiter2doormile.md), which
|
||
// carries each rider's currentlatitude/currentlongitude/lastpingat. We
|
||
// render those positions as markers on the main dispatch map so the
|
||
// operator sees where each rider actually is.
|
||
const RIDER_LOG_POLL_MS = 1000;
|
||
const { data: ridersLocationLogs } = useQuery({
|
||
queryKey: [selectedAppLocationId, selectedDate, ''],
|
||
queryFn: fetchRidersLogs,
|
||
refetchInterval: RIDER_LOG_POLL_MS,
|
||
refetchIntervalInBackground: false,
|
||
staleTime: 0,
|
||
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 stays userid-keyed — it's what joins this live-GPS feed against
|
||
// `riders`/orders elsewhere in this file, which are themselves keyed
|
||
// by a booking's assignedmileruserid (also a userid, confirmed live).
|
||
// milerprofileid is kept separately for the one place that actually
|
||
// needs it: /admin/milers/:id/* calls (the rider-info panel).
|
||
id: String(r.userid ?? ''),
|
||
userid: r.userid,
|
||
milerprofileid: r.milerprofileid,
|
||
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]);
|
||
|
||
// Set of rider ids whose latest position is `active` (i.e. on the road
|
||
// right now). The "All Active Routes" view (viewMode === 'all') uses this to
|
||
// show ONLY currently-active riders — their cards, routes, drop markers and
|
||
// live bike markers — and hide everyone who is offline/idle for the slot.
|
||
// GET /admin/milers/summary's availabilitystatus enum is confirmed live as
|
||
// Available/Assigned/On_Pickup/Offline — not active/pending, which never
|
||
// matched anything and left this set permanently empty.
|
||
const activeRiderIdSet = useMemo(
|
||
() => new Set(liveRiderLocations.filter((r) => r.status !== 'offline' && r.status !== 'blocked').map((r) => String(r.id))),
|
||
[liveRiderLocations]
|
||
);
|
||
// 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,
|
||
// Order status (pending → delivered) only lives in this feed. Poll it in the
|
||
// Active view so a completed delivery drops out and the rider's NEXT active
|
||
// leg automatically becomes the one shown (card + route + flag) without a
|
||
// manual refresh. Other views don't need second-by-second order churn, so
|
||
// they refetch only on the usual triggers (date/slot/hub change, refocus).
|
||
refetchInterval: viewMode === 'all' ? 15_000 : false,
|
||
refetchIntervalInBackground: false
|
||
});
|
||
|
||
// Auto-page through all results for the selected date.
|
||
useEffect(() => {
|
||
if (!shouldFetchLive) return;
|
||
if (liveHasNextPage && !liveIsFetchingNextPage) liveFetchNextPage();
|
||
}, [shouldFetchLive, liveHasNextPage, liveIsFetchingNextPage, liveFetchNextPage]);
|
||
|
||
// Holds the last fully-loaded row set (all pages present). Used below to
|
||
// prevent the "orders vanish then reappear" flash during background refetches
|
||
// where TanStack Query fetches pages sequentially and livePagesData temporarily
|
||
// contains only page 1 while the rest are still loading.
|
||
const prevCompleteRowsRef = useRef([]);
|
||
|
||
// Sticky cache for active orders in the Active Section sidebar. Keys are orderid strings.
|
||
// Orders are added/refreshed when orderstatus === 'active' and evicted only when the
|
||
// API explicitly returns a terminal status. This prevents cards from flashing away
|
||
// during the 15-second poll cycle when the API temporarily omits a row.
|
||
const stickyActiveRef = useRef({});
|
||
|
||
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);
|
||
}
|
||
|
||
// Background refetch loads pages one-by-one: page 1 arrives first, then the
|
||
// auto-pager fetches page 2, 3… While pages are still loading (liveHasNextPage
|
||
// is true and a fetch is in-flight), "out" is a partial snapshot that is
|
||
// smaller than the complete dataset we had before. Returning prevCompleteRowsRef
|
||
// keeps the Active section stable — no cards disappear mid-refetch.
|
||
// On the very first load prevCompleteRowsRef is empty, so we fall through and
|
||
// show whatever partial data is already available (correct for initial render).
|
||
if (liveIsFetching && liveHasNextPage && prevCompleteRowsRef.current.length > 0) {
|
||
return prevCompleteRowsRef.current;
|
||
}
|
||
|
||
// All pages have arrived — this is a complete snapshot. Persist it so the
|
||
// next refetch cycle can return it during the partial-pages window above.
|
||
if (!liveHasNextPage) {
|
||
prevCompleteRowsRef.current = out;
|
||
}
|
||
|
||
return out;
|
||
}, [livePagesData, liveIsFetching, liveHasNextPage]);
|
||
|
||
// 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]);
|
||
|
||
const totalDailyProfit = useMemo(() => {
|
||
let profit = 0;
|
||
const riderDailyData = {};
|
||
|
||
liveRows.forEach((r) => {
|
||
// Cancelled/skipped orders never delivered, so they shouldn't earn
|
||
// revenue or be charged fuel/slot cost either — matches the same fix
|
||
// already applied to ProfitabilitySection.js's calcRiderMetrics.
|
||
const status = String(r.orderstatus ?? r.status ?? '').toLowerCase();
|
||
if (SKIPPED_STATUSES.has(status)) return;
|
||
|
||
// Filter by selectedDate to align with daily slot aggregations.
|
||
// parseDoormileTimestamp (not bare dayjs()) avoids the false-trailing-Z
|
||
// parse bug (utils/doormileTimestamp.js) — near a midnight boundary an
|
||
// unstripped Z could shift a row +5:30 into the wrong calendar day,
|
||
// silently dropping it from (or wrongly adding it to) a rider's daily
|
||
// profit total for selectedDate.
|
||
const dateStr = r.assigntime
|
||
? parseDoormileTimestamp(r.assigntime).format('YYYY-MM-DD')
|
||
: r.deliverydate
|
||
? parseDoormileTimestamp(r.deliverydate).format('YYYY-MM-DD')
|
||
: 'unknown';
|
||
if (selectedDate && dateStr !== selectedDate) return;
|
||
|
||
const batch = getRowBatch(r, selectedTimeField, BATCHES);
|
||
if (!batch || batch === 'all') return;
|
||
|
||
const riderKey = String(r.userid || r.rider_id || 'unassigned');
|
||
if (riderKey === 'unassigned' || riderKey === '0') return;
|
||
|
||
if (!riderDailyData[riderKey]) {
|
||
riderDailyData[riderKey] = { revenue: 0, kms: 0, batches: new Set() };
|
||
}
|
||
|
||
const kms = parseFloat(r.kms || r.actualkms || 0);
|
||
riderDailyData[riderKey].kms += kms;
|
||
riderDailyData[riderKey].revenue += kms <= 8 ? 30 : 30 + (kms - 8) * 6;
|
||
riderDailyData[riderKey].batches.add(batch);
|
||
});
|
||
|
||
Object.values(riderDailyData).forEach((stats) => {
|
||
const variableCost = stats.kms * 2.5;
|
||
const slotCount = Math.min(stats.batches.size, 3);
|
||
const fixedCost = slotCount * (500 / 3);
|
||
const totalCost = variableCost + fixedCost;
|
||
profit += stats.revenue - totalCost;
|
||
});
|
||
|
||
return profit;
|
||
}, [liveRows, selectedTimeField, BATCHES, selectedDate]);
|
||
|
||
// 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 = {};
|
||
|
||
// First, populate riderMap with all riders present in the zones to capture those with 0 orders
|
||
(source.zones || []).forEach((z) => {
|
||
(z.riders || []).forEach((r) => {
|
||
const key = r.rider_id;
|
||
if (!key || key === 'unassigned') return;
|
||
if (!riderMap[key]) {
|
||
riderMap[key] = {
|
||
id: key,
|
||
riderName: r.rider_name || r.username || key,
|
||
orders: [],
|
||
color: RIDER_COLORS[Object.keys(riderMap).length % RIDER_COLORS.length]
|
||
};
|
||
}
|
||
});
|
||
});
|
||
|
||
// Next, map the flat orders list to the riders
|
||
orders.forEach((o) => {
|
||
const key = o.rider_id || o.userid || 'unknown';
|
||
if (key === 'unassigned') return;
|
||
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]
|
||
};
|
||
}
|
||
if (!riderMap[key].orders.some((existing) => existing.orderid === o.orderid)) {
|
||
riderMap[key].orders.push(o);
|
||
}
|
||
});
|
||
|
||
const kitchenMap = {};
|
||
orders.forEach((o) => {
|
||
const name = pickupName(o) || '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;
|
||
|
||
// "All Active Routes" view scoping. This view is ORDER-CENTRIC: it shows ONLY
|
||
// riders who are live on GPS right now AND still have an in-progress order —
|
||
// their card in the sidebar, their single active-leg route line, and their
|
||
// live bike marker. Riders who are on GPS but have nothing left to deliver
|
||
// (everything delivered, or GPS-only with no orders) are intentionally
|
||
// excluded here. Every other view (By Location / By Zone / By Rider) is
|
||
// unchanged.
|
||
const isAllActiveView = viewMode === 'all';
|
||
// Orders belonging to GPS-active riders — the candidate pool for this view's
|
||
// list, drop set and auto-fit. Narrowed to in-progress orders below.
|
||
const allViewOrders = useMemo(
|
||
() => (isAllActiveView ? allOrders.filter((o) => activeRiderIdSet.has(String(o.rider_id))) : allOrders),
|
||
[isAllActiveView, allOrders, activeRiderIdSet]
|
||
);
|
||
// The single gate for the whole Active view: rider ids that are GPS-active AND
|
||
// currently have an in-progress order. Driving the sidebar list, routes,
|
||
// markers and map-fit off this one set guarantees they can never disagree
|
||
// about which riders are shown.
|
||
const activeOrderRiderIdSet = useMemo(
|
||
() => new Set((isAllActiveView ? allViewOrders : []).filter(isActiveDelivery).map((o) => String(o.rider_id))),
|
||
[isAllActiveView, allViewOrders]
|
||
);
|
||
// The riders we render in "All Active Routes": GPS-active riders that still
|
||
// have an active order. Every other view is unchanged.
|
||
const visibleRiders = useMemo(
|
||
() => (isAllActiveView ? riders.filter((r) => activeOrderRiderIdSet.has(String(r.id))) : riders),
|
||
[isAllActiveView, riders, activeOrderRiderIdSet]
|
||
);
|
||
// Active section sidebar riders — built from filteredLiveRows (the SAME
|
||
// batch-filtered rows that drive activeStats' "Orders/Riders" header tiles
|
||
// and every other view), not the raw unfiltered liveRows. Building this off
|
||
// liveRows previously showed active orders from every batch regardless of
|
||
// the selected Morning/Afternoon/Evening pill, while the header tiles above
|
||
// it (sourced from allOrders → filteredLiveRows) reported the batch-filtered
|
||
// count — e.g. tiles reading "0 Orders" while afternoon-batch cards still
|
||
// rendered underneath. Uses a sticky cache so cards only disappear when the
|
||
// API returns an explicit terminal status (delivered / cancelled / skipped).
|
||
// Temporary row omissions during the 15-second poll cycle (single-page
|
||
// refetch, brief API inconsistency) no longer cause cards to flash away and
|
||
// reappear. The cache is reset on batch change (see the effect below) so
|
||
// switching slots doesn't leak a previous batch's sticky cards forward.
|
||
const activeViewRiders = useMemo(() => {
|
||
if (!isAllActiveView) {
|
||
stickyActiveRef.current = {};
|
||
return [];
|
||
}
|
||
|
||
const TERMINAL = new Set(['delivered', 'cancelled', 'skipped', 'complete', 'completed']);
|
||
|
||
filteredLiveRows.forEach((o) => {
|
||
const id = String(o.orderid);
|
||
const s = String(o.orderstatus || '').toLowerCase();
|
||
if (s === 'active') {
|
||
stickyActiveRef.current[id] = o;
|
||
} else if (TERMINAL.has(s)) {
|
||
delete stickyActiveRef.current[id];
|
||
}
|
||
// Non-terminal, non-active statuses for a known order are intentionally
|
||
// ignored — a brief status regression in the poll data must not evict a card.
|
||
});
|
||
|
||
const stableOrders = Object.values(stickyActiveRef.current);
|
||
if (!stableOrders.length) return [];
|
||
|
||
const riderMeta = Object.fromEntries(riders.map((r) => [r.id, r]));
|
||
const byRider = {};
|
||
stableOrders.forEach((o) => {
|
||
const key = String(o.rider_id || o.userid || '');
|
||
if (!key || key === 'unassigned' || key === '0') return;
|
||
if (!byRider[key]) {
|
||
const meta = riderMeta[key];
|
||
byRider[key] = {
|
||
id: key,
|
||
riderName: meta?.riderName || o.rider_name || o.ridername || o.username || `Rider ${key}`,
|
||
orders: [],
|
||
color: meta?.color || getStableRiderColor(key)
|
||
};
|
||
}
|
||
if (!byRider[key].orders.some((ex) => String(ex.orderid) === String(o.orderid))) {
|
||
byRider[key].orders.push(o);
|
||
}
|
||
});
|
||
return Object.values(byRider);
|
||
}, [isAllActiveView, filteredLiveRows, riders]);
|
||
|
||
// Live GPS coordinates of exactly the riders this view shows (active + has an
|
||
// order), fed to MapController's auto-fit so the map frames what's rendered.
|
||
const allViewLivePoints = useMemo(
|
||
() =>
|
||
isAllActiveView
|
||
? liveRiderLocations
|
||
.filter((r) => r.status !== 'offline' && r.status !== 'blocked' && activeOrderRiderIdSet.has(String(r.id)))
|
||
.map((r) => [r.lat, r.lon])
|
||
: [],
|
||
[isAllActiveView, liveRiderLocations, activeOrderRiderIdSet]
|
||
);
|
||
|
||
// Clear the sticky active-order cache whenever the operator switches to a
|
||
// different date, zone, or batch slot — stale cards from the previous filter
|
||
// must not leak into the new view.
|
||
useEffect(() => {
|
||
stickyActiveRef.current = {};
|
||
}, [selectedDate, selectedAppLocationId, selectedBatch]);
|
||
|
||
// Count of in-progress deliveries shown in the Active view list. Derives from
|
||
// the sticky cache (via activeViewRiders) so header and card list always agree.
|
||
const activeDeliveryCount = useMemo(
|
||
() => (isAllActiveView ? activeViewRiders.reduce((sum, r) => sum + r.orders.length, 0) : 0),
|
||
[isAllActiveView, activeViewRiders]
|
||
);
|
||
|
||
// 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'
|
||
};
|
||
}
|
||
// "All Active Routes": the header must reflect exactly what the list/map
|
||
// shows — the in-progress orders and the riders working them — NOT the whole
|
||
// day's totals. Source from `activeViewRiders`, the SAME sticky-cached list
|
||
// that <ActiveSection> renders as cards (see `visibleRiders={activeViewRiders}`
|
||
// below) — not `visibleRiders`, which additionally requires a rider to be
|
||
// present in the independently-polled GPS-log feed with status 'active'/
|
||
// 'pending'. That extra join rarely resolves, which is why this tile used to
|
||
// show 0 riders even while real rider cards were on screen.
|
||
if (isAllActiveView) {
|
||
const activeOrders = activeViewRiders.flatMap((r) => r.orders);
|
||
return {
|
||
orders: activeOrders.length,
|
||
riders: activeViewRiders.length,
|
||
km: activeOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
|
||
profit: activeOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
|
||
label: 'Active Fleet'
|
||
};
|
||
}
|
||
return {
|
||
orders: stats.totalOrders,
|
||
riders: stats.totalRiders,
|
||
km: stats.totalKms,
|
||
profit: stats.totalProfit,
|
||
label: 'Total Fleet'
|
||
};
|
||
}, [focusedRider, focusedKitchen, isAllActiveView, activeViewRiders, 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 () => {
|
||
// GET /admin/consignments/:id/logs is confirmed (jupiter2doormile.md,
|
||
// Status: Done) as the GPS/status trail for one consignment — but
|
||
// this file's own `deliveryid` here has never been confirmed to
|
||
// actually BE a consignmentid (Dispatch.js's data source hasn't been
|
||
// audited against the new API the way orders.js/deliveries.js have).
|
||
// If it's really a bookingid, this 404s and rows stays empty — same
|
||
// degraded-but-not-crashing behaviour as before.
|
||
let rows = [];
|
||
try {
|
||
rows = (await getConsignmentLogs(deliveryid)) || [];
|
||
if (!Array.isArray(rows)) rows = [];
|
||
} catch (err) {
|
||
rows = [];
|
||
}
|
||
// 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);
|
||
// The unused `_ts` binding IS the mechanism — destructuring it out is
|
||
// how the internal timestamp is stripped from each point.
|
||
// eslint-disable-next-line no-unused-vars
|
||
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;
|
||
// parseDoormileTimestamp on both sides (not bare dayjs()) — even though
|
||
// a false-Z shift would cancel out in the diff below if BOTH fields
|
||
// always had it consistently, that's not guaranteed per-field, and an
|
||
// inconsistent shift on just one side would corrupt timeDeltaMin and
|
||
// the anomaly flag it drives.
|
||
const expectedTs = order?.expecteddeliverytime ? parseDoormileTimestamp(order.expecteddeliverytime) : null;
|
||
const actualTs = order?.deliverytime ? parseDoormileTimestamp(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) => {
|
||
if (points.length < 2) return;
|
||
const cacheKey = getTripCacheKey(riderId, tripKey, points);
|
||
// 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;
|
||
|
||
// 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);
|
||
});
|
||
|
||
// Active view: fetch a road-following route scoped to the rider's single
|
||
// in-progress leg — current live GPS position → drop (pickup as fallback
|
||
// when no live fix). Cached under a per-order key so renderRoutes() can
|
||
// draw the road polyline for that leg instead of a straight line, and so
|
||
// it never collides with the per-trip route fetched above.
|
||
if (isAllActiveView) {
|
||
const activeOrder = getActiveOrder(r.orders);
|
||
if (activeOrder) {
|
||
const lp = liveRiderLocations.find((l) => String(l.id) === String(r.id));
|
||
const start =
|
||
lp && Number.isFinite(lp.lat) && Number.isFinite(lp.lon)
|
||
? [lp.lat, lp.lon]
|
||
: hasValidPickup(activeOrder)
|
||
? [toNum(pickupLat(activeOrder)), toNum(pickupLon(activeOrder))]
|
||
: null;
|
||
const dLat = toNum(activeOrder.droplat || activeOrder.deliverylat);
|
||
const dLon = toNum(activeOrder.droplon || activeOrder.deliverylong);
|
||
if (start && Number.isFinite(dLat) && Number.isFinite(dLon)) {
|
||
fetchRoute(r.id, `active-${activeOrder.orderid}`, [start, [dLat, dLon]]);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}, [riders, activeRiders, focusedRider, isAllActiveView, liveRiderLocations, 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).
|
||
// COMMENTED OUT: as per user request to only show the popup when clicking the map number plots/markers.
|
||
/*
|
||
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) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
|
||
: tOrders;
|
||
|
||
if (filteredTOrders.length === 0) return;
|
||
|
||
// Use the full trip orders (unfiltered by kitchen) to build the cache key
|
||
// so it matches the key used to fetch the route in the useEffect.
|
||
const fullSorted = [...tOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
|
||
const fullPts = buildTripPoints(fullSorted);
|
||
const cacheKey = getTripCacheKey(r.id, tNum, fullPts);
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Kitchen badge is a FIXED 31px at every zoom level — deliberately not
|
||
// zoom-responsive.
|
||
//
|
||
// It used to scale with zoom (46px at street level, shrinking as the operator
|
||
// zoomed out) which made the marker read as a big amber blob over a
|
||
// city-wide view. Rather than tune the curve, the size is now constant: a
|
||
// pin that never changes size is easier to scan for, and 31px is the size
|
||
// the scaled version happened to hit at city zoom, which is the level
|
||
// operators actually work at.
|
||
//
|
||
// The white ring and amber glow are pinned to match. They're what made the
|
||
// old marker look oversized — the stylesheet painted a 3px ring and a
|
||
// 20/40px blur regardless of badge size, so a small dot sat inside an ~80px
|
||
// halo. These inline values win over the .kitchen-mark rule in Dispatch.css,
|
||
// which now only carries the unscaled design defaults.
|
||
//
|
||
// The focused badge stays proportionally larger (same 56:46 ratio as the
|
||
// original design) so drilling into a kitchen still visibly marks it.
|
||
const KITCHEN_ICON_SIZE = 31;
|
||
const KITCHEN_ICON_FOCUSED_SIZE = 38;
|
||
|
||
const createKitchenIcon = (name, focused = false) => {
|
||
const size = focused ? KITCHEN_ICON_FOCUSED_SIZE : KITCHEN_ICON_SIZE;
|
||
const anchor = Math.round(size / 2);
|
||
const border = focused ? 3 : 2;
|
||
const glow = 14;
|
||
return L.divIcon({
|
||
className: '',
|
||
iconSize: [size, size],
|
||
iconAnchor: [anchor, anchor],
|
||
popupAnchor: [0, -(anchor + 2)],
|
||
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}" style="width:${size}px;height:${size}px;font-size:${Math.round(
|
||
size * 0.38
|
||
)}px;border-width:${border}px;box-shadow:0 0 ${glow}px rgba(245,158,11,0.8), 0 0 ${glow * 2}px rgba(245,158,11,0.4)">${(name || 'K')
|
||
.charAt(0)
|
||
.toUpperCase()}</div>`
|
||
});
|
||
};
|
||
|
||
const getRiderColor = (rid) => riders.find((r) => String(r.id) === String(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"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => handleRiderFocus(r)}
|
||
onKeyDown={onActivate(() => handleRiderFocus(r))}
|
||
style={{ animationDelay: `${i * 0.05}s`, borderLeft: `3px solid ${r.color}` }}
|
||
>
|
||
<div className="rcard-top">
|
||
<div className="rcard-emo" style={{ background: '#f1f5f9', borderColor: '#e2e8f0', color: '#64748b' }}>
|
||
<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: '#f1f5f9', color: '#475569' }}
|
||
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-header-main">
|
||
<div className="pu-header-left">
|
||
<div className="pu-rider">
|
||
<MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span>
|
||
</div>
|
||
{(o.deliverycustomer || o.customername) && (
|
||
<div className="pu-customer" title={o.deliverycustomer || o.customername}>
|
||
<MdMarkunreadMailbox />
|
||
<span>{o.deliverycustomer || o.customername}</span>
|
||
</div>
|
||
)}
|
||
{o.deliveryid != null && <div className="pu-delivery-id">Delivery #{o.deliveryid}</div>}
|
||
</div>
|
||
{o.step != null && (
|
||
<div className="pu-header-step">
|
||
<span className="pu-header-step-label">Step</span>
|
||
<span className="pu-header-step-value">
|
||
{o.trip_number != null && o.trip_number > 1 ? `T${o.trip_number}·` : ''}
|
||
{o.step}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</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.plannedkms != null || o.kms != null || o.actualkms != null || o.riderkms != null || estMeters !== null) && (
|
||
<div className="pu-distance-row">
|
||
{o.plannedkms != null && o.plannedkms !== '' ? (
|
||
<div className="pu-distance-chip">
|
||
<span className="pu-distance-icon">
|
||
<MdStraighten />
|
||
</span>
|
||
<span className="pu-distance-label">Planned</span>
|
||
<span className="pu-distance-value">{parseFloat(o.plannedkms).toFixed(1)} km</span>
|
||
</div>
|
||
) : 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">{parseFloat(o.kms).toFixed(1)} km</span>
|
||
</div>
|
||
) : null}
|
||
{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">{parseFloat(o.actualkms).toFixed(1)} km</span>
|
||
</div>
|
||
)}
|
||
{o.riderkms != null && o.riderkms !== '' && (
|
||
<div className="pu-distance-chip">
|
||
<span className="pu-distance-icon">
|
||
<MdStraighten />
|
||
</span>
|
||
<span className="pu-distance-label">Rider KMs</span>
|
||
<span className="pu-distance-value">{parseFloat(o.riderkms).toFixed(1)} 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) => {
|
||
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;
|
||
// In "All Active Routes" draw for exactly the riders shown as cards
|
||
// (activeViewRiders) — not `visibleRiders`, which additionally requires a
|
||
// rider to be present in the independently-polled GPS-log feed with status
|
||
// 'active'/'pending'. That join rarely resolves, which left the map with
|
||
// no polyline for a rider whose active-order marker/card was clearly
|
||
// visible and clickable. Every other view keeps using `riders` (unfiltered).
|
||
(isAllActiveView ? activeViewRiders : 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;
|
||
|
||
// In the Active view, collapse the rider down to just their in-progress
|
||
// delivery so the map draws only that one leg's polyline instead of the
|
||
// whole day's route. Every other view keeps the full order list.
|
||
const activeOrder = isAllActiveView ? getActiveOrder(r.orders) : null;
|
||
const rOrders = isAllActiveView ? (activeOrder ? [activeOrder] : []) : r.orders;
|
||
if (isAllActiveView && rOrders.length === 0) return;
|
||
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) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
|
||
: tOrders;
|
||
|
||
if (filteredTOrders.length === 0) return;
|
||
|
||
// Use the full trip orders (unfiltered by kitchen) to build the cache key
|
||
// so it matches the key used to fetch the route in the useEffect.
|
||
const fullSorted = [...tOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
|
||
const fullPts = buildTripPoints(fullSorted);
|
||
const cacheKey = getTripCacheKey(r.id, tNum, fullPts);
|
||
|
||
const roadPoints = osrmRoutes[cacheKey];
|
||
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
|
||
|
||
// Active view: build a route scoped to just the in-progress leg and FOLLOW THE ROADS.
|
||
//
|
||
// Leg endpoints: the rider's CURRENT live GPS position → the drop. The
|
||
// live position is preferred because (a) active riders always have a
|
||
// live GPS fix in this view, so the leg always has 2 valid points even
|
||
// when the order carries no pickup coordinates — the previous cause of a
|
||
// completely missing line — and (b) it shows the rider's REMAINING route
|
||
// to the customer. Falls back to the pickup when there's no live fix.
|
||
const activeLegOrder = isAllActiveView ? sorted[0] : null;
|
||
let activeStraightLeg = null;
|
||
if (isAllActiveView && activeLegOrder) {
|
||
const lp = liveRiderLocations.find((l) => String(l.id) === String(r.id));
|
||
const start =
|
||
lp && Number.isFinite(lp.lat) && Number.isFinite(lp.lon)
|
||
? [lp.lat, lp.lon]
|
||
: hasValidPickup(activeLegOrder)
|
||
? [toNum(pickupLat(activeLegOrder)), toNum(pickupLon(activeLegOrder))]
|
||
: null;
|
||
const dLat = toNum(activeLegOrder.droplat || activeLegOrder.deliverylat);
|
||
const dLon = toNum(activeLegOrder.droplon || activeLegOrder.deliverylong);
|
||
if (start && Number.isFinite(dLat) && Number.isFinite(dLon)) {
|
||
activeStraightLeg = [start, [dLat, dLon]];
|
||
}
|
||
}
|
||
// Prefer the OSRM road-following route for the active leg (fetched as
|
||
// `${r.id}-active-${orderid}` in the route-fetch effect); other views
|
||
// keep the per-trip route.
|
||
const roadToUse = isAllActiveView
|
||
? activeLegOrder && activeStraightLeg
|
||
? osrmRoutes[getTripCacheKey(r.id, `active-${activeLegOrder.orderid}`, activeStraightLeg)]
|
||
: undefined
|
||
: roadPoints;
|
||
const hasRoad = Array.isArray(roadToUse) && roadToUse.length >= 2;
|
||
const failed = roadToUse === false;
|
||
// Other views: wait for OSRM so we don't flash an aerial line before the
|
||
// road polyline lands. Active view: NEVER wait — draw the straight leg
|
||
// immediately and let it upgrade to the road-following route, so the
|
||
// line is never invisible while OSRM is in flight.
|
||
if (!isAllActiveView && !hasRoad && !failed) return;
|
||
|
||
const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : 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;
|
||
};
|
||
|
||
return (
|
||
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
|
||
{!embedded && (
|
||
<div id="hdr">
|
||
<div className="logo">
|
||
<div className="logo-badge">
|
||
<img src={doormileMark} alt="Doormile" className="logo-badge-img" />
|
||
</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' : 'Revenue'} (${activeStats.label})`}
|
||
>
|
||
<span className="strat-stat-icon">{isLoss ? <MdTrendingDown /> : <MdTrendingUp />}</span>
|
||
<span className="strat-stat-label">{isLoss ? 'Loss' : 'Revenue'}</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>
|
||
{!embedded && (
|
||
<button
|
||
className={`sbt ${viewMode === 'all' ? 'active' : ''}`}
|
||
onClick={() => {
|
||
logger.info('View mode changed: All Active Routes');
|
||
setViewMode('all');
|
||
handleRiderFocus(null);
|
||
setFocusedKitchen(null);
|
||
setFocusedZone(null);
|
||
}}
|
||
>
|
||
<span className="sbt-icon">
|
||
<MdPublic />
|
||
</span>
|
||
Active
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={`sbt sbt-profitability ${viewMode === 'profitability' ? 'active' : ''}`}
|
||
onClick={() => {
|
||
logger.info('View mode changed: Profitability');
|
||
setViewMode('profitability');
|
||
handleRiderFocus(null);
|
||
setFocusedKitchen(null);
|
||
setFocusedZone(null);
|
||
}}
|
||
title="View overall and rider profitability"
|
||
>
|
||
<span className="sbt-icon">
|
||
<MdAttachMoney />
|
||
</span>{' '}
|
||
Profitability
|
||
</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>
|
||
{/* 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 === 'profitability' ? (
|
||
<ProfitabilitySection
|
||
riders={riders}
|
||
handleRiderFocus={handleRiderFocus}
|
||
focusedRider={focusedRider}
|
||
totalDailyProfit={totalDailyProfit}
|
||
selectedDate={selectedDate}
|
||
batches={BATCHES}
|
||
/>
|
||
) : 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-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 && !isAllActiveView) || 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 actualKm = focusedRider.orders.reduce((s, o) => s + parseFloat(o.actualkms || 0), 0);
|
||
const riderKm = focusedRider.orders.reduce((s, o) => s + parseFloat(o.riderkms || 0), 0);
|
||
return (
|
||
<div className="rd-stats-grid">
|
||
<div className="rd-stat rd-stat-orders">
|
||
<div className="rd-stat-icon">
|
||
<MdInventory2 />
|
||
</div>
|
||
<div className="rd-stat-value">{focusedRider.orders.length}</div>
|
||
<div className="rd-stat-label">Orders</div>
|
||
</div>
|
||
<div className="rd-stat rd-stat-distance">
|
||
<div className="rd-stat-icon">
|
||
<MdStraighten />
|
||
</div>
|
||
<div className="rd-stat-value">
|
||
{actualKm.toFixed(1)}
|
||
<span className="rd-stat-unit">km</span>
|
||
</div>
|
||
<div className="rd-stat-label">Actual KMs</div>
|
||
</div>
|
||
<div className="rd-stat rd-stat-distance">
|
||
<div className="rd-stat-icon">
|
||
<MdStraighten />
|
||
</div>
|
||
<div className="rd-stat-value">
|
||
{riderKm.toFixed(1)}
|
||
<span className="rd-stat-unit">km</span>
|
||
</div>
|
||
<div className="rd-stat-label">Trip KMs</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;
|
||
|
||
// Completion-time epoch for a delivery. Mirrors the Compare
|
||
// timeline's `tsOf` (Dispatch.js — riderActualTracks builder)
|
||
// so the sequence number on the sidebar in 'time' mode lines
|
||
// up with the Compare timeline's sequenceStep.
|
||
const completionTs = (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 isTimeMode = tripSortMode === 'time';
|
||
let prevKitchenKey = null;
|
||
return Object.entries(trips)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([tNum, tOrders]) => {
|
||
// 'planned' keeps the trip's incoming step order (the
|
||
// caller already sorted by trip_number, step). 'time'
|
||
// re-sorts inside each trip by completion time; step
|
||
// is the tiebreaker so two deliveries logged in the
|
||
// same minute still render in dispatched order.
|
||
// Kitchen-transition badges are computed off the displayed
|
||
// order so the "Switch to X" rows stay accurate after
|
||
// reordering — without this they'd reference the
|
||
// original sequence and confuse the operator.
|
||
const displayOrders = isTimeMode
|
||
? [...tOrders].sort((a, b) => {
|
||
const diff = completionTs(a) - completionTs(b);
|
||
if (diff !== 0) return diff;
|
||
return (a.step || 0) - (b.step || 0);
|
||
})
|
||
: tOrders;
|
||
return (
|
||
<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>
|
||
{/* iOS-style segmented control. Track sits in a soft
|
||
inset bg; the active item is a clean white "thumb"
|
||
with a subtle shadow. We deliberately don't fill
|
||
the active pill with the rider's color — the
|
||
`Trip N` badge to the left already carries that
|
||
identity, and doubling the accent makes the row
|
||
read as competing pills instead of a controlled
|
||
hierarchy. The CSS owns all interactive states. */}
|
||
<div
|
||
className="trip-sort-toggle"
|
||
role="group"
|
||
aria-label="Sort stops by"
|
||
data-mode={isTimeMode ? 'time' : 'planned'}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={`trip-sort-pill ${!isTimeMode ? 'is-active' : ''}`}
|
||
aria-pressed={!isTimeMode}
|
||
onClick={() => setTripSortMode('planned')}
|
||
title="Sort stops by planned step (dispatched order)"
|
||
>
|
||
<MdFormatListBulleted aria-hidden="true" />
|
||
<span>Planned</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`trip-sort-pill ${isTimeMode ? 'is-active' : ''}`}
|
||
aria-pressed={isTimeMode}
|
||
onClick={() => setTripSortMode('time')}
|
||
title="Sort stops by completion time (which delivery was done first)"
|
||
>
|
||
<MdAccessTime aria-hidden="true" />
|
||
<span>By time</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="zone-order-grid">
|
||
{displayOrders.map((o, idx) => {
|
||
const kitchenKey = (pickupName(o) || '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);
|
||
// Badge number is ALWAYS the planned step (o.step)
|
||
// — the same number regardless of sort mode. Time
|
||
// mode only reorders the cards; the badge keeps
|
||
// its dispatched identity so operators can still
|
||
// map a card on screen back to "step 3 of the
|
||
// planned route" without mental translation.
|
||
const displayNum = o.step || idx + 1;
|
||
// "Not yet delivered" indicator for time mode — we
|
||
// pushed these rows to the end via MAX_SAFE_INTEGER
|
||
// sort key, but a visual cue makes that obvious
|
||
// without forcing the operator to read the status pill.
|
||
const isUndeliveredInTimeMode = isTimeMode && !o.deliverytime;
|
||
|
||
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' : ''
|
||
} ${isUndeliveredInTimeMode ? 'is-pending-time' : ''}`}
|
||
role={canFocus ? 'button' : undefined}
|
||
tabIndex={canFocus ? 0 : undefined}
|
||
onClick={
|
||
canFocus
|
||
? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon })
|
||
: undefined
|
||
}
|
||
onKeyDown={
|
||
canFocus
|
||
? onActivate(() => 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" title={`Planned step ${o.step || idx + 1}`}>
|
||
{displayNum}
|
||
</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>
|
||
{Number(o.actualkms || o.kms || 0).toFixed(2)} 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
|
||
}
|
||
onKeyDown={
|
||
canFocus
|
||
? onActivate(() => 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>
|
||
{Number(o.actualkms || o.kms || 0).toFixed(2)} 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
|
||
}
|
||
onKeyDown={
|
||
canFocus
|
||
? onActivate(() => 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>
|
||
{Number(o.actualkms || o.kms || 0).toFixed(2)} 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">
|
||
{!(isAllActiveView && activeDeliveryCount === 0) && (
|
||
<div className="ph">
|
||
{viewMode === 'zones'
|
||
? 'Zone dispatch'
|
||
: viewMode === 'kitchens'
|
||
? 'Kitchen dispatch'
|
||
: isAllActiveView
|
||
? 'Active rider 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"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setFocusedZone(z)}
|
||
onKeyDown={onActivate(() => 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"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setFocusedKitchen(k)}
|
||
onKeyDown={onActivate(() => 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>
|
||
))
|
||
) : isAllActiveView ? (
|
||
<ActiveSection
|
||
// Remount on batch/date/zone change so its internal
|
||
// prevStatusMapRef (used to detect real status
|
||
// transitions and fire the "now delivering" /
|
||
// "reached location" toasts) resets. Without this,
|
||
// switching e.g. Morning → Afternoon swaps in a new
|
||
// set of orders the ref has never seen, so every
|
||
// already-active order in the new batch looks like a
|
||
// fresh transition and fires a stale toast.
|
||
key={`${selectedDate}-${selectedAppLocationId}-${selectedBatch}`}
|
||
visibleRiders={activeViewRiders}
|
||
riders={riders}
|
||
focusedStop={focusedStop}
|
||
handleRiderFocus={handleRiderFocus}
|
||
setFocusedStop={setFocusedStop}
|
||
calculateEstMeters={calculateEstMeters}
|
||
getRiderColor={getRiderColor}
|
||
formatMeters={formatMeters}
|
||
isLoading={shouldFetchLive && liveIsFetching && activeViewRiders.length === 0}
|
||
/>
|
||
) : (
|
||
visibleRiders.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} />}
|
||
{compareOpen && (
|
||
<CompareMapClickUnpin
|
||
onUnpin={() => {
|
||
pinnedPopupsRef.current.clear();
|
||
setCenterPopupOrder(null);
|
||
}}
|
||
/>
|
||
)}
|
||
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
|
||
<MapController
|
||
focusedItem={
|
||
compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone
|
||
}
|
||
viewMode={viewMode}
|
||
orders={allViewOrders}
|
||
kitchens={kitchens}
|
||
locationKey={selectedAppLocationId}
|
||
extraPoints={allViewLivePoints}
|
||
/>
|
||
{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 GET /admin/milers/summary (see
|
||
fetchRidersLogs in api.js). Green pin when the rider's status
|
||
isn't `offline` (Available/Assigned/On_Pickup — confirmed
|
||
live enum), 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
|
||
the summary still returns their GPS row. When a specific
|
||
rider is focused, only that one is shown. */}
|
||
{liveRiderLocations
|
||
.filter((r) =>
|
||
isAllActiveView
|
||
? r.status !== 'offline' && r.status !== 'blocked' && activeOrderRiderIdSet.has(String(r.id))
|
||
: riders.some((rd) => String(rd.id) === String(r.id))
|
||
)
|
||
.filter((r) => !focusedRider || String(focusedRider.id) === String(r.id))
|
||
.map((r) => {
|
||
const isActive = r.status !== 'offline' && r.status !== 'blocked';
|
||
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;
|
||
// Marker icon. ONLY the "All Active Routes" view gets the
|
||
// Swiggy/Zomato/Rapido-style live bike badge (rounded glyph
|
||
// + pulsing ring) that smoothly glides between GPS fixes.
|
||
// Every other view keeps the original teardrop pin exactly
|
||
// as before. Icons are cached per rider (liveIconCacheRef);
|
||
// the sig includes the view so switching modes rebuilds it.
|
||
const safeName = (r.username || '').replace(/[<>&"']/g, '');
|
||
const safeOrder = r.orderid ? String(r.orderid).replace(/[<>&"']/g, '') : '';
|
||
const iconSig = `${isAllActiveView ? 'bike' : 'pin'}|${pinColor}|${safeName}|${safeOrder}|${isActive ? 1 : 0}`;
|
||
let iconEntry = liveIconCacheRef.current.get(r.id);
|
||
if (!iconEntry || iconEntry.sig !== iconSig) {
|
||
const icon = isAllActiveView
|
||
? L.divIcon({
|
||
className: '',
|
||
iconSize: [160, 44],
|
||
iconAnchor: [22, 22],
|
||
popupAnchor: [0, -22],
|
||
html: `<div class="live-rider-bike ${isActive ? 'is-active' : 'is-idle'}" style="--pin-color:${pinColor}">
|
||
<span class="live-rider-bike-pulse"></span>
|
||
<span class="live-rider-bike-badge">
|
||
<svg viewBox="0 0 24 24" width="20" height="20" fill="#fff" aria-hidden="true"><path d="M19.44 9.03 15.41 5H11v2h3.59l2 2H5c-2.8 0-5 2.2-5 5s2.2 5 5 5c2.46 0 4.45-1.69 4.9-4h1.65l2.77-2.77c-.21.54-.32 1.14-.32 1.77 0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.65-2.06-4.77-4.66-4.97ZM7.82 15C7.4 16.15 6.28 17 5 17c-1.63 0-3-1.37-3-3s1.37-3 3-3c1.28 0 2.4.85 2.82 2H5v2h2.82ZM19 17c-1.63 0-3-1.37-3-3s1.37-3 3-3 3 1.37 3 3-1.37 3-3 3Z"/></svg>
|
||
</span>
|
||
<span class="live-rider-bike-label">${safeName}${safeOrder ? ` <span>#${safeOrder}</span>` : ''}</span>
|
||
</div>`
|
||
})
|
||
: 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">${safeName}${safeOrder ? ` <span>#${safeOrder}</span>` : ''}</div>
|
||
</div>`
|
||
});
|
||
iconEntry = { sig: iconSig, icon };
|
||
liveIconCacheRef.current.set(r.id, iconEntry);
|
||
}
|
||
const liveIcon = iconEntry.icon;
|
||
// Shared interaction handlers — identical for both marker types.
|
||
const liveEventHandlers = {
|
||
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));
|
||
}
|
||
};
|
||
// Animated bike (with imperative gliding) only in the active
|
||
// view; the plain react-leaflet Marker everywhere else so
|
||
// existing views behave exactly as they did before.
|
||
const LiveMarker = isAllActiveView ? AnimatedRiderMarker : Marker;
|
||
const positionProps = isAllActiveView ? { target: [r.lat, r.lon], duration: 1000 } : { position: [r.lat, r.lon] };
|
||
return (
|
||
<LiveMarker
|
||
key={`live-${r.id}`}
|
||
{...positionProps}
|
||
icon={liveIcon}
|
||
zIndexOffset={2500}
|
||
eventHandlers={liveEventHandlers}
|
||
>
|
||
<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" />{' '}
|
||
{parseDoormileTimestamp(r.logdate).isValid()
|
||
? parseDoormileTimestamp(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>
|
||
</LiveMarker>
|
||
);
|
||
})}
|
||
|
||
{/* 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)
|
||
);
|
||
|
||
// A status flag SVG used to be built here and never rendered —
|
||
// this marker draws `dropPinHtml` below instead. Removed with
|
||
// the getStatusStyle lookup that only fed it.
|
||
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
|
||
? {
|
||
// Click-only: hovering the numbered pin does
|
||
// nothing. Earlier the modal opened on mouseover,
|
||
// which made the rich order card flash open while
|
||
// panning the map and blocked the operator from
|
||
// reading other steps in the trail. Click toggles
|
||
// the modal — same `pinnedPopupsRef` pattern the
|
||
// planned-route marker uses, so the modal stays
|
||
// pinned (doesn't auto-close on mouse leave) until
|
||
// the user clicks the pin again, the × button, or
|
||
// the map background.
|
||
click: (e) => {
|
||
if (e.originalEvent) e.originalEvent.stopPropagation();
|
||
setFocusedCompareStep((prev) => (prev === t.sequenceStep ? null : t.sequenceStep));
|
||
if (popupHoverTimerRef.current) {
|
||
clearTimeout(popupHoverTimerRef.current);
|
||
popupHoverTimerRef.current = null;
|
||
}
|
||
const id = String(orderForTrack.orderid);
|
||
if (pinnedPopupsRef.current.has(id)) {
|
||
pinnedPopupsRef.current.delete(id);
|
||
setCenterPopupOrder(null);
|
||
} else {
|
||
pinnedPopupsRef.current.add(id);
|
||
setCenterPopupOrder(orderForTrack);
|
||
}
|
||
}
|
||
}
|
||
: { 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 ${parseDoormileTimestamp(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, #000000, #3A3A3A)' : '#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 || {};
|
||
// Renamed from `riders` to `apiRiders` so the outer-component
|
||
// `riders` array (built from live dispatch data, has the real
|
||
// riderName fields) stays accessible inside this block — we
|
||
// join against it below to swap placeholder API names like
|
||
// "Rider 883" for actual operator-facing names.
|
||
const apiRiders = 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 || {};
|
||
|
||
// userid → real rider name. The /batch/efficiency response only
|
||
// ships placeholder names ("Rider 883") because the workolik
|
||
// solver doesn't have our auth/users table. We resolve here:
|
||
// 1. against `riders` (current dispatch data — has riderName)
|
||
// 2. against `ridersAllDay` (full-day list built from live rows)
|
||
// 3. fall back to whatever the API sent.
|
||
const resolveRiderName = (uidLike, fallback) => {
|
||
const uid = String(uidLike ?? '').trim();
|
||
if (!uid) return fallback || '—';
|
||
// The placeholder "Rider 883" pattern is what we WANT to upgrade,
|
||
// so don't treat it as a usable fallback — keep looking. Only
|
||
// accept the fallback if it's a real name (no "Rider <digits>"
|
||
// pattern).
|
||
const fallbackIsPlaceholder = typeof fallback === 'string' && /^Rider\s+\d+$/i.test(fallback.trim());
|
||
const liveMatch = Array.isArray(riders) ? riders.find((x) => String(x.id) === uid) : null;
|
||
if (liveMatch && liveMatch.riderName) return liveMatch.riderName;
|
||
const allDayMatch = Array.isArray(ridersAllDay) ? ridersAllDay.find((x) => String(x.id) === uid) : null;
|
||
if (allDayMatch && allDayMatch.riderName) return allDayMatch.riderName;
|
||
if (!fallbackIsPlaceholder && fallback) return fallback;
|
||
return `Rider ${uid}`;
|
||
};
|
||
|
||
// ─── Fleet-window math for the gantt + duration display ─────────
|
||
const fleetStartSec = parseHMS(fleet.fleet_start);
|
||
const fleetEndSec = parseHMS(fleet.fleet_done);
|
||
const fleetTotalSec =
|
||
fleetStartSec != null && fleetEndSec != null && fleetEndSec > fleetStartSec ? fleetEndSec - fleetStartSec : null;
|
||
|
||
// ─── Utilisation health bucket ─────────────────────────────────
|
||
const util = parseFloat(fleet.avg_utilisation_pct);
|
||
const utilSafe = Number.isFinite(util) ? util : null;
|
||
const utilHealth = utilSafe == null ? 'unknown' : utilSafe >= 85 ? 'good' : utilSafe >= 70 ? 'ok' : 'low';
|
||
const utilColor =
|
||
utilHealth === 'good' ? '#10b981' : utilHealth === 'ok' ? '#f59e0b' : utilHealth === 'low' ? '#ef4444' : '#94a3b8';
|
||
|
||
// ─── Severity for the recommendation banner ────────────────────
|
||
const severity = analysisSeverity({
|
||
assessment: rec?.fleet_balance_assessment,
|
||
spreadMin: parseFloat(fleet.finish_time_spread_minutes),
|
||
stdev: parseFloat(fleet.load_balance_stdev)
|
||
});
|
||
|
||
// ─── Augment each rider with gantt percentages + per-rider util ─
|
||
// `apiRiders` is the API payload's `rider_timelines`; we also
|
||
// attach `displayName` (resolved against the in-app riders
|
||
// lookup) so the JSX below renders real names instead of the
|
||
// placeholder "Rider {userid}" the API returns.
|
||
const ridersWithGantt = apiRiders.map((r) => {
|
||
const startSec = parseHMS(r.started_at);
|
||
const endSec = parseHMS(r.finished_at);
|
||
let startPct = 0;
|
||
let endPct = 100;
|
||
if (fleetStartSec != null && fleetTotalSec) {
|
||
if (startSec != null) startPct = Math.max(0, Math.min(100, ((startSec - fleetStartSec) / fleetTotalSec) * 100));
|
||
if (endSec != null) endPct = Math.max(0, Math.min(100, ((endSec - fleetStartSec) / fleetTotalSec) * 100));
|
||
}
|
||
const activePct = Math.max(0, endPct - startPct);
|
||
const activeMin = parseFloat(r.active_minutes);
|
||
const idleMin = parseFloat(r.idle_minutes);
|
||
// Per-rider utilisation = active / (active + idle). Falls back to
|
||
// the fleet avg when individual fields are missing so the bar's
|
||
// color never goes "unknown" mid-list.
|
||
const totalRiderMin = (Number.isFinite(activeMin) ? activeMin : 0) + (Number.isFinite(idleMin) ? idleMin : 0);
|
||
const riderUtilPct = totalRiderMin > 0 ? (activeMin / totalRiderMin) * 100 : utilSafe;
|
||
const riderHealth = riderUtilPct == null ? 'unknown' : riderUtilPct >= 85 ? 'good' : riderUtilPct >= 70 ? 'ok' : 'low';
|
||
const riderColor =
|
||
riderHealth === 'good' ? '#10b981' : riderHealth === 'ok' ? '#f59e0b' : riderHealth === 'low' ? '#ef4444' : '#94a3b8';
|
||
const displayName = resolveRiderName(r.userid, r.name);
|
||
return {
|
||
...r,
|
||
displayName,
|
||
_startPct: startPct,
|
||
_endPct: endPct,
|
||
_activePct: activePct,
|
||
_riderUtilPct: riderUtilPct,
|
||
_riderColor: riderColor
|
||
};
|
||
});
|
||
// Sort: latest-finish first (= the rider who held up the fleet).
|
||
// Operators care about who's the bottleneck, not who's first.
|
||
ridersWithGantt.sort((a, b) => (parseHMS(b.finished_at) || 0) - (parseHMS(a.finished_at) || 0));
|
||
|
||
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>
|
||
<div className="da-detail-actions">
|
||
{/* Live indicator: confirms to the operator that the
|
||
panel is polling and not just a stale snapshot. */}
|
||
<span
|
||
className={`da-live-tag ${isLoading ? 'is-active' : ''}`}
|
||
title={`Auto-refreshing every ${Math.round(ANALYSIS_POLL_MS / 1000)}s`}
|
||
>
|
||
<span className="da-live-dot" />
|
||
Live · {Math.round(ANALYSIS_POLL_MS / 1000)}s
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="da-result-refresh"
|
||
title="Refresh now"
|
||
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>
|
||
|
||
{/* ─── Hero strip: the three numbers an ops lead glances at
|
||
first — workload, headcount, and how long the batch
|
||
actually ran. Sits above everything else so the answer
|
||
to "was today big or small?" is the first thing visible. */}
|
||
<div className="da-section">
|
||
<div className="da-hero-row">
|
||
<div className="da-hero-card">
|
||
<div className="da-hero-icon" style={{ background: '#0000001f', color: '#000000' }}>
|
||
<MdOutlineInventory2 />
|
||
</div>
|
||
<div className="da-hero-value">{analysisFormatNum(fleet.total_orders)}</div>
|
||
<div className="da-hero-label">Total Orders</div>
|
||
{raw.input_delivery_count != null && raw.input_delivery_count !== fleet.total_orders && (
|
||
<div className="da-hero-sub">{raw.input_delivery_count} input</div>
|
||
)}
|
||
</div>
|
||
<div className="da-hero-card">
|
||
<div className="da-hero-icon" style={{ background: '#0ea5e91f', color: '#0ea5e9' }}>
|
||
<MdOutlineTwoWheeler />
|
||
</div>
|
||
<div className="da-hero-value">{analysisFormatNum(fleet.total_riders)}</div>
|
||
<div className="da-hero-label">Active Riders</div>
|
||
{fleet.orders_per_rider_avg != null && (
|
||
<div className="da-hero-sub">{fleet.orders_per_rider_avg} avg orders / rider</div>
|
||
)}
|
||
</div>
|
||
<div className="da-hero-card">
|
||
<div className="da-hero-icon" style={{ background: '#8b5cf61f', color: '#8b5cf6' }}>
|
||
<MdOutlineAccessTime />
|
||
</div>
|
||
<div className="da-hero-value">
|
||
{fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes}` : '—'}
|
||
{fleet.total_duration_minutes != null && <span className="da-hero-unit"> min</span>}
|
||
</div>
|
||
<div className="da-hero-label">Fleet Window</div>
|
||
{fleet.fleet_start && fleet.fleet_done && (
|
||
<div className="da-hero-sub">
|
||
{fleet.fleet_start} → {fleet.fleet_done}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ─── Health KPI band: efficiency + balance signals.
|
||
These tell the operator HOW the batch ran — utilisation
|
||
ring (visual gauge), load balance stdev (lower = better
|
||
workload spread), finish-time spread (lower = tighter
|
||
finish), and average active minutes per rider. */}
|
||
<div className="da-section">
|
||
<div className="da-section-label">Fleet Health</div>
|
||
<div className="da-health-row">
|
||
{/* Utilisation — the only visual gauge: lets a busy
|
||
ops lead spot "we left 30% of the day on the table"
|
||
without reading the number. */}
|
||
<div className="da-health-card">
|
||
<div
|
||
className="da-ring"
|
||
style={{ '--ring-color': utilColor, '--ring-pct': utilSafe != null ? Math.max(0, Math.min(100, utilSafe)) : 0 }}
|
||
>
|
||
<span className="da-ring-num">
|
||
{utilSafe != null ? `${utilSafe.toFixed(0)}` : '—'}
|
||
{utilSafe != null && <span className="da-ring-unit">%</span>}
|
||
</span>
|
||
</div>
|
||
<div className="da-health-meta">
|
||
<div className="da-health-label">Avg Utilisation</div>
|
||
<div className="da-health-sub" style={{ color: utilColor }}>
|
||
{utilHealth === 'good' ? 'Strong' : utilHealth === 'ok' ? 'Moderate' : utilHealth === 'low' ? 'Low' : '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="da-health-card da-health-card-stat">
|
||
<div className="da-health-value">
|
||
{fleet.load_balance_stdev != null ? parseFloat(fleet.load_balance_stdev).toFixed(2) : '—'}
|
||
</div>
|
||
<div className="da-health-label">Load Balance σ</div>
|
||
<div className="da-health-sub">
|
||
{fleet.load_balance_stdev != null
|
||
? parseFloat(fleet.load_balance_stdev) > 1.0
|
||
? 'Uneven workload'
|
||
: 'Workload balanced'
|
||
: 'Stdev of orders / rider'}
|
||
</div>
|
||
</div>
|
||
<div className="da-health-card da-health-card-stat">
|
||
<div className="da-health-value">
|
||
{fleet.finish_time_spread_minutes != null ? `${fleet.finish_time_spread_minutes}` : '—'}
|
||
{fleet.finish_time_spread_minutes != null && <span className="da-health-unit"> min</span>}
|
||
</div>
|
||
<div className="da-health-label">Finish Spread</div>
|
||
<div className="da-health-sub">
|
||
{fleet.finish_time_stdev_minutes != null
|
||
? `σ ${parseFloat(fleet.finish_time_stdev_minutes).toFixed(1)} min`
|
||
: 'First → last finish'}
|
||
</div>
|
||
</div>
|
||
<div className="da-health-card da-health-card-stat">
|
||
<div className="da-health-value">
|
||
{fleet.avg_active_minutes != null ? `${parseFloat(fleet.avg_active_minutes).toFixed(0)}` : '—'}
|
||
{fleet.avg_active_minutes != null && <span className="da-health-unit"> min</span>}
|
||
</div>
|
||
<div className="da-health-label">Avg Active</div>
|
||
<div className="da-health-sub">Per rider, this batch</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ─── Top Recommendation banner ───────────────────────────
|
||
Three states:
|
||
• Actionable rec → blue insight banner with full
|
||
rider + kitchen + activation rules.
|
||
• No action, imbalance → AMBER warning surfacing the
|
||
backend's `reason` +
|
||
`fleet_balance_assessment` so
|
||
operators don't miss a real
|
||
"57-min spread" signal hidden
|
||
behind a generic empty state.
|
||
• No action, balanced → green success summary. */}
|
||
<div className="da-section">
|
||
<div className="da-section-label">Top Recommendation</div>
|
||
{hasRec ? (
|
||
<div className="da-rec-banner is-action">
|
||
<div className="da-rec-banner-icon">
|
||
<MdInsights />
|
||
</div>
|
||
<div className="da-rec-banner-body">
|
||
<div className="da-rec-head">
|
||
<div className="da-rec-action">
|
||
<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>{resolveRiderName(rec.idle_rider_id, rec.idle_rider_name)}</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?.fleet_balance_assessment && <div className="da-rec-assess">{rec.fleet_balance_assessment}</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-rec-banner is-${severity}`}>
|
||
<div className="da-rec-banner-icon">
|
||
{severity === 'warn' ? <MdWarning /> : severity === 'success' ? <MdCheckCircle /> : <MdInsights />}
|
||
</div>
|
||
<div className="da-rec-banner-body">
|
||
<div className="da-rec-action">
|
||
<span>
|
||
{severity === 'warn'
|
||
? 'Fleet imbalance detected — no feasible substitution'
|
||
: severity === 'success'
|
||
? 'Fleet ran balanced — no reassignment needed'
|
||
: 'No action recommended'}
|
||
</span>
|
||
</div>
|
||
{rec?.reason && <div className="da-rec-desc">{rec.reason}</div>}
|
||
{rec?.fleet_balance_assessment && <div className="da-rec-assess">{rec.fleet_balance_assessment}</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{ridersWithGantt.length > 0 && (
|
||
<div className="da-section">
|
||
<div className="da-section-label">
|
||
Rider Timelines <span className="da-section-count">({ridersWithGantt.length})</span>
|
||
<span className="da-section-hint">Sorted by latest finish — bottleneck riders first</span>
|
||
</div>
|
||
{/* Fleet timeline header: shared axis labels so each
|
||
rider's bar is reads against a common reference. */}
|
||
<div className="da-gantt-axis">
|
||
<span>{fleet.fleet_start || '—'}</span>
|
||
<span className="da-gantt-axis-mid">
|
||
{fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min batch` : 'Batch window'}
|
||
</span>
|
||
<span>{fleet.fleet_done || '—'}</span>
|
||
</div>
|
||
<div className="da-timeline-list">
|
||
{ridersWithGantt.map((r) => {
|
||
const isActive = String(r.status || '').toLowerCase() === 'active';
|
||
const completed = r.completed_orders ?? r.order_count;
|
||
const pending = r.pending_orders ?? 0;
|
||
const completePct =
|
||
completed != null && r.order_count > 0 ? Math.max(0, Math.min(100, (completed / r.order_count) * 100)) : 0;
|
||
const idleHigh = r.idle_minutes != null && r.idle_minutes > 30;
|
||
const lowConf = r.kitchen_confidence != null && r.kitchen_confidence < 0.7;
|
||
return (
|
||
<div key={r.userid} className="da-timeline-card">
|
||
{/* Top: identity + status pill. The status pill
|
||
stays the existing .da-pill so colour rules
|
||
already documented elsewhere apply. */}
|
||
<div className="da-timeline-top">
|
||
<div className="da-timeline-name">
|
||
<MdTwoWheeler style={{ color: r._riderColor }} />
|
||
<span title={r.name && r.name !== r.displayName ? `API name: ${r.name}` : undefined}>{r.displayName}</span>
|
||
<span className="da-timeline-id">#{r.userid}</span>
|
||
</div>
|
||
<span className={`da-pill ${isActive ? 'is-active' : 'is-idle'}`}>{r.status}</span>
|
||
</div>
|
||
|
||
{/* Gantt bar: bottom-aligned bar shows when this
|
||
rider was working inside the fleet window.
|
||
The filled portion is their active span; the
|
||
tail (after finished_at) reads as "fleet
|
||
kept going without me" — i.e. idle time on
|
||
the back end. Width and offset are computed
|
||
as % of total fleet duration. */}
|
||
<div className="da-gantt-row">
|
||
<div className="da-gantt-track" title={`${r.started_at} → ${r.finished_at}`}>
|
||
<div
|
||
className="da-gantt-fill"
|
||
style={{
|
||
left: `${r._startPct}%`,
|
||
width: `${Math.max(2, r._activePct)}%`,
|
||
background: r._riderColor
|
||
}}
|
||
>
|
||
<span className="da-gantt-fill-start">{r.started_at}</span>
|
||
<span className="da-gantt-fill-end">{r.finished_at}</span>
|
||
</div>
|
||
{r._endPct < 99 && (
|
||
<div
|
||
className="da-gantt-idle"
|
||
style={{ left: `${r._endPct}%`, width: `${100 - r._endPct}%` }}
|
||
title={`Idle ${r.idle_minutes} min after finishing`}
|
||
/>
|
||
)}
|
||
</div>
|
||
<span
|
||
className="da-gantt-util"
|
||
style={{ background: `${r._riderColor}22`, color: r._riderColor }}
|
||
title={`Per-rider utilisation: ${r._riderUtilPct != null ? r._riderUtilPct.toFixed(0) : '—'}%`}
|
||
>
|
||
{r._riderUtilPct != null ? `${r._riderUtilPct.toFixed(0)}%` : '—'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Stats row: kitchen (with confidence dot
|
||
when low), orders mini-bar, pace, active,
|
||
idle, free-window, and last GPS position. */}
|
||
<div className="da-timeline-mid">
|
||
{r.kitchen ? (
|
||
<span
|
||
className="da-chip"
|
||
title={
|
||
r.kitchen_confidence != null
|
||
? `Kitchen confidence: ${(r.kitchen_confidence * 100).toFixed(0)}%`
|
||
: undefined
|
||
}
|
||
>
|
||
<MdRestaurant /> {r.kitchen}
|
||
{r.kitchen_confidence != null && (
|
||
<span
|
||
className={`da-conf-dot ${lowConf ? 'is-low' : 'is-ok'}`}
|
||
aria-label={`Confidence ${(r.kitchen_confidence * 100).toFixed(0)}%`}
|
||
/>
|
||
)}
|
||
</span>
|
||
) : (
|
||
<span
|
||
className="da-chip"
|
||
style={{ background: '#f8fafc', color: '#64748b', borderColor: '#e2e8f0', borderStyle: 'dashed' }}
|
||
title="Solver did not identify a primary kitchen for this rider"
|
||
>
|
||
<MdRestaurant /> Unassigned
|
||
</span>
|
||
)}
|
||
<span className="da-chip da-chip-orders" title={`Completed ${completed} of ${r.order_count}`}>
|
||
<MdInventory2 />
|
||
<span className="da-orders-label">
|
||
{completed}/{r.order_count}
|
||
</span>
|
||
<span className="da-orders-bar">
|
||
<span className="da-orders-bar-fill" style={{ width: `${completePct}%` }} />
|
||
</span>
|
||
{pending > 0 && <span className="da-orders-pending">{pending} pending</span>}
|
||
</span>
|
||
{r.pace_orders_per_hour != null && (
|
||
<span className="da-chip" title="Delivery pace this batch">
|
||
<MdSpeed /> {parseFloat(r.pace_orders_per_hour).toFixed(1)} / hr
|
||
</span>
|
||
)}
|
||
{r.active_minutes != null && (
|
||
<span className="da-chip" title="Time actively delivering">
|
||
<MdTimer /> {parseFloat(r.active_minutes).toFixed(0)} min active
|
||
</span>
|
||
)}
|
||
<span
|
||
className="da-chip"
|
||
style={idleHigh ? { background: '#fef3c7', color: '#92400e', borderColor: '#fde68a' } : undefined}
|
||
title="Idle time after this rider finished — fleet kept running"
|
||
>
|
||
<MdAccessTime /> {r.idle_minutes ?? 0} min idle
|
||
</span>
|
||
{/* Free window: time available before the next
|
||
batch starts. Often equals idle_minutes but
|
||
diverges when the next batch sits closer to
|
||
this rider's finish — operators use this to
|
||
decide who can take an extra run. */}
|
||
{r.free_window_minutes != null && (
|
||
<span
|
||
className="da-chip"
|
||
style={
|
||
r.free_window_minutes >= 30
|
||
? { background: '#ecfdf5', color: '#065f46', borderColor: '#a7f3d0' }
|
||
: r.free_window_minutes > 0
|
||
? { background: '#fefce8', color: '#854d0e', borderColor: '#fde68a' }
|
||
: { background: '#fef2f2', color: '#991b1b', borderColor: '#fecaca' }
|
||
}
|
||
title="Free window before the next batch — capacity available for a follow-up run"
|
||
>
|
||
<MdTimer /> {parseFloat(r.free_window_minutes).toFixed(0)} min free
|
||
</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>{resolveRiderName(idle.userid, idle.name)}</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 || relieved.userid != null) && (
|
||
<div className="da-sub-relieved">
|
||
<MdTrendingUp />
|
||
Most relieved: <strong>{resolveRiderName(relieved.userid, 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 {resolveRiderName(o.from_rider_id || o.from_userid, 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>
|
||
)}
|
||
|
||
{/* ─── Rider last-position modal (Analysis section).
|
||
A small Leaflet map pinned to the rider's `last_position` from
|
||
/batch/efficiency. Mounted at the top level so the MapContainer
|
||
is created/destroyed exactly once per open — Leaflet maps are
|
||
expensive to remount and behave badly when their parent moves
|
||
around in the tree. */}
|
||
{riderPositionModal && (
|
||
<div
|
||
className="da-pos-modal-backdrop"
|
||
// The dialog role belongs on the card below, not on the backdrop —
|
||
// the backdrop is a click-outside-to-dismiss scrim, which is
|
||
// presentational. Marking it as the dialog made it a non-interactive
|
||
// element carrying a click handler. Dismissal is still available from
|
||
// the card's own close button, so no keyboard path is lost here.
|
||
role="presentation"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setRiderPositionModal(null);
|
||
}}
|
||
>
|
||
<div
|
||
className="da-pos-modal-card"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`Last known position of ${riderPositionModal.name}`}
|
||
>
|
||
<div className="da-pos-modal-head">
|
||
<div className="da-pos-modal-title-wrap">
|
||
<div
|
||
className="da-pos-modal-avatar"
|
||
style={{ background: `${riderPositionModal.color || '#000000'}22`, color: riderPositionModal.color || '#000000' }}
|
||
>
|
||
<MdTwoWheeler />
|
||
</div>
|
||
<div>
|
||
<div className="da-pos-modal-title">{riderPositionModal.name}</div>
|
||
<div className="da-pos-modal-sub">
|
||
#{riderPositionModal.userid}
|
||
{riderPositionModal.kitchen && <> · {riderPositionModal.kitchen}</>}
|
||
{riderPositionModal.finished_at && <> · finished {riderPositionModal.finished_at}</>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="da-pos-modal-actions">
|
||
<span className={`da-pill ${String(riderPositionModal.status || '').toLowerCase() === 'active' ? 'is-active' : 'is-idle'}`}>
|
||
{riderPositionModal.status || '—'}
|
||
</span>
|
||
<button type="button" className="da-pos-modal-close" aria-label="Close" onClick={() => setRiderPositionModal(null)}>
|
||
<MdClose />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="da-pos-modal-map">
|
||
<MapContainer
|
||
key={`${riderPositionModal.userid}-${riderPositionModal.lat}-${riderPositionModal.lon}`}
|
||
center={[riderPositionModal.lat, riderPositionModal.lon]}
|
||
/* Zoom 13 ≈ city-district view: enough surrounding roads
|
||
and landmarks to give the marker spatial context. Was
|
||
16 (block level) which felt over-zoomed for ops. */
|
||
zoom={13}
|
||
scrollWheelZoom
|
||
style={{ width: '100%', height: '100%' }}
|
||
/* Leaflet caches the container size at construction; the
|
||
modal mounts mid-animation (transform + opacity) so the
|
||
measured size is stale. Force a recalculation on the
|
||
next frame and once more after the rise animation
|
||
completes (~240ms) so the tile pane fills correctly. */
|
||
whenReady={(e) => {
|
||
const map = e.target;
|
||
requestAnimationFrame(() => map.invalidateSize());
|
||
setTimeout(() => map.invalidateSize(), 260);
|
||
}}
|
||
>
|
||
<TileLayer
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
/>
|
||
<Marker position={[riderPositionModal.lat, riderPositionModal.lon]}>
|
||
<Popup>
|
||
<strong>{riderPositionModal.name}</strong>
|
||
<br />
|
||
{riderPositionModal.lat.toFixed(6)}, {riderPositionModal.lon.toFixed(6)}
|
||
</Popup>
|
||
</Marker>
|
||
</MapContainer>
|
||
</div>
|
||
|
||
<div className="da-pos-modal-foot">
|
||
<div className="da-pos-modal-coord">
|
||
<MdLocationOn />
|
||
<span>
|
||
<strong>Lat:</strong> {riderPositionModal.lat.toFixed(6)}
|
||
<span className="da-pos-modal-sep">·</span>
|
||
<strong>Lon:</strong> {riderPositionModal.lon.toFixed(6)}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="da-pos-modal-copy"
|
||
onClick={() => {
|
||
const txt = `${riderPositionModal.lat.toFixed(6)}, ${riderPositionModal.lon.toFixed(6)}`;
|
||
if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(txt);
|
||
}}
|
||
title="Copy coordinates"
|
||
>
|
||
Copy
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Dispatch;
|