updates on the ui changes and updates on the delivery page updated the status

This commit is contained in:
2026-05-25 15:39:18 +05:30
parent e87d5908ea
commit f5307dfb03
4 changed files with 1563 additions and 143 deletions

View File

@@ -121,6 +121,7 @@ const Deliveries = () => {
const [deliverylat, setDeliverylat] = useState('');
const [deliverylong, setDeliverylong] = useState('');
const [currentStatus, setCurrentStatus] = useState('pending');
const [updateStatus, setUpdateStatus] = useState('delivered');
const locationRef = useRef(null);
const tenantRef = useRef(null);
const [page, setPage] = React.useState(0);
@@ -1259,6 +1260,7 @@ const Deliveries = () => {
setDeliverylong(selectedRow.droplon);
setNotes(selectedRow.notes);
setDeliveryamount(selectedRow.deliveryamount);
setUpdateStatus(selectedRow.orderstatus || 'delivered');
setCurrentorder(selectedRow);
setDialogopen(true);
handleMenuClose();
@@ -1657,6 +1659,24 @@ const Deliveries = () => {
<TextField fullWidth type="number" value={deliveryamount} onChange={(e) => setDeliveryamount(+e.target.value)} />
</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}>
<FormLabel>Notes</FormLabel>
<TextField value={notes} fullWidth onChange={(e) => setNotes(e.target.value)} />
@@ -1694,7 +1714,7 @@ const Deliveries = () => {
updateDeliveryMutation.mutate({
deliveryid: currentorder.deliveryid,
orderheaderid: currentorder.orderheaderid,
orderstatus: 'delivered',
orderstatus: updateStatus,
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
deliverylat,
deliverylong,

File diff suppressed because it is too large Load Diff

View File

@@ -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 L from 'leaflet';
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 { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query';
import axios from 'axios';
@@ -41,7 +46,8 @@ import {
MdWarning,
MdClose,
MdFormatListBulleted,
MdTimer
MdTimer,
MdCalendarToday
} from 'react-icons/md';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api';
import {
@@ -55,6 +61,17 @@ import {
import CompareDataPanel from './CompareDataPanel';
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.
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)));
// Named delivery slots — operator's mental model of the day's waves.
// Each entry covers a half-open hour range [startHour, endHour). Hours that
// 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.
// Each entry covers a half-open range [startHour, endHour) measured in
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported
// 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.
// 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 },
{ id: 'slot-4', label: 'Slot 4 · 7 PM', range: '78 PM', startHour: 19, endHour: 20 },
{ id: 'slot-5', label: 'Slot 5 · 8 PM', range: 'After 8 PM', startHour: 20, endHour: 24 }
// Five named waves:
// • Slot 1: morning rush (8 AM → 12:30 PM)
// • Slot 2: lunch (12:20 PM → 3 PM)
// • Slot 3: afternoon (3 PM → 7 PM)
// • Slot 4: evening (7 PM → 8 PM)
// • 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
// human-readable form the defaults use, so user-edited slots still look
// consistent in the UI.
// Every prior storage key. Wiped once on mount so stale layouts
// from earlier code versions can't reappear on the next page load.
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 h = ((startHour + 11) % 12) + 1;
const ampm = startHour >= 12 && startHour < 24 ? 'PM' : 'AM';
return `Slot ${idx + 1} · ${h} ${ampm}`;
return `Slot ${idx + 1} · ${formatHourLabel(startHour)}`;
};
// 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 hr = ((h + 11) % 12) + 1;
const ampm = h >= 12 && h < 24 ? 'PM' : 'AM';
return `${hr} ${ampm}`;
const wholeHour = Math.floor(h);
const minutes = Math.round((h - wholeHour) * 60);
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) => {
@@ -132,6 +173,16 @@ const formatSlotRange = (startHour, 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) => {
for (const b of batches) {
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;
const d = dayjs(t);
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
@@ -700,7 +754,9 @@ const Dispatch = ({
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;
// 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
// formatter (e.g. AM/PM style) flow through to old persisted slots.
return parsed.map((s, i) => ({
@@ -718,6 +774,16 @@ const Dispatch = ({
const [slotEditOpen, setSlotEditOpen] = useState(false);
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
// initializer already loaded from storage).
const slotsInitMountedRef = useRef(false);
@@ -869,6 +935,47 @@ const Dispatch = ({
// directly (closure captures the value at scheduling time).
const isAnimatingRef = useRef(false);
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
// dispatch map (planned routes), right half is a second map that overlays
@@ -974,10 +1081,13 @@ const Dispatch = ({
})
.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.
// Default to the slot containing the current wall-clock time. Use a
// 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(() => {
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
@@ -1756,16 +1866,23 @@ const Dispatch = ({
// 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.
// 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);
useEffect(() => {
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 h = dayjs().hour();
if (h === prevHourRef.current) return;
const h = nowFracHour();
const fromSlot = getBatchForHour(prevHourRef.current, BATCHES);
prevHourRef.current = h;
const toSlot = getBatchForHour(h, BATCHES);
prevHourRef.current = h;
if (!toSlot || toSlot === fromSlot) return;
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
// a single planned route. Per-step segments draw on top with their
// step's color.
@@ -2354,7 +2480,8 @@ const Dispatch = ({
weight: weight + 4,
opacity: opacity * 0.5,
lineJoin: 'round',
lineCap: 'round'
lineCap: 'round',
offset: plannedOffset
}}
/>
);
@@ -2364,7 +2491,14 @@ const Dispatch = ({
const sequenceStep = order
? deliveryToStep.get(String(order.deliveryid))
: 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 =
focusedCompareStep != null && focusedCompareStep === sequenceStep;
// Focused segment pops; non-focused dim when *some* step is
@@ -2386,7 +2520,8 @@ const Dispatch = ({
opacity: segOpacity,
lineJoin: 'round',
lineCap: 'round',
dashArray
dashArray,
offset: plannedOffset
}}
/>
);
@@ -2495,20 +2630,203 @@ const Dispatch = ({
<span className="live-dot error" /> Failed to load
</span>
)}
<label className="live-date-label">
<span>Date</span>
<input
type="date"
value={selectedDate}
max={dayjs().format('YYYY-MM-DD')}
onChange={(e) => {
setSelectedDate(e.target.value);
handleRiderFocus(null);
setFocusedKitchen(null);
setFocusedZone(null);
}}
/>
</label>
{(() => {
// Date-picker chip + custom calendar popover. Replaces the
// OS-native <input type="date"> dialog (which looks different
// on every browser and can't pick up the design system) with
// a single popover that always renders the same way. The
// chip has three regions:
// • prev-day arrow — one-click ±1 day scrubbing
// • 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);
setFocusedKitchen(null);
setFocusedZone(null);
setDatePickerOpen(false);
};
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>
@@ -2604,7 +2922,7 @@ const Dispatch = ({
<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 className="slot-edit-sub">Hours are 024 (24h clock). Half-hour steps allowed (e.g. 12.5 = 12:30). Start &lt; End.</div>
</div>
<div className="slot-edit-list">
{slotsConfig.map((s, idx) => (
@@ -2615,11 +2933,16 @@ const Dispatch = ({
<input
type="number"
min={0}
max={23}
step={1}
max={23.5}
step={0.5}
value={s.startHour}
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) =>
i === idx
? { ...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>
<input
type="number"
min={1}
min={0.5}
max={24}
step={1}
step={0.5}
value={s.endHour}
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) =>
i === idx
? { ...row, endHour: v, range: formatSlotRange(row.startHour, v) }
@@ -3606,12 +3931,41 @@ const Dispatch = ({
mouseout: (e) => e.target.closePopup()
}}
>
<Popup maxWidth={220} autoPan={false}>
<div className="pu-id">RIDER</div>
<div className="pu-rider" style={{ color: p.color }}>{p.riderName}</div>
<div className="pu-row"><span>Progress</span><span>{p.completedCount} / {p.totalCount} delivered</span></div>
<div className="pu-row"><span>Next stop</span><span>#{p.nextStep} · {p.nextCustomer || ''}</span></div>
<div className="pu-row"><span>Position</span><span>{onRoad ? 'on road' : 'estimating'}</span></div>
<Popup maxWidth={240} autoPan={false} className="dispatch-popup route-rider-popup">
<div className="pu-hdr-live">
<div className="pu-hdr-left">
<span className="pu-hdr-title">RIDER ROUTE</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>
</Marker>
);
@@ -3657,14 +4011,60 @@ const Dispatch = ({
mouseout: (e) => e.target.closePopup()
}}
>
<Popup maxWidth={220} autoPan={false}>
<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 maxWidth={260} autoPan={false} className="dispatch-popup live-rider-popup">
<div className="pu-hdr-live">
<div className="pu-hdr-left">
<span className="pu-live-indicator" style={{ '--pulse-color': pinColor }}>
<span className="pu-live-dot"></span>
</span>
<span className="pu-hdr-title">LIVE GPS</span>
</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>
</Marker>
);
@@ -3679,7 +4079,17 @@ const Dispatch = ({
so the operator can read overlap at a glance. */}
{compareOpen && focusedRider && compareViewMode !== 'planned' && (riderActualTracks.map((t, i) => {
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 polylineColor = compareViewMode === 'combined'
? COMBINED_ACTUAL_COLOR
: color;
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 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 (
<React.Fragment key={`actual-${t.deliveryid}`}>
{drawPolyline && (
@@ -3788,7 +4207,8 @@ const Dispatch = ({
weight: isFocusedStep ? 11 : 9,
opacity: isFocusedStep ? 0.75 : 0.55,
lineJoin: 'round',
lineCap: 'round'
lineCap: 'round',
offset: actualOffset
}}
/>
)}
@@ -3796,11 +4216,12 @@ const Dispatch = ({
<Polyline
positions={positions}
pathOptions={{
color,
color: polylineColor,
weight: isFocusedStep ? 6.5 : 5,
opacity: isFocusedStep ? 1 : focusedCompareStep ? 0.55 : 0.95,
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
compete with the map for vertical real estate. */}
{(() => {
// Both planned and actual share the same per-step palette
// — they're separated by stroke style on the unified map
// (planned = dashed, actual = solid). The swatch shows
// the focused step's color when one is focused, or a
// multi-hue gradient ("varies by step") when in overall.
const swatchBg = focusedDelta
// Two color stories depending on view mode:
// • Combined: polylines collapse to fixed indigo (planned)
// and emerald (actual) so the two overlaid layers can be
// told apart at a glance. Legend swatches mirror this.
// • Planned-only / Actual-only: single layer on the map,
// 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)
: `linear-gradient(90deg, ${STEP_PALETTE.slice(0, 6).join(', ')})`;
const plannedSwatchBg = isCombined ? COMBINED_PLANNED_COLOR : stepSwatchBg;
const actualSwatchBg = isCombined ? COMBINED_ACTUAL_COLOR : stepSwatchBg;
return (
<div className="compare-legend">
<span className="compare-legend-item">
<span
className="compare-legend-swatch is-step-color is-dashed"
style={{ background: swatchBg }}
style={{ background: plannedSwatchBg }}
/>
Planned (dashed)
</span>
<span className="compare-legend-item">
<span
className="compare-legend-swatch is-step-color"
style={{ background: swatchBg }}
style={{ background: actualSwatchBg }}
/>
Actual GPS (solid)
</span>

View 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;