updates on the polyline breaks fix
This commit is contained in:
@@ -9985,4 +9985,437 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
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: 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;
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user