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 = ``; 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-` (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 }) => ( {children} ); 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-` // trip key so it doesn't collide with the full-trip route under ``. 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: `
${(name || 'K').charAt(0).toUpperCase()}
` }); 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) => (
handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
{r.riderName}
{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips
{r.orders.length}
{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km₹{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}
{r.orders.slice(0, 15).map(o => S{o.step})}
); 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 ? ` ${isDelivered ? '' : ''} ` : ''; 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: `
${seq > 0 ? seq : ''}${flagSvg}
` }); return ( { 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() }} >
ORDER #{o.orderid}
{o.rider_name || o.ridername || 'Unassigned'}
{o.orderstatus && (
Status {statusStyle.label}
)}
Customer{o.deliverycustomer || '?'}
Kitchen{o.pickupcustomer || '?'}
Trip / StepT{o.trip_number || '-'} S{o.step || '-'}
Distance{o.actualkms || o.kms || 0} km
Profit₹{o.profit || 0}
); }); }; const renderRoutes = () => { if (isAnimating) { return animatedSegments.map((s, i) => ( )); } 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( ); }); }); return routes; }; const toggleRider = (rid) => { const newActive = new Set(activeRiders); if (newActive.has(rid)) newActive.delete(rid); else newActive.add(rid); setActiveRiders(newActive); }; return (
{!embedded && (
D
Dispatch
{locationName &&
{locationName}
}
{/* 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. */}
{(() => { const isLoss = activeStats.profit < 0; const amount = Math.abs(activeStats.profit); return ( {isLoss ? : } {isLoss ? 'Loss' : 'Profit'} {isLoss ? '-' : ''}₹{amount.toFixed(0)} ); })()} {shouldFetchLive && ( <> {liveIsFetching && ( Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''} )} {!liveIsFetching && !liveIsError && ( {filteredLiveRows.length} orders {selectedBatch !== 'all' && filteredLiveRows.length !== liveRows.length && ( / {liveRows.length} today )} )} {liveIsError && ( Failed to load )} )}
{clock}
)}
{shouldFetchLive && (
Slot {/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls horizontally when it overflows. */}
{BATCHES.map((b) => { const isActive = selectedBatch === b.id; return ( ); })}
)}
); }; export default Dispatch;