updates on the polyline breaks fix

This commit is contained in:
2026-06-04 02:11:36 +05:30
parent 3d46c07f89
commit aea96476a4
2 changed files with 880 additions and 98 deletions

View File

@@ -9986,3 +9986,436 @@
padding: 2px 8px; padding: 2px 8px;
border-radius: 10px; 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: 0100, 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;
}

View File

@@ -596,6 +596,12 @@ const buildTripPoints = (sorted) => {
return pts; 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 // Fix for default leaflet marker icons
delete L.Icon.Default.prototype._getIconUrl; delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ L.Icon.Default.mergeOptions({
@@ -786,6 +792,32 @@ const analysisFormatPct = (v) => {
if (!Number.isFinite(n)) return '—'; if (!Number.isFinite(n)) return '—';
return `${n > 1 ? n.toFixed(1) : (n * 100).toFixed(1)}%`; 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 = ({ const Dispatch = ({
data, data,
@@ -1901,13 +1933,13 @@ const Dispatch = ({
}, [compareOpen]); }, [compareOpen]);
const fetchRoute = useCallback(async (riderId, tripKey, points) => { 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 // 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 // callback doesn't need osrmRoutes in its deps — that old pattern caused a
// render loop: each resolved route updated state → recreated fetchRoute → // render loop: each resolved route updated state → recreated fetchRoute →
// re-ran all route-fetching effects for every rider. // re-ran all route-fetching effects for every rider.
if (osrmRoutesRef.current[cacheKey] !== undefined) return; if (osrmRoutesRef.current[cacheKey] !== undefined) return;
if (points.length < 2) return;
// Mark in-flight in both ref (immediate) and state (triggers re-render). // Mark in-flight in both ref (immediate) and state (triggers re-render).
osrmRoutesRef.current[cacheKey] = null; osrmRoutesRef.current[cacheKey] = null;
@@ -2180,7 +2212,12 @@ const Dispatch = ({
if (filteredTOrders.length === 0) return; 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 roadPath = osrmRoutes[cacheKey];
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
@@ -2696,7 +2733,12 @@ const Dispatch = ({
if (filteredTOrders.length === 0) return; 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 roadPoints = osrmRoutes[cacheKey];
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0)); const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
@@ -5446,21 +5488,111 @@ const Dispatch = ({
} }
const fleet = raw.fleet_summary || {}; 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 subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : [];
const rec = raw.top_recommendation; const rec = raw.top_recommendation;
const hasRecRider = !!(rec && (rec.idle_rider_name || rec.idle_rider_id)); const hasRecRider = !!(rec && (rec.idle_rider_name || rec.idle_rider_id));
const hasRec = !!(rec && rec.action && rec.action !== 'none' && hasRecRider); const hasRec = !!(rec && rec.action && rec.action !== 'none' && hasRecRider);
const win = raw.window || {}; const win = raw.window || {};
const fleetMetrics = [ // userid → real rider name. The /batch/efficiency response only
{ label: 'Total Orders', value: analysisFormatNum(fleet.total_orders) }, // ships placeholder names ("Rider 883") because the workolik
{ label: 'Total Riders', value: analysisFormatNum(fleet.total_riders) }, // solver doesn't have our auth/users table. We resolve here:
{ label: 'Avg Orders/Rider', value: fleet.orders_per_rider_avg ?? '—' }, // 1. against `riders` (current dispatch data — has riderName)
{ label: 'Fleet Start', value: fleet.fleet_start || '—' }, // 2. against `ridersAllDay` (full-day list built from live rows)
{ label: 'Fleet Done', value: fleet.fleet_done || '—' }, // 3. fall back to whatever the API sent.
{ label: 'Duration', value: fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min` : '—' } 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 <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)
: 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 ( return (
<div className="da-detail"> <div className="da-detail">
@@ -5503,122 +5635,339 @@ const Dispatch = ({
</button> </button>
</div> </div>
{/* ─── 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. */}
<div className="da-section"> <div className="da-section">
<div className="da-section-label">Fleet Summary</div> <div className="da-hero-row">
<div className="da-metric-grid da-metric-grid-3"> <div className="da-hero-card" style={{ borderTopColor: activeMeta.color }}>
{fleetMetrics.map((m) => ( <div className="da-hero-icon" style={{ background: `${activeMeta.color}22`, color: activeMeta.color }}>
<div key={m.label} className="da-metric"> <MdInventory2 />
<div className="da-metric-label">{m.label}</div>
<div className="da-metric-value">{m.value}</div>
</div> </div>
))} <div className="da-hero-value">{analysisFormatNum(fleet.total_orders)}</div>
<div className="da-hero-label">Total Orders</div>
{raw.input_delivery_count != null && raw.input_delivery_count !== fleet.total_orders && (
<div className="da-hero-sub">
{raw.input_delivery_count} input
</div>
)}
</div>
<div className="da-hero-card" style={{ borderTopColor: '#0ea5e9' }}>
<div className="da-hero-icon" style={{ background: '#0ea5e922', color: '#0ea5e9' }}>
<MdTwoWheeler />
</div>
<div className="da-hero-value">{analysisFormatNum(fleet.total_riders)}</div>
<div className="da-hero-label">Active Riders</div>
{fleet.orders_per_rider_avg != null && (
<div className="da-hero-sub">
{fleet.orders_per_rider_avg} avg orders / rider
</div>
)}
</div>
<div className="da-hero-card" style={{ borderTopColor: '#8b5cf6' }}>
<div className="da-hero-icon" style={{ background: '#8b5cf622', color: '#8b5cf6' }}>
<MdAccessTime />
</div>
<div className="da-hero-value">
{fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes}` : '—'}
{fleet.total_duration_minutes != null && (
<span className="da-hero-unit"> min</span>
)}
</div>
<div className="da-hero-label">Fleet Window</div>
{fleet.fleet_start && fleet.fleet_done && (
<div className="da-hero-sub">
{fleet.fleet_start} {fleet.fleet_done}
</div>
)}
</div>
</div> </div>
</div> </div>
{hasRec ? ( {/* ─── Health KPI band: efficiency + balance signals.
<div className="da-section"> These tell the operator HOW the batch ran — utilisation
<div className="da-section-label">Top Recommendation</div> ring (visual gauge), load balance stdev (lower = better
<div className="da-rec"> workload spread), finish-time spread (lower = tighter
<div className="da-rec-head"> finish), and average active minutes per rider. */}
<div className="da-rec-action"> <div className="da-section">
<MdInsights /> <div className="da-section-label">Fleet Health</div>
<span>{(rec.action || 'recommendation').replaceAll('_', ' ')}</span> <div className="da-health-row">
</div> {/* Utilisation — the only visual gauge: lets a busy
{rec.fleet_improvement_minutes != null && ( ops lead spot "we left 30% of the day on the table"
<span without reading the number. */}
className="da-rec-improve" <div className="da-health-card">
style={ <div className="da-ring" style={{ '--ring-color': utilColor, '--ring-pct': utilSafe != null ? Math.max(0, Math.min(100, utilSafe)) : 0 }}>
rec.fleet_improvement_minutes > 0 <span className="da-ring-num">
? { background: '#dcfce7', color: '#166534' } {utilSafe != null ? `${utilSafe.toFixed(0)}` : '—'}
: { background: '#f1f5f9', color: '#475569' } {utilSafe != null && <span className="da-ring-unit">%</span>}
} </span>
>
{rec.fleet_improvement_minutes > 0 ? '↑' : '•'} Fleet improves by {rec.fleet_improvement_minutes} min
</span>
)}
</div> </div>
<div className="da-rec-line"> <div className="da-health-meta">
<strong>{rec.idle_rider_name || `Rider ${rec.idle_rider_id}`}</strong> <div className="da-health-label">Avg Utilisation</div>
{rec.primary_kitchen && ( <div className="da-health-sub" style={{ color: utilColor }}>
<> · primary kitchen <strong>{rec.primary_kitchen}</strong></> {utilHealth === 'good' ? 'Strong' : utilHealth === 'ok' ? 'Moderate' : utilHealth === 'low' ? 'Low' : '—'}
)}
{rec.second_kitchen && (
<> also serve <strong>{rec.second_kitchen}</strong> after {rec.second_kitchen_dispatch_after || '—'}</>
)}
</div>
{rec.description && (
<div className="da-rec-desc">{rec.description}</div>
)}
{rec.activate_when?.rules?.length > 0 && (
<div className="da-rec-rules">
<div className="da-rec-rules-head">
Activate when ({rec.activate_when.condition || 'AND'}):
</div>
{rec.activate_when.rules.map((rule, i) => (
<div key={i} className="da-rec-rule">
<code>{rule.field} {rule.operator} {rule.value}</code>
{rule.reason && <span className="da-rec-rule-why"> {rule.reason}</span>}
</div>
))}
</div> </div>
)} </div>
</div> </div>
</div> <div className="da-health-card da-health-card-stat">
) : ( <div className="da-health-value">
<div className="da-section"> {fleet.load_balance_stdev != null ? parseFloat(fleet.load_balance_stdev).toFixed(2) : '—'}
<div className="da-section-label">Top Recommendation</div> </div>
<div className="da-rec da-rec-empty"> <div className="da-health-label">Load Balance σ</div>
<div className="da-rec-action"> <div className="da-health-sub">
<MdInsights /> {fleet.load_balance_stdev != null
<span>Fleet is balanced, no reassignment needed right now.</span> ? (parseFloat(fleet.load_balance_stdev) > 1.0 ? 'Uneven workload' : 'Workload balanced')
: 'Stdev of orders / rider'}
</div>
</div>
<div className="da-health-card da-health-card-stat">
<div className="da-health-value">
{fleet.finish_time_spread_minutes != null ? `${fleet.finish_time_spread_minutes}` : '—'}
{fleet.finish_time_spread_minutes != null && (
<span className="da-health-unit"> min</span>
)}
</div>
<div className="da-health-label">Finish Spread</div>
<div className="da-health-sub">
{fleet.finish_time_stdev_minutes != null
? `σ ${parseFloat(fleet.finish_time_stdev_minutes).toFixed(1)} min`
: 'First → last finish'}
</div>
</div>
<div className="da-health-card da-health-card-stat">
<div className="da-health-value">
{fleet.avg_active_minutes != null ? `${parseFloat(fleet.avg_active_minutes).toFixed(0)}` : '—'}
{fleet.avg_active_minutes != null && (
<span className="da-health-unit"> min</span>
)}
</div>
<div className="da-health-label">Avg Active</div>
<div className="da-health-sub">
Per rider, this batch
</div> </div>
</div> </div>
</div> </div>
)} </div>
{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. */}
<div className="da-section">
<div className="da-section-label">Top Recommendation</div>
{hasRec ? (
<div className="da-rec-banner is-action">
<div className="da-rec-banner-icon"><MdInsights /></div>
<div className="da-rec-banner-body">
<div className="da-rec-head">
<div className="da-rec-action">
<span>{(rec.action || 'recommendation').replaceAll('_', ' ')}</span>
</div>
{rec.fleet_improvement_minutes != null && (
<span
className="da-rec-improve"
style={
rec.fleet_improvement_minutes > 0
? { background: '#dcfce7', color: '#166534' }
: { background: '#f1f5f9', color: '#475569' }
}
>
{rec.fleet_improvement_minutes > 0 ? '↑' : '•'} Fleet improves by {rec.fleet_improvement_minutes} min
</span>
)}
</div>
<div className="da-rec-line">
<strong>{resolveRiderName(rec.idle_rider_id, rec.idle_rider_name)}</strong>
{rec.primary_kitchen && (
<> · primary kitchen <strong>{rec.primary_kitchen}</strong></>
)}
{rec.second_kitchen && (
<> also serve <strong>{rec.second_kitchen}</strong> after {rec.second_kitchen_dispatch_after || '—'}</>
)}
</div>
{rec.description && (
<div className="da-rec-desc">{rec.description}</div>
)}
{rec?.fleet_balance_assessment && (
<div className="da-rec-assess">{rec.fleet_balance_assessment}</div>
)}
{rec.activate_when?.rules?.length > 0 && (
<div className="da-rec-rules">
<div className="da-rec-rules-head">
Activate when ({rec.activate_when.condition || 'AND'}):
</div>
{rec.activate_when.rules.map((rule, i) => (
<div key={i} className="da-rec-rule">
<code>{rule.field} {rule.operator} {rule.value}</code>
{rule.reason && <span className="da-rec-rule-why"> {rule.reason}</span>}
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className={`da-rec-banner is-${severity}`}>
<div className="da-rec-banner-icon">
{severity === 'warn' ? <MdWarning /> : severity === 'success' ? <MdCheckCircle /> : <MdInsights />}
</div>
<div className="da-rec-banner-body">
<div className="da-rec-action">
<span>
{severity === 'warn'
? 'Fleet imbalance detected — no feasible substitution'
: severity === 'success'
? 'Fleet ran balanced — no reassignment needed'
: 'No action recommended'}
</span>
</div>
{rec?.reason && (
<div className="da-rec-desc">{rec.reason}</div>
)}
{rec?.fleet_balance_assessment && (
<div className="da-rec-assess">{rec.fleet_balance_assessment}</div>
)}
</div>
</div>
)}
</div>
{ridersWithGantt.length > 0 && (
<div className="da-section"> <div className="da-section">
<div className="da-section-label"> <div className="da-section-label">
Rider Timelines <span className="da-section-count">({riders.length})</span> Rider Timelines <span className="da-section-count">({ridersWithGantt.length})</span>
<span className="da-section-hint">Sorted by latest finish bottleneck riders first</span>
</div>
{/* Fleet timeline header: shared axis labels so each
rider's bar is reads against a common reference. */}
<div className="da-gantt-axis">
<span>{fleet.fleet_start || '—'}</span>
<span className="da-gantt-axis-mid">
{fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min batch` : 'Batch window'}
</span>
<span>{fleet.fleet_done || '—'}</span>
</div> </div>
<div className="da-timeline-list"> <div className="da-timeline-list">
{riders.map((r) => { {ridersWithGantt.map((r) => {
const isActive = String(r.status || '').toLowerCase() === 'active'; 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 ( return (
<div key={r.userid} className="da-timeline-card"> <div key={r.userid} className="da-timeline-card">
{/* Top: identity + status pill. The status pill
stays the existing .da-pill so colour rules
already documented elsewhere apply. */}
<div className="da-timeline-top"> <div className="da-timeline-top">
<div className="da-timeline-name"> <div className="da-timeline-name">
<MdTwoWheeler style={{ color: activeMeta.color }} /> <MdTwoWheeler style={{ color: r._riderColor }} />
<span>{r.name}</span> <span title={r.name && r.name !== r.displayName ? `API name: ${r.name}` : undefined}>
{r.displayName}
</span>
<span className="da-timeline-id">#{r.userid}</span> <span className="da-timeline-id">#{r.userid}</span>
</div> </div>
<span <span className={`da-pill ${isActive ? 'is-active' : 'is-idle'}`}>
className={`da-pill ${isActive ? 'is-active' : 'is-idle'}`}
>
{r.status} {r.status}
</span> </span>
</div> </div>
{/* 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. */}
<div className="da-gantt-row">
<div
className="da-gantt-track"
title={`${r.started_at}${r.finished_at}`}
>
<div
className="da-gantt-fill"
style={{
left: `${r._startPct}%`,
width: `${Math.max(2, r._activePct)}%`,
background: r._riderColor
}}
>
<span className="da-gantt-fill-start">{r.started_at}</span>
<span className="da-gantt-fill-end">{r.finished_at}</span>
</div>
{r._endPct < 99 && (
<div
className="da-gantt-idle"
style={{ left: `${r._endPct}%`, width: `${100 - r._endPct}%` }}
title={`Idle ${r.idle_minutes} min after finishing`}
/>
)}
</div>
<span
className="da-gantt-util"
style={{ background: `${r._riderColor}22`, color: r._riderColor }}
title={`Per-rider utilisation: ${r._riderUtilPct != null ? r._riderUtilPct.toFixed(0) : '—'}%`}
>
{r._riderUtilPct != null ? `${r._riderUtilPct.toFixed(0)}%` : '—'}
</span>
</div>
{/* Stats row: kitchen (with confidence dot
when low), orders mini-bar, pace, idle. */}
<div className="da-timeline-mid"> <div className="da-timeline-mid">
{r.kitchen && ( {r.kitchen && (
<span className="da-chip"> <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 && (
<span
className={`da-conf-dot ${lowConf ? 'is-low' : 'is-ok'}`}
aria-label={`Confidence ${(r.kitchen_confidence * 100).toFixed(0)}%`}
/>
)}
</span> </span>
)} )}
<span className="da-chip"> <span className="da-chip da-chip-orders" title={`Completed ${completed} of ${r.order_count}`}>
<MdInventory2 /> {r.order_count} orders <MdInventory2 />
</span> <span className="da-orders-label">
<span className="da-chip"> {completed}/{r.order_count}
<MdAccessTime /> {r.started_at} {r.finished_at} </span>
<span className="da-orders-bar">
<span
className="da-orders-bar-fill"
style={{ width: `${completePct}%` }}
/>
</span>
{pending > 0 && (
<span className="da-orders-pending">{pending} pending</span>
)}
</span> </span>
{r.pace_orders_per_hour != null && (
<span className="da-chip" title="Delivery pace this batch">
<MdSpeed /> {parseFloat(r.pace_orders_per_hour).toFixed(1)} / hr
</span>
)}
{r.active_minutes != null && (
<span className="da-chip" title="Time actively delivering">
<MdTimer /> {parseFloat(r.active_minutes).toFixed(0)} min active
</span>
)}
<span <span
className="da-chip" className="da-chip"
style={ style={idleHigh ? { background: '#fef3c7', color: '#92400e', borderColor: '#fde68a' } : undefined}
r.idle_minutes > 30 title="Idle time after this rider finished — fleet kept running"
? { background: '#fef3c7', color: '#92400e' }
: undefined
}
> >
<MdTimer /> {r.idle_minutes} min idle <MdAccessTime /> {r.idle_minutes ?? 0} min idle
</span> </span>
</div> </div>
</div> </div>
@@ -5642,7 +5991,7 @@ const Dispatch = ({
<div key={i} className="da-sub-card"> <div key={i} className="da-sub-card">
<div className="da-sub-head"> <div className="da-sub-head">
<div className="da-sub-title"> <div className="da-sub-title">
<strong>{idle.name || `Rider ${idle.userid}`}</strong>{' '} <strong>{resolveRiderName(idle.userid, idle.name)}</strong>{' '}
covers <strong>{s.target_kitchen}</strong> covers <strong>{s.target_kitchen}</strong>
</div> </div>
<span <span
@@ -5673,10 +6022,10 @@ const Dispatch = ({
<MdStraighten /> +{s.extra_km_for_idle_rider} km for idle rider <MdStraighten /> +{s.extra_km_for_idle_rider} km for idle rider
</span> </span>
</div> </div>
{relieved.name && ( {(relieved.name || relieved.userid != null) && (
<div className="da-sub-relieved"> <div className="da-sub-relieved">
<MdTrendingUp /> <MdTrendingUp />
Most relieved: <strong>{relieved.name}</strong>{' '} Most relieved: <strong>{resolveRiderName(relieved.userid, relieved.name)}</strong>{' '}
({relieved.original_finish} {relieved.new_finish}, saves{' '} ({relieved.original_finish} {relieved.new_finish}, saves{' '}
{relieved.time_saved_minutes} min) {relieved.time_saved_minutes} min)
</div> </div>
@@ -5690,7 +6039,7 @@ const Dispatch = ({
<div key={o.deliveryid} className="da-transfer-row"> <div key={o.deliveryid} className="da-transfer-row">
<span className="da-transfer-id">#{o.deliveryid}</span> <span className="da-transfer-id">#{o.deliveryid}</span>
<span className="da-transfer-from"> <span className="da-transfer-from">
from {o.from_rider_name} from {resolveRiderName(o.from_rider_id || o.from_userid, o.from_rider_name)}
</span> </span>
<span className="da-transfer-time"> <span className="da-transfer-time">
{o.original_delivery_time} {o.estimated_delivery_time} {o.original_delivery_time} {o.estimated_delivery_time}