updates on the dispatch analysis page design updates

This commit is contained in:
2026-06-04 04:15:57 +05:30
parent aea96476a4
commit 80d56805cf
2 changed files with 688 additions and 222 deletions

View File

@@ -308,9 +308,9 @@ function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return [];
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
const cleanedPings = pings.filter(p =>
Number.isFinite(p.lat) &&
Number.isFinite(p.lng) &&
const cleanedPings = pings.filter(p =>
Number.isFinite(p.lat) &&
Number.isFinite(p.lng) &&
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
);
@@ -343,7 +343,7 @@ function kalmanSmoothGps(pings, options = {}) {
const speedKmh = (km / dtSec) * 3600;
if (speedKmh <= maxSpeedKmh) {
break;
break;
} else {
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
if (startIdx + 2 < cleanedPings.length) {
@@ -847,6 +847,17 @@ const Dispatch = ({
const [analysisResults, setAnalysisResults] = useState({});
const [analysisLoadingWindow, setAnalysisLoadingWindow] = useState(null);
const [activeBatchKey, setActiveBatchKey] = useState(null);
// Position modal for rider's `last_position` in the analysis section.
// Shape: { lat, lon, name, userid, kitchen, status } | null.
// Held outside the analysis IIFE so a single MapContainer mounts at the
// top level (Leaflet is expensive to remount mid-list).
const [riderPositionModal, setRiderPositionModal] = useState(null);
useEffect(() => {
if (!riderPositionModal) return undefined;
const onKey = (e) => { if (e.key === 'Escape') setRiderPositionModal(null); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [riderPositionModal]);
// TODO: wire to real tenant context once the standalone Dispatch screen
// surfaces it. 916 matches the example tenant in the API spec.
@@ -874,6 +885,37 @@ const Dispatch = ({
tenantId: ANALYSIS_TENANT_ID
});
};
// ─── Auto-refresh the active analysis batch every 15s while the
// Analysis view is open. Skips firing when:
// • the user isn't on the Analysis tab,
// • no batch is selected,
// • the previous request is still in flight (prevents queue
// stacking on slow networks),
// • the browser tab is hidden (saves API quota on routes.workolik).
// Loading state is tracked through a ref so the interval doesn't
// reset on every in-flight flip. ─────────────────────────────────
const ANALYSIS_POLL_MS = 15000;
const analysisLoadingRef = useRef(null);
useEffect(() => {
analysisLoadingRef.current = analysisLoadingWindow;
}, [analysisLoadingWindow]);
useEffect(() => {
if (topView !== 'analysis') return undefined;
if (!activeBatchKey) return undefined;
const tick = () => {
if (analysisLoadingRef.current) return;
if (typeof document !== 'undefined' && document.hidden) return;
batchEfficiencyMutation.mutate({
batch: activeBatchKey,
tenantId: ANALYSIS_TENANT_ID
});
};
const id = setInterval(tick, ANALYSIS_POLL_MS);
return () => clearInterval(id);
// batchEfficiencyMutation is stable for the component lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topView, activeBatchKey]);
const [activeRiders, setActiveRiders] = useState(new Set());
const [internalFocusedRider, setInternalFocusedRider] = useState(null);
const [focusedKitchen, setFocusedKitchen] = useState(null);
@@ -3799,20 +3841,20 @@ const Dispatch = ({
// original sequence and confuse the operator.
const displayOrders = isTimeMode
? [...tOrders].sort((a, b) => {
const diff = completionTs(a) - completionTs(b);
if (diff !== 0) return diff;
return (a.step || 0) - (b.step || 0);
})
const diff = completionTs(a) - completionTs(b);
if (diff !== 0) return diff;
return (a.step || 0) - (b.step || 0);
})
: tOrders;
return (
<div key={tNum} className="trip-block">
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
<span className="trip-stats">
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
<span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</span>
{/* iOS-style segmented control. Track sits in a soft
<div key={tNum} className="trip-block">
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
<span className="trip-stats">
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
<span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</span>
{/* iOS-style segmented control. Track sits in a soft
inset bg; the active item is a clean white "thumb"
with a subtle shadow. We deliberately don't fill
the active pill with the rider's color — the
@@ -3820,190 +3862,190 @@ const Dispatch = ({
identity, and doubling the accent makes the row
read as competing pills instead of a controlled
hierarchy. The CSS owns all interactive states. */}
<div
className="trip-sort-toggle"
role="group"
aria-label="Sort stops by"
data-mode={isTimeMode ? 'time' : 'planned'}
>
<button
type="button"
className={`trip-sort-pill ${!isTimeMode ? 'is-active' : ''}`}
aria-pressed={!isTimeMode}
onClick={() => setTripSortMode('planned')}
title="Sort stops by planned step (dispatched order)"
<div
className="trip-sort-toggle"
role="group"
aria-label="Sort stops by"
data-mode={isTimeMode ? 'time' : 'planned'}
>
<MdFormatListBulleted aria-hidden="true" />
<span>Planned</span>
</button>
<button
type="button"
className={`trip-sort-pill ${isTimeMode ? 'is-active' : ''}`}
aria-pressed={isTimeMode}
onClick={() => setTripSortMode('time')}
title="Sort stops by completion time (which delivery was done first)"
>
<MdAccessTime aria-hidden="true" />
<span>By time</span>
</button>
<button
type="button"
className={`trip-sort-pill ${!isTimeMode ? 'is-active' : ''}`}
aria-pressed={!isTimeMode}
onClick={() => setTripSortMode('planned')}
title="Sort stops by planned step (dispatched order)"
>
<MdFormatListBulleted aria-hidden="true" />
<span>Planned</span>
</button>
<button
type="button"
className={`trip-sort-pill ${isTimeMode ? 'is-active' : ''}`}
aria-pressed={isTimeMode}
onClick={() => setTripSortMode('time')}
title="Sort stops by completion time (which delivery was done first)"
>
<MdAccessTime aria-hidden="true" />
<span>By time</span>
</button>
</div>
</div>
<div className="zone-order-grid">
{displayOrders.map((o, idx) => {
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey;
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
const isGoingOn = activeOrderId && o.orderid === activeOrderId;
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const statusStyle = getStatusStyle(o.orderstatus);
const profit = parseFloat(o.profit || 0);
const isLoss = profit < 0;
const estMeters = calculateEstMeters(focusedRider.id, o);
// Badge number is ALWAYS the planned step (o.step)
// — the same number regardless of sort mode. Time
// mode only reorders the cards; the badge keeps
// its dispatched identity so operators can still
// map a card on screen back to "step 3 of the
// planned route" without mental translation.
const displayNum = o.step || idx + 1;
// "Not yet delivered" indicator for time mode — we
// pushed these rows to the end via MAX_SAFE_INTEGER
// sort key, but a visual cue makes that obvious
// without forcing the operator to read the status pill.
const isUndeliveredInTimeMode =
isTimeMode && !o.deliverytime;
return (
<React.Fragment key={o.orderid}>
{showTransition && (
<div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
)}
<div
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''} ${isUndeliveredInTimeMode ? 'is-pending-time' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
>
<div className="zone-order-card-head">
<div
className="zone-order-num"
title={`Planned step ${o.step || idx + 1}`}
>
{displayNum}
</div>
<div className="zone-order-id-block">
<div className="zone-order-id">Order #{o.orderid}</div>
</div>
{(() => {
// Stack the status pill and delivery time vertically on
// the right of the header so the operator sees the order
// outcome and the wall-clock time it landed at a glance.
const actual = formatTimeOnly(o.deliverytime);
const expected = formatTimeOnly(o.expecteddeliverytime);
const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase());
const showEstDrop = !isDelivered && estMeters !== null;
if (!o.orderstatus && !actual && !expected && !showEstDrop) return null;
return (
<div className="zone-order-status-stack">
{o.orderstatus && (
<span
className="zone-order-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{(actual || expected) && (
<span
className={`zone-order-time ${actual ? '' : 'is-expected'}`}
title={actual ? `Delivered at ${actual}` : `Expected at ${expected}`}
>
<MdAccessTime />{actual || expected}
</span>
)}
{showEstDrop && (
<span
className="zone-order-est-drop"
title="Estimated distance to drop location"
>
<MdMyLocation />{formatMeters(estMeters)}
</span>
)}
</div>
);
})()}
{onChangeRider && (
<button
type="button"
className="zone-order-change-rider"
title="Change rider"
onClick={(e) => {
e.stopPropagation();
onChangeRider(o, focusedRider);
}}
>
<MdSwapHoriz />
</button>
)}
</div>
<div className="zone-order-customer">
<Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || ''}
</div>
{o.pickupcustomer && (
<div className="zone-order-line" title={`Kitchen: ${o.pickupcustomer}`}>
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
</div>
)}
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
)}
{o.ordernotes && (
<div className="zone-order-line zone-order-notes" title={o.ordernotes}>
<Ico><MdNotes /></Ico>{o.ordernotes}
</div>
)}
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}{Math.abs(profit).toFixed(0)}
</span>
{o.deliverycharge != null && (
<span className="zone-order-chip" title="Delivery charge">
{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
</span>
)}
<span className="zone-order-chip zone-order-trip">
T{o.trip_number || tNum} · S{o.step || idx + 1}
</span>
</div>
</div>
</React.Fragment>
);
})}
</div>
</div>
<div className="zone-order-grid">
{displayOrders.map((o, idx) => {
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey;
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
const isGoingOn = activeOrderId && o.orderid === activeOrderId;
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const statusStyle = getStatusStyle(o.orderstatus);
const profit = parseFloat(o.profit || 0);
const isLoss = profit < 0;
const estMeters = calculateEstMeters(focusedRider.id, o);
// Badge number is ALWAYS the planned step (o.step)
// — the same number regardless of sort mode. Time
// mode only reorders the cards; the badge keeps
// its dispatched identity so operators can still
// map a card on screen back to "step 3 of the
// planned route" without mental translation.
const displayNum = o.step || idx + 1;
// "Not yet delivered" indicator for time mode — we
// pushed these rows to the end via MAX_SAFE_INTEGER
// sort key, but a visual cue makes that obvious
// without forcing the operator to read the status pill.
const isUndeliveredInTimeMode =
isTimeMode && !o.deliverytime;
return (
<React.Fragment key={o.orderid}>
{showTransition && (
<div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
)}
<div
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''} ${isUndeliveredInTimeMode ? 'is-pending-time' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
>
<div className="zone-order-card-head">
<div
className="zone-order-num"
title={`Planned step ${o.step || idx + 1}`}
>
{displayNum}
</div>
<div className="zone-order-id-block">
<div className="zone-order-id">Order #{o.orderid}</div>
</div>
{(() => {
// Stack the status pill and delivery time vertically on
// the right of the header so the operator sees the order
// outcome and the wall-clock time it landed at a glance.
const actual = formatTimeOnly(o.deliverytime);
const expected = formatTimeOnly(o.expecteddeliverytime);
const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase());
const showEstDrop = !isDelivered && estMeters !== null;
if (!o.orderstatus && !actual && !expected && !showEstDrop) return null;
return (
<div className="zone-order-status-stack">
{o.orderstatus && (
<span
className="zone-order-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{(actual || expected) && (
<span
className={`zone-order-time ${actual ? '' : 'is-expected'}`}
title={actual ? `Delivered at ${actual}` : `Expected at ${expected}`}
>
<MdAccessTime />{actual || expected}
</span>
)}
{showEstDrop && (
<span
className="zone-order-est-drop"
title="Estimated distance to drop location"
>
<MdMyLocation />{formatMeters(estMeters)}
</span>
)}
</div>
);
})()}
{onChangeRider && (
<button
type="button"
className="zone-order-change-rider"
title="Change rider"
onClick={(e) => {
e.stopPropagation();
onChangeRider(o, focusedRider);
}}
>
<MdSwapHoriz />
</button>
)}
</div>
<div className="zone-order-customer">
<Ico><MdMarkunreadMailbox /></Ico>{o.deliverycustomer || ''}
</div>
{o.pickupcustomer && (
<div className="zone-order-line" title={`Kitchen: ${o.pickupcustomer}`}>
<Ico><MdRestaurant /></Ico>{o.pickupcustomer}
</div>
)}
{(o.deliverysuburb || o.deliveryaddress) && (
<div className="zone-order-line" title={o.deliveryaddress || o.deliverysuburb}>
<Ico><MdLocationOn /></Ico>{o.deliverysuburb || extractArea(o.deliveryaddress)}
</div>
)}
{o.ordernotes && (
<div className="zone-order-line zone-order-notes" title={o.ordernotes}>
<Ico><MdNotes /></Ico>{o.ordernotes}
</div>
)}
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}{Math.abs(profit).toFixed(0)}
</span>
{o.deliverycharge != null && (
<span className="zone-order-chip" title="Delivery charge">
{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
</span>
)}
<span className="zone-order-chip zone-order-trip">
T{o.trip_number || tNum} · S{o.step || idx + 1}
</span>
</div>
</div>
</React.Fragment>
);
})}
</div>
</div>
);
});
})()}
@@ -5511,8 +5553,8 @@ const Dispatch = ({
if (!uid) return fallback || '—';
// The placeholder "Rider 883" pattern is what we WANT to upgrade,
// so don't treat it as a usable fallback — keep looking. Only
// accept the fallback if it's a real name (no "Rider <digits>"
// pattern).
// accept the fallback if it's a real name (no "Rider <digits>"
// pattern).
const fallbackIsPlaceholder = typeof fallback === 'string' && /^Rider\s+\d+$/i.test(fallback.trim());
const liveMatch = Array.isArray(riders)
? riders.find((x) => String(x.id) === uid)
@@ -5612,27 +5654,38 @@ const Dispatch = ({
Fetched at {cached.fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'}
</div>
</div>
<button
type="button"
className="da-result-refresh"
title="Refresh"
onClick={() => {
// Force refetch (bypass cache)
setAnalysisResults((prev) => {
const next = { ...prev };
delete next[activeBatchKey];
return next;
});
batchEfficiencyMutation.mutate({
batch: activeBatchKey,
tenantId: ANALYSIS_TENANT_ID
});
}}
disabled={isLoading}
style={{ background: `${activeMeta.color}22`, color: activeMeta.color }}
>
<MdRefresh />
</button>
<div className="da-detail-actions">
{/* Live indicator: confirms to the operator that the
panel is polling and not just a stale snapshot. */}
<span
className={`da-live-tag ${isLoading ? 'is-active' : ''}`}
title={`Auto-refreshing every ${Math.round(ANALYSIS_POLL_MS / 1000)}s`}
>
<span className="da-live-dot" />
Live · {Math.round(ANALYSIS_POLL_MS / 1000)}s
</span>
<button
type="button"
className="da-result-refresh"
title="Refresh now"
onClick={() => {
// Force refetch (bypass cache)
setAnalysisResults((prev) => {
const next = { ...prev };
delete next[activeBatchKey];
return next;
});
batchEfficiencyMutation.mutate({
batch: activeBatchKey,
tenantId: ANALYSIS_TENANT_ID
});
}}
disabled={isLoading}
style={{ background: `${activeMeta.color}22`, color: activeMeta.color }}
>
<MdRefresh />
</button>
</div>
</div>
{/* ─── Hero strip: the three numbers an ops lead glances at
@@ -5924,9 +5977,10 @@ const Dispatch = ({
</div>
{/* Stats row: kitchen (with confidence dot
when low), orders mini-bar, pace, idle. */}
when low), orders mini-bar, pace, active,
idle, free-window, and last GPS position. */}
<div className="da-timeline-mid">
{r.kitchen && (
{r.kitchen ? (
<span className="da-chip" title={r.kitchen_confidence != null ? `Kitchen confidence: ${(r.kitchen_confidence * 100).toFixed(0)}%` : undefined}>
<MdRestaurant /> {r.kitchen}
{r.kitchen_confidence != null && (
@@ -5936,6 +5990,14 @@ const Dispatch = ({
/>
)}
</span>
) : (
<span
className="da-chip"
style={{ background: '#f8fafc', color: '#64748b', borderColor: '#e2e8f0', borderStyle: 'dashed' }}
title="Solver did not identify a primary kitchen for this rider"
>
<MdRestaurant /> Unassigned
</span>
)}
<span className="da-chip da-chip-orders" title={`Completed ${completed} of ${r.order_count}`}>
<MdInventory2 />
@@ -5969,6 +6031,26 @@ const Dispatch = ({
>
<MdAccessTime /> {r.idle_minutes ?? 0} min idle
</span>
{/* Free window: time available before the next
batch starts. Often equals idle_minutes but
diverges when the next batch sits closer to
this rider's finish — operators use this to
decide who can take an extra run. */}
{r.free_window_minutes != null && (
<span
className="da-chip"
style={
r.free_window_minutes >= 30
? { background: '#ecfdf5', color: '#065f46', borderColor: '#a7f3d0' }
: r.free_window_minutes > 0
? { background: '#fefce8', color: '#854d0e', borderColor: '#fde68a' }
: { background: '#fef2f2', color: '#991b1b', borderColor: '#fecaca' }
}
title="Free window before the next batch — capacity available for a follow-up run"
>
<MdTimer /> {parseFloat(r.free_window_minutes).toFixed(0)} min free
</span>
)}
</div>
</div>
);
@@ -6073,6 +6155,117 @@ const Dispatch = ({
</div>
)}
{/* ─── Rider last-position modal (Analysis section).
A small Leaflet map pinned to the rider's `last_position` from
/batch/efficiency. Mounted at the top level so the MapContainer
is created/destroyed exactly once per open — Leaflet maps are
expensive to remount and behave badly when their parent moves
around in the tree. */}
{riderPositionModal && (
<div
className="da-pos-modal-backdrop"
role="dialog"
aria-modal="true"
aria-label={`Last known position of ${riderPositionModal.name}`}
onClick={(e) => {
if (e.target === e.currentTarget) setRiderPositionModal(null);
}}
>
<div className="da-pos-modal-card">
<div className="da-pos-modal-head">
<div className="da-pos-modal-title-wrap">
<div
className="da-pos-modal-avatar"
style={{ background: `${riderPositionModal.color || '#662582'}22`, color: riderPositionModal.color || '#662582' }}
>
<MdTwoWheeler />
</div>
<div>
<div className="da-pos-modal-title">{riderPositionModal.name}</div>
<div className="da-pos-modal-sub">
#{riderPositionModal.userid}
{riderPositionModal.kitchen && <> · {riderPositionModal.kitchen}</>}
{riderPositionModal.finished_at && <> · finished {riderPositionModal.finished_at}</>}
</div>
</div>
</div>
<div className="da-pos-modal-actions">
<span
className={`da-pill ${String(riderPositionModal.status || '').toLowerCase() === 'active' ? 'is-active' : 'is-idle'}`}
>
{riderPositionModal.status || '—'}
</span>
<button
type="button"
className="da-pos-modal-close"
aria-label="Close"
onClick={() => setRiderPositionModal(null)}
>
<MdClose />
</button>
</div>
</div>
<div className="da-pos-modal-map">
<MapContainer
key={`${riderPositionModal.userid}-${riderPositionModal.lat}-${riderPositionModal.lon}`}
center={[riderPositionModal.lat, riderPositionModal.lon]}
/* Zoom 13 ≈ city-district view: enough surrounding roads
and landmarks to give the marker spatial context. Was
16 (block level) which felt over-zoomed for ops. */
zoom={13}
scrollWheelZoom
style={{ width: '100%', height: '100%' }}
/* Leaflet caches the container size at construction; the
modal mounts mid-animation (transform + opacity) so the
measured size is stale. Force a recalculation on the
next frame and once more after the rise animation
completes (~240ms) so the tile pane fills correctly. */
whenReady={(e) => {
const map = e.target;
requestAnimationFrame(() => map.invalidateSize());
setTimeout(() => map.invalidateSize(), 260);
}}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[riderPositionModal.lat, riderPositionModal.lon]}>
<Popup>
<strong>{riderPositionModal.name}</strong>
<br />
{riderPositionModal.lat.toFixed(6)}, {riderPositionModal.lon.toFixed(6)}
</Popup>
</Marker>
</MapContainer>
</div>
<div className="da-pos-modal-foot">
<div className="da-pos-modal-coord">
<MdLocationOn />
<span>
<strong>Lat:</strong> {riderPositionModal.lat.toFixed(6)}
<span className="da-pos-modal-sep">·</span>
<strong>Lon:</strong> {riderPositionModal.lon.toFixed(6)}
</span>
</div>
<button
type="button"
className="da-pos-modal-copy"
onClick={() => {
const txt = `${riderPositionModal.lat.toFixed(6)}, ${riderPositionModal.lon.toFixed(6)}`;
if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(txt);
}}
title="Copy coordinates"
>
Copy
</button>
</div>
</div>
</div>
)}
</div>
);
};