diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 499dfae..33df7e3 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -90,6 +90,27 @@ opacity: 0.8; } +/* Operating-city pill — sits to the RIGHT of the "Dispatch" heading inline. */ +.testing-container .logo-city { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px 4px 8px; + border-radius: 999px; + background: var(--accent-soft); + border: 1px solid rgba(59, 130, 246, 0.25); + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; +} + +.testing-container .logo-city svg { + font-size: 13px; + flex-shrink: 0; +} + .testing-container .hdr-sep { width: 1px; height: 20px; @@ -114,6 +135,19 @@ border: 1px solid var(--border); } +/* Header right-cluster — profit/loss + orders pill + date picker, sits to the + LEFT of the running clock. Pushed against the clock with margin-left:auto so + the .logo on the left stays anchored and the cluster floats right. */ +.testing-container .hdr-stats { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; + margin-right: 12px; + min-width: 0; + flex-wrap: nowrap; +} + /* Tabs */ .testing-container #strat-row { height: 48px; @@ -179,6 +213,88 @@ vertical-align: middle; } +/* Strat-row quick stats — total orders + profit/loss chips next to the view-mode buttons */ +.testing-container .strat-stats { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: 8px; + padding-left: 12px; + border-left: 1px solid var(--border); + height: 32px; +} + +/* Right-floating variant — used for the profit/loss chip when there's no + live-controls block to nest inside. */ +.testing-container .strat-stats.strat-stats-right { + margin-left: auto; + padding-left: 0; + border-left: none; +} + +.testing-container .strat-stat { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + line-height: 1; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + transition: all 0.15s ease; + white-space: nowrap; +} + +.testing-container .strat-stat-icon { + font-size: 13px; + line-height: 1; +} + +.testing-container .strat-stat-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.testing-container .strat-stat-value { + font-size: 13px; + font-weight: 800; +} + +.testing-container .strat-stat-orders { + background: var(--accent-soft); + border-color: rgba(59, 130, 246, 0.25); +} + +.testing-container .strat-stat-orders .strat-stat-value { + color: var(--accent); +} + +.testing-container .strat-stat-profit { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.testing-container .strat-stat-profit .strat-stat-value, +.testing-container .strat-stat-profit .strat-stat-label { + color: var(--success); +} + +.testing-container .strat-stat-loss { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.35); +} + +.testing-container .strat-stat-loss .strat-stat-value, +.testing-container .strat-stat-loss .strat-stat-label { + color: #dc2626; +} + /* Live data controls (date picker + load status) */ .testing-container .live-controls { margin-left: auto; @@ -203,6 +319,13 @@ .testing-container .live-status-ready { color: var(--success); } .testing-container .live-status-error { color: #ef4444; } +.testing-container .live-status-sub { + color: var(--text-muted); + font-weight: 500; + font-size: 11px; + opacity: 0.85; +} + .testing-container .live-dot { width: 8px; height: 8px; @@ -259,6 +382,42 @@ background: var(--bg-sub); border-bottom: 1px solid var(--border); flex-shrink: 0; + /* Prevent the row itself from squishing when the chip list overflows. */ + min-width: 0; +} + +/* Horizontal scroller for the slot chips. Keeps the "Slot" label fixed on the + left and lets the chip list scroll when it overflows the viewport. */ +.testing-container .batch-scroll { + display: flex; + align-items: center; + gap: 8px; + overflow-x: auto; + overflow-y: hidden; + flex: 1; + min-width: 0; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.4) transparent; + padding-bottom: 2px; +} + +.testing-container .batch-scroll::-webkit-scrollbar { + height: 6px; +} + +.testing-container .batch-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.testing-container .batch-scroll::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.3); + border-radius: 999px; +} + +.testing-container .batch-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.55); } .testing-container .batch-label { @@ -268,6 +427,7 @@ text-transform: uppercase; letter-spacing: 0.08em; margin-right: 4px; + flex-shrink: 0; } .testing-container .batch-btn { @@ -284,12 +444,14 @@ cursor: pointer; transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); font-family: inherit; + /* Chips must not shrink — the scroller takes the overflow instead. */ + flex-shrink: 0; + white-space: nowrap; } .testing-container .batch-btn:hover { border-color: var(--text-muted); color: var(--text); - transform: translateY(-1px); } .testing-container .batch-btn.active { @@ -298,11 +460,11 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); } -/* Per-batch active color so each wave reads at a glance */ -.testing-container .batch-btn.batch-all.active { background: linear-gradient(135deg, #3b82f6, #6366f1); } -.testing-container .batch-btn.batch-morning.active { background: linear-gradient(135deg, #f59e0b, #f97316); } -.testing-container .batch-btn.batch-lunch.active { background: linear-gradient(135deg, #10b981, #14b8a6); } -.testing-container .batch-btn.batch-dinner.active { background: linear-gradient(135deg, #6366f1, #8b5cf6); } +/* Unified active style for hourly slot chips — single accent gradient instead of + per-wave colors since 12 different colors would be visually noisy. */ +.testing-container .batch-btn.batch-slot.active { + background: linear-gradient(135deg, #3b82f6, #6366f1); +} .testing-container .batch-btn-icon { font-size: 14px; @@ -480,6 +642,178 @@ z-index: 5; } +/* Sidebar header — moved here from the top bar. Layered card: title row with + a scope badge, an area chip, then two stat tiles for orders + riders. */ +.testing-container .sb-header { + position: relative; + padding: 18px 18px 16px; + background: linear-gradient(180deg, #ffffff 0%, var(--bg-sub) 100%); + border-bottom: 1px solid var(--border); + overflow: hidden; +} + +.testing-container .sb-header::before { + content: ''; + position: absolute; + inset: -40px -40px auto auto; + width: 160px; + height: 160px; + border-radius: 50%; + background: radial-gradient(circle at center, var(--accent-soft) 0%, transparent 70%); + pointer-events: none; +} + +.testing-container .sb-header > * { + position: relative; +} + +/* Top row — title on the left, active-scope badge on the right */ +.testing-container .sb-header-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} + +.testing-container .sb-header-title { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.testing-container .sb-title-bar { + display: inline-block; + width: 3px; + height: 14px; + border-radius: 2px; + background: linear-gradient(180deg, var(--accent), #6366f1); +} + +.testing-container .sb-title-text { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.12em; + color: var(--text); +} + +.testing-container .sb-header-scope { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border-radius: 999px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + background: var(--bg-sub); + border: 1px solid var(--border); + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.testing-container .sb-scope-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.18); + flex-shrink: 0; +} + +/* Area chip — small location pill */ +.testing-container .sb-header-area { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; + padding: 4px 10px 4px 8px; + border-radius: 999px; + background: var(--accent-soft); + border: 1px solid rgba(59, 130, 246, 0.22); + color: var(--accent); + font-size: 11px; + font-weight: 700; +} + +.testing-container .sb-area-icon { + display: inline-flex; + align-items: center; + font-size: 14px; + line-height: 1; +} + +/* Stat tiles — two side-by-side cards, large numerals */ +.testing-container .sb-header-tiles { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.testing-container .sb-tile { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + background: #fff; + border: 1px solid var(--border); + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.04); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.testing-container .sb-tile:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06); +} + +.testing-container .sb-tile-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 10px; + font-size: 18px; + flex-shrink: 0; +} + +.testing-container .sb-tile-orders .sb-tile-icon { + background: var(--accent-soft); + color: var(--accent); +} + +.testing-container .sb-tile-riders .sb-tile-icon { + background: rgba(245, 158, 11, 0.12); + color: var(--kitchen); +} + +.testing-container .sb-tile-body { + min-width: 0; + line-height: 1.1; +} + +.testing-container .sb-tile-value { + font-size: 22px; + font-weight: 800; + letter-spacing: -0.01em; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.testing-container .sb-tile-label { + margin-top: 2px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + .testing-container #stats-strip { display: grid; grid-template-columns: repeat(2, 1fr); @@ -825,26 +1159,92 @@ gap: 16px; padding-bottom: 20px; position: relative; + border-radius: 8px; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.testing-container .step-row.clickable { + cursor: pointer; +} + +.testing-container .step-row.clickable:hover { + background: rgba(99, 102, 241, 0.06); +} + +.testing-container .step-row.clickable:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.testing-container .step-row.active { + background: rgba(99, 102, 241, 0.1); + box-shadow: inset 3px 0 0 #6366f1; + padding-left: 8px; + margin-left: -8px; +} + +/* Currently-going-on order in the focused-rider view — first non-delivered, + non-skipped stop in trip+step order. Light green tint + green accent rail + + a small "IN PROGRESS" tag so users see at a glance which delivery is live. */ +.testing-container .step-row.is-going-on { + background: rgba(34, 197, 94, 0.12); + box-shadow: inset 3px 0 0 var(--success); + padding-left: 8px; + margin-left: -8px; + position: relative; + border-radius: 6px; +} + +.testing-container .step-row.is-going-on:hover { + background: rgba(34, 197, 94, 0.18); +} + +.testing-container .step-row.is-going-on::after { + content: 'IN PROGRESS'; + position: absolute; + top: 6px; + right: 8px; + padding: 2px 8px; + border-radius: 999px; + background: var(--success); + color: #fff; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.08em; + box-shadow: 0 2px 6px rgba(34, 197, 94, 0.35); + animation: going-on-pulse 1.6s ease-in-out infinite; +} + +@keyframes going-on-pulse { + 0%, 100% { box-shadow: 0 2px 6px rgba(34, 197, 94, 0.35); } + 50% { box-shadow: 0 2px 14px rgba(34, 197, 94, 0.7); } +} + +/* When the going-on row is ALSO the focused/clicked stop, keep the green rail + (priority signal) but tint a hair stronger so the click state is still felt. */ +.testing-container .step-row.is-going-on.active { + background: rgba(34, 197, 94, 0.2); + box-shadow: inset 3px 0 0 var(--success); } .testing-container .step-row:not(:last-child)::before { content: ''; position: absolute; - left: 11px; - top: 24px; + left: 15px; + top: 32px; bottom: 0; width: 2px; background: var(--border); } .testing-container .step-dot { - width: 24px; - height: 24px; + width: 32px; + height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 10px; + font-size: 14px; font-weight: 800; z-index: 2; flex-shrink: 0; @@ -866,6 +1266,19 @@ font-weight: 700; } +.testing-container .step-label-row { + display: flex; + align-items: flex-start; +} + +.testing-container .step-customer { + flex: 1; + min-width: 0; + word-break: break-word; + overflow-wrap: anywhere; + line-height: 1.35; +} + .testing-container .kitchen-tag { color: var(--kitchen); } @@ -889,6 +1302,10 @@ color: var(--success); } +.testing-container .step-profit.is-loss { + color: #dc2626; +} + /* Enriched step row metadata */ .testing-container .step-location { font-size: 11px; @@ -1289,6 +1706,97 @@ background: var(--kitchen); } +/* Clickable area chip (button variant) — same look as the chip but with cursor + + stronger active state for the currently-expanded suburb. */ +.testing-container .zone-chip.zone-chip-clickable { + cursor: pointer; + font-family: inherit; +} + +.testing-container .zone-chip.zone-chip-clickable.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25); +} + +.testing-container .zone-chip.zone-chip-clickable.active .zone-chip-count { + background: rgba(255, 255, 255, 0.25); + color: #fff; +} + +/* Inline drill-down panel for a selected suburb */ +.testing-container .zone-suburb-panel { + margin-top: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: #fff; + overflow: hidden; + animation: zone-suburb-panel-in 0.18s ease-out; +} + +@keyframes zone-suburb-panel-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.testing-container .zone-suburb-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + background: var(--accent-soft); + border-bottom: 1px solid var(--border); +} + +.testing-container .zone-suburb-panel-title { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.01em; +} + +.testing-container .zone-suburb-panel-count { + margin-left: 6px; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; +} + +.testing-container .zone-suburb-panel-close { + width: 24px; + height: 24px; + border-radius: 50%; + border: none; + background: rgba(15, 23, 42, 0.06); + color: var(--text-muted); + font-size: 18px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: all 0.15s; +} + +.testing-container .zone-suburb-panel-close:hover { + background: rgba(239, 68, 68, 0.15); + color: #dc2626; +} + +.testing-container .zone-suburb-panel-empty { + padding: 16px; + font-size: 12px; + color: var(--text-muted); + text-align: center; +} + .testing-container .kitchen-transition { padding: 12px; background: var(--kitchen-soft); @@ -1403,29 +1911,46 @@ /* Markers */ .testing-container .cmark { border-radius: 50%; - border: 2px solid #fff; + border: 3px solid #fff; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + letter-spacing: 0.02em; } .testing-container .kitchen-mark { background: var(--kitchen); border: 3px solid #fff; border-radius: 50%; - width: 34px; - height: 34px; + width: 46px; + height: 46px; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 900; + font-size: 18px; box-shadow: 0 0 20px rgba(245, 158, 11, 0.8), 0 0 40px rgba(245, 158, 11, 0.4); } +/* Focused kitchen marker — larger, brighter, with a pulsing halo so users + never lose sight of the kitchen they drilled into. */ +.testing-container .kitchen-mark.is-focused { + width: 56px; + height: 56px; + font-size: 22px; + border-width: 4px; + animation: kitchen-mark-pulse 1.8s ease-in-out infinite; +} + +@keyframes kitchen-mark-pulse { + 0%, 100% { box-shadow: 0 0 20px rgba(245, 158, 11, 0.9), 0 0 40px rgba(245, 158, 11, 0.5), 0 0 0 0 rgba(245, 158, 11, 0.55); } + 50% { box-shadow: 0 0 30px rgba(245, 158, 11, 1), 0 0 60px rgba(245, 158, 11, 0.7), 0 0 0 18px rgba(245, 158, 11, 0); } +} + /* Popups - Clean White Look */ .testing-container .leaflet-popup-content-wrapper { background: #ffffff; diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 49fe434..6f88e41 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -1,10 +1,27 @@ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +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 } from '@tanstack/react-query'; -import { MdMap, MdDirectionsBike, MdRestaurant, MdPublic } from 'react-icons/md'; +import { + MdMap, + MdDirectionsBike, + MdRestaurant, + MdPublic, + MdInventory2, + MdTrendingUp, + MdTrendingDown, + MdAccountBalanceWallet, + MdStraighten, + MdLocationOn, + MdMarkunreadMailbox, + MdMoveToInbox, + MdPlace, + MdTwoWheeler, + MdNotes, + MdSwapHoriz +} from 'react-icons/md'; import { fetchDeliveries } from '../../api/api'; import './Dispatch.css'; import { RAW_DISPATCH_DATA } from './DispatchData'; @@ -37,22 +54,32 @@ const hasValidPickup = (o) => Number.isFinite(toNum(o.pickuplat)) && Number.isFi // 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". -const BATCHES = [ - // { id: 'all', label: 'All', icon: '🍽️' }, // hidden for now — restore for an unfiltered view - { id: 'morning', label: 'Morning', icon: '🌅' }, - { id: 'lunch', label: 'Lunch', icon: '🍱' }, - { id: 'dinner', label: 'Dinner', icon: '🌙' } -]; +// Hourly delivery slots: 7am→8am … 6pm→7pm. Slot id is `slot-` (24h). +// To shift the window edit BATCH_START_HOUR / BATCH_END_HOUR (end is exclusive). +const BATCH_START_HOUR = 7; +const BATCH_END_HOUR = 19; +const formatHour12 = (h) => { + const period = h >= 12 ? 'pm' : 'am'; + const hr = h % 12 === 0 ? 12 : h % 12; + return `${hr}${period}`; +}; +const BATCHES = Array.from({ length: BATCH_END_HOUR - BATCH_START_HOUR }, (_, i) => { + const start = BATCH_START_HOUR + i; + return { + id: `slot-${start}`, + label: `${formatHour12(start)}-${formatHour12(start + 1)}`, + startHour: start + }; +}); const getRowBatch = (r) => { 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() + d.minute() / 60; - if (h < 10.5) return 'morning'; - if (h < 15) return 'lunch'; - return 'dinner'; + const h = d.hour(); + if (h < BATCH_START_HOUR || h >= BATCH_END_HOUR) return null; + return `slot-${h}`; }; const FINAL_STATUSES = new Set(['delivered']); @@ -199,6 +226,14 @@ const MapController = ({ focusedItem, viewMode, orders }) => { return null; }; +// Inline-icon wrapper used wherever a Material icon precedes some text — keeps the +// SVG vertically centered with the adjacent text and inherits the parent color. +const Ico = ({ children }) => ( + + {children} + +); + const Dispatch = ({ data, embedded = false, @@ -218,13 +253,27 @@ const Dispatch = ({ 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(''); const [osrmRoutes, setOsrmRoutes] = useState({}); const [isAnimating, setIsAnimating] = useState(false); const [animatedSegments, setAnimatedSegments] = useState([]); const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); - const [selectedBatch, setSelectedBatch] = useState('morning'); + // 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; @@ -258,10 +307,11 @@ const Dispatch = ({ // 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, morning: 0, lunch: 0, dinner: 0 }; + const counts = { all: liveRows.length }; + BATCHES.forEach((b) => { counts[b.id] = 0; }); liveRows.forEach((r) => { const b = getRowBatch(r); - if (b) counts[b] += 1; + if (b) counts[b] = (counts[b] || 0) + 1; }); return counts; }, [liveRows]); @@ -462,6 +512,7 @@ const Dispatch = ({ (r) => { if (onRiderSelect) onRiderSelect(r ? r.id : null); if (!isControlled) setInternalFocusedRider(r); + setFocusedStop(null); }, [isControlled, onRiderSelect] ); @@ -499,9 +550,13 @@ const Dispatch = ({ const fetchRoute = useCallback(async (riderId, tripKey, points) => { const cacheKey = `${riderId}-${tripKey}`; - if (osrmRoutes[cacheKey]) return; + // Already cached (array) or known-failed (false). `null` means in-flight. + if (osrmRoutes[cacheKey] !== undefined) return; if (points.length < 2) return; + // Mark as in-flight so simultaneous renders don't fire duplicate requests. + 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`; @@ -511,9 +566,14 @@ const Dispatch = ({ if (data.routes && data.routes[0]) { const poly = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]); 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. + setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false })); } } catch (e) { console.error('OSRM Fetch error:', e); + setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false })); } }, [osrmRoutes]); @@ -566,6 +626,60 @@ const Dispatch = ({ }); }, [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); @@ -630,12 +744,12 @@ const Dispatch = ({ }); }; - const createKitchenIcon = (name) => L.divIcon({ + const createKitchenIcon = (name, focused = false) => L.divIcon({ className: '', - iconSize: [34, 34], - iconAnchor: [17, 17], - popupAnchor: [0, -18], - html: `
${(name || 'K').charAt(0).toUpperCase()}
` + iconSize: focused ? [56, 56] : [46, 46], + iconAnchor: focused ? [28, 28] : [23, 23], + popupAnchor: [0, focused ? -30 : -24], + html: `
${(name || 'K').charAt(0).toUpperCase()}
` }); const getRiderColor = (rid) => riders.find(r => r.id === rid)?.color || '#475569'; @@ -644,7 +758,7 @@ const Dispatch = ({ const renderRiderCard = (r, i) => (
handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
-
🏍
+
{r.riderName}
{r.orders[0]?.zone_name || 'Coimbatore'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips
@@ -652,7 +766,7 @@ const Dispatch = ({
{r.orders.length}
-
📏 {r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km💰 ₹{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}
+
{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km₹{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}
{r.orders.slice(0, 15).map(o => S{o.step})}
@@ -673,7 +787,8 @@ const Dispatch = ({ // Use the 'step' field from data, fallback to index const seq = o.step || (focusedRider || focusedKitchen ? (ordersToRender.indexOf(o) + 1) : 0); - const sz = 22; + // 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(); @@ -691,12 +806,25 @@ const Dispatch = ({ className: '', iconSize: [sz, sz], iconAnchor: [sz / 2, sz / 2], - popupAnchor: [0, -22], // Lift popup above the flag, not just the marker - html: `
${seq > 0 ? seq : ''}${flagSvg}
` + popupAnchor: [0, -28], // Lift popup above the flag, not just the larger 32px marker + html: `
${seq > 0 ? seq : ''}${flagSvg}
` }); return ( - + { + if (inst) orderMarkerRefs.current[String(o.orderid)] = inst; + else delete orderMarkerRefs.current[String(o.orderid)]; + }} + eventHandlers={{ + mouseover: (e) => e.target.openPopup(), + mouseout: (e) => e.target.closePopup() + }} + >
ORDER #{o.orderid}
{o.rider_name || o.ridername || 'Unassigned'}
@@ -752,20 +880,29 @@ const Dispatch = ({ const roadPoints = osrmRoutes[cacheKey]; const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); - // Always render the actual road polyline from OSRM — applies to every view - // (riders, zones, all routes, kitchens). If OSRM hasn't responded yet we just - // don't draw, instead of flashing an aerial line that snaps to the road later. - const finalPoints = roadPoints; + // 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( - + ); }); @@ -786,85 +923,148 @@ const Dispatch = ({
D
-
Rider Dispatch
-
-
Coimbatore · {activeStats.orders} orders · {activeStats.riders} {activeStats.riders === 1 ? 'rider' : 'riders'} ({activeStats.label})
+
Dispatch
+
Coimbatore
+ + {/* Header right-cluster: profit/loss chip, total-orders pill, date picker. + Sits to the LEFT of the running clock so the operator sees fleet + health + current wave size + selected date together in one row. */} +
+ {(() => { + const isLoss = activeStats.profit < 0; + const amount = Math.abs(activeStats.profit); + return ( + + {isLoss ? : } + {isLoss ? 'Loss' : 'Profit'} + {isLoss ? '-' : ''}₹{amount.toFixed(0)} + + ); + })()} + + {shouldFetchLive && ( + <> + {liveIsFetching && ( + + Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''} + + )} + {!liveIsFetching && !liveIsError && ( + + {filteredLiveRows.length} orders + {selectedBatch !== 'all' && filteredLiveRows.length !== liveRows.length && ( + / {liveRows.length} today + )} + + )} + {liveIsError && ( + + Failed to load + + )} + + + )} +
+
{clock}
)}
- {zoneCards.length > 0 && ( - - )} + + - - - {shouldFetchLive && ( -
- {liveIsFetching && ( - - Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''} - - )} - {!liveIsFetching && !liveIsError && ( - - {liveRows.length} orders - - )} - {liveIsError && ( - - Failed to load - - )} - -
- )}
{shouldFetchLive && (
- Batch - {BATCHES.map((b) => ( - - ))} + Slot + {/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls + horizontally when it overflows. */} +
+ {BATCHES.map((b) => { + const isActive = selectedBatch === b.id; + return ( + + ); + })} +
)}