From aea96476a41798e2fd870821c333cdefef493547 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Thu, 4 Jun 2026 02:11:36 +0530 Subject: [PATCH] updates on the polyline breaks fix --- src/pages/nearle/dispatch/Dispatch.css | 433 ++++++++++++++++++++ src/pages/nearle/dispatch/Dispatch.js | 545 ++++++++++++++++++++----- 2 files changed, 880 insertions(+), 98 deletions(-) diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 45b0cd0..79611eb 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -9985,4 +9985,437 @@ font-weight: 800; padding: 2px 8px; border-radius: 10px; +} + +/* ========================================================================= + ANALYSIS PAGE — refreshed UI primitives (added 2026-06-03) + ========================================================================= */ + +/* ─── Hero strip: three prominent tiles at the top of the analysis card. + Border-top stripe carries the batch / metric accent colour. ────────── */ +.dispatch-container .da-hero-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +@media (max-width: 900px) { + .dispatch-container .da-hero-row { + grid-template-columns: 1fr; + } +} + +.dispatch-container .da-hero-card { + position: relative; + padding: 18px 18px 16px; + background: #fff; + border: 1px solid #e2e8f0; + border-top: 3px solid #94a3b8; + border-radius: 12px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + +.dispatch-container .da-hero-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + margin-bottom: 4px; +} + +.dispatch-container .da-hero-value { + font-size: 32px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.02em; + line-height: 1.05; +} + +.dispatch-container .da-hero-unit { + font-size: 16px; + font-weight: 600; + color: #64748b; + margin-left: 2px; +} + +.dispatch-container .da-hero-label { + font-size: 11px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 2px; +} + +.dispatch-container .da-hero-sub { + font-size: 12px; + font-weight: 500; + color: #94a3b8; + margin-top: 6px; +} + +/* ─── Health KPI band: a row of secondary indicators. + First card hosts the utilisation ring; the rest are stat tiles. ───── */ +.dispatch-container .da-health-row { + display: grid; + grid-template-columns: 1.2fr repeat(3, 1fr); + gap: 12px; +} + +@media (max-width: 1100px) { + .dispatch-container .da-health-row { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 600px) { + .dispatch-container .da-health-row { + grid-template-columns: 1fr; + } +} + +.dispatch-container .da-health-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 14px 16px; + display: flex; + align-items: center; + gap: 14px; + min-width: 0; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.dispatch-container .da-health-card-stat { + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.dispatch-container .da-health-value { + font-size: 24px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.dispatch-container .da-health-unit { + font-size: 13px; + font-weight: 600; + color: #64748b; + margin-left: 2px; +} + +.dispatch-container .da-health-label { + font-size: 11px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.dispatch-container .da-health-sub { + font-size: 11.5px; + font-weight: 500; + color: #94a3b8; +} + +.dispatch-container .da-health-meta { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +/* ─── Utilisation ring (CSS-only conic gradient). + Driven by two custom properties set inline from JSX: + --ring-color: rim accent + --ring-pct: 0–100, fills the ring proportionally ─────────────── */ +.dispatch-container .da-ring { + --ring-color: #10b981; + --ring-pct: 0; + width: 72px; + height: 72px; + border-radius: 50%; + background: + conic-gradient(var(--ring-color) calc(var(--ring-pct) * 1%), #f1f5f9 0); + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + flex-shrink: 0; +} + +.dispatch-container .da-ring::after { + content: ''; + position: absolute; + inset: 6px; + background: #fff; + border-radius: 50%; +} + +.dispatch-container .da-ring-num { + position: relative; + z-index: 1; + font-size: 18px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.02em; +} + +.dispatch-container .da-ring-unit { + font-size: 11px; + font-weight: 700; + color: #64748b; + margin-left: 1px; +} + +/* ─── Recommendation banner — severity variants. + Replaces .da-rec / .da-rec-empty with an alert-style row that + surfaces backend `reason` + `fleet_balance_assessment` text. ──────── */ +.dispatch-container .da-rec-banner { + display: flex; + gap: 14px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid; + align-items: flex-start; +} + +.dispatch-container .da-rec-banner.is-action { + background: #eff6ff; + border-color: #bfdbfe; + color: #1e3a8a; +} + +.dispatch-container .da-rec-banner.is-warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; +} + +.dispatch-container .da-rec-banner.is-success { + background: #f0fdf4; + border-color: #bbf7d0; + color: #166534; +} + +.dispatch-container .da-rec-banner.is-info { + background: #f8fafc; + border-color: #e2e8f0; + color: #475569; +} + +.dispatch-container .da-rec-banner-icon { + flex: 0 0 auto; + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.55); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.dispatch-container .da-rec-banner-body { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.dispatch-container .da-rec-banner .da-rec-action { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 700; + color: inherit; + text-transform: capitalize; + letter-spacing: -0.005em; +} + +.dispatch-container .da-rec-banner .da-rec-desc, +.dispatch-container .da-rec-banner .da-rec-line { + font-size: 12.5px; + line-height: 1.5; + color: inherit; + opacity: 0.9; +} + +.dispatch-container .da-rec-banner .da-rec-assess { + margin-top: 2px; + font-size: 12px; + font-weight: 500; + line-height: 1.55; + padding: 8px 10px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.04); + color: inherit; + opacity: 0.95; +} + +/* ─── Section label "hint" — small grey caption next to the label. ─── */ +.dispatch-container .da-section-hint { + margin-left: 10px; + font-size: 11px; + font-weight: 500; + color: #94a3b8; + text-transform: none; + letter-spacing: 0; +} + +/* ─── Gantt axis (shared header above all rider bars). ──────────────── */ +.dispatch-container .da-gantt-axis { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 4px 6px; + font-size: 11px; + font-weight: 600; + color: #64748b; +} + +.dispatch-container .da-gantt-axis-mid { + font-weight: 500; + color: #94a3b8; + font-style: italic; +} + +/* ─── Per-rider gantt bar. + Track is a soft slab; .da-gantt-fill is the rider's active span + (positioned via inline left%/width%). The trailing .da-gantt-idle + is a diagonal hatch tail rendering "fleet kept going after this + rider finished". ──────────────────────────────────────────────── */ +.dispatch-container .da-gantt-row { + display: flex; + align-items: center; + gap: 10px; + margin: 8px 0 4px; +} + +.dispatch-container .da-gantt-track { + position: relative; + flex: 1; + height: 24px; + background: #f1f5f9; + border-radius: 8px; + overflow: hidden; +} + +.dispatch-container .da-gantt-fill { + position: absolute; + top: 0; + bottom: 0; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px; + color: #fff; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.01em; + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.08); + overflow: hidden; + white-space: nowrap; +} + +.dispatch-container .da-gantt-fill-start, +.dispatch-container .da-gantt-fill-end { + text-shadow: 0 1px 1px rgba(15, 23, 42, 0.18); + flex-shrink: 0; +} + +.dispatch-container .da-gantt-idle { + position: absolute; + top: 0; + bottom: 0; + background-image: + repeating-linear-gradient( + 135deg, + #e2e8f0 0 6px, + #f1f5f9 6px 12px + ); + border-left: 1px dashed #cbd5e1; +} + +.dispatch-container .da-gantt-util { + flex: 0 0 auto; + min-width: 44px; + text-align: center; + font-size: 11.5px; + font-weight: 800; + padding: 4px 8px; + border-radius: 6px; + letter-spacing: 0.01em; +} + +/* ─── Kitchen-confidence dot inside the kitchen chip. ──────────────── */ +.dispatch-container .da-conf-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-left: 4px; + flex-shrink: 0; +} + +.dispatch-container .da-conf-dot.is-ok { + background: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18); +} + +.dispatch-container .da-conf-dot.is-low { + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); +} + +/* ─── Orders mini progress bar inside a chip. ──────────────────────── */ +.dispatch-container .da-chip.da-chip-orders { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.dispatch-container .da-orders-label { + font-weight: 700; + color: #0f172a; + font-size: 11.5px; +} + +.dispatch-container .da-orders-bar { + position: relative; + width: 56px; + height: 6px; + border-radius: 4px; + background: #e2e8f0; + overflow: hidden; + flex-shrink: 0; +} + +.dispatch-container .da-orders-bar-fill { + position: absolute; + inset: 0; + background: linear-gradient(90deg, #10b981 0%, #34d399 100%); + border-radius: 4px; + width: 0; + transition: width 0.3s ease; +} + +.dispatch-container .da-orders-pending { + color: #b45309; + font-weight: 600; + font-size: 10.5px; } \ No newline at end of file diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 30a2454..b393c04 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -596,6 +596,12 @@ const buildTripPoints = (sorted) => { return pts; }; +const getTripCacheKey = (riderId, tripKey, points) => { + if (!points || points.length === 0) return `${riderId}-${tripKey}`; + const pointsSig = points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join('|'); + return `${riderId}-${tripKey}-${pointsSig}`; +}; + // Fix for default leaflet marker icons delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ @@ -786,6 +792,32 @@ const analysisFormatPct = (v) => { if (!Number.isFinite(n)) return '—'; return `${n > 1 ? n.toFixed(1) : (n * 100).toFixed(1)}%`; }; +// Parse "HH:mm:ss" or "HH:mm" → seconds since midnight. Returns null when the +// string is missing or malformed. Used to compute gantt percentages for the +// rider timelines on the Analysis page — the API ships those fields as bare +// clock strings, not full ISO datetimes, so we can't lean on dayjs. +const parseHMS = (s) => { + if (!s || typeof s !== 'string') return null; + const parts = s.split(':').map(Number); + if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return null; + const [h, m, sec = 0] = parts; + return h * 3600 + m * 60 + sec; +}; +// Derive an "imbalance severity" bucket from the assessment text + stdev. +// Used to color the recommendation banner correctly when `action == 'none'` — +// the current backend returns no action but still flags real imbalance in +// `fleet_balance_assessment` (e.g. "High imbalance — 57 min spread"), and +// muting that behind a green "balanced" empty state would be misleading. +const analysisSeverity = ({ assessment, spreadMin, stdev }) => { + const text = String(assessment || '').toLowerCase(); + if (/high imbalance|severe|critical/.test(text)) return 'warn'; + if (/imbalance|recommend/.test(text)) return 'warn'; + // Numeric fallback when the assessment string is missing / unrecognised. + if (spreadMin != null && spreadMin > 30) return 'warn'; + if (stdev != null && stdev > 1.0) return 'warn'; + if (/balanced|excellent|good/.test(text)) return 'success'; + return 'info'; +}; const Dispatch = ({ data, @@ -1901,13 +1933,13 @@ const Dispatch = ({ }, [compareOpen]); const fetchRoute = useCallback(async (riderId, tripKey, points) => { - const cacheKey = `${riderId}-${tripKey}`; + if (points.length < 2) return; + const cacheKey = getTripCacheKey(riderId, tripKey, points); // Use the ref (not state) for the in-flight / already-cached check so this // callback doesn't need osrmRoutes in its deps — that old pattern caused a // render loop: each resolved route updated state → recreated fetchRoute → // re-ran all route-fetching effects for every rider. if (osrmRoutesRef.current[cacheKey] !== undefined) return; - if (points.length < 2) return; // Mark in-flight in both ref (immediate) and state (triggers re-render). osrmRoutesRef.current[cacheKey] = null; @@ -2180,7 +2212,12 @@ const Dispatch = ({ if (filteredTOrders.length === 0) return; - const cacheKey = `${r.id}-${tNum}`; + // Use the full trip orders (unfiltered by kitchen) to build the cache key + // so it matches the key used to fetch the route in the useEffect. + const fullSorted = [...tOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); + const fullPts = buildTripPoints(fullSorted); + const cacheKey = getTripCacheKey(r.id, tNum, fullPts); + const roadPath = osrmRoutes[cacheKey]; const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); @@ -2696,7 +2733,12 @@ const Dispatch = ({ if (filteredTOrders.length === 0) return; - const cacheKey = `${r.id}-${tNum}`; + // Use the full trip orders (unfiltered by kitchen) to build the cache key + // so it matches the key used to fetch the route in the useEffect. + const fullSorted = [...tOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); + const fullPts = buildTripPoints(fullSorted); + const cacheKey = getTripCacheKey(r.id, tNum, fullPts); + const roadPoints = osrmRoutes[cacheKey]; const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); @@ -5446,21 +5488,111 @@ const Dispatch = ({ } const fleet = raw.fleet_summary || {}; - const riders = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : []; + // Renamed from `riders` to `apiRiders` so the outer-component + // `riders` array (built from live dispatch data, has the real + // riderName fields) stays accessible inside this block — we + // join against it below to swap placeholder API names like + // "Rider 883" for actual operator-facing names. + const apiRiders = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : []; const subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : []; const rec = raw.top_recommendation; const hasRecRider = !!(rec && (rec.idle_rider_name || rec.idle_rider_id)); const hasRec = !!(rec && rec.action && rec.action !== 'none' && hasRecRider); const win = raw.window || {}; - const fleetMetrics = [ - { label: 'Total Orders', value: analysisFormatNum(fleet.total_orders) }, - { label: 'Total Riders', value: analysisFormatNum(fleet.total_riders) }, - { label: 'Avg Orders/Rider', value: fleet.orders_per_rider_avg ?? '—' }, - { label: 'Fleet Start', value: fleet.fleet_start || '—' }, - { label: 'Fleet Done', value: fleet.fleet_done || '—' }, - { label: 'Duration', value: fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min` : '—' } - ]; + // userid → real rider name. The /batch/efficiency response only + // ships placeholder names ("Rider 883") because the workolik + // solver doesn't have our auth/users table. We resolve here: + // 1. against `riders` (current dispatch data — has riderName) + // 2. against `ridersAllDay` (full-day list built from live rows) + // 3. fall back to whatever the API sent. + const resolveRiderName = (uidLike, fallback) => { + const uid = String(uidLike ?? '').trim(); + 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 " + // 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) + : null; + if (liveMatch && liveMatch.riderName) return liveMatch.riderName; + const allDayMatch = Array.isArray(ridersAllDay) + ? ridersAllDay.find((x) => String(x.id) === uid) + : null; + if (allDayMatch && allDayMatch.riderName) return allDayMatch.riderName; + if (!fallbackIsPlaceholder && fallback) return fallback; + return `Rider ${uid}`; + }; + + // ─── Fleet-window math for the gantt + duration display ───────── + const fleetStartSec = parseHMS(fleet.fleet_start); + const fleetEndSec = parseHMS(fleet.fleet_done); + const fleetTotalSec = + fleetStartSec != null && fleetEndSec != null && fleetEndSec > fleetStartSec + ? fleetEndSec - fleetStartSec + : null; + + // ─── Utilisation health bucket ───────────────────────────────── + const util = parseFloat(fleet.avg_utilisation_pct); + const utilSafe = Number.isFinite(util) ? util : null; + const utilHealth = + utilSafe == null ? 'unknown' + : utilSafe >= 85 ? 'good' + : utilSafe >= 70 ? 'ok' + : 'low'; + const utilColor = + utilHealth === 'good' ? '#10b981' + : utilHealth === 'ok' ? '#f59e0b' + : utilHealth === 'low' ? '#ef4444' + : '#94a3b8'; + + // ─── Severity for the recommendation banner ──────────────────── + const severity = analysisSeverity({ + assessment: rec?.fleet_balance_assessment, + spreadMin: parseFloat(fleet.finish_time_spread_minutes), + stdev: parseFloat(fleet.load_balance_stdev) + }); + + // ─── Augment each rider with gantt percentages + per-rider util ─ + // `apiRiders` is the API payload's `rider_timelines`; we also + // attach `displayName` (resolved against the in-app riders + // lookup) so the JSX below renders real names instead of the + // placeholder "Rider {userid}" the API returns. + const ridersWithGantt = apiRiders.map((r) => { + const startSec = parseHMS(r.started_at); + const endSec = parseHMS(r.finished_at); + let startPct = 0; + let endPct = 100; + if (fleetStartSec != null && fleetTotalSec) { + if (startSec != null) startPct = Math.max(0, Math.min(100, ((startSec - fleetStartSec) / fleetTotalSec) * 100)); + if (endSec != null) endPct = Math.max(0, Math.min(100, ((endSec - fleetStartSec) / fleetTotalSec) * 100)); + } + const activePct = Math.max(0, endPct - startPct); + const activeMin = parseFloat(r.active_minutes); + const idleMin = parseFloat(r.idle_minutes); + // Per-rider utilisation = active / (active + idle). Falls back to + // the fleet avg when individual fields are missing so the bar's + // color never goes "unknown" mid-list. + const totalRiderMin = (Number.isFinite(activeMin) ? activeMin : 0) + (Number.isFinite(idleMin) ? idleMin : 0); + const riderUtilPct = totalRiderMin > 0 ? (activeMin / totalRiderMin) * 100 : utilSafe; + const riderHealth = + riderUtilPct == null ? 'unknown' + : riderUtilPct >= 85 ? 'good' + : riderUtilPct >= 70 ? 'ok' + : 'low'; + const riderColor = + riderHealth === 'good' ? '#10b981' + : riderHealth === 'ok' ? '#f59e0b' + : riderHealth === 'low' ? '#ef4444' + : '#94a3b8'; + const displayName = resolveRiderName(r.userid, r.name); + return { ...r, displayName, _startPct: startPct, _endPct: endPct, _activePct: activePct, _riderUtilPct: riderUtilPct, _riderColor: riderColor }; + }); + // Sort: latest-finish first (= the rider who held up the fleet). + // Operators care about who's the bottleneck, not who's first. + ridersWithGantt.sort((a, b) => (parseHMS(b.finished_at) || 0) - (parseHMS(a.finished_at) || 0)); return (
@@ -5503,122 +5635,339 @@ const Dispatch = ({
+ {/* ─── Hero strip: the three numbers an ops lead glances at + first — workload, headcount, and how long the batch + actually ran. Sits above everything else so the answer + to "was today big or small?" is the first thing visible. */}
-
Fleet Summary
-
- {fleetMetrics.map((m) => ( -
-
{m.label}
-
{m.value}
+
+
+
+
- ))} +
{analysisFormatNum(fleet.total_orders)}
+
Total Orders
+ {raw.input_delivery_count != null && raw.input_delivery_count !== fleet.total_orders && ( +
+ {raw.input_delivery_count} input +
+ )} +
+
+
+ +
+
{analysisFormatNum(fleet.total_riders)}
+
Active Riders
+ {fleet.orders_per_rider_avg != null && ( +
+ {fleet.orders_per_rider_avg} avg orders / rider +
+ )} +
+
+
+ +
+
+ {fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes}` : '—'} + {fleet.total_duration_minutes != null && ( + min + )} +
+
Fleet Window
+ {fleet.fleet_start && fleet.fleet_done && ( +
+ {fleet.fleet_start} → {fleet.fleet_done} +
+ )} +
- {hasRec ? ( -
-
Top Recommendation
-
-
-
- - {(rec.action || 'recommendation').replaceAll('_', ' ')} -
- {rec.fleet_improvement_minutes != null && ( - 0 - ? { background: '#dcfce7', color: '#166534' } - : { background: '#f1f5f9', color: '#475569' } - } - > - {rec.fleet_improvement_minutes > 0 ? '↑' : '•'} Fleet improves by {rec.fleet_improvement_minutes} min - - )} + {/* ─── Health KPI band: efficiency + balance signals. + These tell the operator HOW the batch ran — utilisation + ring (visual gauge), load balance stdev (lower = better + workload spread), finish-time spread (lower = tighter + finish), and average active minutes per rider. */} +
+
Fleet Health
+
+ {/* Utilisation — the only visual gauge: lets a busy + ops lead spot "we left 30% of the day on the table" + without reading the number. */} +
+
+ + {utilSafe != null ? `${utilSafe.toFixed(0)}` : '—'} + {utilSafe != null && %} +
-
- {rec.idle_rider_name || `Rider ${rec.idle_rider_id}`} - {rec.primary_kitchen && ( - <> · primary kitchen {rec.primary_kitchen} - )} - {rec.second_kitchen && ( - <> → also serve {rec.second_kitchen} after {rec.second_kitchen_dispatch_after || '—'} - )} -
- {rec.description && ( -
{rec.description}
- )} - {rec.activate_when?.rules?.length > 0 && ( -
-
- Activate when ({rec.activate_when.condition || 'AND'}): -
- {rec.activate_when.rules.map((rule, i) => ( -
- {rule.field} {rule.operator} {rule.value} - {rule.reason && — {rule.reason}} -
- ))} +
+
Avg Utilisation
+
+ {utilHealth === 'good' ? 'Strong' : utilHealth === 'ok' ? 'Moderate' : utilHealth === 'low' ? 'Low' : '—'}
- )} +
-
- ) : ( -
-
Top Recommendation
-
-
- - Fleet is balanced, no reassignment needed right now. +
+
+ {fleet.load_balance_stdev != null ? parseFloat(fleet.load_balance_stdev).toFixed(2) : '—'} +
+
Load Balance σ
+
+ {fleet.load_balance_stdev != null + ? (parseFloat(fleet.load_balance_stdev) > 1.0 ? 'Uneven workload' : 'Workload balanced') + : 'Stdev of orders / rider'} +
+
+
+
+ {fleet.finish_time_spread_minutes != null ? `${fleet.finish_time_spread_minutes}` : '—'} + {fleet.finish_time_spread_minutes != null && ( + min + )} +
+
Finish Spread
+
+ {fleet.finish_time_stdev_minutes != null + ? `σ ${parseFloat(fleet.finish_time_stdev_minutes).toFixed(1)} min` + : 'First → last finish'} +
+
+
+
+ {fleet.avg_active_minutes != null ? `${parseFloat(fleet.avg_active_minutes).toFixed(0)}` : '—'} + {fleet.avg_active_minutes != null && ( + min + )} +
+
Avg Active
+
+ Per rider, this batch
- )} +
- {riders.length > 0 && ( + {/* ─── Top Recommendation banner ─────────────────────────── + Three states: + • Actionable rec → blue insight banner with full + rider + kitchen + activation rules. + • No action, imbalance → AMBER warning surfacing the + backend's `reason` + + `fleet_balance_assessment` so + operators don't miss a real + "57-min spread" signal hidden + behind a generic empty state. + • No action, balanced → green success summary. */} +
+
Top Recommendation
+ {hasRec ? ( +
+
+
+
+
+ {(rec.action || 'recommendation').replaceAll('_', ' ')} +
+ {rec.fleet_improvement_minutes != null && ( + 0 + ? { background: '#dcfce7', color: '#166534' } + : { background: '#f1f5f9', color: '#475569' } + } + > + {rec.fleet_improvement_minutes > 0 ? '↑' : '•'} Fleet improves by {rec.fleet_improvement_minutes} min + + )} +
+
+ {resolveRiderName(rec.idle_rider_id, rec.idle_rider_name)} + {rec.primary_kitchen && ( + <> · primary kitchen {rec.primary_kitchen} + )} + {rec.second_kitchen && ( + <> → also serve {rec.second_kitchen} after {rec.second_kitchen_dispatch_after || '—'} + )} +
+ {rec.description && ( +
{rec.description}
+ )} + {rec?.fleet_balance_assessment && ( +
{rec.fleet_balance_assessment}
+ )} + {rec.activate_when?.rules?.length > 0 && ( +
+
+ Activate when ({rec.activate_when.condition || 'AND'}): +
+ {rec.activate_when.rules.map((rule, i) => ( +
+ {rule.field} {rule.operator} {rule.value} + {rule.reason && — {rule.reason}} +
+ ))} +
+ )} +
+
+ ) : ( +
+
+ {severity === 'warn' ? : severity === 'success' ? : } +
+
+
+ + {severity === 'warn' + ? 'Fleet imbalance detected — no feasible substitution' + : severity === 'success' + ? 'Fleet ran balanced — no reassignment needed' + : 'No action recommended'} + +
+ {rec?.reason && ( +
{rec.reason}
+ )} + {rec?.fleet_balance_assessment && ( +
{rec.fleet_balance_assessment}
+ )} +
+
+ )} +
+ + {ridersWithGantt.length > 0 && (
- Rider Timelines ({riders.length}) + Rider Timelines ({ridersWithGantt.length}) + Sorted by latest finish — bottleneck riders first +
+ {/* Fleet timeline header: shared axis labels so each + rider's bar is reads against a common reference. */} +
+ {fleet.fleet_start || '—'} + + {fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min batch` : 'Batch window'} + + {fleet.fleet_done || '—'}
- {riders.map((r) => { + {ridersWithGantt.map((r) => { const isActive = String(r.status || '').toLowerCase() === 'active'; + const completed = r.completed_orders ?? r.order_count; + const pending = r.pending_orders ?? 0; + const completePct = (completed != null && r.order_count > 0) + ? Math.max(0, Math.min(100, (completed / r.order_count) * 100)) + : 0; + const idleHigh = r.idle_minutes != null && r.idle_minutes > 30; + const lowConf = r.kitchen_confidence != null && r.kitchen_confidence < 0.7; return (
+ {/* Top: identity + status pill. The status pill + stays the existing .da-pill so colour rules + already documented elsewhere apply. */}
- - {r.name} + + + {r.displayName} + #{r.userid}
- + {r.status}
+ + {/* Gantt bar: bottom-aligned bar shows when this + rider was working inside the fleet window. + The filled portion is their active span; the + tail (after finished_at) reads as "fleet + kept going without me" — i.e. idle time on + the back end. Width and offset are computed + as % of total fleet duration. */} +
+
+
+ {r.started_at} + {r.finished_at} +
+ {r._endPct < 99 && ( +
+ )} +
+ + {r._riderUtilPct != null ? `${r._riderUtilPct.toFixed(0)}%` : '—'} + +
+ + {/* Stats row: kitchen (with confidence dot + when low), orders mini-bar, pace, idle. */}
{r.kitchen && ( - + {r.kitchen} + {r.kitchen_confidence != null && ( + + )} )} - - {r.order_count} orders - - - {r.started_at} → {r.finished_at} + + + + {completed}/{r.order_count} + + + + + {pending > 0 && ( + {pending} pending + )} + {r.pace_orders_per_hour != null && ( + + {parseFloat(r.pace_orders_per_hour).toFixed(1)} / hr + + )} + {r.active_minutes != null && ( + + {parseFloat(r.active_minutes).toFixed(0)} min active + + )} 30 - ? { background: '#fef3c7', color: '#92400e' } - : undefined - } + style={idleHigh ? { background: '#fef3c7', color: '#92400e', borderColor: '#fde68a' } : undefined} + title="Idle time after this rider finished — fleet kept running" > - {r.idle_minutes} min idle + {r.idle_minutes ?? 0} min idle
@@ -5642,7 +5991,7 @@ const Dispatch = ({
- {idle.name || `Rider ${idle.userid}`}{' '} + {resolveRiderName(idle.userid, idle.name)}{' '} covers {s.target_kitchen}
+{s.extra_km_for_idle_rider} km for idle rider
- {relieved.name && ( + {(relieved.name || relieved.userid != null) && (
- Most relieved: {relieved.name}{' '} + Most relieved: {resolveRiderName(relieved.userid, relieved.name)}{' '} ({relieved.original_finish} → {relieved.new_finish}, saves{' '} {relieved.time_saved_minutes} min)
@@ -5690,7 +6039,7 @@ const Dispatch = ({
#{o.deliveryid} - from {o.from_rider_name} + from {resolveRiderName(o.from_rider_id || o.from_userid, o.from_rider_name)} {o.original_delivery_time} → {o.estimated_delivery_time}