From 4edc20040f637bf89cb98719441f783e72912f84 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Thu, 21 May 2026 19:06:55 +0530 Subject: [PATCH] update on the pickup and edit slot in the dispatch page --- src/pages/nearle/dispatch/Dispatch.css | 414 +++++++++++++++- src/pages/nearle/dispatch/Dispatch.js | 656 +++++++++++++++++++++---- 2 files changed, 969 insertions(+), 101 deletions(-) diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 230c876..7797d42 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -544,6 +544,330 @@ flex-shrink: 0; } +/* Slot-time-field dropdown — picks which timestamp column drives slot + bucketing. Styled to match the location-pill dropdown in the header so + both feel like the same kind of filter control. */ +.testing-container .time-field-wrap { + position: relative; + display: inline-block; + flex-shrink: 0; + margin-right: 4px; +} + +.testing-container .time-field-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 8px 5px 10px; + border-radius: 999px; + background: rgba(123, 31, 162, 0.08); + border: 1px solid rgba(123, 31, 162, 0.25); + color: #7b1fa2; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + cursor: pointer; + font-family: inherit; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.testing-container .time-field-btn:hover { + background: rgba(123, 31, 162, 0.14); + border-color: rgba(123, 31, 162, 0.45); +} + +.testing-container .time-field-btn.open { + background: rgba(123, 31, 162, 0.18); + border-color: rgba(123, 31, 162, 0.55); + box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18); +} + +.testing-container .time-field-btn svg { + font-size: 13px; + flex-shrink: 0; +} + +.testing-container .time-field-caret { + font-size: 15px; + transition: transform 0.2s ease; +} + +.testing-container .time-field-btn.open .time-field-caret { + transform: rotate(180deg); +} + +.testing-container .time-field-text { + white-space: nowrap; +} + +.testing-container .time-field-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 180px; + background: #fff; + border: 1px solid rgba(123, 31, 162, 0.18); + border-radius: 12px; + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16); + padding: 6px; + z-index: 1000; + animation: logo-city-menu-in 0.14s ease-out; +} + +.testing-container .time-field-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 0; + background: transparent; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + color: #1e293b; + cursor: pointer; + font-family: inherit; + text-align: left; + transition: background 0.12s ease; +} + +.testing-container .time-field-option:hover { + background: rgba(123, 31, 162, 0.06); +} + +.testing-container .time-field-option.active { + background: rgba(123, 31, 162, 0.1); + color: #7b1fa2; +} + +.testing-container .time-field-option-icon { + font-size: 14px; + color: #7b1fa2; + flex-shrink: 0; +} + +.testing-container .time-field-option-check { + margin-left: auto; + color: #7b1fa2; + font-weight: 800; +} + +/* Slot timings editor — popover anchored to a small "Edit slots" button in + the batch row. Lets the operator tweak start/end hours, add new slots, + delete existing ones, or reset to the default 5-slot layout. */ +.testing-container .slot-edit-wrap { + position: relative; + display: inline-block; + flex-shrink: 0; + margin-right: 4px; +} + +.testing-container .slot-edit-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.04); + border: 1px dashed rgba(15, 23, 42, 0.18); + color: #475569; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + cursor: pointer; + font-family: inherit; +} + +.testing-container .slot-edit-btn:hover { + background: rgba(15, 23, 42, 0.08); + border-color: rgba(15, 23, 42, 0.32); + color: #0f172a; +} + +.testing-container .slot-edit-btn.open { + background: rgba(123, 31, 162, 0.1); + border-color: rgba(123, 31, 162, 0.5); + border-style: solid; + color: #7b1fa2; +} + +.testing-container .slot-edit-btn svg { + font-size: 13px; + flex-shrink: 0; +} + +.testing-container .slot-edit-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 340px; + background: #fff; + border: 1px solid rgba(123, 31, 162, 0.18); + border-radius: 14px; + box-shadow: 0 20px 44px rgba(15, 23, 42, 0.2); + padding: 12px; + z-index: 1000; + animation: logo-city-menu-in 0.14s ease-out; +} + +.testing-container .slot-edit-head { + margin-bottom: 10px; +} + +.testing-container .slot-edit-title { + font-size: 13px; + font-weight: 800; + color: #0f172a; +} + +.testing-container .slot-edit-sub { + font-size: 11px; + color: #64748b; + margin-top: 2px; +} + +.testing-container .slot-edit-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 260px; + overflow-y: auto; + padding-right: 2px; +} + +.testing-container .slot-edit-row { + display: grid; + grid-template-columns: 22px 70px 70px 1fr 28px; + align-items: center; + gap: 8px; +} + +.testing-container .slot-edit-idx { + width: 22px; + height: 22px; + border-radius: 6px; + background: rgba(123, 31, 162, 0.12); + color: #7b1fa2; + font-size: 11px; + font-weight: 800; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.testing-container .slot-edit-field { + display: flex; + flex-direction: column; + gap: 2px; +} + +.testing-container .slot-edit-field-label { + font-size: 9px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.testing-container .slot-edit-field input { + width: 100%; + border: 1px solid rgba(15, 23, 42, 0.16); + border-radius: 8px; + padding: 5px 8px; + font-size: 12px; + font-weight: 700; + color: #0f172a; + font-family: inherit; + background: #fff; +} + +.testing-container .slot-edit-field input:focus { + outline: none; + border-color: #7b1fa2; + box-shadow: 0 0 0 3px rgba(123, 31, 162, 0.18); +} + +.testing-container .slot-edit-preview { + font-size: 11px; + color: #475569; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.testing-container .slot-edit-remove { + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid rgba(220, 38, 38, 0.32); + background: rgba(220, 38, 38, 0.06); + color: #dc2626; + font-size: 16px; + font-weight: 800; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + padding: 0; +} + +.testing-container .slot-edit-remove:hover:not(:disabled) { + background: rgba(220, 38, 38, 0.14); + border-color: rgba(220, 38, 38, 0.55); +} + +.testing-container .slot-edit-remove:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.testing-container .slot-edit-actions { + display: flex; + gap: 8px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed rgba(15, 23, 42, 0.1); +} + +.testing-container .slot-edit-add, +.testing-container .slot-edit-reset { + flex: 1; + border-radius: 8px; + padding: 7px 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + font-family: inherit; + border: 1px solid transparent; +} + +.testing-container .slot-edit-add { + background: #7b1fa2; + color: #fff; + border-color: #7b1fa2; +} + +.testing-container .slot-edit-add:hover { + background: #6a1591; +} + +.testing-container .slot-edit-reset { + background: #fff; + color: #475569; + border-color: rgba(15, 23, 42, 0.16); +} + +.testing-container .slot-edit-reset:hover { + background: rgba(15, 23, 42, 0.04); + color: #0f172a; +} + .testing-container .batch-btn { display: inline-flex; align-items: center; @@ -738,6 +1062,60 @@ 50% { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35), 0 0 0 8px rgba(255, 255, 255, 0.15); } } +/* Live rider pin (from /partners/getriderlogs/) — colored teardrop with a + floating label showing the rider's username + current order. Status drives + the color: green for active, red otherwise. Lives next to the synthetic + bike markers but uses a distinct visual so the operator can tell that this + one is real-GPS, not route-progress estimate. */ +.testing-container .live-rider-pin { + --pin-color: #16a34a; + position: relative; + width: 24px; + height: 41px; +} + +.testing-container .live-rider-pin-marker { + position: absolute; + left: 0; + top: 0; + width: 24px; + height: 24px; + background: var(--pin-color); + border: 3px solid #fff; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); +} + +.testing-container .live-rider-pin-marker::after { + content: ''; + position: absolute; + inset: 4px; + background: #fff; + border-radius: 50%; +} + +.testing-container .live-rider-pin-label { + position: absolute; + left: 30px; + top: 2px; + background: var(--pin-color); + color: #fff; + font-size: 11px; + font-weight: 700; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + line-height: 1.2; +} + +.testing-container .live-rider-pin-label span { + font-weight: 500; + opacity: 0.85; + margin-left: 4px; +} + /* Body layout */ .testing-container #body { flex: 1; @@ -2136,15 +2514,24 @@ color: var(--text-muted); line-height: 1.4; margin-top: 3px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + /* Force a single-line, ellipsised row — long unstructured addresses used to + wrap to 2-3 lines and made cards look noisy. Full address still surfaces + on hover via the `title` attribute. */ + white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; } .testing-container .zone-order-notes { font-style: italic; color: #475569; + /* Notes can be longer; let them breathe over 2 lines and override the + single-line ellipsis applied to .zone-order-line above. */ + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: initial; } /* Footer stat chips */ @@ -3557,6 +3944,27 @@ font-family: inherit; } +/* Permanent banner sitting above the rider's GPS pin in the Rider Info map. + Shows the suburb/area name reverse-geocoded from lat/lon so the operator + can read the location without opening the popup. Styled to override the + default leaflet tooltip chrome (rounded chip, brand purple). */ +.testing-container .ri-map .leaflet-tooltip.ri-area-banner { + background: #7b1fa2; + color: #fff; + border: 0; + border-radius: 8px; + padding: 4px 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.25); +} + +.testing-container .ri-map .leaflet-tooltip.ri-area-banner::before { + border-top-color: #7b1fa2; +} + /* Mobile — collapse the sidebar above the main panel, single-column stats */ @media (max-width: 600px) { .testing-container .rider-info-mode { diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 8c203f5..880bbcc 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, ZoomControl } from 'react-leaflet'; +import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip, useMap, ZoomControl } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import dayjs from 'dayjs'; @@ -33,7 +33,7 @@ import { MdPower, MdSearch } from 'react-icons/md'; -import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs } from '../../api/api'; +import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api'; import './Dispatch.css'; // Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes. @@ -59,6 +59,29 @@ const toNum = (v) => { return Number.isFinite(n) ? n : NaN; }; +// Long delivery addresses come in two shapes: +// 1. Comma-separated: "Room No:C-4, Second Floor, ..., Vetrilaikara St, +// Peelamedu" — we keep the last two segments (typically street + area). +// 2. Free-form / space-separated: "Vistara Homes 71 & 72 ... Uppilipalayam +// post Coimbatore - 641 015 Opposite ..." — Indian addresses often run +// everything into one comma-less string. There's no reliable way to +// pick the locality token, so we hard-cap to the last 6 words and trim +// to ~40 chars; the full address still lives in the row's title tooltip. +const extractArea = (addr) => { + if (!addr) return ''; + const str = String(addr).trim(); + if (!str) return ''; + if (str.includes(',')) { + const parts = str.split(',').map((s) => s.trim()).filter(Boolean); + if (parts.length === 0) return str; + if (parts.length <= 2) return parts.join(', '); + return parts.slice(-2).join(', '); + } + const words = str.split(/\s+/).filter(Boolean); + const tail = words.length > 6 ? words.slice(-6).join(' ') : str; + return tail.length > 40 ? `${tail.slice(0, 40).trim()}…` : tail; +}; + 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. @@ -71,7 +94,11 @@ const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isF // fall outside every slot (e.g. 11 AM, the gap between Slot 1 and Slot 2) // produce a null batch and the order won't appear in any chip. // Slot 5 ends at 24 so anything from 8 PM until midnight buckets there. -const BATCHES = [ +// Default slot layout. Used as the seed for the editable slot config the +// operator can tweak at runtime — see slotsConfig state + the slot-edit +// popover below. Don't read BATCHES_DEFAULT directly at runtime; read +// component state instead so user edits take effect. +const BATCHES_DEFAULT = [ { id: 'slot-1', label: 'Slot 1 · 8 AM', range: '8–11 AM', startHour: 8, endHour: 11 }, { id: 'slot-2', label: 'Slot 2 · 12 PM', range: '12–3 PM', startHour: 12, endHour: 15 }, { id: 'slot-3', label: 'Slot 3 · 3 PM', range: '3–7 PM', startHour: 15, endHour: 19 }, @@ -79,25 +106,65 @@ const BATCHES = [ { id: 'slot-5', label: 'Slot 5 · 8 PM', range: 'After 8 PM', startHour: 20, endHour: 24 } ]; -const getBatchForHour = (h) => { - for (const b of BATCHES) { +const SLOTS_STORAGE_KEY = 'dispatch.slots.v1'; + +// Build a label like "Slot 1 · 8 AM" from a startHour (24h). Mirrors the +// human-readable form the defaults use, so user-edited slots still look +// consistent in the UI. +const formatSlotLabel = (idx, startHour) => { + const h = ((startHour + 11) % 12) + 1; + const ampm = startHour >= 12 && startHour < 24 ? 'PM' : 'AM'; + return `Slot ${idx + 1} · ${h} ${ampm}`; +}; + +const formatHourLabel = (h) => { + const hr = ((h + 11) % 12) + 1; + const ampm = h >= 12 && h < 24 ? 'PM' : 'AM'; + return `${hr} ${ampm}`; +}; + +const formatSlotRange = (startHour, endHour) => { + if (endHour >= 24) return `After ${formatHourLabel(startHour)}`; + return `${formatHourLabel(startHour)}–${formatHourLabel(endHour)}`; +}; + +const getBatchForHour = (h, batches) => { + for (const b of batches) { if (h >= b.startHour && h < b.endHour) return b.id; } return null; }; -const getRowBatch = (r) => { - // Filter by actual delivery time first; fall back to expected delivery time only - // when the order hasn't been completed yet. Both are real delivery-clock fields — - // not arrival/assign/pickup timestamps, which led to mis-bucketing earlier. - const t = r.deliverytime || r.expecteddeliverytime; +// Time fields the operator can pick from to drive slot bucketing. Each +// option maps to a column on the delivery row; the chosen one becomes the +// timestamp `getRowBatch` reads. "Delivery" defaults to actual deliverytime +// with a fallback to expecteddeliverytime so undelivered orders still bucket. +const TIME_FIELDS = [ + { id: 'delivery', label: 'Delivery', keys: ['deliverytime', 'expecteddeliverytime'] }, + { id: 'assigned', label: 'Assigned', keys: ['assigntime'] }, + { id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] }, + { id: 'started', label: 'Started', keys: ['starttime'] }, + { id: 'arrived', label: 'Arrived', keys: ['arrivaltime'] }, + { id: 'pickup', label: 'Pickup', keys: ['pickuptime'] } +]; + +const getTimeFieldValue = (r, fieldId) => { + const field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0]; + for (const k of field.keys) { + if (r?.[k]) return r[k]; + } + return null; +}; + +const getRowBatch = (r, fieldId = 'delivery', batches = BATCHES_DEFAULT) => { + const t = getTimeFieldValue(r, fieldId); if (!t) return null; 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)) return null; const d = dayjs(t); if (!d.isValid()) return null; - return getBatchForHour(d.hour()); + return getBatchForHour(d.hour(), batches); }; const FINAL_STATUSES = new Set(['delivered']); @@ -334,6 +401,56 @@ const Dispatch = ({ const [locationMenuOpen, setLocationMenuOpen] = useState(false); const locationMenuRef = useRef(null); + // Which timestamp column drives slot bucketing. Default = delivery time + // (operator's primary mental model — "did this order land in the X-Y wave?"). + // Switching to Assigned/Accepted/Arrived/Pickup/Started rebuckets every row + // through `getRowBatch(_, selectedTimeField)`. + const [selectedTimeField, setSelectedTimeField] = useState('delivery'); + const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false); + const timeFieldMenuRef = useRef(null); + + // Operator-editable slot configuration. Seeded from localStorage so edits + // survive reloads; falls back to BATCHES_DEFAULT otherwise. Each entry has + // id/label/range/startHour/endHour just like the default list — the rest + // of the file reads BATCHES (derived below) without caring whether the + // values came from defaults or from operator edits. + const [slotsConfig, setSlotsConfig] = useState(() => { + if (typeof window === 'undefined') return BATCHES_DEFAULT; + try { + const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY); + if (!raw) return BATCHES_DEFAULT; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length === 0) return BATCHES_DEFAULT; + // Re-derive label + range from the saved hours so any UI tweaks to the + // formatter (e.g. AM/PM style) flow through to old persisted slots. + return parsed.map((s, i) => ({ + id: s.id || `slot-${i + 1}`, + startHour: Number(s.startHour) || 0, + endHour: Number(s.endHour) || 24, + label: formatSlotLabel(i, Number(s.startHour) || 0), + range: formatSlotRange(Number(s.startHour) || 0, Number(s.endHour) || 24) + })); + } catch (e) { + return BATCHES_DEFAULT; + } + }); + const BATCHES = slotsConfig; + const [slotEditOpen, setSlotEditOpen] = useState(false); + const slotEditRef = useRef(null); + + // Persist edits whenever slotsConfig changes (skip the first render — the + // initializer already loaded from storage). + const slotsInitMountedRef = useRef(false); + useEffect(() => { + if (!slotsInitMountedRef.current) { slotsInitMountedRef.current = true; return; } + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(SLOTS_STORAGE_KEY, JSON.stringify( + slotsConfig.map(({ id, startHour, endHour }) => ({ id, startHour, endHour })) + )); + } catch (e) { /* quota / private-mode — ignore */ } + }, [slotsConfig]); + // Close the location dropdown on any click outside its wrapper. useEffect(() => { if (!locationMenuOpen) return; @@ -346,6 +463,30 @@ const Dispatch = ({ return () => document.removeEventListener('mousedown', onDocClick); }, [locationMenuOpen]); + // Same click-outside behavior for the slot-time-field dropdown. + useEffect(() => { + if (!timeFieldMenuOpen) return; + const onDocClick = (e) => { + if (timeFieldMenuRef.current && !timeFieldMenuRef.current.contains(e.target)) { + setTimeFieldMenuOpen(false); + } + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, [timeFieldMenuOpen]); + + // Click-outside dismissal for the slot-edit popover. + useEffect(() => { + if (!slotEditOpen) return; + const onDocClick = (e) => { + if (slotEditRef.current && !slotEditRef.current.contains(e.target)) { + setSlotEditOpen(false); + } + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, [slotEditOpen]); + // Rider Info view — operator picks a rider in the sidebar, the main panel // shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode // ('rider-info') so it follows the same toggle pattern as the other modes. @@ -369,6 +510,41 @@ const Dispatch = ({ refetchOnWindowFocus: false }); + // Reverse-geocode the selected rider's GPS so we can show a small banner + // above the map pin telling the operator which suburb/area the rider is in. + // Nominatim is rate-limited (1 req/sec public), so we round coords to 4 + // decimals (~11 m) — that turns the query key into a stable cache slot and + // stops jittery GPS fixes from re-firing the request every poll cycle. + const riderInfoCoordsKey = useMemo(() => { + const lat = parseFloat(riderInfoData?.latitude); + const lon = parseFloat(riderInfoData?.longitude); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { lat: lat.toFixed(4), lon: lon.toFixed(4) }; + }, [riderInfoData?.latitude, riderInfoData?.longitude]); + + const { data: riderInfoArea } = useQuery({ + queryKey: ['reverseGeocode', riderInfoCoordsKey?.lat, riderInfoCoordsKey?.lon], + queryFn: async () => { + const res = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${riderInfoCoordsKey.lat}&lon=${riderInfoCoordsKey.lon}&format=json&zoom=16&addressdetails=1`, + { headers: { Accept: 'application/json' } } + ); + if (!res.ok) return null; + const j = await res.json(); + const a = j?.address || {}; + // Prefer the most specific locality name available; the bigger admin + // levels (city/county/state) are kept only as last-resort fallbacks. + const area = + a.suburb || a.neighbourhood || a.village || a.hamlet || + a.city_district || a.town || a.city || a.county || a.state || ''; + return { area, display: j?.display_name || '' }; + }, + enabled: viewMode === 'rider-info' && !!riderInfoCoordsKey, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1 + }); + const locationName = useMemo(() => { if (!appLocations) return null; const match = appLocations.find((l) => String(l.applocationid) === String(selectedAppLocationId)); @@ -398,11 +574,59 @@ const Dispatch = ({ const [isAnimating, setIsAnimating] = useState(false); const [animatedSegments, setAnimatedSegments] = useState([]); const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); + + // Pull the partners/getriderlogs feed for the currently selected hub + date. + // This endpoint returns the exact live GPS position for every rider at the + // hub (latitude/longitude/logdate/status). We render those positions as + // markers on the main dispatch map so the operator sees where each rider + // actually is — matching the Reports → Riders Logs page. The synthetic + // bike markers driven by riderPositions are route-progress estimates, not + // real GPS, so they stay separate. + const { data: ridersLocationLogs } = useQuery({ + queryKey: [selectedAppLocationId, selectedDate, ''], + queryFn: fetchRidersLogs, + refetchInterval: 30_000, + refetchIntervalInBackground: false, + staleTime: 15 * 1000, + refetchOnWindowFocus: false + }); + + // Normalize the feed into map-ready rider points. Drop entries without a + // usable lat/lon — those would crash the Leaflet Marker. + const liveRiderLocations = useMemo(() => { + return (ridersLocationLogs || []) + .map((r) => { + const lat = parseFloat(r?.latitude); + const lon = parseFloat(r?.longitude); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { + id: String(r.userid ?? ''), + userid: r.userid, + username: r.username || `Rider #${r.userid}`, + status: String(r.status || '').toLowerCase(), + contactno: r.contactno, + orderid: r.orderid, + logdate: r.logdate, + lat, + lon + }; + }) + .filter(Boolean); + }, [ridersLocationLogs]); // Default to the slot containing the current hour; if we're outside every slot // window (e.g. before 8 AM or in the 11–12 gap) fall back to the first slot. const [selectedBatch, setSelectedBatch] = useState(() => { - return getBatchForHour(dayjs().hour()) || BATCHES[0].id; + return getBatchForHour(dayjs().hour(), BATCHES_DEFAULT) || BATCHES_DEFAULT[0].id; }); + + // If the operator deletes the slot currently selected, fall back to the + // first remaining slot so the page doesn't show an empty bucket. + useEffect(() => { + if (selectedBatch === 'all') return; + if (!BATCHES.some((b) => b.id === selectedBatch)) { + setSelectedBatch(BATCHES[0]?.id || 'all'); + } + }, [BATCHES, selectedBatch]); const activeBatchRef = useRef(null); // Live deliveries query — runs only when no `data` prop is passed (i.e., standalone page). @@ -467,22 +691,23 @@ const Dispatch = ({ }, [liveRows]); // Per-batch counts shown on the batch selector pills (uses unfiltered rows so counts stay - // visible even when a single batch is active). + // visible even when a single batch is active). Recomputes whenever the operator + // picks a different timestamp column to bucket on, or edits the slot ranges. const batchCounts = useMemo(() => { const counts = { all: liveRows.length }; BATCHES.forEach((b) => { counts[b.id] = 0; }); liveRows.forEach((r) => { - const b = getRowBatch(r); + const b = getRowBatch(r, selectedTimeField, BATCHES); if (b) counts[b] = (counts[b] || 0) + 1; }); return counts; - }, [liveRows]); + }, [liveRows, selectedTimeField, BATCHES]); // 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]); + return liveRows.filter((r) => getRowBatch(r, selectedTimeField, BATCHES) === selectedBatch); + }, [liveRows, selectedBatch, selectedTimeField, BATCHES]); // Reshape flat delivery rows into the zones/riders/orders structure Dispatch consumes. const liveData = useMemo(() => { @@ -822,15 +1047,15 @@ const Dispatch = ({ const tick = () => { const h = dayjs().hour(); if (h === prevHourRef.current) return; - const fromSlot = getBatchForHour(prevHourRef.current); + const fromSlot = getBatchForHour(prevHourRef.current, BATCHES); prevHourRef.current = h; - const toSlot = getBatchForHour(h); + const toSlot = getBatchForHour(h, BATCHES); if (!toSlot || toSlot === fromSlot) return; setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur)); }; const id = setInterval(tick, 30 * 1000); return () => clearInterval(id); - }, [shouldFetchLive]); + }, [shouldFetchLive, BATCHES]); // Reset focusedStop when the focused kitchen changes so a stale stop from a // previously focused kitchen doesn't linger after switching kitchens. @@ -1334,6 +1559,162 @@ const Dispatch = ({ {shouldFetchLive && viewMode !== 'rider-info' && (
Slot + {/* Dropdown to pick which timestamp drives slot bucketing. Mirrors + the hub-location dropdown's look so it reads as the same kind of + filter control. The chosen field reruns batchCounts + + filteredLiveRows via selectedTimeField. */} +
+ + {timeFieldMenuOpen && ( +
+ {TIME_FIELDS.map((f) => { + const isActive = f.id === selectedTimeField; + return ( + + ); + })} +
+ )} +
+ {/* Slot editor — lets the operator tweak start/end hours, add a new + slot, remove an existing one, or reset to defaults. Persists via + SLOTS_STORAGE_KEY in localStorage. */} +
+ + {slotEditOpen && ( +
+
+
Slot timings
+
Hours are 0–24 (24h clock). Start < End.
+
+
+ {slotsConfig.map((s, idx) => ( +
+ {idx + 1} + + + + {formatSlotRange(s.startHour, s.endHour)} + + +
+ ))} +
+
+ + +
+
+ )} +
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls horizontally when it overflows. */}
@@ -1556,8 +1937,26 @@ const Dispatch = ({ attribution='© OpenStreetMap contributors' /> + {/* Permanent banner above the pin — Nominatim + reverse-geocode tells the operator which + suburb/area the rider is in. Falls back to + a "Locating…" hint while the request is in + flight so the pin never looks unlabeled. */} + + {riderInfoArea?.area || 'Locating area…'} +
{d.username || `Rider #${d.userid}`}
+ {riderInfoArea?.area && ( +
+ {riderInfoArea.area} +
+ )}
{d.logdate ? `Last seen ${d.logdate}` : `${lat.toFixed(6)}, ${lon.toFixed(6)}`}
@@ -1761,9 +2160,9 @@ const Dispatch = ({ {o.pickupcustomer}
)} - {(o.deliveryaddress || o.deliverysuburb) && ( + {(o.deliverysuburb || o.deliveryaddress) && (
- {o.deliveryaddress || o.deliverysuburb} + {o.deliverysuburb || extractArea(o.deliveryaddress)}
)} {o.ordernotes && ( @@ -1809,83 +2208,91 @@ const Dispatch = ({ {focusedKitchen.orders.length} orders {focusedKitchen.riders.size} riders
-
- {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 ( -
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} - > -
{idx + 1}
-
-
- {o.deliverycustomer} - {o.orderstatus && (() => { - const s = getStatusStyle(o.orderstatus); - const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered'; - return ( - - - {s.label} + {/* Render the kitchen's orders with the same zone-order-card layout + used by the focused-zone view, so By Location, By Zone, and By + Rider all look consistent. Kitchen name is omitted from each + card because the focused kitchen header already provides it. */} +
+
Orders ({focusedKitchen.orders.length})
+ {focusedKitchen.orders.length === 0 ? ( +
No orders for this kitchen.
+ ) : ( +
+ {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 isStopActive = focusedStop && focusedStop.orderid === o.orderid; + const statusStyle = getStatusStyle(o.orderstatus); + const profit = parseFloat(o.profit || 0); + const isLoss = profit < 0; + return ( +
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} + > +
+
{idx + 1}
+
+
Order #{o.orderid}
+
+ {o.rider_name || o.ridername || 'Unassigned'} +
+
+ {o.orderstatus && ( + + {statusStyle.label} + + )} +
+ +
+ {o.deliverycustomer || '—'} +
+ + {(o.deliverysuburb || o.deliveryaddress) && ( +
+ {o.deliverysuburb || extractArea(o.deliveryaddress)} +
+ )} + {o.ordernotes && ( +
+ {o.ordernotes} +
+ )} + +
+ + {o.actualkms || o.kms || 0} km - ); - })()} -
-
Order #{o.orderid} · Rider: {o.rider_name || o.ridername}
- {/* 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) && ( -
- {o.deliveryaddress || o.deliverysuburb} + + {isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)} + + {o.deliverycharge != null && ( + + ₹{parseFloat(o.deliverycharge).toFixed(0)} chg + + )} + {o.ordertype && ( + + {o.ordertype} + + )} + + T{o.trip_number || '-'} · S{o.step || '-'} + +
- )} - {o.ordernotes && ( -
{o.ordernotes}
- )} -
- {o.actualkms || o.kms || 0} km - {(() => { - const p = parseFloat(o.profit || 0); - const isLoss = p < 0; - return ( - - {isLoss ? '-' : ''}₹{Math.abs(p).toFixed(0)} - - ); - })()} - {o.deliverycharge != null && ( - ₹{parseFloat(o.deliverycharge).toFixed(0)} chg - )} - {o.ordertype && ( - {o.ordertype} - )} -
-
+ ); + })}
- ); - })} + )}
)} @@ -1949,9 +2356,9 @@ const Dispatch = ({ {o.pickupcustomer}
)} - {(o.deliveryaddress || o.deliverysuburb) && ( + {(o.deliverysuburb || o.deliveryaddress) && (
- {o.deliveryaddress || o.deliverysuburb} + {o.deliverysuburb || extractArea(o.deliveryaddress)}
)} {o.ordernotes && ( @@ -2202,6 +2609,59 @@ const Dispatch = ({ ); })} + + {/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the + Reports → Riders Logs map: green pin when the rider's last log + row is `active`, red otherwise, with the rider's username as a + label. Scoped to riders who actually have orders in the + currently selected slot — `riders` is derived from + filteredLiveRows so it already reflects the slot filter. A + rider with zero orders in the current slot is hidden, even if + getriderlogs still returns their GPS row. When a specific + rider is focused, only that one is shown. */} + {liveRiderLocations + .filter((r) => riders.some((rd) => String(rd.id) === String(r.id))) + .filter((r) => !focusedRider || String(focusedRider.id) === String(r.id)) + .map((r) => { + const isActive = r.status === 'active'; + const pinColor = isActive ? '#16a34a' : '#dc2626'; + const liveIcon = L.divIcon({ + className: '', + iconSize: [140, 56], + iconAnchor: [12, 41], + popupAnchor: [58, -40], + html: `
+
+
${(r.username || '').replace(/[<>&"']/g, '')}${r.orderid ? ` #${String(r.orderid).replace(/[<>&"']/g, '')}` : ''}
+
` + }); + return ( + { + const match = riders.find((rd) => String(rd.id) === String(r.id)); + if (match) handleRiderFocus(match); + }, + mouseover: (e) => e.target.openPopup(), + mouseout: (e) => e.target.closePopup() + }} + > + +
LIVE GPS
+
{r.username}
+
Status{r.status || 'unknown'}
+ {r.orderid &&
Order#{r.orderid}
} + {r.contactno &&
Phone{r.contactno}
} + {r.logdate &&
Last seen{r.logdate}
} +
Position{r.lat.toFixed(5)}, {r.lon.toFixed(5)}
+
+
+ ); + })}