update on the pickup and edit slot in the dispatch page

This commit is contained in:
2026-05-21 19:06:55 +05:30
parent f2e48bdd28
commit 4edc20040f
2 changed files with 969 additions and 101 deletions

View File

@@ -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 {

View File

@@ -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: '811 AM', startHour: 8, endHour: 11 },
{ id: 'slot-2', label: 'Slot 2 · 12 PM', range: '123 PM', startHour: 12, endHour: 15 },
{ id: 'slot-3', label: 'Slot 3 · 3 PM', range: '37 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 1112 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' && (
<div id="batch-row">
<span className="batch-label">Slot</span>
{/* 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. */}
<div className="time-field-wrap" ref={timeFieldMenuRef}>
<button
type="button"
className={`time-field-btn ${timeFieldMenuOpen ? 'open' : ''}`}
onClick={() => setTimeFieldMenuOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={timeFieldMenuOpen}
title="Bucket slots by this timestamp"
>
<MdAccessTime />
<span className="time-field-text">{TIME_FIELDS.find((f) => f.id === selectedTimeField)?.label || 'Delivery'}</span>
<MdExpandMore className="time-field-caret" />
</button>
{timeFieldMenuOpen && (
<div className="time-field-menu" role="listbox">
{TIME_FIELDS.map((f) => {
const isActive = f.id === selectedTimeField;
return (
<button
key={f.id}
type="button"
role="option"
aria-selected={isActive}
className={`time-field-option ${isActive ? 'active' : ''}`}
onClick={() => {
setSelectedTimeField(f.id);
setTimeFieldMenuOpen(false);
}}
>
<MdAccessTime className="time-field-option-icon" />
<span>{f.label}</span>
{isActive && <span className="time-field-option-check"></span>}
</button>
);
})}
</div>
)}
</div>
{/* 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. */}
<div className="slot-edit-wrap" ref={slotEditRef}>
<button
type="button"
className={`slot-edit-btn ${slotEditOpen ? 'open' : ''}`}
onClick={() => setSlotEditOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={slotEditOpen}
title="Adjust slot timings"
>
<MdAccessTime />
<span>Edit slots</span>
</button>
{slotEditOpen && (
<div className="slot-edit-panel" role="dialog" aria-label="Edit slot timings">
<div className="slot-edit-head">
<div className="slot-edit-title">Slot timings</div>
<div className="slot-edit-sub">Hours are 024 (24h clock). Start &lt; End.</div>
</div>
<div className="slot-edit-list">
{slotsConfig.map((s, idx) => (
<div key={s.id} className="slot-edit-row">
<span className="slot-edit-idx">{idx + 1}</span>
<label className="slot-edit-field">
<span className="slot-edit-field-label">Start</span>
<input
type="number"
min={0}
max={23}
step={1}
value={s.startHour}
onChange={(e) => {
const v = Math.max(0, Math.min(23, parseInt(e.target.value, 10) || 0));
setSlotsConfig((cur) => cur.map((row, i) =>
i === idx
? { ...row, startHour: v, label: formatSlotLabel(i, v), range: formatSlotRange(v, row.endHour) }
: row
));
}}
/>
</label>
<label className="slot-edit-field">
<span className="slot-edit-field-label">End</span>
<input
type="number"
min={1}
max={24}
step={1}
value={s.endHour}
onChange={(e) => {
const v = Math.max(1, Math.min(24, parseInt(e.target.value, 10) || 1));
setSlotsConfig((cur) => cur.map((row, i) =>
i === idx
? { ...row, endHour: v, range: formatSlotRange(row.startHour, v) }
: row
));
}}
/>
</label>
<span className="slot-edit-preview" title={`${formatSlotLabel(idx, s.startHour)}${formatSlotRange(s.startHour, s.endHour)}`}>
{formatSlotRange(s.startHour, s.endHour)}
</span>
<button
type="button"
className="slot-edit-remove"
onClick={() => setSlotsConfig((cur) => cur.filter((_, i) => i !== idx).map((row, i) => ({
...row,
id: `slot-${i + 1}`,
label: formatSlotLabel(i, row.startHour)
})))}
disabled={slotsConfig.length <= 1}
title={slotsConfig.length <= 1 ? 'Keep at least one slot' : 'Remove this slot'}
>
×
</button>
</div>
))}
</div>
<div className="slot-edit-actions">
<button
type="button"
className="slot-edit-add"
onClick={() => setSlotsConfig((cur) => {
const last = cur[cur.length - 1];
const start = Math.min(23, (last?.endHour ?? 0));
const end = Math.min(24, start + 1);
const i = cur.length;
return [
...cur,
{
id: `slot-${i + 1}`,
startHour: start,
endHour: end,
label: formatSlotLabel(i, start),
range: formatSlotRange(start, end)
}
];
})}
>
+ Add slot
</button>
<button
type="button"
className="slot-edit-reset"
onClick={() => setSlotsConfig(BATCHES_DEFAULT)}
>
Reset to defaults
</button>
</div>
</div>
)}
</div>
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
horizontally when it overflows. */}
<div className="batch-scroll">
@@ -1556,8 +1937,26 @@ const Dispatch = ({
attribution='&copy; OpenStreetMap contributors'
/>
<Marker position={[lat, lon]}>
{/* 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. */}
<Tooltip
direction="top"
offset={[0, -10]}
permanent
className="ri-area-banner"
>
{riderInfoArea?.area || 'Locating area…'}
</Tooltip>
<Popup>
<div style={{ fontWeight: 700, marginBottom: 2 }}>{d.username || `Rider #${d.userid}`}</div>
{riderInfoArea?.area && (
<div style={{ fontSize: 12, color: '#0f172a', marginBottom: 4 }}>
{riderInfoArea.area}
</div>
)}
<div style={{ fontSize: 11, color: '#64748b' }}>
{d.logdate ? `Last seen ${d.logdate}` : `${lat.toFixed(6)}, ${lon.toFixed(6)}`}
</div>
@@ -1761,9 +2160,9 @@ const Dispatch = ({
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
</div>
)}
{(o.deliveryaddress || o.deliverysuburb) && (
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
)}
{o.ordernotes && (
@@ -1809,84 +2208,92 @@ const Dispatch = ({
<span><Ico><MdInventory2 /></Ico>{focusedKitchen.orders.length} orders</span>
<span><Ico><MdTwoWheeler /></Ico>{focusedKitchen.riders.size} riders</span>
</div>
<div className="step-wrap">
{/* 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. */}
<div className="zone-detail-section">
<div className="zone-section-label">Orders <span className="section-count">({focusedKitchen.orders.length})</span></div>
{focusedKitchen.orders.length === 0 ? (
<div className="zone-suburb-panel-empty">No orders for this kitchen.</div>
) : (
<div className="zone-order-grid">
{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;
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
const statusStyle = getStatusStyle(o.orderstatus);
const profit = parseFloat(o.profit || 0);
const isLoss = profit < 0;
return (
<div
key={o.orderid}
className={`step-row ${canFocus ? 'clickable' : ''} ${isActive ? 'active' : ''}`}
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
title={canFocus ? (isActive ? 'Click to show full kitchen view' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
>
<div className="step-col-left"><div className="step-dot delivery" style={{ background: getRiderColor(o.rider_id), color: '#fff', borderColor: getRiderColor(o.rider_id) }}>{idx + 1}</div></div>
<div className="step-col-body">
<div className="step-label step-label-row">
<span className="step-customer"><Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer}</span>
{o.orderstatus && (() => {
const s = getStatusStyle(o.orderstatus);
const isDel = String(o.orderstatus || '').toLowerCase() === 'delivered';
return (
<span className="step-flag">
<svg className="step-flag-svg" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<line x1="1.5" y1="0" x2="1.5" y2="18" stroke="#0f172a" strokeWidth="1.4" strokeLinecap="round" />
<polygon points="2,1 13,1 10,5 13,9 2,9" fill={s.bg} stroke="#0f172a" strokeWidth="0.5" strokeLinejoin="round" />
{isDel && (
<polyline points="4,5 6,7 9,3" fill="none" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
)}
</svg>
<span className="step-flag-label" style={{ color: s.bg }}>{s.label}</span>
</span>
);
})()}
<div className="zone-order-card-head">
<div className="zone-order-num">{idx + 1}</div>
<div className="zone-order-id-block">
<div className="zone-order-id">Order #{o.orderid}</div>
<div className="zone-order-rider">
<Ico><MdTwoWheeler /></Ico>{o.rider_name || o.ridername || 'Unassigned'}
</div>
<div className="step-dest">Order #{o.orderid} · Rider: {o.rider_name || o.ridername}</div>
{/* In the By-Kitchen view we show the customer's delivery address,
not the kitchen's location (locationname/locationsuburb describe
the pickup spot, which is redundant when the kitchen is already
the focused context). */}
{(o.deliveryaddress || o.deliverysuburb) && (
<div className="step-location" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
</div>
{o.orderstatus && (
<span
className="zone-order-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
</div>
<div className="zone-order-customer">
<Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || ''}
</div>
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
)}
{o.ordernotes && (
<div className="step-notes" title={o.ordernotes}><Ico><MdNotes /></Ico>{o.ordernotes}</div>
<div className="zone-order-line zone-order-notes" title={o.ordernotes}>
<Ico><MdNotes /></Ico>{o.ordernotes}
</div>
)}
<div className="step-detail">
<span><Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km</span>
{(() => {
const p = parseFloat(o.profit || 0);
const isLoss = p < 0;
return (
<span className={`step-profit ${isLoss ? 'is-loss' : ''}`}>
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}{Math.abs(p).toFixed(0)}
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}{Math.abs(profit).toFixed(0)}
</span>
);
})()}
{o.deliverycharge != null && (
<span className="step-charges">{parseFloat(o.deliverycharge).toFixed(0)} chg</span>
<span className="zone-order-chip" title="Delivery charge">
{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
<span className={`step-type type-${String(o.ordertype).toLowerCase()}`}>{o.ordertype}</span>
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
</span>
)}
</div>
<span className="zone-order-chip zone-order-trip">
T{o.trip_number || '-'} · S{o.step || '-'}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
</div>
@@ -1949,9 +2356,9 @@ const Dispatch = ({
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
</div>
)}
{(o.deliveryaddress || o.deliverysuburb) && (
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliveryaddress || o.deliverysuburb}
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
)}
{o.ordernotes && (
@@ -2202,6 +2609,59 @@ const Dispatch = ({
</Marker>
);
})}
{/* 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: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
<div class="live-rider-pin-marker"></div>
<div class="live-rider-pin-label">${(r.username || '').replace(/[<>&"']/g, '')}${r.orderid ? ` <span>#${String(r.orderid).replace(/[<>&"']/g, '')}</span>` : ''}</div>
</div>`
});
return (
<Marker
key={`live-${r.id}`}
position={[r.lat, r.lon]}
icon={liveIcon}
zIndexOffset={2500}
eventHandlers={{
click: () => {
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()
}}
>
<Popup maxWidth={220}>
<div className="pu-id">LIVE GPS</div>
<div className="pu-rider" style={{ color: pinColor }}>{r.username}</div>
<div className="pu-row"><span>Status</span><span>{r.status || 'unknown'}</span></div>
{r.orderid && <div className="pu-row"><span>Order</span><span>#{r.orderid}</span></div>}
{r.contactno && <div className="pu-row"><span>Phone</span><span>{r.contactno}</span></div>}
{r.logdate && <div className="pu-row"><span>Last seen</span><span>{r.logdate}</span></div>}
<div className="pu-row"><span>Position</span><span>{r.lat.toFixed(5)}, {r.lon.toFixed(5)}</span></div>
</Popup>
</Marker>
);
})}
</MapContainer>
<div id="ov-tl">