1819 lines
89 KiB
JavaScript
1819 lines
89 KiB
JavaScript
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='© 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;
|