new chnages in ui

This commit is contained in:
2026-05-27 15:22:45 +05:30
parent 8c2248974e
commit 15f15958e6
4 changed files with 1206 additions and 456 deletions

View File

@@ -111,32 +111,23 @@ const pickupLat = (o) => o.pickuplat || o.pickuplatitude || o.pickup_lat;
const pickupLon = (o) => o.pickuplong || o.pickuplongitude || o.picklongitude || o.pickup_lon;
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 batches — operator's mental model of the day's waves.
// 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.
// 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)
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
// Three named batches, bucketed by assigntime per spec:
// • Morning Batch: before 8 AM (00:00 → 08:00)
// • Afternoon Batch: 9 AM → 12 PM (09:00 → 12:00)
// • Evening Batch: 4 PM → 7 PM (16:00 → 19:00)
// Gaps (89 AM, 12 PM4 PM, 7 PM+) intentionally fall outside every batch.
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 }
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12 },
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
];
// 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';
// v7: three-named-batch layout (Morning / Afternoon / Evening).
// Bumping the key drops cached 5-slot layouts from v6 and earlier.
const SLOTS_STORAGE_KEY = 'dispatch.slots.v7';
// Every prior storage key. Wiped once on mount so stale layouts
// from earlier code versions can't reappear on the next page load.
@@ -145,7 +136,8 @@ const LEGACY_SLOTS_STORAGE_KEYS = [
'dispatch.slots.v2',
'dispatch.slots.v3',
'dispatch.slots.v4',
'dispatch.slots.v5'
'dispatch.slots.v5',
'dispatch.slots.v6'
];
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
@@ -178,9 +170,12 @@ const formatSlotRange = (startHour, endHour) => {
// 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.
// Prefer the explicit `name` (e.g. "Morning Batch") when provided; fall back
// to the auto-generated "Slot N · 8 AM" label for any user-added slot that
// doesn't carry a name.
const BATCHES_DEFAULT = BATCHES_DEFAULT_RAW.map((s, i) => ({
...s,
label: formatSlotLabel(i, s.startHour),
label: s.name || formatSlotLabel(i, s.startHour),
range: formatSlotRange(s.startHour, s.endHour)
}));
@@ -196,12 +191,14 @@ const getBatchForHour = (h, batches) => {
// timestamp `getRowBatch` reads. "Delivery" defaults to actual deliverytime
// with a fallback to expecteddeliverytime so undelivered orders still bucket.
const TIME_FIELDS = [
{ id: 'delivery', label: 'Delivery', keys: ['deliverytime', 'expecteddeliverytime'] },
{ id: 'delivered', label: 'Delivered', keys: ['deliverytime'] },
{ id: 'pending', label: 'Pending', keys: ['expecteddeliverytime'] },
{ id: 'assigned', label: 'Assigned', keys: ['assigntime'] },
{ id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] },
{ id: 'started', label: 'Started', keys: ['starttime'] },
{ id: 'arrived', label: 'Arrived', keys: ['arrivaltime'] },
{ id: 'pickup', label: 'Pickup', keys: ['pickuptime'] }
{ id: 'pickup', label: 'Pickup', keys: ['pickuptime'] },
{ id: 'all', label: 'All', keys: ['deliverytime', 'expecteddeliverytime', 'assigntime', 'acceptedtime', 'arrivaltime', 'pickuptime', 'starttime'] }
];
const getTimeFieldValue = (r, fieldId) => {
@@ -212,7 +209,7 @@ const getTimeFieldValue = (r, fieldId) => {
return null;
};
const getRowBatch = (r, fieldId = 'delivery', batches = BATCHES_DEFAULT) => {
const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => {
const t = getTimeFieldValue(r, fieldId);
if (!t) return null;
const str = String(t).trim();
@@ -548,6 +545,22 @@ L.Icon.Default.mergeOptions({
const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000'];
// Deterministic rider-id → palette slot mapping. The main `riders` array
// assigns colors by iteration order (see useMemo around line 1308), which
// means the same rider can shuffle to a different color across live-data
// refetches. The Rider Info list needs a fixed color per rider so the dot
// next to "Rajan A" doesn't flip from blue to green on the next poll, so
// it uses this hash-based lookup instead of `getRiderColor(id)`.
const getStableRiderColor = (id) => {
const s = String(id ?? '');
if (!s) return RIDER_COLORS[0];
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return RIDER_COLORS[h % RIDER_COLORS.length];
};
// STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES,
// STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the
// extracted CompareDataPanel component can import them without forcing
@@ -715,6 +728,13 @@ const Dispatch = ({
// or vice versa without immediately triggering a close.
const activePopupMarkerRef = useRef(null);
const popupHoverTimerRef = useRef(null);
// Order shown in the centered popup overlay. Rendered outside the leaflet
// map (see `dispatch-popup-center` overlay near the bottom of the JSX) so
// `position: fixed` actually centers on the viewport. Drives behavior that
// used to flow through leaflet's marker-attached <Popup>: hover opens it,
// mouseout closes it after a ~200ms grace window (unless pinned), click
// toggles a pinned state stored in pinnedPopupsRef.
const [centerPopupOrder, setCenterPopupOrder] = useState(null);
const isControlled = selectedRiderId !== undefined;
const [clock, setClock] = useState('');
@@ -736,11 +756,11 @@ const Dispatch = ({
const [locationMenuOpen, setLocationMenuOpen] = useState(false);
const locationMenuRef = useRef(null);
// Which timestamp column drives slot bucketing. Default = delivery time
// (operator's primary mental model — "did this order land in the X-Y wave?").
// Switching to Assigned/Accepted/Arrived/Pickup/Started rebuckets every row
// through `getRowBatch(_, selectedTimeField)`.
const [selectedTimeField, setSelectedTimeField] = useState('delivery');
// Which timestamp column drives slot bucketing. Default = assigntime so
// orders bucket into Morning/Afternoon/Evening by when they were assigned,
// per current spec. The status-wise time-field dropdown is hidden for now
// (see commented-out block in JSX), so this stays fixed at 'assigned'.
const [selectedTimeField, setSelectedTimeField] = useState('assigned');
const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false);
const timeFieldMenuRef = useRef(null);
@@ -755,18 +775,27 @@ const Dispatch = ({
const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY);
if (!raw) return BATCHES_DEFAULT;
const parsed = JSON.parse(raw);
// 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 the parsed slots length does not match BATCHES_DEFAULT_RAW, the data
// is stale (e.g. written from the older 5-slot layout). Discard it and
// load the default 3-batch layout.
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) => ({
id: s.id || `slot-${i + 1}`,
startHour: Number(s.startHour) || 0,
endHour: Number(s.endHour) || 24,
label: formatSlotLabel(i, Number(s.startHour) || 0),
range: formatSlotRange(Number(s.startHour) || 0, Number(s.endHour) || 24)
}));
// formatter (e.g. AM/PM style) flow through to old persisted slots. If
// the saved id matches a default batch, prefer that batch's friendly
// name ("Morning Batch", etc.) over the generated "Slot N · time" label.
return parsed.map((s, i) => {
const id = s.id || `slot-${i + 1}`;
const startHour = Number(s.startHour) || 0;
const endHour = Number(s.endHour) || 24;
const defaultMatch = BATCHES_DEFAULT.find((b) => b.id === id);
return {
id,
startHour,
endHour,
label: defaultMatch?.name || formatSlotLabel(i, startHour),
range: formatSlotRange(startHour, endHour)
};
});
} catch (e) {
return BATCHES_DEFAULT;
}
@@ -1956,16 +1985,20 @@ const Dispatch = ({
}, [selectedBatch]);
// When the user clicks a step in the focused-rider sidebar (sets focusedStop),
// also open that marker's popup so they see the order details without a second click.
// Wait one frame so MapController has a chance to recenter first.
// surface that order in the centered popup overlay so the details show up
// without a second click. focusedStop is a slim { orderid, lat, lon }, so
// hydrate the full order out of allOrders before passing it to the popup
// (renderOrderPopupContent needs the rich timeline + status fields).
// Wait ~350ms so MapController has a chance to recenter first (matches
// the prior delay before openPopup was called).
useEffect(() => {
if (!focusedStop) return;
const t = setTimeout(() => {
const marker = orderMarkerRefs.current[String(focusedStop.orderid)];
if (marker && typeof marker.openPopup === 'function') marker.openPopup();
const fullOrder = allOrders?.find?.((o) => String(o.orderid) === String(focusedStop.orderid));
if (fullOrder) setCenterPopupOrder(fullOrder);
}, 350);
return () => clearTimeout(t);
}, [focusedStop]);
}, [focusedStop, allOrders]);
const startAnimation = () => {
if (isAnimating) {
@@ -2152,68 +2185,62 @@ const Dispatch = ({
const getRiderColor = (rid) => riders.find(r => r.id === rid)?.color || '#475569';
// Shared rider-card markup, used in the "By Rider" panel and inside the focused-zone detail.
const renderRiderCard = (r, i) => (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div>
<div className="rcard-info">
<div className="rcard-name">{r.riderName}</div>
<div className="rcard-zone">{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips</div>
const renderRiderCard = (r, i) => {
const total = r.orders.length;
const delivered = r.orders.filter((o) =>
FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase())
).length;
const isDone = total > 0 && delivered >= total;
return (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div>
<div className="rcard-info">
<div className="rcard-name">{r.riderName}</div>
<div className="rcard-zone">{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips</div>
</div>
<div
className={`rcard-badge ${isDone ? 'is-done' : ''}`}
style={isDone ? undefined : { background: `${r.color}18`, color: r.color }}
title={`${delivered} delivered of ${total} total`}
>
{delivered}/{total}
</div>
</div>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (total / 15) * 100)}%`, background: r.color }}></div></div>
<div className="rcard-meta"><span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span><span><Ico><MdAccountBalanceWallet /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</span></div>
<div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div>
<div className="rcard-badge" style={{ background: `${r.color}18`, color: r.color }}>{r.orders.length}</div>
</div>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (r.orders.length / 15) * 100)}%`, background: r.color }}></div></div>
<div className="rcard-meta"><span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span><span><Ico><MdAccountBalanceWallet /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}</span></div>
<div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div>
</div>
);
);
};
// Returns true when the order's centered popup should stay open even after
// the cursor leaves the marker: either explicitly pinned via click, or the
// matching compare-step is focused (so clicking a step in the right panel
// keeps the card visible while the maps recenter).
const isOrderPopupPinned = (o) => {
if (!o) return false;
if (pinnedPopupsRef.current.has(String(o.orderid))) return true;
if (compareOpen && focusedRider && o.deliveryid != null) {
const track = riderActualTracks.find((t) => String(t.deliveryid) === String(o.deliveryid));
if (track && focusedCompareStep === track.sequenceStep) return true;
}
return false;
};
// Shared order-popup body used by both the planned-route number markers
// and the Compare mode actual-track drop pins. Extracted so clicking a
// pin on either layer surfaces the exact same Timeline + Details + KM
// chips — operators learn the layout once and trust it everywhere.
// Hover behavior (keep-open while cursor is over the card) is owned by
// the centered overlay wrapper, not this function.
const renderOrderPopupContent = (o) => {
const statusStyle = getStatusStyle(o.orderstatus);
const isPinned = () => {
if (pinnedPopupsRef.current.has(String(o.orderid))) return true;
if (compareOpen && focusedRider && o.deliveryid != null) {
const track = riderActualTracks.find((t) => String(t.deliveryid) === String(o.deliveryid));
if (track && focusedCompareStep === track.sequenceStep) return true;
}
return false;
};
const handleMouseEnter = () => {
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = null;
}
};
const handleMouseLeave = () => {
if (isPinned()) return;
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
}
popupHoverTimerRef.current = setTimeout(() => {
if (activePopupMarkerRef.current) {
activePopupMarkerRef.current.closePopup();
activePopupMarkerRef.current = null;
}
popupHoverTimerRef.current = null;
}, 200);
};
return (
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{ height: '100%', width: '100%' }}
>
<div style={{ height: '100%', width: '100%' }}>
<div className="pu-header">
<div className="pu-header-top">
<div className="pu-id">ORDER #{o.orderid}</div>
@@ -2254,21 +2281,31 @@ const Dispatch = ({
<div className="pu-section">
<div className="pu-section-label">Details</div>
<div className="pu-details-grid">
{o.pickupcustomer && (
{(o.pickupcustomer || o.locationname || o.pickuplocation) && (
<div className="pu-detail">
<div className="pu-detail-icon"><MdRestaurant /></div>
<div className="pu-detail-body">
<div className="pu-detail-label">Kitchen</div>
<div className="pu-detail-value" title={o.pickupcustomer}>{o.pickupcustomer}</div>
<div className="pu-detail-label">Pickup</div>
<div
className="pu-detail-value"
title={o.pickupcustomer || o.locationname || o.pickuplocation}
>
{o.pickupcustomer || o.locationname || o.pickuplocation}
</div>
</div>
</div>
)}
{(o.locationname || o.pickuplocation) && (
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="pu-detail">
<div className="pu-detail-icon"><MdPlace /></div>
<div className="pu-detail-body">
<div className="pu-detail-label">Pickup</div>
<div className="pu-detail-value" title={o.locationname || o.pickuplocation}>{o.locationname || o.pickuplocation}</div>
<div className="pu-detail-label">Drop</div>
<div
className="pu-detail-value"
title={o.deliveryaddress || o.deliverysuburb}
>
{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
</div>
</div>
)}
@@ -2403,46 +2440,37 @@ const Dispatch = ({
else delete orderMarkerRefs.current[String(o.orderid)];
}}
eventHandlers={{
mouseover: (e) => {
const marker = e.target;
mouseover: () => {
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = null;
}
activePopupMarkerRef.current = marker;
marker.openPopup();
setCenterPopupOrder(o);
},
mouseout: (e) => {
const marker = e.target;
mouseout: () => {
if (pinnedPopupsRef.current.has(String(o.orderid))) return;
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
}
popupHoverTimerRef.current = setTimeout(() => {
marker.closePopup();
if (activePopupMarkerRef.current === marker) {
activePopupMarkerRef.current = null;
}
setCenterPopupOrder((cur) =>
cur && String(cur.orderid) === String(o.orderid) ? null : cur
);
popupHoverTimerRef.current = null;
}, 200);
},
click: (e) => {
click: () => {
const id = String(o.orderid);
if (pinnedPopupsRef.current.has(id)) {
pinnedPopupsRef.current.delete(id);
e.target.closePopup();
setCenterPopupOrder(null);
} else {
pinnedPopupsRef.current.add(id);
e.target.openPopup();
setCenterPopupOrder(o);
}
}
}}
>
<Popup maxWidth={520} minWidth={460} className="dispatch-popup" autoPan={true} autoPanPadding={[40, 40]}>
{renderOrderPopupContent(o)}
</Popup>
</Marker>
/>
);
});
};
@@ -2963,11 +2991,10 @@ const Dispatch = ({
{shouldFetchLive && viewMode !== 'rider-info' && (
<div id="batch-row">
<span className="batch-label">Slot</span>
{/* Dropdown to pick which timestamp drives slot bucketing. Mirrors
the hub-location dropdown's look so it reads as the same kind of
filter control. The chosen field reruns batchCounts +
filteredLiveRows via selectedTimeField. */}
<span className="batch-label">Batch</span>
{/* Status-wise (time-field) filter is hidden for now per spec —
bucketing is locked to `assigntime`. Restore this block to bring
back the Delivered/Pending/Assigned/... dropdown.
<div className="time-field-wrap" ref={timeFieldMenuRef}>
<button
type="button"
@@ -2978,7 +3005,7 @@ const Dispatch = ({
title="Bucket slots by this timestamp"
>
<MdAccessTime />
<span className="time-field-text">{TIME_FIELDS.find((f) => f.id === selectedTimeField)?.label || 'Delivery'}</span>
<span className="time-field-text">{TIME_FIELDS.find((f) => f.id === selectedTimeField)?.label || 'Delivered'}</span>
<MdExpandMore className="time-field-caret" />
</button>
{timeFieldMenuOpen && (
@@ -3006,9 +3033,11 @@ const Dispatch = ({
</div>
)}
</div>
{/* Slot editor — lets the operator tweak start/end hours, add a new
slot, remove an existing one, or reset to defaults. Persists via
SLOTS_STORAGE_KEY in localStorage. */}
*/}
{/* Slot editor (Edit slots button + panel) is hidden for now per
spec — the three batches (Morning / Afternoon / Evening) are
fixed. Restore this block to bring back the operator-editable
start/end hours, add-slot, and reset-to-defaults controls.
<div className="slot-edit-wrap" ref={slotEditRef}>
<button
type="button"
@@ -3040,9 +3069,6 @@ const Dispatch = ({
step={0.5}
value={s.startHour}
onChange={(e) => {
// 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));
@@ -3127,6 +3153,7 @@ const Dispatch = ({
</div>
)}
</div>
*/}
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
horizontally when it overflows. */}
<div className="batch-scroll">
@@ -3191,7 +3218,7 @@ const Dispatch = ({
className={`ri-rider-item ${isActive ? 'active' : ''}`}
onClick={() => setRiderInfoUserid(r.id)}
>
<span className="ri-rider-dot" style={{ background: getRiderColor(r.id) }} />
<span className="ri-rider-dot" style={{ background: getStableRiderColor(r.id) }} />
<span className="ri-rider-info-block">
<span className="ri-rider-name">{r.riderName}</span>
<span className="ri-rider-meta">#{r.id}</span>
@@ -4371,32 +4398,29 @@ const Dispatch = ({
eventHandlers={
orderForTrack
? {
// Match the planned-route marker UX: hover opens
// the rich order popup, leaving it pinned while
// a step is focused (so click-to-focus keeps the
// modal visible after the cursor moves away). The
// Match the planned-route marker UX: hover surfaces
// the rich order card in the centered overlay. The
// ~200ms grace timer on mouseout lets the cursor
// travel onto the popup itself without flicker.
mouseover: (e) => {
const marker = e.target;
// travel onto the overlay without flicker. Pinning
// is implicit while focusedCompareStep === this
// step, so the card stays put while the user is
// inspecting this delivery.
mouseover: () => {
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = null;
}
activePopupMarkerRef.current = marker;
marker.openPopup();
setCenterPopupOrder(orderForTrack);
},
mouseout: (e) => {
mouseout: () => {
if (focusedCompareStep === t.sequenceStep) return;
const marker = e.target;
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
}
popupHoverTimerRef.current = setTimeout(() => {
marker.closePopup();
if (activePopupMarkerRef.current === marker) {
activePopupMarkerRef.current = null;
}
setCenterPopupOrder((cur) =>
cur && String(cur.orderid) === String(orderForTrack.orderid) ? null : cur
);
popupHoverTimerRef.current = null;
}, 200);
},
@@ -4452,17 +4476,6 @@ const Dispatch = ({
})()}
</Tooltip>
)}
{orderForTrack && (
<Popup
maxWidth={520}
minWidth={460}
className="dispatch-popup"
autoPan={true}
autoPanPadding={[40, 40]}
>
{renderOrderPopupContent(orderForTrack)}
</Popup>
)}
</Marker>
</React.Fragment>
);
@@ -4508,6 +4521,10 @@ const Dispatch = ({
</div> */}
</div>
{/* Right-corner rider/kitchen legend hidden per spec — the same
delivered/total count now renders inside the left sidebar's
rider card badge (see renderRiderCard). Restore this block to
bring back the floating top-right chip list.
<div id="ov-tr">
{viewMode === 'kitchens' ? (
kitchens.slice(0, 10).map(k => {
@@ -4541,6 +4558,7 @@ const Dispatch = ({
})
)}
</div>
*/}
<div id="ov-br">
<button className={`sbt ${isAnimating ? 'active' : ''}`} onClick={startAnimation} style={{ boxShadow: 'var(--shadow-lg)', background: isAnimating ? 'var(--accent)' : '#fff' }}>
@@ -4852,6 +4870,48 @@ const Dispatch = ({
</div>
)}
{/* Centered order popup — sibling of the map (NOT inside leaflet's
transformed panes) so position: fixed actually pins it to the
viewport. Replaces the marker-attached leaflet Popup so the rich
order card stays fully visible at the screen center on small
laptop displays. */}
{centerPopupOrder && (
<div
className="dispatch-popup-center"
role="dialog"
aria-label={`Order ${centerPopupOrder.orderid} details`}
onMouseEnter={() => {
if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = null;
}
}}
onMouseLeave={() => {
if (isOrderPopupPinned(centerPopupOrder)) return;
if (popupHoverTimerRef.current) clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = setTimeout(() => {
setCenterPopupOrder(null);
popupHoverTimerRef.current = null;
}, 200);
}}
>
<div className="dispatch-popup-card dispatch-popup">
<button
type="button"
className="dispatch-popup-center-close"
aria-label="Close order details"
onClick={() => {
pinnedPopupsRef.current.delete(String(centerPopupOrder.orderid));
setCenterPopupOrder(null);
}}
>
×
</button>
{renderOrderPopupContent(centerPopupOrder)}
</div>
</div>
)}
</div>
);
};