updates on the dispatch analysis page design updates
This commit is contained in:
@@ -9513,6 +9513,58 @@
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Auto-refresh indicator (analysis section).
|
||||||
|
A pill that sits next to the manual refresh button, telling the
|
||||||
|
operator the panel is polling and at what cadence. The dot pulses
|
||||||
|
green when idle, amber while a refresh is in flight. ─────────── */
|
||||||
|
.dispatch-container .da-detail-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-live-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #166534;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
padding: 4px 9px 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-live-tag.is-active {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-live-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18);
|
||||||
|
animation: da-live-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-live-tag.is-active .da-live-dot {
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.22);
|
||||||
|
animation: da-live-pulse 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes da-live-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(0.85); }
|
||||||
|
}
|
||||||
|
|
||||||
.dispatch-container .da-metric-grid {
|
.dispatch-container .da-metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -9872,6 +9924,227 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clickable chip variant — used for the "last position" GPS chip that
|
||||||
|
opens the in-app Leaflet position modal. Keeps the same shell as
|
||||||
|
.da-chip but reads as a link on hover. Also serves as a button reset
|
||||||
|
for the <button> element used as the trigger. */
|
||||||
|
.dispatch-container .da-chip.da-chip-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-chip.da-chip-link:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-chip.da-chip-link:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-chip.da-chip-link:focus-visible {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────────────────────────────────────────────────────
|
||||||
|
Rider last-position modal (Analysis section).
|
||||||
|
Top-level overlay: backdrop + centered card holding a Leaflet map.
|
||||||
|
Visual language matches the rest of the analysis surface
|
||||||
|
(gradient header strip, neutral chrome, brand purple accent).
|
||||||
|
─────────────────────────────────────────────────────────────────── */
|
||||||
|
.dispatch-container .da-pos-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 24px;
|
||||||
|
animation: da-pos-fade 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes da-pos-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-card {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow:
|
||||||
|
0 24px 60px rgba(15, 23, 42, 0.28),
|
||||||
|
0 8px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
animation: da-pos-rise 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes da-pos-rise {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-head {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: linear-gradient(135deg, #66258208 0%, #9255AB14 100%);
|
||||||
|
border-bottom: 1px solid #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-title {
|
||||||
|
font-size: 15.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #475569;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-close:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-close:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet requires a concrete pixel height on its container — a flex
|
||||||
|
`1 1 auto` with `min-height` collapses to 0 inside this card on some
|
||||||
|
layouts, leaving the tile pane blank. Pin a fixed height so the map
|
||||||
|
always has something to draw against. */
|
||||||
|
.dispatch-container .da-pos-modal-map {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 420px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-map .leaflet-container {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
background: #e2e8f0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-foot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-coord {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #334155;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-coord strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-sep {
|
||||||
|
margin: 0 8px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-copy {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #662582;
|
||||||
|
border: 1px solid #662582;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-copy:hover {
|
||||||
|
background: #4D1C61;
|
||||||
|
border-color: #4D1C61;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-container .da-pos-modal-copy:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Substitution opportunities ---- */
|
/* ---- Substitution opportunities ---- */
|
||||||
.dispatch-container .da-sub-list {
|
.dispatch-container .da-sub-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -308,9 +308,9 @@ function kalmanSmoothGps(pings, options = {}) {
|
|||||||
if (!Array.isArray(pings) || pings.length === 0) return [];
|
if (!Array.isArray(pings) || pings.length === 0) return [];
|
||||||
|
|
||||||
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
|
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
|
||||||
const cleanedPings = pings.filter(p =>
|
const cleanedPings = pings.filter(p =>
|
||||||
Number.isFinite(p.lat) &&
|
Number.isFinite(p.lat) &&
|
||||||
Number.isFinite(p.lng) &&
|
Number.isFinite(p.lng) &&
|
||||||
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
|
(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;
|
const speedKmh = (km / dtSec) * 3600;
|
||||||
|
|
||||||
if (speedKmh <= maxSpeedKmh) {
|
if (speedKmh <= maxSpeedKmh) {
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
|
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
|
||||||
if (startIdx + 2 < cleanedPings.length) {
|
if (startIdx + 2 < cleanedPings.length) {
|
||||||
@@ -847,6 +847,17 @@ const Dispatch = ({
|
|||||||
const [analysisResults, setAnalysisResults] = useState({});
|
const [analysisResults, setAnalysisResults] = useState({});
|
||||||
const [analysisLoadingWindow, setAnalysisLoadingWindow] = useState(null);
|
const [analysisLoadingWindow, setAnalysisLoadingWindow] = useState(null);
|
||||||
const [activeBatchKey, setActiveBatchKey] = 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
|
// TODO: wire to real tenant context once the standalone Dispatch screen
|
||||||
// surfaces it. 916 matches the example tenant in the API spec.
|
// surfaces it. 916 matches the example tenant in the API spec.
|
||||||
@@ -874,6 +885,37 @@ const Dispatch = ({
|
|||||||
tenantId: ANALYSIS_TENANT_ID
|
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 [activeRiders, setActiveRiders] = useState(new Set());
|
||||||
const [internalFocusedRider, setInternalFocusedRider] = useState(null);
|
const [internalFocusedRider, setInternalFocusedRider] = useState(null);
|
||||||
const [focusedKitchen, setFocusedKitchen] = useState(null);
|
const [focusedKitchen, setFocusedKitchen] = useState(null);
|
||||||
@@ -3799,20 +3841,20 @@ const Dispatch = ({
|
|||||||
// original sequence and confuse the operator.
|
// original sequence and confuse the operator.
|
||||||
const displayOrders = isTimeMode
|
const displayOrders = isTimeMode
|
||||||
? [...tOrders].sort((a, b) => {
|
? [...tOrders].sort((a, b) => {
|
||||||
const diff = completionTs(a) - completionTs(b);
|
const diff = completionTs(a) - completionTs(b);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
return (a.step || 0) - (b.step || 0);
|
return (a.step || 0) - (b.step || 0);
|
||||||
})
|
})
|
||||||
: tOrders;
|
: tOrders;
|
||||||
return (
|
return (
|
||||||
<div key={tNum} className="trip-block">
|
<div key={tNum} className="trip-block">
|
||||||
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
|
<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="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
|
||||||
<span className="trip-stats">
|
<span className="trip-stats">
|
||||||
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
|
<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><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
|
||||||
</span>
|
</span>
|
||||||
{/* iOS-style segmented control. Track sits in a soft
|
{/* iOS-style segmented control. Track sits in a soft
|
||||||
inset bg; the active item is a clean white "thumb"
|
inset bg; the active item is a clean white "thumb"
|
||||||
with a subtle shadow. We deliberately don't fill
|
with a subtle shadow. We deliberately don't fill
|
||||||
the active pill with the rider's color — the
|
the active pill with the rider's color — the
|
||||||
@@ -3820,190 +3862,190 @@ const Dispatch = ({
|
|||||||
identity, and doubling the accent makes the row
|
identity, and doubling the accent makes the row
|
||||||
read as competing pills instead of a controlled
|
read as competing pills instead of a controlled
|
||||||
hierarchy. The CSS owns all interactive states. */}
|
hierarchy. The CSS owns all interactive states. */}
|
||||||
<div
|
<div
|
||||||
className="trip-sort-toggle"
|
className="trip-sort-toggle"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Sort stops by"
|
aria-label="Sort stops by"
|
||||||
data-mode={isTimeMode ? 'time' : 'planned'}
|
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)"
|
|
||||||
>
|
>
|
||||||
<MdFormatListBulleted aria-hidden="true" />
|
<button
|
||||||
<span>Planned</span>
|
type="button"
|
||||||
</button>
|
className={`trip-sort-pill ${!isTimeMode ? 'is-active' : ''}`}
|
||||||
<button
|
aria-pressed={!isTimeMode}
|
||||||
type="button"
|
onClick={() => setTripSortMode('planned')}
|
||||||
className={`trip-sort-pill ${isTimeMode ? 'is-active' : ''}`}
|
title="Sort stops by planned step (dispatched order)"
|
||||||
aria-pressed={isTimeMode}
|
>
|
||||||
onClick={() => setTripSortMode('time')}
|
<MdFormatListBulleted aria-hidden="true" />
|
||||||
title="Sort stops by completion time (which delivery was done first)"
|
<span>Planned</span>
|
||||||
>
|
</button>
|
||||||
<MdAccessTime aria-hidden="true" />
|
<button
|
||||||
<span>By time</span>
|
type="button"
|
||||||
</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>
|
</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 || '—';
|
if (!uid) return fallback || '—';
|
||||||
// The placeholder "Rider 883" pattern is what we WANT to upgrade,
|
// The placeholder "Rider 883" pattern is what we WANT to upgrade,
|
||||||
// so don't treat it as a usable fallback — keep looking. Only
|
// so don't treat it as a usable fallback — keep looking. Only
|
||||||
// accept the fallback if it's a real name (no "Rider <digits>"
|
// accept the fallback if it's a real name (no "Rider <digits>"
|
||||||
// pattern).
|
// pattern).
|
||||||
const fallbackIsPlaceholder = typeof fallback === 'string' && /^Rider\s+\d+$/i.test(fallback.trim());
|
const fallbackIsPlaceholder = typeof fallback === 'string' && /^Rider\s+\d+$/i.test(fallback.trim());
|
||||||
const liveMatch = Array.isArray(riders)
|
const liveMatch = Array.isArray(riders)
|
||||||
? riders.find((x) => String(x.id) === uid)
|
? riders.find((x) => String(x.id) === uid)
|
||||||
@@ -5612,27 +5654,38 @@ const Dispatch = ({
|
|||||||
Fetched at {cached.fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'}
|
Fetched at {cached.fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="da-detail-actions">
|
||||||
type="button"
|
{/* Live indicator: confirms to the operator that the
|
||||||
className="da-result-refresh"
|
panel is polling and not just a stale snapshot. */}
|
||||||
title="Refresh"
|
<span
|
||||||
onClick={() => {
|
className={`da-live-tag ${isLoading ? 'is-active' : ''}`}
|
||||||
// Force refetch (bypass cache)
|
title={`Auto-refreshing every ${Math.round(ANALYSIS_POLL_MS / 1000)}s`}
|
||||||
setAnalysisResults((prev) => {
|
>
|
||||||
const next = { ...prev };
|
<span className="da-live-dot" />
|
||||||
delete next[activeBatchKey];
|
Live · {Math.round(ANALYSIS_POLL_MS / 1000)}s
|
||||||
return next;
|
</span>
|
||||||
});
|
<button
|
||||||
batchEfficiencyMutation.mutate({
|
type="button"
|
||||||
batch: activeBatchKey,
|
className="da-result-refresh"
|
||||||
tenantId: ANALYSIS_TENANT_ID
|
title="Refresh now"
|
||||||
});
|
onClick={() => {
|
||||||
}}
|
// Force refetch (bypass cache)
|
||||||
disabled={isLoading}
|
setAnalysisResults((prev) => {
|
||||||
style={{ background: `${activeMeta.color}22`, color: activeMeta.color }}
|
const next = { ...prev };
|
||||||
>
|
delete next[activeBatchKey];
|
||||||
<MdRefresh />
|
return next;
|
||||||
</button>
|
});
|
||||||
|
batchEfficiencyMutation.mutate({
|
||||||
|
batch: activeBatchKey,
|
||||||
|
tenantId: ANALYSIS_TENANT_ID
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{ background: `${activeMeta.color}22`, color: activeMeta.color }}
|
||||||
|
>
|
||||||
|
<MdRefresh />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Hero strip: the three numbers an ops lead glances at
|
{/* ─── Hero strip: the three numbers an ops lead glances at
|
||||||
@@ -5924,9 +5977,10 @@ const Dispatch = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats row: kitchen (with confidence dot
|
{/* 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">
|
<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}>
|
<span className="da-chip" title={r.kitchen_confidence != null ? `Kitchen confidence: ${(r.kitchen_confidence * 100).toFixed(0)}%` : undefined}>
|
||||||
<MdRestaurant /> {r.kitchen}
|
<MdRestaurant /> {r.kitchen}
|
||||||
{r.kitchen_confidence != null && (
|
{r.kitchen_confidence != null && (
|
||||||
@@ -5936,6 +5990,14 @@ const Dispatch = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</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}`}>
|
<span className="da-chip da-chip-orders" title={`Completed ${completed} of ${r.order_count}`}>
|
||||||
<MdInventory2 />
|
<MdInventory2 />
|
||||||
@@ -5969,6 +6031,26 @@ const Dispatch = ({
|
|||||||
>
|
>
|
||||||
<MdAccessTime /> {r.idle_minutes ?? 0} min idle
|
<MdAccessTime /> {r.idle_minutes ?? 0} min idle
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -6073,6 +6155,117 @@ const Dispatch = ({
|
|||||||
</div>
|
</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='© <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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user