diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 33df7e3..9e204aa 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -2052,6 +2052,37 @@ 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 { padding: 16px 20px; font-size: 12px; @@ -2059,4 +2090,238 @@ color: var(--text-muted); border-top: 1px solid var(--border); 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; + } } \ No newline at end of file diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 6f88e41..4350cc6 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -3,7 +3,7 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, ZoomControl } import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import dayjs from 'dayjs'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { MdMap, MdDirectionsBike, @@ -22,7 +22,7 @@ import { MdNotes, MdSwapHoriz } from 'react-icons/md'; -import { fetchDeliveries } from '../../api/api'; +import { fetchDeliveries, fetchAppLocations } from '../../api/api'; import './Dispatch.css'; 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 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, // 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 t = r.expecteddeliverytime || r.deliverydate || r.pickupslot; - if (!t) return null; - const d = dayjs(t); - if (!d.isValid()) return null; - const h = d.hour(); - if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) return null; - return `slot-${h}`; + // 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']); @@ -114,8 +132,8 @@ const computeRiderPosition = (r) => { prevLat = toNum(prev.droplat || prev.deliverylat); prevLon = toNum(prev.droplon || prev.deliverylong); } else if (hasValidPickup(next)) { - prevLat = toNum(next.pickuplat); - prevLon = toNum(next.pickuplong); + prevLat = toNum(pickupLat(next)); + prevLon = toNum(pickupLon(next)); } else { return null; } @@ -179,7 +197,7 @@ const buildTripPoints = (sorted) => { if (!valid.length) return []; const pickupSrc = sorted.find(hasValidPickup); 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)])); return pts; }; @@ -194,7 +212,7 @@ L.Icon.Default.mergeOptions({ 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(); useEffect(() => { @@ -202,10 +220,19 @@ const MapController = ({ focusedItem, viewMode, orders }) => { 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([parseFloat(o.pickuplat), parseFloat(o.pickuplong)])); + 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)]); } @@ -221,7 +248,7 @@ const MapController = ({ focusedItem, viewMode, orders }) => { } else { map.setView([11.022, 76.982], 12, { animate: true }); } - }, [focusedItem, viewMode, orders, map]); + }, [focusedItem, viewMode, orders, kitchens, map]); return null; }; @@ -263,7 +290,26 @@ const Dispatch = ({ 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')); @@ -458,15 +504,15 @@ const Dispatch = ({ kitchenMap[key] = { id: key, kitchenName: name, - lat: toNum(o.pickuplat), - lon: toNum(o.pickuplong), + 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(o.pickuplat); - kitchenMap[key].lon = toNum(o.pickuplong); + 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); @@ -550,11 +596,15 @@ const Dispatch = ({ const fetchRoute = useCallback(async (riderId, tripKey, points) => { const cacheKey = `${riderId}-${tripKey}`; - // Already cached (array) or known-failed (false). `null` means in-flight. - if (osrmRoutes[cacheKey] !== undefined) return; + // 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 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 })); const coords = points.map(p => `${p[1]},${p[0]}`).join(';'); @@ -562,20 +612,32 @@ const Dispatch = ({ try { const res = await fetch(url); - const data = await res.json(); - if (data.routes && data.routes[0]) { - const poly = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]); + 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 })); } - }, [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(() => { if (embedded) return undefined; @@ -761,7 +823,7 @@ const Dispatch = ({
{r.riderName}
-
{r.orders[0]?.zone_name || 'Coimbatore'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips
+
{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips
{r.orders.length}
@@ -924,7 +986,7 @@ const Dispatch = ({
D
Dispatch
-
Coimbatore
+ {locationName &&
{locationName}
}
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker. @@ -1505,7 +1567,27 @@ const Dispatch = ({ 'Rider dispatch' }
- {viewMode === 'zones' ? ( + {allOrders.length === 0 && !liveIsFetching ? ( + (() => { + const slotLabel = BATCHES.find(b => b.id === selectedBatch)?.label; + const hasDayData = shouldFetchLive && liveRows.length > 0; + return ( +
+
+ +
+
+ {slotLabel ? `No orders in ${slotLabel}` : 'No orders'} +
+
+ {hasDayData + ? `${liveRows.length} order${liveRows.length === 1 ? '' : 's'} exist in other slots today` + : 'No deliveries found for this date'} +
+
+ ); + })() + ) : viewMode === 'zones' ? ( zoneCards.map((z, i) => { const delivered = z.statusCounts.delivered || 0; const profitNeg = z.totalProfit < 0; @@ -1613,16 +1695,13 @@ const Dispatch = ({
)} -
- {(focusedRider || focusedKitchen) ? 'Detailed breakdown' : focusedZone ? `${focusedZone.activeRidersCount} riders in ${focusedZone.name} — click one to drill in` : '💡 Click a card to view detailed route breakdown'} -
- + {kitchens .filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon)) .filter(k => !focusedRider || k.riders.has(focusedRider.id))