updated the fix on the slots and some ui fixes
This commit is contained in:
@@ -2052,6 +2052,37 @@
|
|||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empty slot state — shown in the sidebar list when no orders match the selected batch */
|
||||||
|
.testing-container .empty-slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .empty-slot-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
color: var(--border);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .empty-slot-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing-container .empty-slot-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--border);
|
||||||
|
max-width: 220px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.testing-container #desc {
|
.testing-container #desc {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -2060,3 +2091,237 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive breakpoints ─────────────────────────────────────
|
||||||
|
Targets: laptop 1280px, compact laptop 1100px, small 960px.
|
||||||
|
The sidebar is the primary layout element to shrink — the map
|
||||||
|
takes the freed space automatically (it's flex: 1).
|
||||||
|
────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Large laptop — subtle sidebar reduction */
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.testing-container #sidebar {
|
||||||
|
width: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact laptop (common 1366×768 screens) */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.testing-container #sidebar {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-rider-name {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.testing-container .sb-tile-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.testing-container #hdr {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.testing-container #strat-row {
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.testing-container #batch-row {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
.testing-container .sbt {
|
||||||
|
padding: 7px 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small laptop / 1024px */
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.testing-container #sidebar {
|
||||||
|
width: 290px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header — hide decorative city pill, tighten spacing */
|
||||||
|
.testing-container .logo-city {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.testing-container .logo-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.testing-container #clock {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
.testing-container .hdr-stats {
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.testing-container .strat-stat {
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
/* Hide the verbose "Profit / Loss" text label; keep icon + value */
|
||||||
|
.testing-container .strat-stat-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.testing-container .live-status {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
/* Hide the "/ N today" sub-text to keep status compact */
|
||||||
|
.testing-container .live-status-sub {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs — smaller */
|
||||||
|
.testing-container .sbt {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.testing-container .sbt .sbt-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Batch slots — smaller pills */
|
||||||
|
.testing-container .batch-btn {
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.testing-container .batch-btn-count {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar content */
|
||||||
|
.testing-container .sb-header {
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
}
|
||||||
|
.testing-container .sb-tile-value {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.testing-container .sb-tile {
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.testing-container .sb-tile-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.testing-container .rcard {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.testing-container .rcard-name {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.testing-container .rcard-zone {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.testing-container .step-wrap {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.testing-container #route-detail {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-rider-name {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-stat-value {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-stat {
|
||||||
|
padding: 12px 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map overlay chips — narrower */
|
||||||
|
.testing-container #ov-tr {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
.testing-container .rchip {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very small laptop / tablet landscape — 960px */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.testing-container #sidebar {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make strat-row horizontally scrollable if buttons overflow */
|
||||||
|
.testing-container #strat-row {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.testing-container #strat-row::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Keep buttons from shrinking inside the scroll container */
|
||||||
|
.testing-container .sbt {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zone stat pills — drop the text label, keep icon + value */
|
||||||
|
.testing-container .zone-stat-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.testing-container .zone-stat-pill {
|
||||||
|
padding: 3px 7px;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.testing-container .zone-stat-value {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focused-rider stat tiles */
|
||||||
|
.testing-container .rd-stats-grid {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-stat {
|
||||||
|
padding: 10px 6px 8px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-stat-value {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-stat-label {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.testing-container .rd-stat-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide map overlay rider/kitchen chip list — not enough space */
|
||||||
|
.testing-container #ov-tr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zone card adjustments */
|
||||||
|
.testing-container .zone-card-name {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.testing-container .zone-card-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trim padding in various panels */
|
||||||
|
.testing-container #riders-panel {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.testing-container .trip-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, ZoomControl }
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
MdMap,
|
MdMap,
|
||||||
MdDirectionsBike,
|
MdDirectionsBike,
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
MdNotes,
|
MdNotes,
|
||||||
MdSwapHoriz
|
MdSwapHoriz
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
import { fetchDeliveries } from '../../api/api';
|
import { fetchDeliveries, fetchAppLocations } from '../../api/api';
|
||||||
import './Dispatch.css';
|
import './Dispatch.css';
|
||||||
import { RAW_DISPATCH_DATA } from './DispatchData';
|
import { RAW_DISPATCH_DATA } from './DispatchData';
|
||||||
|
|
||||||
@@ -50,7 +50,11 @@ const toNum = (v) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasValidDrop = (o) => Number.isFinite(toNum(o.droplat || o.deliverylat)) && Number.isFinite(toNum(o.droplon || o.deliverylong));
|
const hasValidDrop = (o) => Number.isFinite(toNum(o.droplat || o.deliverylat)) && Number.isFinite(toNum(o.droplon || o.deliverylong));
|
||||||
const hasValidPickup = (o) => Number.isFinite(toNum(o.pickuplat)) && Number.isFinite(toNum(o.pickuplong));
|
// 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,
|
// 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".
|
// lunch wave, dinner wave). Anything outside a window OR with no parsable time falls under "all".
|
||||||
@@ -73,13 +77,27 @@ const BATCHES = Array.from({ length: BATCH_END_HOUR - BATCH_START_HOUR }, (_, i)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getRowBatch = (r) => {
|
const getRowBatch = (r) => {
|
||||||
const t = r.expecteddeliverytime || r.deliverydate || r.pickupslot;
|
// Try fields in priority order. Bare date strings like "YYYY-MM-DD" have no time
|
||||||
if (!t) return null;
|
// 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);
|
const d = dayjs(t);
|
||||||
if (!d.isValid()) return null;
|
if (!d.isValid()) continue;
|
||||||
const h = d.hour();
|
const h = d.hour();
|
||||||
if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) return null;
|
if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) continue;
|
||||||
return `slot-${h}`;
|
return `slot-${h}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FINAL_STATUSES = new Set(['delivered']);
|
const FINAL_STATUSES = new Set(['delivered']);
|
||||||
@@ -114,8 +132,8 @@ const computeRiderPosition = (r) => {
|
|||||||
prevLat = toNum(prev.droplat || prev.deliverylat);
|
prevLat = toNum(prev.droplat || prev.deliverylat);
|
||||||
prevLon = toNum(prev.droplon || prev.deliverylong);
|
prevLon = toNum(prev.droplon || prev.deliverylong);
|
||||||
} else if (hasValidPickup(next)) {
|
} else if (hasValidPickup(next)) {
|
||||||
prevLat = toNum(next.pickuplat);
|
prevLat = toNum(pickupLat(next));
|
||||||
prevLon = toNum(next.pickuplong);
|
prevLon = toNum(pickupLon(next));
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -179,7 +197,7 @@ const buildTripPoints = (sorted) => {
|
|||||||
if (!valid.length) return [];
|
if (!valid.length) return [];
|
||||||
const pickupSrc = sorted.find(hasValidPickup);
|
const pickupSrc = sorted.find(hasValidPickup);
|
||||||
const pts = [];
|
const pts = [];
|
||||||
if (pickupSrc) pts.push([toNum(pickupSrc.pickuplat), toNum(pickupSrc.pickuplong)]);
|
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)]));
|
valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
|
||||||
return pts;
|
return pts;
|
||||||
};
|
};
|
||||||
@@ -194,7 +212,7 @@ L.Icon.Default.mergeOptions({
|
|||||||
|
|
||||||
const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000'];
|
const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000'];
|
||||||
|
|
||||||
const MapController = ({ focusedItem, viewMode, orders }) => {
|
const MapController = ({ focusedItem, viewMode, orders, kitchens }) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -202,10 +220,19 @@ const MapController = ({ focusedItem, viewMode, orders }) => {
|
|||||||
if (focusedItem) {
|
if (focusedItem) {
|
||||||
if (focusedItem.orders) {
|
if (focusedItem.orders) {
|
||||||
pts = focusedItem.orders.map(o => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
pts = focusedItem.orders.map(o => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
||||||
focusedItem.orders.forEach(o => pts.push([parseFloat(o.pickuplat), parseFloat(o.pickuplong)]));
|
focusedItem.orders.forEach(o => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))]));
|
||||||
} else {
|
} else {
|
||||||
pts = [[focusedItem.lat, focusedItem.lon]];
|
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') {
|
} else if (viewMode === 'all') {
|
||||||
pts = orders.map(o => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
pts = orders.map(o => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
|
||||||
}
|
}
|
||||||
@@ -221,7 +248,7 @@ const MapController = ({ focusedItem, viewMode, orders }) => {
|
|||||||
} else {
|
} else {
|
||||||
map.setView([11.022, 76.982], 12, { animate: true });
|
map.setView([11.022, 76.982], 12, { animate: true });
|
||||||
}
|
}
|
||||||
}, [focusedItem, viewMode, orders, map]);
|
}, [focusedItem, viewMode, orders, kitchens, map]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -263,7 +290,26 @@ const Dispatch = ({
|
|||||||
const orderMarkerRefs = useRef({});
|
const orderMarkerRefs = useRef({});
|
||||||
const isControlled = selectedRiderId !== undefined;
|
const isControlled = selectedRiderId !== undefined;
|
||||||
const [clock, setClock] = useState('');
|
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({});
|
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 [isAnimating, setIsAnimating] = useState(false);
|
||||||
const [animatedSegments, setAnimatedSegments] = useState([]);
|
const [animatedSegments, setAnimatedSegments] = useState([]);
|
||||||
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'));
|
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||||
@@ -458,15 +504,15 @@ const Dispatch = ({
|
|||||||
kitchenMap[key] = {
|
kitchenMap[key] = {
|
||||||
id: key,
|
id: key,
|
||||||
kitchenName: name,
|
kitchenName: name,
|
||||||
lat: toNum(o.pickuplat),
|
lat: toNum(pickupLat(o)),
|
||||||
lon: toNum(o.pickuplong),
|
lon: toNum(pickupLon(o)),
|
||||||
orders: [],
|
orders: [],
|
||||||
riders: new Set()
|
riders: new Set()
|
||||||
};
|
};
|
||||||
} else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) {
|
} else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) {
|
||||||
// Upgrade to first valid pickup coords we see for this kitchen
|
// Upgrade to first valid pickup coords we see for this kitchen
|
||||||
kitchenMap[key].lat = toNum(o.pickuplat);
|
kitchenMap[key].lat = toNum(pickupLat(o));
|
||||||
kitchenMap[key].lon = toNum(o.pickuplong);
|
kitchenMap[key].lon = toNum(pickupLon(o));
|
||||||
}
|
}
|
||||||
kitchenMap[key].orders.push(o);
|
kitchenMap[key].orders.push(o);
|
||||||
if (o.rider_id) kitchenMap[key].riders.add(o.rider_id);
|
if (o.rider_id) kitchenMap[key].riders.add(o.rider_id);
|
||||||
@@ -550,11 +596,15 @@ const Dispatch = ({
|
|||||||
|
|
||||||
const fetchRoute = useCallback(async (riderId, tripKey, points) => {
|
const fetchRoute = useCallback(async (riderId, tripKey, points) => {
|
||||||
const cacheKey = `${riderId}-${tripKey}`;
|
const cacheKey = `${riderId}-${tripKey}`;
|
||||||
// Already cached (array) or known-failed (false). `null` means in-flight.
|
// Use the ref (not state) for the in-flight / already-cached check so this
|
||||||
if (osrmRoutes[cacheKey] !== undefined) return;
|
// 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;
|
if (points.length < 2) return;
|
||||||
|
|
||||||
// Mark as in-flight so simultaneous renders don't fire duplicate requests.
|
// Mark in-flight in both ref (immediate) and state (triggers re-render).
|
||||||
|
osrmRoutesRef.current[cacheKey] = null;
|
||||||
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: null }));
|
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: null }));
|
||||||
|
|
||||||
const coords = points.map(p => `${p[1]},${p[0]}`).join(';');
|
const coords = points.map(p => `${p[1]},${p[0]}`).join(';');
|
||||||
@@ -562,20 +612,32 @@ const Dispatch = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const json = await res.json();
|
||||||
if (data.routes && data.routes[0]) {
|
if (json.routes && json.routes[0]) {
|
||||||
const poly = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]);
|
const poly = json.routes[0].geometry.coordinates.map(c => [c[1], c[0]]);
|
||||||
|
osrmRoutesRef.current[cacheKey] = poly;
|
||||||
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: poly }));
|
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: poly }));
|
||||||
} else {
|
} else {
|
||||||
// OSRM responded but couldn't route — record as failed so renderRoutes
|
// OSRM responded but couldn't route — record as failed so renderRoutes
|
||||||
// shows the aerial fallback instead of an empty gap.
|
// shows the aerial fallback instead of an empty gap.
|
||||||
|
osrmRoutesRef.current[cacheKey] = false;
|
||||||
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
|
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('OSRM Fetch error:', e);
|
console.error('OSRM Fetch error:', e);
|
||||||
|
osrmRoutesRef.current[cacheKey] = false;
|
||||||
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
|
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
|
||||||
}
|
}
|
||||||
}, [osrmRoutes]);
|
}, []); // 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(() => {
|
useEffect(() => {
|
||||||
if (embedded) return undefined;
|
if (embedded) return undefined;
|
||||||
@@ -761,7 +823,7 @@ const Dispatch = ({
|
|||||||
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div>
|
<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-info">
|
||||||
<div className="rcard-name">{r.riderName}</div>
|
<div className="rcard-name">{r.riderName}</div>
|
||||||
<div className="rcard-zone">{r.orders[0]?.zone_name || 'Coimbatore'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips</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>
|
||||||
<div className="rcard-badge" style={{ background: `${r.color}18`, color: r.color }}>{r.orders.length}</div>
|
<div className="rcard-badge" style={{ background: `${r.color}18`, color: r.color }}>{r.orders.length}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -924,7 +986,7 @@ const Dispatch = ({
|
|||||||
<div className="logo">
|
<div className="logo">
|
||||||
<div className="logo-badge">D</div>
|
<div className="logo-badge">D</div>
|
||||||
<div className="logo-name">Dispatch</div>
|
<div className="logo-name">Dispatch</div>
|
||||||
<div className="logo-city"><MdPlace /> Coimbatore</div>
|
{locationName && <div className="logo-city"><MdPlace /> {locationName}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker.
|
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker.
|
||||||
@@ -1505,7 +1567,27 @@ const Dispatch = ({
|
|||||||
'Rider dispatch'
|
'Rider dispatch'
|
||||||
}</div>
|
}</div>
|
||||||
<div id="rider-cards">
|
<div id="rider-cards">
|
||||||
{viewMode === 'zones' ? (
|
{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) => {
|
zoneCards.map((z, i) => {
|
||||||
const delivered = z.statusCounts.delivered || 0;
|
const delivered = z.statusCounts.delivered || 0;
|
||||||
const profitNeg = z.totalProfit < 0;
|
const profitNeg = z.totalProfit < 0;
|
||||||
@@ -1613,16 +1695,13 @@ const Dispatch = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div id="desc">
|
|
||||||
{(focusedRider || focusedKitchen) ? 'Detailed breakdown' : focusedZone ? `${focusedZone.activeRidersCount} riders in ${focusedZone.name} — click one to drill in` : '💡 Click a card to view detailed route breakdown'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="map-wrap" className={viewMode === 'kitchens' ? 'view-mode-kitchens' : ''}>
|
<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}>
|
<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' />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© OpenStreetMap contributors' />
|
||||||
<ZoomControl position="bottomright" />
|
<ZoomControl position="bottomright" />
|
||||||
<MapController focusedItem={((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} />
|
<MapController focusedItem={((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} kitchens={kitchens} />
|
||||||
{kitchens
|
{kitchens
|
||||||
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
|
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
|
||||||
.filter(k => !focusedRider || k.riders.has(focusedRider.id))
|
.filter(k => !focusedRider || k.riders.has(focusedRider.id))
|
||||||
|
|||||||
Reference in New Issue
Block a user