updates on the ui changes and updates on the delivery page updated the status
This commit is contained in:
@@ -121,6 +121,7 @@ const Deliveries = () => {
|
|||||||
const [deliverylat, setDeliverylat] = useState('');
|
const [deliverylat, setDeliverylat] = useState('');
|
||||||
const [deliverylong, setDeliverylong] = useState('');
|
const [deliverylong, setDeliverylong] = useState('');
|
||||||
const [currentStatus, setCurrentStatus] = useState('pending');
|
const [currentStatus, setCurrentStatus] = useState('pending');
|
||||||
|
const [updateStatus, setUpdateStatus] = useState('delivered');
|
||||||
const locationRef = useRef(null);
|
const locationRef = useRef(null);
|
||||||
const tenantRef = useRef(null);
|
const tenantRef = useRef(null);
|
||||||
const [page, setPage] = React.useState(0);
|
const [page, setPage] = React.useState(0);
|
||||||
@@ -1259,6 +1260,7 @@ const Deliveries = () => {
|
|||||||
setDeliverylong(selectedRow.droplon);
|
setDeliverylong(selectedRow.droplon);
|
||||||
setNotes(selectedRow.notes);
|
setNotes(selectedRow.notes);
|
||||||
setDeliveryamount(selectedRow.deliveryamount);
|
setDeliveryamount(selectedRow.deliveryamount);
|
||||||
|
setUpdateStatus(selectedRow.orderstatus || 'delivered');
|
||||||
setCurrentorder(selectedRow);
|
setCurrentorder(selectedRow);
|
||||||
setDialogopen(true);
|
setDialogopen(true);
|
||||||
handleMenuClose();
|
handleMenuClose();
|
||||||
@@ -1657,6 +1659,24 @@ const Deliveries = () => {
|
|||||||
|
|
||||||
<TextField fullWidth type="number" value={deliveryamount} onChange={(e) => setDeliveryamount(+e.target.value)} />
|
<TextField fullWidth type="number" value={deliveryamount} onChange={(e) => setDeliveryamount(+e.target.value)} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
value={updateStatus}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUpdateStatus(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="pending">Pending</MenuItem>
|
||||||
|
<MenuItem value="accepted">Accepted</MenuItem>
|
||||||
|
<MenuItem value="started">Started</MenuItem>
|
||||||
|
<MenuItem value="arrived">Arrived</MenuItem>
|
||||||
|
<MenuItem value="delivered">Delivered</MenuItem>
|
||||||
|
<MenuItem value="cancelled">Cancelled</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<FormLabel>Notes</FormLabel>
|
<FormLabel>Notes</FormLabel>
|
||||||
<TextField value={notes} fullWidth onChange={(e) => setNotes(e.target.value)} />
|
<TextField value={notes} fullWidth onChange={(e) => setNotes(e.target.value)} />
|
||||||
@@ -1694,7 +1714,7 @@ const Deliveries = () => {
|
|||||||
updateDeliveryMutation.mutate({
|
updateDeliveryMutation.mutate({
|
||||||
deliveryid: currentorder.deliveryid,
|
deliveryid: currentorder.deliveryid,
|
||||||
orderheaderid: currentorder.orderheaderid,
|
orderheaderid: currentorder.orderheaderid,
|
||||||
orderstatus: 'delivered',
|
orderstatus: updateStatus,
|
||||||
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||||
deliverylat,
|
deliverylat,
|
||||||
deliverylong,
|
deliverylong,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,11 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
|||||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip, useMap, useMapEvents, ZoomControl } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip, useMap, useMapEvents, ZoomControl } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
// Side-effect import: patches L.Polyline so pathOptions.offset (in screen px)
|
||||||
|
// renders the line perpendicular-shifted from its actual geometry. Used by
|
||||||
|
// Compare → Combined mode to render planned + actual as parallel rails when
|
||||||
|
// they share the same road geometry (otherwise they'd stack and read as one).
|
||||||
|
import '../../../utils/leafletPolylineOffset';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -41,7 +46,8 @@ import {
|
|||||||
MdWarning,
|
MdWarning,
|
||||||
MdClose,
|
MdClose,
|
||||||
MdFormatListBulleted,
|
MdFormatListBulleted,
|
||||||
MdTimer
|
MdTimer,
|
||||||
|
MdCalendarToday
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api';
|
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +61,17 @@ import {
|
|||||||
import CompareDataPanel from './CompareDataPanel';
|
import CompareDataPanel from './CompareDataPanel';
|
||||||
import './Dispatch.css';
|
import './Dispatch.css';
|
||||||
|
|
||||||
|
// Combined-mode rail colors. The per-step palette (STEP_PALETTE) is great for
|
||||||
|
// "which step is this" but useless for "is this line planned or actual" when
|
||||||
|
// both layers overlay. In Combined view only we drop the step palette on
|
||||||
|
// polylines and use fixed, high-contrast colors: indigo for the dispatched
|
||||||
|
// plan (matches the "Compare" button + the prior "Planned Route" label) and
|
||||||
|
// emerald for the actual GPS trail (signals "live / real" data). Per-step
|
||||||
|
// distinction in Combined view is carried by the numbered drop pins, which
|
||||||
|
// keep STEP_PALETTE so the timeline link to a specific delivery survives.
|
||||||
|
const COMBINED_PLANNED_COLOR = '#6366f1';
|
||||||
|
const COMBINED_ACTUAL_COLOR = '#10b981';
|
||||||
|
|
||||||
// Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes.
|
// Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes.
|
||||||
const MOTORBIKE_SVG = `<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#fff" d="M200,112a40,40,0,0,0-12.07,1.86L161.6,72H200a8,8,0,0,1,8,8v8a8,8,0,0,0,16,0V80a24,24,0,0,0-24-24H160a8,8,0,0,0-6.79,3.77l-9.34,15.06L130.39,52A8,8,0,0,0,124,48H88a8,8,0,0,0,0,16h31.69l13.34,21.84L107.5,128H56A40,40,0,1,0,96,168.4V160a8,8,0,0,1,16,0v8.4a40.06,40.06,0,0,0,32,31.2V184a8,8,0,0,1,16,0v15.6A40,40,0,1,0,200,112ZM56,184a24,24,0,1,1,24-24A24,24,0,0,1,56,184Zm70.46-44.71h0L141,116.45,156.6,142h0a40,40,0,0,0-14,28h-16A40.16,40.16,0,0,0,126.46,139.29ZM200,184a24,24,0,1,1,24-24A24,24,0,0,1,200,184Z"/></svg>`;
|
const MOTORBIKE_SVG = `<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#fff" d="M200,112a40,40,0,0,0-12.07,1.86L161.6,72H200a8,8,0,0,1,8,8v8a8,8,0,0,0,16,0V80a24,24,0,0,0-24-24H160a8,8,0,0,0-6.79,3.77l-9.34,15.06L130.39,52A8,8,0,0,0,124,48H88a8,8,0,0,0,0,16h31.69l13.34,21.84L107.5,128H56A40,40,0,1,0,96,168.4V160a8,8,0,0,1,16,0v8.4a40.06,40.06,0,0,0,32,31.2V184a8,8,0,0,1,16,0v15.6A40,40,0,1,0,200,112ZM56,184a24,24,0,1,1,24-24A24,24,0,0,1,56,184Zm70.46-44.71h0L141,116.45,156.6,142h0a40,40,0,0,0-14,28h-16A40.16,40.16,0,0,0,126.46,139.29ZM200,184a24,24,0,1,1,24-24A24,24,0,0,1,200,184Z"/></svg>`;
|
||||||
|
|
||||||
@@ -94,37 +111,61 @@ const pickupLon = (o) => o.pickuplong || o.pickuplongitude || o.picklongitude ||
|
|||||||
const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isFinite(toNum(pickupLon(o)));
|
const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isFinite(toNum(pickupLon(o)));
|
||||||
|
|
||||||
// Named delivery slots — operator's mental model of the day's waves.
|
// Named delivery slots — operator's mental model of the day's waves.
|
||||||
// Each entry covers a half-open hour range [startHour, endHour). Hours that
|
// Each entry covers a half-open range [startHour, endHour) measured in
|
||||||
// fall outside every slot (e.g. 11 AM, the gap between Slot 1 and Slot 2)
|
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported
|
||||||
// produce a null batch and the order won't appear in any chip.
|
// so slot 1 can end at 12:30 PM and slot 2 can start there.
|
||||||
// Slot 5 ends at 24 so anything from 8 PM until midnight buckets there.
|
// Slot 5 ends at 24 so anything from 8 PM until midnight buckets there.
|
||||||
// Default slot layout. Used as the seed for the editable slot config the
|
// Default slot layout. Used as the seed for the editable slot config the
|
||||||
// operator can tweak at runtime — see slotsConfig state + the slot-edit
|
// operator can tweak at runtime — see slotsConfig state + the slot-edit
|
||||||
// popover below. Don't read BATCHES_DEFAULT directly at runtime; read
|
// popover below. Don't read BATCHES_DEFAULT directly at runtime; read
|
||||||
// component state instead so user edits take effect.
|
// component state instead so user edits take effect.
|
||||||
const BATCHES_DEFAULT = [
|
// Five named waves:
|
||||||
{ id: 'slot-1', label: 'Slot 1 · 8 AM', range: '8–11 AM', startHour: 8, endHour: 11 },
|
// • Slot 1: morning rush (8 AM → 12:30 PM)
|
||||||
{ id: 'slot-2', label: 'Slot 2 · 12 PM', range: '12–3 PM', startHour: 12, endHour: 15 },
|
// • Slot 2: lunch (12:20 PM → 3 PM)
|
||||||
{ id: 'slot-3', label: 'Slot 3 · 3 PM', range: '3–7 PM', startHour: 15, endHour: 19 },
|
// • Slot 3: afternoon (3 PM → 7 PM)
|
||||||
{ id: 'slot-4', label: 'Slot 4 · 7 PM', range: '7–8 PM', startHour: 19, endHour: 20 },
|
// • Slot 4: evening (7 PM → 8 PM)
|
||||||
{ id: 'slot-5', label: 'Slot 5 · 8 PM', range: 'After 8 PM', startHour: 20, endHour: 24 }
|
// • Slot 5: night (8 PM → midnight)
|
||||||
|
const BATCHES_DEFAULT_RAW = [
|
||||||
|
{ id: 'slot-1', startHour: 8, endHour: 12.5 },
|
||||||
|
{ id: 'slot-2', startHour: 12 + 20 / 60, endHour: 15 },
|
||||||
|
{ id: 'slot-3', startHour: 15, endHour: 19 },
|
||||||
|
{ id: 'slot-4', startHour: 19, endHour: 20 },
|
||||||
|
{ id: 'slot-5', startHour: 20, endHour: 24 }
|
||||||
];
|
];
|
||||||
|
|
||||||
const SLOTS_STORAGE_KEY = 'dispatch.slots.v1';
|
// v6: the five-named-wave layout with validation checks for array lengths.
|
||||||
|
// Bumping the key drops cached layouts from v5 and earlier in favour of the new defaults.
|
||||||
|
const SLOTS_STORAGE_KEY = 'dispatch.slots.v6';
|
||||||
|
|
||||||
// Build a label like "Slot 1 · 8 AM" from a startHour (24h). Mirrors the
|
// Every prior storage key. Wiped once on mount so stale layouts
|
||||||
// human-readable form the defaults use, so user-edited slots still look
|
// from earlier code versions can't reappear on the next page load.
|
||||||
// consistent in the UI.
|
const LEGACY_SLOTS_STORAGE_KEYS = [
|
||||||
|
'dispatch.slots.v1',
|
||||||
|
'dispatch.slots.v2',
|
||||||
|
'dispatch.slots.v3',
|
||||||
|
'dispatch.slots.v4',
|
||||||
|
'dispatch.slots.v5'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
|
||||||
|
// fractional startHour (24h, half-hour steps). Mirrors the human-readable
|
||||||
|
// form the defaults use, so user-edited slots still look consistent.
|
||||||
const formatSlotLabel = (idx, startHour) => {
|
const formatSlotLabel = (idx, startHour) => {
|
||||||
const h = ((startHour + 11) % 12) + 1;
|
return `Slot ${idx + 1} · ${formatHourLabel(startHour)}`;
|
||||||
const ampm = startHour >= 12 && startHour < 24 ? 'PM' : 'AM';
|
|
||||||
return `Slot ${idx + 1} · ${h} ${ampm}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render a fractional hour as a human-readable clock label. Whole hours
|
||||||
|
// render as "8 AM"; half-hour values render as "12:30 PM". Other fractions
|
||||||
|
// round to the nearest minute (covers any future extension to quarter-hours
|
||||||
|
// without losing precision in the label).
|
||||||
const formatHourLabel = (h) => {
|
const formatHourLabel = (h) => {
|
||||||
const hr = ((h + 11) % 12) + 1;
|
const wholeHour = Math.floor(h);
|
||||||
const ampm = h >= 12 && h < 24 ? 'PM' : 'AM';
|
const minutes = Math.round((h - wholeHour) * 60);
|
||||||
return `${hr} ${ampm}`;
|
const hr = ((wholeHour + 11) % 12) + 1;
|
||||||
|
const ampm = wholeHour >= 12 && wholeHour < 24 ? 'PM' : 'AM';
|
||||||
|
if (minutes === 0) return `${hr} ${ampm}`;
|
||||||
|
const mm = String(minutes).padStart(2, '0');
|
||||||
|
return `${hr}:${mm} ${ampm}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatSlotRange = (startHour, endHour) => {
|
const formatSlotRange = (startHour, endHour) => {
|
||||||
@@ -132,6 +173,16 @@ const formatSlotRange = (startHour, endHour) => {
|
|||||||
return `${formatHourLabel(startHour)}–${formatHourLabel(endHour)}`;
|
return `${formatHourLabel(startHour)}–${formatHourLabel(endHour)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Derive the operator-facing label/range strings from BATCHES_DEFAULT_RAW.
|
||||||
|
// Doing it through the formatters (instead of hardcoding "Slot 2 · 12:30 PM"
|
||||||
|
// etc.) guarantees user-edited slots and default slots render the same way
|
||||||
|
// — no chance of drift between the two paths.
|
||||||
|
const BATCHES_DEFAULT = BATCHES_DEFAULT_RAW.map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
label: formatSlotLabel(i, s.startHour),
|
||||||
|
range: formatSlotRange(s.startHour, s.endHour)
|
||||||
|
}));
|
||||||
|
|
||||||
const getBatchForHour = (h, batches) => {
|
const getBatchForHour = (h, batches) => {
|
||||||
for (const b of batches) {
|
for (const b of batches) {
|
||||||
if (h >= b.startHour && h < b.endHour) return b.id;
|
if (h >= b.startHour && h < b.endHour) return b.id;
|
||||||
@@ -168,7 +219,10 @@ const getRowBatch = (r, fieldId = 'delivery', batches = BATCHES_DEFAULT) => {
|
|||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
||||||
const d = dayjs(t);
|
const d = dayjs(t);
|
||||||
if (!d.isValid()) return null;
|
if (!d.isValid()) return null;
|
||||||
return getBatchForHour(d.hour(), batches);
|
// Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which
|
||||||
|
// starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would
|
||||||
|
// truncate to 12 and mis-bucket the back half of every hour.
|
||||||
|
return getBatchForHour(d.hour() + d.minute() / 60, batches);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sits inside the Compare MapContainer and unpins any pinned popup whenever
|
// Sits inside the Compare MapContainer and unpins any pinned popup whenever
|
||||||
@@ -700,7 +754,9 @@ const Dispatch = ({
|
|||||||
const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY);
|
const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY);
|
||||||
if (!raw) return BATCHES_DEFAULT;
|
if (!raw) return BATCHES_DEFAULT;
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return BATCHES_DEFAULT;
|
// If the parsed slots length does not match BATCHES_DEFAULT_RAW, the data is stale
|
||||||
|
// (e.g. written from a 3-slot layout version during hot reload). Discard it and load default 5 slots.
|
||||||
|
if (!Array.isArray(parsed) || parsed.length !== BATCHES_DEFAULT_RAW.length) return BATCHES_DEFAULT;
|
||||||
// Re-derive label + range from the saved hours so any UI tweaks to the
|
// 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.
|
// formatter (e.g. AM/PM style) flow through to old persisted slots.
|
||||||
return parsed.map((s, i) => ({
|
return parsed.map((s, i) => ({
|
||||||
@@ -718,6 +774,16 @@ const Dispatch = ({
|
|||||||
const [slotEditOpen, setSlotEditOpen] = useState(false);
|
const [slotEditOpen, setSlotEditOpen] = useState(false);
|
||||||
const slotEditRef = useRef(null);
|
const slotEditRef = useRef(null);
|
||||||
|
|
||||||
|
// One-shot housekeeping: drop every prior slot-storage key so a stale
|
||||||
|
// layout written under an older schema version (e.g. v1/v2/v3 with five
|
||||||
|
// slots) can never resurface. Runs once per mount because deps are [].
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
LEGACY_SLOTS_STORAGE_KEYS.forEach((k) => window.localStorage.removeItem(k));
|
||||||
|
} catch (e) { /* private-mode / quota — ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Persist edits whenever slotsConfig changes (skip the first render — the
|
// Persist edits whenever slotsConfig changes (skip the first render — the
|
||||||
// initializer already loaded from storage).
|
// initializer already loaded from storage).
|
||||||
const slotsInitMountedRef = useRef(false);
|
const slotsInitMountedRef = useRef(false);
|
||||||
@@ -869,6 +935,47 @@ const Dispatch = ({
|
|||||||
// directly (closure captures the value at scheduling time).
|
// directly (closure captures the value at scheduling time).
|
||||||
const isAnimatingRef = useRef(false);
|
const isAnimatingRef = useRef(false);
|
||||||
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'));
|
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||||
|
// Custom date-picker popover state. The visible month can drift from the
|
||||||
|
// selected date (operator browses months without committing), so we keep
|
||||||
|
// the "viewing" month separately. Resets to the selected date's month
|
||||||
|
// every time the popover opens so the user never has to navigate back
|
||||||
|
// from wherever they last browsed.
|
||||||
|
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||||
|
const [calViewMonth, setCalViewMonth] = useState(() =>
|
||||||
|
dayjs(selectedDate).isValid() ? dayjs(selectedDate).startOf('month') : dayjs().startOf('month')
|
||||||
|
);
|
||||||
|
const datePickerRef = useRef(null);
|
||||||
|
|
||||||
|
// Close the date-picker on outside click or Escape. Mounted only while the
|
||||||
|
// popover is open so we're not leaving global listeners around when it's
|
||||||
|
// not needed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!datePickerOpen) return undefined;
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (!datePickerRef.current) return;
|
||||||
|
if (!datePickerRef.current.contains(e.target)) setDatePickerOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') setDatePickerOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [datePickerOpen]);
|
||||||
|
|
||||||
|
// When the popover opens, snap the visible month back to whichever month
|
||||||
|
// contains the currently-selected date. Otherwise the user could open it
|
||||||
|
// expecting to see "May 2026" and find themselves still parked in January
|
||||||
|
// from their last browsing session.
|
||||||
|
useEffect(() => {
|
||||||
|
if (datePickerOpen) {
|
||||||
|
const d = dayjs(selectedDate);
|
||||||
|
if (d.isValid()) setCalViewMonth(d.startOf('month'));
|
||||||
|
}
|
||||||
|
}, [datePickerOpen, selectedDate]);
|
||||||
|
|
||||||
// Compare mode — when ON, the body splits 50/50: left half is the regular
|
// Compare mode — when ON, the body splits 50/50: left half is the regular
|
||||||
// dispatch map (planned routes), right half is a second map that overlays
|
// dispatch map (planned routes), right half is a second map that overlays
|
||||||
@@ -974,10 +1081,13 @@ const Dispatch = ({
|
|||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}, [ridersLocationLogs]);
|
}, [ridersLocationLogs]);
|
||||||
// Default to the slot containing the current hour; if we're outside every slot
|
// Default to the slot containing the current wall-clock time. Use a
|
||||||
// window (e.g. before 8 AM or in the 11–12 gap) fall back to the first slot.
|
// fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If
|
||||||
|
// the current time falls outside every slot window (e.g. before 8 AM)
|
||||||
|
// fall back to the first slot.
|
||||||
const [selectedBatch, setSelectedBatch] = useState(() => {
|
const [selectedBatch, setSelectedBatch] = useState(() => {
|
||||||
return getBatchForHour(dayjs().hour(), BATCHES_DEFAULT) || BATCHES_DEFAULT[0].id;
|
const now = dayjs();
|
||||||
|
return getBatchForHour(now.hour() + now.minute() / 60, BATCHES_DEFAULT) || BATCHES_DEFAULT[0].id;
|
||||||
});
|
});
|
||||||
|
|
||||||
// If the operator deletes the slot currently selected, fall back to the
|
// If the operator deletes the slot currently selected, fall back to the
|
||||||
@@ -1756,16 +1866,23 @@ const Dispatch = ({
|
|||||||
// window — BUT only if the user is still sitting on the slot that's just been
|
// window — BUT only if the user is still sitting on the slot that's just been
|
||||||
// left, so a manual pick (e.g. "let me inspect Slot 1") is never overridden.
|
// left, so a manual pick (e.g. "let me inspect Slot 1") is never overridden.
|
||||||
// Polls every 30s; only fires when the current hour actually changes.
|
// Polls every 30s; only fires when the current hour actually changes.
|
||||||
|
// prevHourRef tracks the *fractional* hour (hour + minute/60) so the
|
||||||
|
// ticker notices slot boundaries that fall mid-hour — slot 1 ending at
|
||||||
|
// 12:30 means the auto-advance must fire on the 11:30→12:30 crossing,
|
||||||
|
// not just on the wall-clock hour change.
|
||||||
const prevHourRef = useRef(null);
|
const prevHourRef = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldFetchLive) return;
|
if (!shouldFetchLive) return;
|
||||||
if (prevHourRef.current === null) prevHourRef.current = dayjs().hour();
|
const nowFracHour = () => {
|
||||||
|
const d = dayjs();
|
||||||
|
return d.hour() + d.minute() / 60;
|
||||||
|
};
|
||||||
|
if (prevHourRef.current === null) prevHourRef.current = nowFracHour();
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const h = dayjs().hour();
|
const h = nowFracHour();
|
||||||
if (h === prevHourRef.current) return;
|
|
||||||
const fromSlot = getBatchForHour(prevHourRef.current, BATCHES);
|
const fromSlot = getBatchForHour(prevHourRef.current, BATCHES);
|
||||||
prevHourRef.current = h;
|
|
||||||
const toSlot = getBatchForHour(h, BATCHES);
|
const toSlot = getBatchForHour(h, BATCHES);
|
||||||
|
prevHourRef.current = h;
|
||||||
if (!toSlot || toSlot === fromSlot) return;
|
if (!toSlot || toSlot === fromSlot) return;
|
||||||
setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur));
|
setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur));
|
||||||
};
|
};
|
||||||
@@ -2342,6 +2459,15 @@ const Dispatch = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Combined view rail-offset: shift the planned route +5px
|
||||||
|
// perpendicular to its direction of travel so it sits *next to*
|
||||||
|
// the actual GPS polyline (which gets -5px below) instead of
|
||||||
|
// stacking on the same pixels. When the rider follows the
|
||||||
|
// dispatched route, the two layers now read as parallel rails
|
||||||
|
// hugging the same road; when they diverge, the rails fan apart
|
||||||
|
// and the deviation is unmissable. Planned-only and Actual-only
|
||||||
|
// modes keep offset = 0 since there's only one layer to draw.
|
||||||
|
const plannedOffset = compareViewMode === 'combined' ? 5 : 0;
|
||||||
// One halo under the whole trip so crossing roads still read as
|
// One halo under the whole trip so crossing roads still read as
|
||||||
// a single planned route. Per-step segments draw on top with their
|
// a single planned route. Per-step segments draw on top with their
|
||||||
// step's color.
|
// step's color.
|
||||||
@@ -2354,7 +2480,8 @@ const Dispatch = ({
|
|||||||
weight: weight + 4,
|
weight: weight + 4,
|
||||||
opacity: opacity * 0.5,
|
opacity: opacity * 0.5,
|
||||||
lineJoin: 'round',
|
lineJoin: 'round',
|
||||||
lineCap: 'round'
|
lineCap: 'round',
|
||||||
|
offset: plannedOffset
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2364,7 +2491,14 @@ const Dispatch = ({
|
|||||||
const sequenceStep = order
|
const sequenceStep = order
|
||||||
? deliveryToStep.get(String(order.deliveryid))
|
? deliveryToStep.get(String(order.deliveryid))
|
||||||
: null;
|
: null;
|
||||||
const color = sequenceStep ? stepColor(sequenceStep - 1) : r.color;
|
// Combined view collapses the per-step palette to a single
|
||||||
|
// indigo so the planned polyline reads as one layer; the
|
||||||
|
// numbered drop pins (which keep STEP_PALETTE) still carry
|
||||||
|
// the per-step identity. Planned-only mode keeps per-step
|
||||||
|
// colors because there's no second layer to distinguish from.
|
||||||
|
const color = compareViewMode === 'combined'
|
||||||
|
? COMBINED_PLANNED_COLOR
|
||||||
|
: (sequenceStep ? stepColor(sequenceStep - 1) : r.color);
|
||||||
const isFocusedThisStep =
|
const isFocusedThisStep =
|
||||||
focusedCompareStep != null && focusedCompareStep === sequenceStep;
|
focusedCompareStep != null && focusedCompareStep === sequenceStep;
|
||||||
// Focused segment pops; non-focused dim when *some* step is
|
// Focused segment pops; non-focused dim when *some* step is
|
||||||
@@ -2386,7 +2520,8 @@ const Dispatch = ({
|
|||||||
opacity: segOpacity,
|
opacity: segOpacity,
|
||||||
lineJoin: 'round',
|
lineJoin: 'round',
|
||||||
lineCap: 'round',
|
lineCap: 'round',
|
||||||
dashArray
|
dashArray,
|
||||||
|
offset: plannedOffset
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2495,20 +2630,203 @@ const Dispatch = ({
|
|||||||
<span className="live-dot error" /> Failed to load
|
<span className="live-dot error" /> Failed to load
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<label className="live-date-label">
|
{(() => {
|
||||||
<span>Date</span>
|
// Date-picker chip + custom calendar popover. Replaces the
|
||||||
<input
|
// OS-native <input type="date"> dialog (which looks different
|
||||||
type="date"
|
// on every browser and can't pick up the design system) with
|
||||||
value={selectedDate}
|
// a single popover that always renders the same way. The
|
||||||
max={dayjs().format('YYYY-MM-DD')}
|
// chip has three regions:
|
||||||
onChange={(e) => {
|
// • prev-day arrow — one-click ±1 day scrubbing
|
||||||
setSelectedDate(e.target.value);
|
// • center card — opens the calendar popover on click
|
||||||
|
// • next-day arrow — disabled when viewing today
|
||||||
|
// The popover itself carries the month grid + quick presets.
|
||||||
|
const today = dayjs().startOf('day');
|
||||||
|
const todayStr = today.format('YYYY-MM-DD');
|
||||||
|
const picked = dayjs(selectedDate);
|
||||||
|
const isToday = selectedDate === todayStr;
|
||||||
|
const isFuture = picked.isAfter(today, 'day');
|
||||||
|
|
||||||
|
const commitDate = (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
const str = next.format('YYYY-MM-DD');
|
||||||
|
if (str === selectedDate) {
|
||||||
|
setDatePickerOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.isAfter(today, 'day')) return; // guard future
|
||||||
|
setSelectedDate(str);
|
||||||
handleRiderFocus(null);
|
handleRiderFocus(null);
|
||||||
setFocusedKitchen(null);
|
setFocusedKitchen(null);
|
||||||
setFocusedZone(null);
|
setFocusedZone(null);
|
||||||
}}
|
setDatePickerOpen(false);
|
||||||
/>
|
};
|
||||||
</label>
|
|
||||||
|
const goPrevDay = () => commitDate(picked.subtract(1, 'day'));
|
||||||
|
const goNextDay = () => {
|
||||||
|
if (isToday || isFuture) return;
|
||||||
|
commitDate(picked.add(1, 'day'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a fixed 6×7 grid of dayjs instances starting from
|
||||||
|
// the Sunday before the month's first day. Days outside the
|
||||||
|
// visible month render as faded "other-month" cells so the
|
||||||
|
// grid never jumps in height as months change.
|
||||||
|
const monthStart = calViewMonth.startOf('month');
|
||||||
|
const gridStart = monthStart.subtract(monthStart.day(), 'day');
|
||||||
|
const cells = Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day'));
|
||||||
|
|
||||||
|
const prevMonth = () => setCalViewMonth((m) => m.subtract(1, 'month'));
|
||||||
|
const nextMonth = () => {
|
||||||
|
const candidate = calViewMonth.add(1, 'month');
|
||||||
|
// Allow navigating into the current month (so "today" can
|
||||||
|
// be reached) but not into purely-future months.
|
||||||
|
if (candidate.startOf('month').isAfter(today, 'month')) return;
|
||||||
|
setCalViewMonth(candidate);
|
||||||
|
};
|
||||||
|
const canGoNextMonth = !calViewMonth.add(1, 'month').startOf('month').isAfter(today, 'month');
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`date-chip${isToday ? ' is-today' : ''}${datePickerOpen ? ' is-open' : ''}`} ref={datePickerRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-chip-nav"
|
||||||
|
onClick={goPrevDay}
|
||||||
|
aria-label="Previous day"
|
||||||
|
title="Previous day"
|
||||||
|
>
|
||||||
|
<MdChevronLeft />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-chip-main"
|
||||||
|
onClick={() => setDatePickerOpen((o) => !o)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={datePickerOpen}
|
||||||
|
>
|
||||||
|
<span className="date-chip-icon" aria-hidden="true">
|
||||||
|
<MdCalendarToday />
|
||||||
|
</span>
|
||||||
|
<span className="date-chip-text">
|
||||||
|
<span className="date-chip-label">
|
||||||
|
Date{isToday && <span className="date-chip-today-pill">Today</span>}
|
||||||
|
</span>
|
||||||
|
<span className="date-chip-value">
|
||||||
|
{picked.isValid() ? picked.format('ddd, MMM D, YYYY') : '—'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`date-chip-chevron${datePickerOpen ? ' is-open' : ''}`} aria-hidden="true">
|
||||||
|
<MdExpandMore />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-chip-nav"
|
||||||
|
onClick={goNextDay}
|
||||||
|
disabled={isToday || isFuture}
|
||||||
|
aria-label="Next day"
|
||||||
|
title={isToday ? "You're viewing today" : 'Next day'}
|
||||||
|
>
|
||||||
|
<MdChevronRight />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{datePickerOpen && (
|
||||||
|
<div className="date-cal-popover" role="dialog" aria-label="Pick a date">
|
||||||
|
{/* Month header — month/year title flanked by
|
||||||
|
prev/next arrows. The next-month arrow disables
|
||||||
|
once we'd cross into a purely-future month so
|
||||||
|
the operator never lands on a month they can't
|
||||||
|
pick a date in. */}
|
||||||
|
<div className="date-cal-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-cal-nav"
|
||||||
|
onClick={prevMonth}
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<MdChevronLeft />
|
||||||
|
</button>
|
||||||
|
<div className="date-cal-title">
|
||||||
|
{calViewMonth.format('MMMM YYYY')}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-cal-nav"
|
||||||
|
onClick={nextMonth}
|
||||||
|
disabled={!canGoNextMonth}
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<MdChevronRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="date-cal-weekdays">
|
||||||
|
{WEEKDAYS.map((w) => (
|
||||||
|
<div key={w} className="date-cal-weekday">{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="date-cal-grid">
|
||||||
|
{cells.map((d) => {
|
||||||
|
const inMonth = d.month() === calViewMonth.month();
|
||||||
|
const isSel = d.format('YYYY-MM-DD') === selectedDate;
|
||||||
|
const isTodayCell = d.format('YYYY-MM-DD') === todayStr;
|
||||||
|
const disabled = d.isAfter(today, 'day');
|
||||||
|
const cls = [
|
||||||
|
'date-cal-day',
|
||||||
|
!inMonth && 'is-other-month',
|
||||||
|
isSel && 'is-selected',
|
||||||
|
isTodayCell && 'is-today',
|
||||||
|
disabled && 'is-disabled'
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.format('YYYY-MM-DD')}
|
||||||
|
type="button"
|
||||||
|
className={cls}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => commitDate(d)}
|
||||||
|
aria-current={isTodayCell ? 'date' : undefined}
|
||||||
|
aria-pressed={isSel}
|
||||||
|
>
|
||||||
|
{d.date()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick presets — the three dates ops scrub to
|
||||||
|
most often. Saves a month-nav + a day-click for
|
||||||
|
the common cases. */}
|
||||||
|
<div className="date-cal-presets">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-cal-preset"
|
||||||
|
onClick={() => commitDate(today)}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-cal-preset"
|
||||||
|
onClick={() => commitDate(today.subtract(1, 'day'))}
|
||||||
|
>
|
||||||
|
Yesterday
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-cal-preset"
|
||||||
|
onClick={() => commitDate(today.subtract(7, 'day'))}
|
||||||
|
>
|
||||||
|
−7 days
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2604,7 +2922,7 @@ const Dispatch = ({
|
|||||||
<div className="slot-edit-panel" role="dialog" aria-label="Edit slot timings">
|
<div className="slot-edit-panel" role="dialog" aria-label="Edit slot timings">
|
||||||
<div className="slot-edit-head">
|
<div className="slot-edit-head">
|
||||||
<div className="slot-edit-title">Slot timings</div>
|
<div className="slot-edit-title">Slot timings</div>
|
||||||
<div className="slot-edit-sub">Hours are 0–24 (24h clock). Start < End.</div>
|
<div className="slot-edit-sub">Hours are 0–24 (24h clock). Half-hour steps allowed (e.g. 12.5 = 12:30). Start < End.</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="slot-edit-list">
|
<div className="slot-edit-list">
|
||||||
{slotsConfig.map((s, idx) => (
|
{slotsConfig.map((s, idx) => (
|
||||||
@@ -2615,11 +2933,16 @@ const Dispatch = ({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={23}
|
max={23.5}
|
||||||
step={1}
|
step={0.5}
|
||||||
value={s.startHour}
|
value={s.startHour}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = Math.max(0, Math.min(23, parseInt(e.target.value, 10) || 0));
|
// Half-hour-aware: parseFloat + snap to nearest 0.5
|
||||||
|
// so 12.5 (12:30) is a valid value and odd inputs
|
||||||
|
// like 12.7 round to 12.5.
|
||||||
|
const raw = parseFloat(e.target.value);
|
||||||
|
const snapped = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0;
|
||||||
|
const v = Math.max(0, Math.min(23.5, snapped));
|
||||||
setSlotsConfig((cur) => cur.map((row, i) =>
|
setSlotsConfig((cur) => cur.map((row, i) =>
|
||||||
i === idx
|
i === idx
|
||||||
? { ...row, startHour: v, label: formatSlotLabel(i, v), range: formatSlotRange(v, row.endHour) }
|
? { ...row, startHour: v, label: formatSlotLabel(i, v), range: formatSlotRange(v, row.endHour) }
|
||||||
@@ -2632,12 +2955,14 @@ const Dispatch = ({
|
|||||||
<span className="slot-edit-field-label">End</span>
|
<span className="slot-edit-field-label">End</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={0.5}
|
||||||
max={24}
|
max={24}
|
||||||
step={1}
|
step={0.5}
|
||||||
value={s.endHour}
|
value={s.endHour}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = Math.max(1, Math.min(24, parseInt(e.target.value, 10) || 1));
|
const raw = parseFloat(e.target.value);
|
||||||
|
const snapped = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0.5;
|
||||||
|
const v = Math.max(0.5, Math.min(24, snapped));
|
||||||
setSlotsConfig((cur) => cur.map((row, i) =>
|
setSlotsConfig((cur) => cur.map((row, i) =>
|
||||||
i === idx
|
i === idx
|
||||||
? { ...row, endHour: v, range: formatSlotRange(row.startHour, v) }
|
? { ...row, endHour: v, range: formatSlotRange(row.startHour, v) }
|
||||||
@@ -3606,12 +3931,41 @@ const Dispatch = ({
|
|||||||
mouseout: (e) => e.target.closePopup()
|
mouseout: (e) => e.target.closePopup()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Popup maxWidth={220} autoPan={false}>
|
<Popup maxWidth={240} autoPan={false} className="dispatch-popup route-rider-popup">
|
||||||
<div className="pu-id">RIDER</div>
|
<div className="pu-hdr-live">
|
||||||
<div className="pu-rider" style={{ color: p.color }}>{p.riderName}</div>
|
<div className="pu-hdr-left">
|
||||||
<div className="pu-row"><span>Progress</span><span>{p.completedCount} / {p.totalCount} delivered</span></div>
|
<span className="pu-hdr-title">RIDER ROUTE</span>
|
||||||
<div className="pu-row"><span>Next stop</span><span>#{p.nextStep} · {p.nextCustomer || '—'}</span></div>
|
</div>
|
||||||
<div className="pu-row"><span>Position</span><span>{onRoad ? 'on road' : 'estimating…'}</span></div>
|
</div>
|
||||||
|
<div className="pu-rider-profile">
|
||||||
|
<div className="pu-avatar" style={{ backgroundColor: `${p.color}12`, color: p.color }}>
|
||||||
|
<MdDirectionsBike />
|
||||||
|
</div>
|
||||||
|
<div className="pu-rider-info-text">
|
||||||
|
<div className="pu-rider-name" style={{ color: p.color }}>{p.riderName}</div>
|
||||||
|
<div className="pu-rider-meta">Active route details</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pu-body-content">
|
||||||
|
<div className="pu-info-row">
|
||||||
|
<span className="pu-info-label">Progress</span>
|
||||||
|
<span className="pu-info-value">
|
||||||
|
<strong>{p.completedCount}</strong> / {p.totalCount} delivered
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pu-info-row">
|
||||||
|
<span className="pu-info-label">Next Stop</span>
|
||||||
|
<span className="pu-info-value text-indigo" style={{ color: '#4f46e5' }}>
|
||||||
|
#{p.nextStep} · {p.nextCustomer || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pu-info-row">
|
||||||
|
<span className="pu-info-label">Position</span>
|
||||||
|
<span className="pu-info-value" style={{ color: '#64748b' }}>
|
||||||
|
{onRoad ? 'On road' : 'Estimating…'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@@ -3657,14 +4011,60 @@ const Dispatch = ({
|
|||||||
mouseout: (e) => e.target.closePopup()
|
mouseout: (e) => e.target.closePopup()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Popup maxWidth={220} autoPan={false}>
|
<Popup maxWidth={260} autoPan={false} className="dispatch-popup live-rider-popup">
|
||||||
<div className="pu-id">LIVE GPS</div>
|
<div className="pu-hdr-live">
|
||||||
<div className="pu-rider" style={{ color: pinColor }}>{r.username}</div>
|
<div className="pu-hdr-left">
|
||||||
<div className="pu-row"><span>Status</span><span>{r.status || 'unknown'}</span></div>
|
<span className="pu-live-indicator" style={{ '--pulse-color': pinColor }}>
|
||||||
{r.orderid && <div className="pu-row"><span>Order</span><span>#{r.orderid}</span></div>}
|
<span className="pu-live-dot"></span>
|
||||||
{r.contactno && <div className="pu-row"><span>Phone</span><span>{r.contactno}</span></div>}
|
</span>
|
||||||
{r.logdate && <div className="pu-row"><span>Last seen</span><span>{r.logdate}</span></div>}
|
<span className="pu-hdr-title">LIVE GPS</span>
|
||||||
<div className="pu-row"><span>Position</span><span>{r.lat.toFixed(5)}, {r.lon.toFixed(5)}</span></div>
|
</div>
|
||||||
|
{r.status && (
|
||||||
|
<span className={`pu-status-badge ${r.status.toLowerCase() === 'active' ? 'active' : 'idle'}`}>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pu-rider-profile">
|
||||||
|
<div className="pu-avatar" style={{ backgroundColor: `${pinColor}12`, color: pinColor }}>
|
||||||
|
<MdDirectionsBike />
|
||||||
|
</div>
|
||||||
|
<div className="pu-rider-info-text">
|
||||||
|
<div className="pu-rider-name">{r.username || `Rider #${r.id}`}</div>
|
||||||
|
<div className="pu-rider-meta">Rider ID: #{r.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pu-body-content">
|
||||||
|
{r.orderid && (
|
||||||
|
<div className="pu-info-row">
|
||||||
|
<span className="pu-info-label">Active Order</span>
|
||||||
|
<span className="pu-info-value pu-order-badge">#{r.orderid}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.contactno && (
|
||||||
|
<div className="pu-info-row">
|
||||||
|
<span className="pu-info-label">Phone</span>
|
||||||
|
<a href={`tel:${r.contactno}`} className="pu-info-value pu-phone-link">
|
||||||
|
{r.contactno}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.logdate && (
|
||||||
|
<div className="pu-info-row">
|
||||||
|
<span className="pu-info-label">Last Seen</span>
|
||||||
|
<span className="pu-info-value pu-time-stamp">
|
||||||
|
<MdAccessTime className="inline-icon" />{' '}
|
||||||
|
{dayjs(r.logdate).isValid() ? dayjs(r.logdate).format('hh:mm:ss A') : r.logdate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pu-info-row">
|
||||||
|
<span className="pu-info-label">Position</span>
|
||||||
|
<span className="pu-info-value pu-coordinates">
|
||||||
|
{r.lat.toFixed(5)}, {r.lon.toFixed(5)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@@ -3679,7 +4079,17 @@ const Dispatch = ({
|
|||||||
so the operator can read overlap at a glance. */}
|
so the operator can read overlap at a glance. */}
|
||||||
{compareOpen && focusedRider && compareViewMode !== 'planned' && (riderActualTracks.map((t, i) => {
|
{compareOpen && focusedRider && compareViewMode !== 'planned' && (riderActualTracks.map((t, i) => {
|
||||||
if (t.coords.length === 0) return null;
|
if (t.coords.length === 0) return null;
|
||||||
|
// `color` drives the drop pin, start pin, and tooltip header so
|
||||||
|
// those keep their per-step palette identity (the same colors
|
||||||
|
// the timeline uses). `polylineColor` is what the GPS line
|
||||||
|
// itself draws with — collapsed to a single emerald in Combined
|
||||||
|
// view so the actual layer reads as one cohesive trail next to
|
||||||
|
// the indigo planned rail; Actual-only mode keeps step palette
|
||||||
|
// on the polyline since there's no second layer to confuse with.
|
||||||
const color = stepColor(i);
|
const color = stepColor(i);
|
||||||
|
const polylineColor = compareViewMode === 'combined'
|
||||||
|
? COMBINED_ACTUAL_COLOR
|
||||||
|
: color;
|
||||||
const startPos = [t.coords[0].lat, t.coords[0].lng];
|
const startPos = [t.coords[0].lat, t.coords[0].lng];
|
||||||
const endPos = [t.coords[t.coords.length - 1].lat, t.coords[t.coords.length - 1].lng];
|
const endPos = [t.coords[t.coords.length - 1].lat, t.coords[t.coords.length - 1].lng];
|
||||||
const snapped = osrmTrackRoutes[t.deliveryid];
|
const snapped = osrmTrackRoutes[t.deliveryid];
|
||||||
@@ -3778,6 +4188,15 @@ const Dispatch = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Combined view rail-offset (negative side): mirror image of
|
||||||
|
// the +5px shift applied to the planned polyline in
|
||||||
|
// renderRoutes. With planned at +5 and actual at -5, the two
|
||||||
|
// layers sit as parallel rails ~10px apart when they share
|
||||||
|
// a road, so the operator can read both even on tight match.
|
||||||
|
// Actual-only and Planned-only modes leave offset = 0 since
|
||||||
|
// there's only one layer drawing on the map.
|
||||||
|
const actualOffset = compareViewMode === 'combined' ? -5 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`actual-${t.deliveryid}`}>
|
<React.Fragment key={`actual-${t.deliveryid}`}>
|
||||||
{drawPolyline && (
|
{drawPolyline && (
|
||||||
@@ -3788,7 +4207,8 @@ const Dispatch = ({
|
|||||||
weight: isFocusedStep ? 11 : 9,
|
weight: isFocusedStep ? 11 : 9,
|
||||||
opacity: isFocusedStep ? 0.75 : 0.55,
|
opacity: isFocusedStep ? 0.75 : 0.55,
|
||||||
lineJoin: 'round',
|
lineJoin: 'round',
|
||||||
lineCap: 'round'
|
lineCap: 'round',
|
||||||
|
offset: actualOffset
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -3796,11 +4216,12 @@ const Dispatch = ({
|
|||||||
<Polyline
|
<Polyline
|
||||||
positions={positions}
|
positions={positions}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color,
|
color: polylineColor,
|
||||||
weight: isFocusedStep ? 6.5 : 5,
|
weight: isFocusedStep ? 6.5 : 5,
|
||||||
opacity: isFocusedStep ? 1 : focusedCompareStep ? 0.55 : 0.95,
|
opacity: isFocusedStep ? 1 : focusedCompareStep ? 0.55 : 0.95,
|
||||||
lineJoin: 'round',
|
lineJoin: 'round',
|
||||||
lineCap: 'round'
|
lineCap: 'round',
|
||||||
|
offset: actualOffset
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -4251,27 +4672,33 @@ const Dispatch = ({
|
|||||||
what each line/marker means. Lives in the header so it doesn't
|
what each line/marker means. Lives in the header so it doesn't
|
||||||
compete with the map for vertical real estate. */}
|
compete with the map for vertical real estate. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Both planned and actual share the same per-step palette
|
// Two color stories depending on view mode:
|
||||||
// — they're separated by stroke style on the unified map
|
// • Combined: polylines collapse to fixed indigo (planned)
|
||||||
// (planned = dashed, actual = solid). The swatch shows
|
// and emerald (actual) so the two overlaid layers can be
|
||||||
// the focused step's color when one is focused, or a
|
// told apart at a glance. Legend swatches mirror this.
|
||||||
// multi-hue gradient ("varies by step") when in overall.
|
// • Planned-only / Actual-only: single layer on the map,
|
||||||
const swatchBg = focusedDelta
|
// so polylines keep STEP_PALETTE and the swatch shows
|
||||||
|
// the focused step's color or a step-gradient strip
|
||||||
|
// (signals "varies by step").
|
||||||
|
const isCombined = compareViewMode === 'combined';
|
||||||
|
const stepSwatchBg = focusedDelta
|
||||||
? stepColor(focusedDelta.sequenceStep - 1)
|
? stepColor(focusedDelta.sequenceStep - 1)
|
||||||
: `linear-gradient(90deg, ${STEP_PALETTE.slice(0, 6).join(', ')})`;
|
: `linear-gradient(90deg, ${STEP_PALETTE.slice(0, 6).join(', ')})`;
|
||||||
|
const plannedSwatchBg = isCombined ? COMBINED_PLANNED_COLOR : stepSwatchBg;
|
||||||
|
const actualSwatchBg = isCombined ? COMBINED_ACTUAL_COLOR : stepSwatchBg;
|
||||||
return (
|
return (
|
||||||
<div className="compare-legend">
|
<div className="compare-legend">
|
||||||
<span className="compare-legend-item">
|
<span className="compare-legend-item">
|
||||||
<span
|
<span
|
||||||
className="compare-legend-swatch is-step-color is-dashed"
|
className="compare-legend-swatch is-step-color is-dashed"
|
||||||
style={{ background: swatchBg }}
|
style={{ background: plannedSwatchBg }}
|
||||||
/>
|
/>
|
||||||
Planned (dashed)
|
Planned (dashed)
|
||||||
</span>
|
</span>
|
||||||
<span className="compare-legend-item">
|
<span className="compare-legend-item">
|
||||||
<span
|
<span
|
||||||
className="compare-legend-swatch is-step-color"
|
className="compare-legend-swatch is-step-color"
|
||||||
style={{ background: swatchBg }}
|
style={{ background: actualSwatchBg }}
|
||||||
/>
|
/>
|
||||||
Actual GPS (solid)
|
Actual GPS (solid)
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
154
src/utils/leafletPolylineOffset.js
Normal file
154
src/utils/leafletPolylineOffset.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// Vendored from leaflet-polylineoffset@1.1.1 (MIT).
|
||||||
|
//
|
||||||
|
// Why this lives in-tree instead of being an npm dep:
|
||||||
|
// • The published package would require --legacy-peer-deps because of an
|
||||||
|
// unrelated React-17 peer-dep conflict elsewhere in the project, and we
|
||||||
|
// don't want a renderer plugin to force a global resolver flag.
|
||||||
|
// • It's frozen upstream (no meaningful updates since 2020), tiny, and
|
||||||
|
// has zero runtime deps besides leaflet (already in package.json).
|
||||||
|
//
|
||||||
|
// What it does:
|
||||||
|
// Monkey-patches L.Polyline so that any path passed with a numeric
|
||||||
|
// `offset` in pathOptions is rendered shifted perpendicular to its
|
||||||
|
// direction of travel by that many pixels (positive = right of travel,
|
||||||
|
// negative = left). Used by Dispatch.js's Compare → Combined view to
|
||||||
|
// render planned + actual as parallel rails when they share the same
|
||||||
|
// road geometry; without this they overlap and read as one polyline.
|
||||||
|
//
|
||||||
|
// Import once for the side effect:
|
||||||
|
// import '../../../utils/leafletPolylineOffset';
|
||||||
|
//
|
||||||
|
// Then add to any pathOptions:
|
||||||
|
// pathOptions={{ ..., offset: 5 }}
|
||||||
|
//
|
||||||
|
// Plays nicely with both SVG and Canvas renderers.
|
||||||
|
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
L.PolylineOffset = {
|
||||||
|
translatePoint(pt, dist, radians) {
|
||||||
|
return L.point(pt.x + dist * Math.cos(radians), pt.y + dist * Math.sin(radians));
|
||||||
|
},
|
||||||
|
|
||||||
|
offsetPointLine(points, distance) {
|
||||||
|
const l = points.length;
|
||||||
|
if (l < 2) {
|
||||||
|
throw new Error('Line should be defined by at least 2 points');
|
||||||
|
}
|
||||||
|
let a = points[0];
|
||||||
|
let b;
|
||||||
|
const offsetAngle = Math.PI / 2;
|
||||||
|
const offsetSegments = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < l; i++) {
|
||||||
|
b = points[i];
|
||||||
|
// Each segment's offset angle is perpendicular to its direction.
|
||||||
|
const segAngle = Math.atan2(b.y - a.y, b.x - a.x);
|
||||||
|
offsetSegments.push({
|
||||||
|
offsetAngle: segAngle - offsetAngle,
|
||||||
|
original: [a, b],
|
||||||
|
offset: [
|
||||||
|
this.translatePoint(a, distance, segAngle - offsetAngle),
|
||||||
|
this.translatePoint(b, distance, segAngle - offsetAngle)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
a = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
return offsetSegments;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Find the intersection of two segments by extending them to infinity
|
||||||
|
// along their direction, then walking along segment 1 by parameter t.
|
||||||
|
// Returns null when the segments are parallel (no intersection).
|
||||||
|
intersection(l1a, l1b, l2a, l2b) {
|
||||||
|
const line1 = this.segmentAsVector(l1a, l1b);
|
||||||
|
const line2 = this.segmentAsVector(l2a, l2b);
|
||||||
|
|
||||||
|
const denom = -line2.x * line1.y + line1.x * line2.y;
|
||||||
|
if (denom === 0) return null;
|
||||||
|
|
||||||
|
const s = (-line1.y * (l1a.x - l2a.x) + line1.x * (l1a.y - l2a.y)) / denom;
|
||||||
|
const t = (line2.x * (l1a.y - l2a.y) - line2.y * (l1a.x - l2a.x)) / denom;
|
||||||
|
|
||||||
|
if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
|
||||||
|
return L.point(l1a.x + t * line1.x, l1a.y + t * line1.y);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
segmentAsVector(a, b) {
|
||||||
|
return L.point(b.x - a.x, b.y - a.y);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Walk the offset segments and join adjacent ones at their intersection
|
||||||
|
// points (mitered corners). When two consecutive segments don't intersect
|
||||||
|
// within their bounds (sharp turn, or co-linear), fall back to the offset
|
||||||
|
// endpoint so the polyline doesn't gap.
|
||||||
|
joinLineSegments(segments) {
|
||||||
|
const joined = [];
|
||||||
|
let last = segments[0].offset;
|
||||||
|
joined.push(last[0]);
|
||||||
|
|
||||||
|
for (let i = 1; i < segments.length; i++) {
|
||||||
|
const next = segments[i].offset;
|
||||||
|
const inter = this.intersection(last[0], last[1], next[0], next[1]);
|
||||||
|
if (inter) {
|
||||||
|
joined.push(inter);
|
||||||
|
} else {
|
||||||
|
joined.push(last[1]);
|
||||||
|
}
|
||||||
|
last = next;
|
||||||
|
}
|
||||||
|
joined.push(last[1]);
|
||||||
|
return joined;
|
||||||
|
},
|
||||||
|
|
||||||
|
offsetPoints(points, offset) {
|
||||||
|
if (!points || points.length < 2) return points;
|
||||||
|
const offsets = this.offsetPointLine(points, offset);
|
||||||
|
return this.joinLineSegments(offsets);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Operates on a ring of LatLngs by projecting → offsetting → unprojecting,
|
||||||
|
// since leaflet polyline math is in screen pixels but our points are LatLng.
|
||||||
|
offsetLatLngs(map, latlngs, offset) {
|
||||||
|
const points = latlngs.map((ll) => map.latLngToLayerPoint(ll));
|
||||||
|
const offsetPts = this.offsetPoints(points, offset);
|
||||||
|
return offsetPts.map((p) => map.layerPointToLatLng(p));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patch Polyline._projectLatlngs (used by both SVG and Canvas renderers) so
|
||||||
|
// that when an offset is set, the projected ring is offset before clipping.
|
||||||
|
// We keep the original on _projectLatlngsOriginal so we can call through.
|
||||||
|
const originalProject = L.Polyline.prototype._projectLatlngs;
|
||||||
|
|
||||||
|
L.Polyline.prototype._projectLatlngs = function patchedProject(latlngs, result, projectedBounds) {
|
||||||
|
const offset = this.options.offset;
|
||||||
|
if (!offset || typeof offset !== 'number') {
|
||||||
|
return originalProject.call(this, latlngs, result, projectedBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse for multi-ring polylines (shouldn't happen for simple lines,
|
||||||
|
// but the leaflet API allows it).
|
||||||
|
const flat = latlngs[0] instanceof L.LatLng;
|
||||||
|
if (!flat) {
|
||||||
|
for (let i = 0; i < latlngs.length; i++) {
|
||||||
|
this._projectLatlngs(latlngs[i], result, projectedBounds);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projected = latlngs.map((ll) => this._map.latLngToLayerPoint(ll));
|
||||||
|
const offsetted = L.PolylineOffset.offsetPoints(projected, offset);
|
||||||
|
// Update projectedBounds with each offset point so the renderer's
|
||||||
|
// viewport-clipping check still works.
|
||||||
|
for (let i = 0; i < offsetted.length; i++) {
|
||||||
|
projectedBounds.extend(offsetted[i]);
|
||||||
|
}
|
||||||
|
result.push(offsetted);
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default L;
|
||||||
Reference in New Issue
Block a user