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

@@ -9513,6 +9513,58 @@
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -9872,6 +9924,227 @@
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 ---- */
.dispatch-container .da-sub-list {
display: flex;

View File

@@ -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);
@@ -5612,10 +5654,20 @@ const Dispatch = ({
Fetched at {cached.fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'}
</div>
</div>
<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"
title="Refresh now"
onClick={() => {
// Force refetch (bypass cache)
setAnalysisResults((prev) => {
@@ -5634,6 +5686,7 @@ const Dispatch = ({
<MdRefresh />
</button>
</div>
</div>
{/* ─── Hero strip: the three numbers an ops lead glances at
first — workload, headcount, and how long the batch
@@ -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>
);
};