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

1819 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, ZoomControl } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import dayjs from 'dayjs';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import {
MdMap,
MdDirectionsBike,
MdRestaurant,
MdPublic,
MdInventory2,
MdTrendingUp,
MdTrendingDown,
MdAccountBalanceWallet,
MdStraighten,
MdLocationOn,
MdMarkunreadMailbox,
MdMoveToInbox,
MdPlace,
MdTwoWheeler,
MdNotes,
MdSwapHoriz
} from 'react-icons/md';
import { fetchDeliveries, fetchAppLocations } from '../../api/api';
import './Dispatch.css';
import { RAW_DISPATCH_DATA } from './DispatchData';
// Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes.
const MOTORBIKE_SVG = `<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#fff" d="M200,112a40,40,0,0,0-12.07,1.86L161.6,72H200a8,8,0,0,1,8,8v8a8,8,0,0,0,16,0V80a24,24,0,0,0-24-24H160a8,8,0,0,0-6.79,3.77l-9.34,15.06L130.39,52A8,8,0,0,0,124,48H88a8,8,0,0,0,0,16h31.69l13.34,21.84L107.5,128H56A40,40,0,1,0,96,168.4V160a8,8,0,0,1,16,0v8.4a40.06,40.06,0,0,0,32,31.2V184a8,8,0,0,1,16,0v15.6A40,40,0,1,0,200,112ZM56,184a24,24,0,1,1,24-24A24,24,0,0,1,56,184Zm70.46-44.71h0L141,116.45,156.6,142h0a40,40,0,0,0-14,28h-16A40.16,40.16,0,0,0,126.46,139.29ZM200,184a24,24,0,1,1,24-24A24,24,0,0,1,200,184Z"/></svg>`;
const STATUS_STYLES = {
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' },
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' },
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' },
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' },
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' },
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' },
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }
};
const getStatusStyle = (status) =>
STATUS_STYLES[String(status || '').toLowerCase()] || { label: status || 'Unknown', bg: '#64748b', fg: '#fff' };
const toNum = (v) => {
const n = parseFloat(v);
return Number.isFinite(n) ? n : NaN;
};
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)));
// Batch buckets by expected delivery time-of-day (operator's mental model — morning rush,
// lunch wave, dinner wave). Anything outside a window OR with no parsable time falls under "all".
// Hourly delivery slots: 7am→8am … 6pm→7pm. Slot id is `slot-<startHour>` (24h).
// To shift the window edit BATCH_START_HOUR / BATCH_END_HOUR (end is exclusive).
const BATCH_START_HOUR = 7;
const BATCH_END_HOUR = 19;
const formatHour12 = (h) => {
const period = h >= 12 ? 'pm' : 'am';
const hr = h % 12 === 0 ? 12 : h % 12;
return `${hr}${period}`;
};
const BATCHES = Array.from({ length: BATCH_END_HOUR - BATCH_START_HOUR }, (_, i) => {
const start = BATCH_START_HOUR + i;
return {
id: `slot-${start}`,
label: `${formatHour12(start)}-${formatHour12(start + 1)}`,
startHour: start
};
});
const getRowBatch = (r) => {
// Try fields in priority order. Bare date strings like "YYYY-MM-DD" have no time
// component and parse to midnight (hour 0) which is below BATCH_START_HOUR — skip
// them early so we fall through to a field that actually has a time of day.
const candidates = [
r.expecteddeliverytime,
r.assigntime,
r.deliverydate,
r.pickupslot
];
for (const t of candidates) {
if (!t) continue;
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)) continue;
const d = dayjs(t);
if (!d.isValid()) continue;
const h = d.hour();
if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) continue;
return `slot-${h}`;
}
return null;
};
const FINAL_STATUSES = new Set(['delivered']);
const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
// Compute one "live position" per rider: midpoint between the last delivered drop and the next non-final drop.
// If nothing delivered yet, midpoint is kitchen pickup → first drop. If all delivered, returns null.
const computeRiderPosition = (r) => {
const sorted = [...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);
});
const nextIdx = sorted.findIndex((o) => {
const s = String(o.orderstatus || '').toLowerCase();
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
if (nextIdx === -1) return null;
const next = sorted[nextIdx];
if (!hasValidDrop(next)) return null;
const nextLat = toNum(next.droplat || next.deliverylat);
const nextLon = toNum(next.droplon || next.deliverylong);
// Pick the previous reference point: previous order's drop if available, else this order's pickup (kitchen).
let prevLat;
let prevLon;
const prev = nextIdx > 0 ? sorted[nextIdx - 1] : null;
if (prev && hasValidDrop(prev)) {
prevLat = toNum(prev.droplat || prev.deliverylat);
prevLon = toNum(prev.droplon || prev.deliverylong);
} else if (hasValidPickup(next)) {
prevLat = toNum(pickupLat(next));
prevLon = toNum(pickupLon(next));
} else {
return null;
}
const aerialLat = (prevLat + nextLat) / 2;
const aerialLon = (prevLon + nextLon) / 2;
const completedCount = sorted.filter((o) => FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase())).length;
return {
id: r.id,
color: r.color,
riderName: r.riderName,
aerialLat,
aerialLon,
prevLat,
prevLon,
nextLat,
nextLon,
completedCount,
totalCount: sorted.length,
nextStep: next.step || nextIdx + 1,
nextCustomer: next.deliverycustomer || ''
};
};
// Walk a polyline and return the [lat,lon] point at half the total length.
// Uses planar distance — fine at city scale and avoids a haversine import.
const polylineMidpoint = (points) => {
if (!points || points.length < 2) return null;
const segLens = [];
let total = 0;
for (let i = 0; i < points.length - 1; i++) {
const dx = points[i + 1][0] - points[i][0];
const dy = points[i + 1][1] - points[i][1];
const d = Math.sqrt(dx * dx + dy * dy);
segLens.push(d);
total += d;
}
if (total === 0) return points[0];
const target = total / 2;
let acc = 0;
for (let i = 0; i < segLens.length; i++) {
if (acc + segLens[i] >= target) {
const t = (target - acc) / segLens[i];
return [
points[i][0] + t * (points[i + 1][0] - points[i][0]),
points[i][1] + t * (points[i + 1][1] - points[i][1])
];
}
acc += segLens[i];
}
return points[points.length - 1];
};
// Build a polyline-ready point list for a sorted trip:
// - drop NaN drops
// - prepend the first valid pickup we can find (so the line starts at the kitchen)
const buildTripPoints = (sorted) => {
const valid = sorted.filter(hasValidDrop);
if (!valid.length) return [];
const pickupSrc = sorted.find(hasValidPickup);
const pts = [];
if (pickupSrc) pts.push([toNum(pickupLat(pickupSrc)), toNum(pickupLon(pickupSrc))]);
valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
return pts;
};
// Fix for default leaflet marker icons
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000'];
const MapController = ({ focusedItem, viewMode, orders, kitchens }) => {
const map = useMap();
useEffect(() => {
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') {
// Fit to all kitchen pickup positions so the user sees them when switching to By Location
pts = (kitchens || [])
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
.map(k => [k.lat, k.lon]);
// Fall back to delivery drops if no valid kitchen coords are available
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)]);
}
if (pts.length > 0) {
const filtered = pts.filter(p => !isNaN(p[0]) && !isNaN(p[1]));
if (filtered.length > 0) {
const bounds = L.latLngBounds(filtered);
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [50, 50], animate: true });
}
}
} else {
map.setView([11.022, 76.982], 12, { animate: true });
}
}, [focusedItem, viewMode, orders, kitchens, map]);
return null;
};
// Inline-icon wrapper used wherever a Material icon precedes some text — keeps the
// SVG vertically centered with the adjacent text and inherits the parent color.
const Ico = ({ children }) => (
<span className="ico-inline" style={{ display: 'inline-flex', alignItems: 'center', verticalAlign: '-2px', marginRight: 4 }}>
{children}
</span>
);
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
}) => {
// 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);
const [activeRiders, setActiveRiders] = useState(new Set());
const [internalFocusedRider, setInternalFocusedRider] = useState(null);
const [focusedKitchen, setFocusedKitchen] = useState(null);
const [focusedZone, setFocusedZone] = useState(null);
// Suburb chip clicked inside the focused-zone "Areas Covered" section. When set,
// an inline drill-down panel lists orders in that suburb directly below the chips.
const [selectedSuburb, setSelectedSuburb] = useState(null);
// Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map.
const [focusedStop, setFocusedStop] = useState(null);
// Holds leaflet marker instances keyed by orderid so we can imperatively open
// their popups when the user clicks a step in the focused-rider sidebar.
const orderMarkerRefs = useRef({});
const isControlled = selectedRiderId !== undefined;
const [clock, setClock] = useState('');
// Fetch the logged-in user's hub/location name from the API.
// applocationid in localStorage is the hub the user selected at login.
const liveAppLocationId = typeof window !== 'undefined' ? localStorage.getItem('applocationid') : null;
const { data: appLocations } = useQuery({
queryKey: ['appLocations'],
queryFn: fetchAppLocations,
staleTime: 5 * 60 * 1000
});
const locationName = useMemo(() => {
if (!appLocations || !liveAppLocationId) return null;
const match = appLocations.find((l) => String(l.applocationid) === String(liveAppLocationId));
return match?.locationname || null;
}, [appLocations, liveAppLocationId]);
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({});
const [isAnimating, setIsAnimating] = useState(false);
const [animatedSegments, setAnimatedSegments] = useState([]);
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'));
// Default to the slot containing the current hour, otherwise the earliest slot.
const [selectedBatch, setSelectedBatch] = useState(() => {
const h = dayjs().hour();
if (h >= BATCH_START_HOUR && h < BATCH_END_HOUR) return `slot-${h}`;
return BATCHES[0].id;
});
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', 0, liveUserid, 'all', selectedDate, selectedDate, 50, '', 0, 0, 0],
queryFn: fetchDeliveries,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
enabled: shouldFetchLive
});
// Auto-page through all results for the selected date.
useEffect(() => {
if (!shouldFetchLive) return;
if (liveHasNextPage && !liveIsFetchingNextPage) liveFetchNextPage();
}, [shouldFetchLive, liveHasNextPage, liveIsFetchingNextPage, liveFetchNextPage]);
const liveRows = useMemo(
() => (livePagesData?.pages || []).flatMap((p) => p.rows || []),
[livePagesData]
);
// Per-batch counts shown on the batch selector pills (uses unfiltered rows so counts stay
// visible even when a single batch is active).
const batchCounts = useMemo(() => {
const counts = { all: liveRows.length };
BATCHES.forEach((b) => { counts[b.id] = 0; });
liveRows.forEach((r) => {
const b = getRowBatch(r);
if (b) counts[b] = (counts[b] || 0) + 1;
});
return counts;
}, [liveRows]);
// 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) === selectedBatch);
}, [liveRows, selectedBatch]);
// 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: [] };
const zoneBuckets = {};
filteredLiveRows.forEach((r) => {
const zoneName = r.zone_name || 'All Orders';
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[zoneName]) zoneBuckets[zoneName] = { zone_name: zoneName, riders: {} };
if (!zoneBuckets[zoneName].riders[riderKey]) {
zoneBuckets[zoneName].riders[riderKey] = { rider_id: riderKey, rider_name: riderName, orders: [] };
}
zoneBuckets[zoneName].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 || RAW_DISPATCH_DATA;
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 || RAW_DISPATCH_DATA;
const orders = [];
(source.zones || []).forEach(z => {
(z.riders || []).forEach(r => {
r.orders.forEach(o => {
orders.push({ ...o, zone_name: z.zone_name, rider_name: r.rider_name, rider_id: r.rider_id });
});
});
});
const riderMap = {};
orders.forEach(o => {
const key = o.rider_id || o.userid || 'unknown';
if (!riderMap[key]) {
riderMap[key] = {
id: key,
riderName: o.rider_name || o.username || o.rider || key,
orders: [],
color: RIDER_COLORS[Object.keys(riderMap).length % RIDER_COLORS.length]
};
}
riderMap[key].orders.push(o);
});
const kitchenMap = {};
orders.forEach(o => {
const name = o.pickupcustomer || o.kitchen_key || 'Unknown';
const key = name.toLowerCase().trim();
if (!kitchenMap[key]) {
kitchenMap[key] = {
id: key,
kitchenName: name,
lat: toNum(pickupLat(o)),
lon: toNum(pickupLon(o)),
orders: [],
riders: new Set()
};
} else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) {
// Upgrade to first valid pickup coords we see for this kitchen
kitchenMap[key].lat = toNum(pickupLat(o));
kitchenMap[key].lon = toNum(pickupLon(o));
}
kitchenMap[key].orders.push(o);
if (o.rider_id) kitchenMap[key].riders.add(o.rider_id);
});
const totalKms = orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
const totalProfit = orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0);
// Sort each rider's orders by (trip_number, step) so every downstream view —
// sidebar step list, step-id badges on the card, OSRM trip points, animation —
// sees them in delivery order 1→N.
const sortedRiders = Object.values(riderMap).map((r) => ({
...r,
orders: [...r.orders].sort((a, b) => {
const tA = a.trip_number || 1;
const tB = b.trip_number || 1;
if (tA !== tB) return tA - tB;
return (a.step || 0) - (b.step || 0);
})
}));
return {
riders: sortedRiders.sort((a, b) => b.orders.length - a.orders.length),
kitchens: Object.values(kitchenMap).sort((a, b) => b.orders.length - a.orders.length),
allOrders: orders,
stats: {
totalOrders: orders.length,
totalKms,
totalProfit,
totalRiders: Object.keys(riderMap).length
}
};
}, [data, liveData]);
// Resolve focusedRider: prop-derived when controlled, local state otherwise.
const focusedRider = isControlled
? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
: internalFocusedRider;
// 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]
);
const activeStats = useMemo(() => {
if (focusedRider) {
return {
orders: focusedRider.orders.length,
riders: 1,
km: focusedRider.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
profit: focusedRider.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
label: 'Focused Rider'
};
}
if (focusedKitchen) {
return {
orders: focusedKitchen.orders.length,
riders: focusedKitchen.riders.size,
km: focusedKitchen.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
profit: focusedKitchen.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
label: 'Focused Kitchen'
};
}
return {
orders: stats.totalOrders,
riders: stats.totalRiders,
km: stats.totalKms,
profit: stats.totalProfit,
label: 'Total Fleet'
};
}, [focusedRider, focusedKitchen, stats]);
// Live rider positions (Rapido-style bikes on the map)
const riderPositions = useMemo(() => riders.map(computeRiderPosition).filter(Boolean), [riders]);
const fetchRoute = useCallback(async (riderId, tripKey, points) => {
const cacheKey = `${riderId}-${tripKey}`;
// Use the ref (not state) for the in-flight / already-cached check so this
// callback doesn't need osrmRoutes in its deps — that old pattern caused a
// render loop: each resolved route updated state → recreated fetchRoute →
// re-ran all route-fetching effects for every rider.
if (osrmRoutesRef.current[cacheKey] !== undefined) return;
if (points.length < 2) return;
// Mark in-flight in both ref (immediate) and state (triggers re-render).
osrmRoutesRef.current[cacheKey] = null;
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: null }));
const coords = points.map(p => `${p[1]},${p[0]}`).join(';');
const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`;
try {
const res = await fetch(url);
const json = await res.json();
if (json.routes && json.routes[0]) {
const poly = json.routes[0].geometry.coordinates.map(c => [c[1], c[0]]);
osrmRoutesRef.current[cacheKey] = poly;
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: poly }));
} else {
// OSRM responded but couldn't route — record as failed so renderRoutes
// shows the aerial fallback instead of an empty gap.
osrmRoutesRef.current[cacheKey] = false;
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
}
} catch (e) {
console.error('OSRM Fetch error:', e);
osrmRoutesRef.current[cacheKey] = false;
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
}
}, []); // stable — cache reads go through osrmRoutesRef, not state
// Clear the OSRM route cache whenever the date or batch changes. Without this,
// routes fetched for the previous day/slot linger and are shown against the new
// data — especially visible when the same rider ID appears across different batches
// and the cached polyline from the earlier slot is drawn over the new orders.
useEffect(() => {
osrmRoutesRef.current = {};
setOsrmRoutes({});
}, [selectedDate, selectedBatch]);
useEffect(() => {
if (embedded) return undefined;
const tick = () => {
const n = new Date();
setClock([n.getHours(), n.getMinutes(), n.getSeconds()].map(v => String(v).padStart(2, '0')).join(':'));
};
const timer = setInterval(tick, 1000);
tick();
return () => clearInterval(timer);
}, [embedded]);
useEffect(() => {
setActiveRiders(new Set(riders.map(r => r.id)));
}, [riders]);
useEffect(() => {
riders.forEach(r => {
const isActive = activeRiders.has(r.id);
if (!isActive) return;
if (focusedRider && focusedRider.id !== r.id) return;
const trips = {};
r.orders.forEach(o => {
const t = o.trip_number || 1;
if (!trips[t]) trips[t] = [];
trips[t].push(o);
});
Object.entries(trips).forEach(([tNum, tOrders]) => {
const sorted = [...tOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
const pts = buildTripPoints(sorted);
if (pts.length >= 2) fetchRoute(r.id, tNum, pts);
});
});
}, [riders, activeRiders, focusedRider, fetchRoute]);
// Fetch a road route for each rider's CURRENT segment (prev stop → next stop) so the
// bike can sit on the polyline rather than the aerial midpoint. Cached under a `seg-<step>`
// trip key so it doesn't collide with the full-trip route under `<tripNum>`.
useEffect(() => {
riderPositions.forEach((p) => {
if (focusedKitchen) return;
if (focusedRider && focusedRider.id !== p.id) return;
if (!activeRiders.has(p.id)) return;
const pts = [[p.prevLat, p.prevLon], [p.nextLat, p.nextLon]];
fetchRoute(p.id, `seg-${p.nextStep}`, pts);
});
}, [riderPositions, focusedRider, focusedKitchen, activeRiders, fetchRoute]);
// Auto-advance the selected slot when the wall-clock crosses an hour boundary,
// BUT only if the user is still sitting on the previous hour's slot — so a manual
// pick (e.g. "let me inspect 9am-10am") is never overridden. Polls every 30s.
const prevHourRef = useRef(null);
useEffect(() => {
if (!shouldFetchLive) return;
if (prevHourRef.current === null) prevHourRef.current = dayjs().hour();
const tick = () => {
const h = dayjs().hour();
if (h === prevHourRef.current) return;
const fromSlot = `slot-${prevHourRef.current}`;
prevHourRef.current = h;
if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) return;
const toSlot = `slot-${h}`;
setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur));
};
const id = setInterval(tick, 30 * 1000);
return () => clearInterval(id);
}, [shouldFetchLive]);
// 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]);
// Clear the suburb drill-down when leaving / switching zones so the panel
// doesn't pop back open with a stale selection in a different zone.
useEffect(() => {
setSelectedSuburb(null);
}, [focusedZone?.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),
// also open that marker's popup so they see the order details without a second click.
// Wait one frame so MapController has a chance to recenter first.
useEffect(() => {
if (!focusedStop) return;
const t = setTimeout(() => {
const marker = orderMarkerRefs.current[String(focusedStop.orderid)];
if (marker && typeof marker.openPopup === 'function') marker.openPopup();
}, 350);
return () => clearTimeout(t);
}, [focusedStop]);
const startAnimation = () => {
if (isAnimating) {
setIsAnimating(false);
setAnimatedSegments([]);
return;
}
setIsAnimating(true);
setAnimatedSegments([]);
const allSegs = [];
riders.forEach(r => {
if (!activeRiders.has(r.id)) return;
if (focusedRider && focusedRider.id !== r.id) return;
if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return;
const trips = {};
r.orders.forEach(o => {
const t = o.trip_number || 1;
if (!trips[t]) trips[t] = [];
trips[t].push(o);
});
Object.entries(trips).forEach(([tNum, tOrders]) => {
// Filter orders by focused kitchen if active
const filteredTOrders = focusedKitchen
? tOrders.filter(o => (o.pickupcustomer || o.kitchen_key || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
: tOrders;
if (filteredTOrders.length === 0) return;
const cacheKey = `${r.id}-${tNum}`;
const roadPath = osrmRoutes[cacheKey];
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
// Aerial fallback — NaN-safe build
const aerialPath = buildTripPoints(sorted);
const isKitchenAerial = (viewMode === 'kitchens' || focusedKitchen);
const path = roadPath || aerialPath;
if (path.length < 2) return;
for (let i = 0; i < path.length - 1; i++) {
allSegs.push({
from: path[i],
to: path[i + 1],
color: r.color,
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), 1000);
}
}, s.delay);
});
};
const createKitchenIcon = (name, focused = false) => L.divIcon({
className: '',
iconSize: focused ? [56, 56] : [46, 46],
iconAnchor: focused ? [28, 28] : [23, 23],
popupAnchor: [0, focused ? -30 : -24],
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}">${(name || 'K').charAt(0).toUpperCase()}</div>`
});
const getRiderColor = (rid) => riders.find(r => r.id === rid)?.color || '#475569';
// Shared rider-card markup, used in the "By Rider" panel and inside the focused-zone detail.
const renderRiderCard = (r, i) => (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div>
<div className="rcard-info">
<div className="rcard-name">{r.riderName}</div>
<div className="rcard-zone">{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips</div>
</div>
<div className="rcard-badge" style={{ background: `${r.color}18`, color: r.color }}>{r.orders.length}</div>
</div>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (r.orders.length / 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><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>
);
const renderMarkers = () => {
let ordersToRender = allOrders;
if (focusedZone) ordersToRender = focusedZone.orders;
if (focusedRider) ordersToRender = focusedRider.orders;
if (focusedKitchen) ordersToRender = focusedKitchen.orders;
ordersToRender = ordersToRender.filter(hasValidDrop);
return ordersToRender.map((o, idx) => {
const rid = o.rider_id;
const active = rid ? activeRiders.has(rid) : true;
const color = getRiderColor(rid);
// Use the 'step' field from data, fallback to index
const seq = o.step || (focusedRider || focusedKitchen ? (ordersToRender.indexOf(o) + 1) : 0);
// Bumped from 22 → 32 so the step number reads at city-level zoom.
const sz = 32;
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 = L.divIcon({
className: '',
iconSize: [sz, sz],
iconAnchor: [sz / 2, sz / 2],
popupAnchor: [0, -28], // Lift popup above the flag, not just the larger 32px marker
html: `<div class="cmark${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>`
});
return (
<Marker
key={o.orderid}
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={{
mouseover: (e) => e.target.openPopup(),
mouseout: (e) => e.target.closePopup()
}}
>
<Popup maxWidth={250}>
<div className="pu-id">ORDER #{o.orderid}</div>
<div className="pu-rider" style={{ color }}>{o.rider_name || o.ridername || 'Unassigned'}</div>
{o.orderstatus && (
<div className="pu-row">
<span>Status</span>
<span className="status-chip" style={{ background: statusStyle.bg, color: statusStyle.fg, marginLeft: 0 }}>{statusStyle.label}</span>
</div>
)}
<div className="pu-row"><span>Customer</span><span>{o.deliverycustomer || '?'}</span></div>
<div className="pu-row"><span>Kitchen</span><span>{o.pickupcustomer || '?'}</span></div>
<div className="pu-row"><span>Trip / Step</span><span>T{o.trip_number || '-'} S{o.step || '-'}</span></div>
<div className="pu-row"><span>Distance</span><span>{o.actualkms || o.kms || 0} km</span></div>
<div className="pu-row"><span>Profit</span><span>{o.profit || 0}</span></div>
</Popup>
</Marker>
);
});
};
const renderRoutes = () => {
if (isAnimating) {
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;
riders.forEach(r => {
const isActive = activeRiders.has(r.id);
if (focusedRider && focusedRider.id !== r.id) return;
if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return;
if (zoneRiderIds && !zoneRiderIds.has(String(r.id))) return;
const rOrders = r.orders;
const trips = {};
rOrders.forEach(o => {
const t = o.trip_number || 1;
if (!trips[t]) trips[t] = [];
trips[t].push(o);
});
Object.entries(trips).forEach(([tNum, tOrders]) => {
// Filter orders by focused kitchen if active
const filteredTOrders = focusedKitchen
? tOrders.filter(o => (o.pickupcustomer || o.kitchen_key || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
: tOrders;
if (filteredTOrders.length === 0) return;
const cacheKey = `${r.id}-${tNum}`;
const roadPoints = osrmRoutes[cacheKey];
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
// Cache values:
// Array → OSRM road polyline (use it)
// false → OSRM permanently failed (draw aerial fallback so user sees something)
// null → request in-flight (DON'T draw anything yet — avoids the aerial flash)
// undefined → not yet requested (same as in-flight, wait)
const hasRoad = Array.isArray(roadPoints) && roadPoints.length >= 2;
const failed = roadPoints === false;
if (!hasRoad && !failed) return; // still loading — don't show aerial flash
const finalPoints = hasRoad ? roadPoints : buildTripPoints(sorted);
if (!finalPoints || finalPoints.length < 2) return;
const isKitchenView = (viewMode === 'kitchens' || focusedKitchen);
const opacity = isActive ? 1.0 : 0.1;
const weight = isKitchenView ? 7 : 6;
// Aerial fallback (OSRM permanently failed) is rendered dashed so it visually
// reads as an estimate vs. an actual routed road polyline.
const dashArray = failed ? '8 6' : undefined;
routes.push(
<React.Fragment key={`${r.id}-${tNum}`}>
<Polyline positions={finalPoints} pathOptions={{ color: '#ffffff', weight: weight + 4, opacity: opacity * 0.5, lineJoin: 'round', lineCap: 'round' }} />
<Polyline positions={finalPoints} pathOptions={{ color: r.color, weight, opacity, lineJoin: 'round', lineCap: 'round', dashArray }} />
</React.Fragment>
);
});
});
return routes;
};
const toggleRider = (rid) => {
const newActive = new Set(activeRiders);
if (newActive.has(rid)) newActive.delete(rid);
else newActive.add(rid);
setActiveRiders(newActive);
};
return (
<div className={`testing-container${embedded ? ' embedded' : ''}`}>
{!embedded && (
<div id="hdr">
<div className="logo">
<div className="logo-badge">D</div>
<div className="logo-name">Dispatch</div>
{locationName && <div className="logo-city"><MdPlace /> {locationName}</div>}
</div>
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker.
Sits to the LEFT of the running clock so the operator sees fleet
health + current wave size + selected date together in one row. */}
<div className="hdr-stats">
{(() => {
const isLoss = activeStats.profit < 0;
const amount = Math.abs(activeStats.profit);
return (
<span
className={`strat-stat ${isLoss ? 'strat-stat-loss' : 'strat-stat-profit'}`}
title={`${isLoss ? 'Loss' : 'Profit'} (${activeStats.label})`}
>
<span className="strat-stat-icon">{isLoss ? <MdTrendingDown /> : <MdTrendingUp />}</span>
<span className="strat-stat-label">{isLoss ? 'Loss' : 'Profit'}</span>
<span className="strat-stat-value">{isLoss ? '-' : ''}{amount.toFixed(0)}</span>
</span>
);
})()}
{shouldFetchLive && (
<>
{liveIsFetching && (
<span className="live-status">
<span className="live-dot" /> Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''}
</span>
)}
{!liveIsFetching && !liveIsError && (
<span className="live-status live-status-ready">
<span className="live-dot ready" /> {filteredLiveRows.length} orders
{selectedBatch !== 'all' && filteredLiveRows.length !== liveRows.length && (
<span className="live-status-sub"> / {liveRows.length} today</span>
)}
</span>
)}
{liveIsError && (
<span className="live-status live-status-error">
<span className="live-dot error" /> Failed to load
</span>
)}
<label className="live-date-label">
<span>Date</span>
<input
type="date"
value={selectedDate}
max={dayjs().format('YYYY-MM-DD')}
onChange={(e) => {
setSelectedDate(e.target.value);
handleRiderFocus(null);
setFocusedKitchen(null);
setFocusedZone(null);
}}
/>
</label>
</>
)}
</div>
<div id="clock">{clock}</div>
</div>
)}
<div id="strat-row">
<button className={`sbt ${viewMode === 'kitchens' ? 'active' : ''}`} onClick={() => { setViewMode('kitchens'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPlace /></span> By Location</button>
<button
className={`sbt ${viewMode === 'zones' ? 'active' : ''}`}
onClick={() => { setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
><span className="sbt-icon"><MdMap /></span> By Zone</button>
<button className={`sbt ${viewMode === 'riders' ? 'active' : ''}`} onClick={() => { setViewMode('riders'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdDirectionsBike /></span> By Rider</button>
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span> All Routes</button>
</div>
{shouldFetchLive && (
<div id="batch-row">
<span className="batch-label">Slot</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} slot`}
>
<span className="batch-btn-label">{b.label}</span>
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
</button>
);
})}
</div>
</div>
)}
<div id="body">
<div id="sidebar">
{/* Sidebar header — replaces the top-bar meta line. Hidden when a specific
rider is focused, since the focused-rider view already shows that rider's
stats prominently (name + Orders/Distance/Profit tiles). */}
{!focusedRider && (
<div className="sb-header">
<div className="sb-header-top">
<div className="sb-header-title">
<span className="sb-title-bar" aria-hidden="true" />
<span className="sb-title-text">RIDER DISPATCH</span>
</div>
<span className="sb-header-scope" title={activeStats.label}>
<span className="sb-scope-dot" />
{activeStats.label}
</span>
</div>
<div className="sb-header-tiles">
<div className="sb-tile sb-tile-orders">
<span className="sb-tile-icon"><MdInventory2 /></span>
<div className="sb-tile-body">
<div className="sb-tile-value">{activeStats.orders}</div>
<div className="sb-tile-label">{activeStats.orders === 1 ? 'Order' : 'Orders'}</div>
</div>
</div>
<div className="sb-tile sb-tile-riders">
<span className="sb-tile-icon"><MdTwoWheeler /></span>
<div className="sb-tile-body">
<div className="sb-tile-value">{activeStats.riders}</div>
<div className="sb-tile-label">{activeStats.riders === 1 ? 'Rider' : 'Riders'}</div>
</div>
</div>
</div>
</div>
)}
{/* Stats strip hidden for now — restore by removing this comment wrapper.
<div id="stats-strip">
<div className="sc"><div className="sc-lbl">Orders</div><div className="sc-val g">{activeStats.orders}</div><div className="sc-sub">{activeStats.label}</div></div>
<div className="sc"><div className="sc-lbl">Riders</div><div className="sc-val">{activeStats.riders}</div><div className="sc-sub">Active</div></div>
<div className="sc"><div className="sc-lbl">Distance</div><div className="sc-val">{activeStats.km.toFixed(1)} km</div><div className="sc-sub">Kilometers</div></div>
<div className="sc"><div className="sc-lbl">Profit</div><div className="sc-val g">₹{activeStats.profit.toFixed(0)}</div><div className="sc-sub">Earned</div></div>
</div>
*/}
{(focusedRider || focusedKitchen) ? (
<div id="route-detail">
<button className="rd-back" onClick={() => { handleRiderFocus(null); setFocusedKitchen(null); }}> Back to {focusedZone ? focusedZone.name : 'list'}</button>
{focusedRider ? (
<>
<div className="rd-rider-name" style={{ color: focusedRider.color }}>{focusedRider.riderName}</div>
{(() => {
const totalKm = focusedRider.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
const totalProfit = focusedRider.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0);
const isLoss = totalProfit < 0;
return (
<div className="rd-stats-grid">
<div className="rd-stat rd-stat-orders">
<div className="rd-stat-icon"><MdInventory2 /></div>
<div className="rd-stat-value">{focusedRider.orders.length}</div>
<div className="rd-stat-label">Orders</div>
</div>
<div className="rd-stat rd-stat-distance">
<div className="rd-stat-icon"><MdStraighten /></div>
<div className="rd-stat-value">{totalKm.toFixed(1)}<span className="rd-stat-unit">km</span></div>
<div className="rd-stat-label">Distance</div>
</div>
<div className={`rd-stat rd-stat-profit ${isLoss ? 'is-loss' : 'is-gain'}`}>
<div className="rd-stat-icon">{isLoss ? <MdTrendingDown /> : <MdTrendingUp />}</div>
<div className="rd-stat-value">
{isLoss ? '-' : ''}{Math.abs(totalProfit).toFixed(0)}
</div>
<div className="rd-stat-label">{isLoss ? 'Loss' : 'Profit'}</div>
</div>
</div>
);
})()}
{(() => {
const trips = {};
focusedRider.orders.forEach(o => {
const t = o.trip_number || 1;
if (!trips[t]) trips[t] = [];
trips[t].push(o);
});
// Identify the rider's currently-going-on order — first non-final,
// non-skipped stop in (trip, step) order. Highlighted in light green
// so users see at a glance which delivery is in progress.
const sortedAll = [...focusedRider.orders].sort((a, b) => {
const tA = a.trip_number || 1;
const tB = b.trip_number || 1;
if (tA !== tB) return tA - tB;
return (a.step || 0) - (b.step || 0);
});
const activeOrder = sortedAll.find((o) => {
const s = String(o.orderstatus || '').toLowerCase();
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
const activeOrderId = activeOrder ? activeOrder.orderid : null;
let prevKitchenKey = null;
return Object.entries(trips)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([tNum, tOrders]) => (
<div key={tNum} className="trip-block">
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
<span className="trip-stats">
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
<span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</span>
</div>
<div className="step-wrap">
{tOrders.map((o, idx) => {
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey;
return (
<React.Fragment key={o.orderid}>
{showTransition && (
<div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
)}
{idx === 0 && (
<div className="step-row">
<div className="step-col-left"><div className="step-dot kitchen">K</div></div>
<div className="step-col-body">
<div className="step-label"><span className="kitchen-tag"><Ico><MdMoveToInbox /></Ico>{o.pickupcustomer}</span></div>
<div className="step-dest">Pickup point · Trip {tNum}</div>
</div>
</div>
)}
{(() => {
const isActive = 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);
return (
<div
className={`step-row ${canFocus ? 'clickable' : ''} ${isActive ? 'active' : ''} ${isGoingOn ? 'is-going-on' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
title={canFocus ? (isActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
>
<div className="step-col-left"><div className="step-dot delivery">{o.step || idx + 1}</div></div>
<div className="step-col-body">
<div className="step-label step-label-row">
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
{o.orderstatus && (() => {
const s = getStatusStyle(o.orderstatus);
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
return (
<span className="step-flag">
<svg className="step-flag-svg" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="1.5" y1="0" x2="1.5" y2="18" stroke="#0f172a" strokeWidth="1.4" strokeLinecap="round" />
<polygon points="2,1 13,1 10,5 13,9 2,9" fill={s.bg} stroke="#0f172a" strokeWidth="0.5" strokeLinejoin="round" />
{isDel && (
<polyline points="4,5 6,7 9,3" fill="none" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
)}
</svg>
<span className="step-flag-label" style={{ color: s.bg }}>{s.label}</span>
</span>
);
})()}
</div>
<div className="step-dest">Order #{o.orderid}</div>
{/* Show the customer's delivery address rather than the kitchen's
pickup location (locationname/locationsuburb) — the kitchen is
already implied by the surrounding trip header. */}
{(o.deliveryaddress || o.deliverysuburb) && (
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
</div>
)}
{o.ordernotes && (
<div className="step-notes" title={o.ordernotes}><Ico><MdNotes /></Ico>{o.ordernotes}</div>
)}
<div className="step-detail">
<span><Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km</span>
{(() => {
const p = parseFloat(o.profit || 0);
const isLoss = p < 0;
return (
<span className={`step-profit ${isLoss ? 'is-loss' : ''}`}>
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}{Math.abs(p).toFixed(0)}
</span>
);
})()}
{o.deliverycharge != null && (
<span className="step-charges">{parseFloat(o.deliverycharge).toFixed(0)} chg</span>
)}
{o.ordertype && (
<span className={`step-type type-${String(o.ordertype).toLowerCase()}`}>{o.ordertype}</span>
)}
</div>
</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>
<div className="step-wrap">
{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 isActive = focusedStop && focusedStop.orderid === o.orderid;
return (
<div
key={o.orderid}
className={`step-row ${canFocus ? 'clickable' : ''} ${isActive ? 'active' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
title={canFocus ? (isActive ? 'Click to show full kitchen view' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
>
<div className="step-col-left"><div className="step-dot delivery" style={{ background: getRiderColor(o.rider_id), color: '#fff', borderColor: getRiderColor(o.rider_id) }}>{idx + 1}</div></div>
<div className="step-col-body">
<div className="step-label step-label-row">
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
{o.orderstatus && (() => {
const s = getStatusStyle(o.orderstatus);
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
return (
<span className="step-flag">
<svg className="step-flag-svg" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="1.5" y1="0" x2="1.5" y2="18" stroke="#0f172a" strokeWidth="1.4" strokeLinecap="round" />
<polygon points="2,1 13,1 10,5 13,9 2,9" fill={s.bg} stroke="#0f172a" strokeWidth="0.5" strokeLinejoin="round" />
{isDel && (
<polyline points="4,5 6,7 9,3" fill="none" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
)}
</svg>
<span className="step-flag-label" style={{ color: s.bg }}>{s.label}</span>
</span>
);
})()}
</div>
<div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername}</div>
{/* In the By-Kitchen view we show the customer's delivery address,
not the kitchen's location (locationname/locationsuburb describe
the pickup spot, which is redundant when the kitchen is already
the focused context). */}
{(o.deliveryaddress || o.deliverysuburb) && (
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
</div>
)}
{o.ordernotes && (
<div className="step-notes" title={o.ordernotes}><Ico><MdNotes /></Ico>{o.ordernotes}</div>
)}
<div className="step-detail">
<span><Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km</span>
{(() => {
const p = parseFloat(o.profit || 0);
const isLoss = p < 0;
return (
<span className={`step-profit ${isLoss ? 'is-loss' : ''}`}>
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}{Math.abs(p).toFixed(0)}
</span>
);
})()}
{o.deliverycharge != null && (
<span className="step-charges">{parseFloat(o.deliverycharge).toFixed(0)} chg</span>
)}
{o.ordertype && (
<span className={`step-type type-${String(o.ordertype).toLowerCase()}`}>{o.ordertype}</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} orders</span>
<span><Ico><MdTwoWheeler /></Ico>{focusedZone.activeRidersCount} riders</span>
<span><Ico><MdStraighten /></Ico>{focusedZone.totalKms.toFixed(1)} km</span>
<span className={`step-profit ${focusedZone.totalProfit < 0 ? 'is-loss' : ''}`}>
<Ico><MdAccountBalanceWallet /></Ico>{focusedZone.totalProfit < 0 ? '-' : ''}{Math.abs(focusedZone.totalProfit).toFixed(0)}
</span>
</div>
{/* Status breakdown */}
{Object.keys(focusedZone.statusCounts).length > 0 && focusedZone.totalOrders > 0 && (
<div className="zone-detail-section">
<div className="zone-section-label">Status Breakdown</div>
<div className="zone-status-bar tall">
{Object.entries(focusedZone.statusCounts).map(([status, count]) => {
const style = getStatusStyle(status);
const pct = (count / focusedZone.totalOrders) * 100;
return (
<div
key={status}
className="zone-status-seg"
style={{ width: `${pct}%`, background: style.bg }}
title={`${style.label}: ${count}`}
>
{pct > 7 && <span className="zone-status-seg-label">{count}</span>}
</div>
);
})}
</div>
<div className="zone-status-legend">
{Object.entries(focusedZone.statusCounts).map(([status, count]) => {
const style = getStatusStyle(status);
return (
<span key={status} className="legend-item">
<span className="legend-dot" style={{ background: style.bg }} />
{style.label} <strong>{count}</strong>
</span>
);
})}
</div>
</div>
)}
{/* Areas covered (delivery suburbs) — clicking a chip drills down to that
suburb's orders in an inline panel below the chip row. */}
{focusedZone.suburbs.length > 0 && (
<div className="zone-detail-section">
<div className="zone-section-label">Areas Covered <span className="section-count">({focusedZone.suburbs.length})</span></div>
<div className="zone-chips">
{focusedZone.suburbs.map((s) => {
const isActive = selectedSuburb === s.name;
return (
<button
type="button"
key={s.name}
className={`zone-chip zone-chip-clickable ${isActive ? 'active' : ''}`}
onClick={() => setSelectedSuburb(isActive ? null : s.name)}
title={isActive ? 'Click again to close' : `Show ${s.count} order${s.count === 1 ? '' : 's'} in ${s.name}`}
>
<span className="zone-chip-name">{s.name}</span>
<span className="zone-chip-count">{s.count}</span>
</button>
);
})}
</div>
{selectedSuburb && (() => {
// Suburb strings in the source data can have trailing whitespace
// and inconsistent casing (e.g. "uppilipalayam "). The tally that
// builds chip names keeps them as-is, so normalize BOTH sides to
// a trimmed lowercase key when filtering.
const norm = (v) => String(v || '').trim().toLowerCase();
const target = norm(selectedSuburb);
const suburbOrders = focusedZone.orders.filter((o) =>
norm(o.deliverysuburb || o.locationsuburb) === target
);
return (
<div className="zone-suburb-panel">
<div className="zone-suburb-panel-head">
<div className="zone-suburb-panel-title">
<Ico><MdLocationOn /></Ico>{selectedSuburb}
<span className="zone-suburb-panel-count">{suburbOrders.length} {suburbOrders.length === 1 ? 'order' : 'orders'}</span>
</div>
<button
type="button"
className="zone-suburb-panel-close"
onClick={() => setSelectedSuburb(null)}
title="Close"
aria-label="Close suburb panel"
>×</button>
</div>
{suburbOrders.length === 0 ? (
<div className="zone-suburb-panel-empty">No orders in this area.</div>
) : (
<div className="step-wrap">
{suburbOrders.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 riderColor = getRiderColor(o.rider_id);
const statusStyle = getStatusStyle(o.orderstatus);
return (
<div
key={o.orderid}
className={`step-row ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
>
<div className="step-col-left">
<div className="step-dot delivery" style={{ background: riderColor, color: '#fff', borderColor: riderColor }}>
{idx + 1}
</div>
</div>
<div className="step-col-body">
<div className="step-label step-label-row">
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || ''}</span>
{o.orderstatus && (
<span className="step-flag-label" style={{ color: statusStyle.bg }}>{statusStyle.label}</span>
)}
</div>
<div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername || '—'}</div>
{(o.deliveryaddress || o.deliverysuburb) && (
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
})()}
</div>
)}
{/* Pickup kitchens */}
{focusedZone.kitchens.length > 0 && (
<div className="zone-detail-section">
<div className="zone-section-label">Kitchens <span className="section-count">({focusedZone.kitchens.length})</span></div>
<div className="zone-chips">
{focusedZone.kitchens.map((k) => (
<span key={k.name} className="zone-chip kitchen">
<span className="zone-chip-name"><Ico><MdRestaurant /></Ico>{k.name}</span>
<span className="zone-chip-count kitchen">{k.count}</span>
</span>
))}
</div>
</div>
)}
{/* Riders */}
<div className="zone-detail-section">
<div className="zone-section-label">Riders <span className="section-count">({focusedZone.activeRidersCount})</span></div>
{focusedZone.riders
.map((zr) => riders.find((rd) => String(rd.id) === String(zr.rider_id)))
.filter(Boolean)
.map(renderRiderCard)}
</div>
</div>
) : (
<div id="riders-panel">
<div className="ph">{
viewMode === 'zones' ? 'Zone dispatch' :
viewMode === 'kitchens' ? 'Kitchen dispatch' :
'Rider dispatch'
}</div>
<div id="rider-cards">
{allOrders.length === 0 && !liveIsFetching ? (
(() => {
const slotLabel = BATCHES.find(b => b.id === selectedBatch)?.label;
const hasDayData = shouldFetchLive && liveRows.length > 0;
return (
<div className="empty-slot">
<div className="empty-slot-icon">
<MdInventory2 />
</div>
<div className="empty-slot-title">
{slotLabel ? `No orders in ${slotLabel}` : 'No orders'}
</div>
<div className="empty-slot-sub">
{hasDayData
? `${liveRows.length} order${liveRows.length === 1 ? '' : 's'} exist in other slots today`
: 'No deliveries found for this date'}
</div>
</div>
);
})()
) : viewMode === 'zones' ? (
zoneCards.map((z, i) => {
const delivered = z.statusCounts.delivered || 0;
const profitNeg = z.totalProfit < 0;
return (
<div key={z.id} className="rcard zone-card" onClick={() => setFocusedZone(z)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="zone-card-header">
<div className="zone-card-emoji"><MdMap /></div>
<div className="zone-card-titles">
<div className="zone-card-name">{z.name}</div>
<div className="zone-card-sub">
{z.activeRidersCount} {z.activeRidersCount === 1 ? 'rider' : 'riders'} · {z.totalOrders} {z.totalOrders === 1 ? 'order' : 'orders'}
</div>
</div>
<span className="zone-card-arrow" aria-hidden="true"></span>
</div>
{/* Status segments + delivered counter */}
{z.totalOrders > 0 && (
<div className="zone-progress-row">
<div
className="zone-status-bar"
title={Object.entries(z.statusCounts)
.map(([k, v]) => `${getStatusStyle(k).label}: ${v}`)
.join(' · ')}
>
{Object.entries(z.statusCounts).map(([status, count]) => {
const style = getStatusStyle(status);
const pct = (count / z.totalOrders) * 100;
return (
<div
key={status}
className="zone-status-seg"
style={{ width: `${pct}%`, background: style.bg }}
/>
);
})}
</div>
<div className="zone-progress-label">
{delivered}/{z.totalOrders}
</div>
</div>
)}
{/* Stat pills */}
<div className="zone-stat-pills">
<span className="zone-stat-pill" title="Areas covered">
<span className="zone-stat-icon"><MdLocationOn /></span>
<span className="zone-stat-value">{z.suburbs.length}</span>
<span className="zone-stat-label">{z.suburbs.length === 1 ? 'area' : 'areas'}</span>
</span>
<span className="zone-stat-pill" title="Total distance">
<span className="zone-stat-icon"><MdStraighten /></span>
<span className="zone-stat-value">{z.totalKms.toFixed(1)}</span>
<span className="zone-stat-label">km</span>
</span>
<span className="zone-stat-pill" title="Kitchens">
<span className="zone-stat-icon"><MdRestaurant /></span>
<span className="zone-stat-value">{z.kitchens.length}</span>
<span className="zone-stat-label">{z.kitchens.length === 1 ? 'kitchen' : 'kitchens'}</span>
</span>
<span className={`zone-stat-pill ${profitNeg ? 'profit-negative' : 'profit-positive'}`} title="Total profit">
<span className="zone-stat-icon"><MdAccountBalanceWallet /></span>
<span className="zone-stat-value">
{profitNeg ? `-₹${Math.abs(z.totalProfit).toFixed(0)}` : `${z.totalProfit.toFixed(0)}`}
</span>
</span>
</div>
{z.suburbs.length > 0 && (
<div className="zone-card-suburbs">
<span className="zone-card-suburbs-text">
{z.suburbs.slice(0, 3).map((s) => s.name).join(' · ')}
</span>
{z.suburbs.length > 3 && (
<span className="zone-card-suburbs-more">+{z.suburbs.length - 3}</span>
)}
</div>
)}
</div>
);
})
) : viewMode === 'kitchens' ? (
kitchens.map((k, i) => (
<div key={k.id} className="rcard" onClick={() => setFocusedKitchen(k)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: '#f59e0b18', borderColor: '#f59e0b50', color: '#f59e0b' }}><MdRestaurant /></div>
<div className="rcard-info">
<div className="rcard-name">{k.kitchenName}</div>
<div className="rcard-zone">{k.riders.size} riders · <Ico><MdAccountBalanceWallet /></Ico>{k.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</div>
</div>
<div className="rcard-badge" style={{ background: '#f59e0b18', color: '#f59e0b' }}>{k.orders.length}</div>
</div>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (k.orders.length / 20) * 100)}%`, background: '#f59e0b' }}></div></div>
<div className="rcard-meta"><span><Ico><MdStraighten /></Ico>{k.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span><span>{k.riders.size} riders</span></div>
<div className="step-ids">
{Array.from(k.riders).slice(0, 10).map(rid => (
<span key={rid} className="step-id" style={{ color: getRiderColor(rid) }}>{riders.find(r => r.id === rid)?.riderName.split(' ')[0]}</span>
))}
</div>
</div>
))
) : (
riders.map(renderRiderCard)
)}
</div>
</div>
)}
</div>
<div id="map-wrap" className={viewMode === 'kitchens' ? 'view-mode-kitchens' : ''}>
<MapContainer center={[11.022, 76.982]} zoom={12} scrollWheelZoom style={{ height: '100%', width: '100%' }} zoomControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" />
<MapController focusedItem={((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} kitchens={kitchens} />
{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}>
<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()}
{!focusedKitchen && riderPositions
.filter(p => activeRiders.has(p.id))
.filter(p => !focusedRider || focusedRider.id === p.id)
.map(p => {
// Prefer the road polyline midpoint; fall back to aerial midpoint until OSRM responds.
const segPolyline = osrmRoutes[`${p.id}-seg-${p.nextStep}`];
const roadMid = polylineMidpoint(segPolyline);
const pos = roadMid || [p.aerialLat, p.aerialLon];
const onRoad = Boolean(roadMid);
const bikeIcon = L.divIcon({
className: '',
iconSize: [44, 44],
iconAnchor: [22, 22],
popupAnchor: [0, -22],
html: `<div class="rider-bike${onRoad ? ' on-road' : ''}" style="--rider-color:${p.color}">
<div class="rider-bike-ring"></div>
<div class="rider-bike-svg">${MOTORBIKE_SVG}</div>
<div class="rider-bike-progress">${p.completedCount}/${p.totalCount}</div>
</div>`
});
return (
<Marker
key={`bike-${p.id}`}
position={pos}
icon={bikeIcon}
zIndexOffset={3000}
eventHandlers={{
click: () => handleRiderFocus(riders.find((r) => r.id === p.id) || null),
mouseover: (e) => e.target.openPopup(),
mouseout: (e) => e.target.closePopup()
}}
>
<Popup maxWidth={220}>
<div className="pu-id">RIDER</div>
<div className="pu-rider" style={{ color: p.color }}>{p.riderName}</div>
<div className="pu-row"><span>Progress</span><span>{p.completedCount} / {p.totalCount} delivered</span></div>
<div className="pu-row"><span>Next stop</span><span>#{p.nextStep} · {p.nextCustomer || ''}</span></div>
<div className="pu-row"><span>Position</span><span>{onRoad ? 'on road' : 'estimating'}</span></div>
</Popup>
</Marker>
);
})}
</MapContainer>
<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>
<div id="ov-tr">
{viewMode === 'kitchens' ? (
kitchens.slice(0, 10).map(k => (
<div key={k.id} className={`rchip ${focusedKitchen?.id === k.id ? 'active' : ''}`} onClick={() => setFocusedKitchen(k)}>
<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">{k.orders.length}</span>
</div>
))
) : (
riders.slice(0, 10).map(r => (
<div key={r.id} className={`rchip ${focusedRider?.id === r.id ? 'active' : ''}`} onClick={() => handleRiderFocus(r)}>
<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">{r.orders.length}</span>
</div>
))
)}
</div>
<div id="ov-br">
<button className={`sbt ${isAnimating ? 'active' : ''}`} onClick={startAnimation} style={{ boxShadow: 'var(--shadow-lg)', background: isAnimating ? 'var(--accent)' : '#fff' }}>
<span>{isAnimating ? '⏹' : '▶'}</span> {isAnimating ? 'Stop' : 'Animate Routes'}
</button>
</div>
</div>
</div>
</div>
);
};
export default Dispatch;