Files
dailygrubs_console/src/pages/nearle/dispatch/CompareDataPanel.js

977 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo } from 'react';
import {
MdPublic,
MdSwapHoriz,
MdExpandMore,
MdCheckCircle,
MdAccessTime,
MdStraighten,
MdErrorOutline,
MdFormatListBulleted,
MdTimer,
MdWarning,
MdClose,
MdSpeed,
MdStar,
MdFlag,
MdHourglassBottom
} from 'react-icons/md';
import {
stepColor,
getStatusStyle,
FINAL_STATUSES,
SKIPPED_STATUSES,
ordinal
} from './dispatchShared';
// Right-side data panel rendered in Compare mode. Pure presentation +
// memoized derivations: feed it the comparison state from Dispatch and
// the panel handles its own layout (compliance score, day overview, route
// sequence with cascade grouping, KPIs, highlights, trips, focused-step
// details, deviations, full step list).
//
// Props:
// focusedRider — the rider whose day is being compared
// compareDeltas — per-step actual deltas (see useMemo
// in Dispatch.js)
// compareSummary — day rollup: actualKm/onTime/etc
// actualOrdered — compareDeltas sorted by sequenceStep (visit
// order). Used by the route-sequence section.
// focusedCompareStep — currently focused step (1..N) or null
// setFocusedCompareStep — setter; pass a function-updater for toggle
// sequenceOpen — whether the "Route sequence" section is open
// setSequenceOpen — setter for sequenceOpen
// expandedSeqGroups — Set of expanded sequence-diff group indices
// setExpandedSeqGroups — setter (Set state)
// onClose — called when the user clicks the × header btn
function CompareDataPanel({
focusedRider,
compareDeltas,
compareSummary,
actualOrdered,
focusedCompareStep,
setFocusedCompareStep,
sequenceOpen,
setSequenceOpen,
expandedSeqGroups,
setExpandedSeqGroups,
onClose
}) {
// All derivations live in a single useMemo so the cost of re-running
// them is paid only when an upstream input actually changes — not on
// every parent render (e.g. cursor moving over the map, sync toggle
// toggling, etc.). Keeping them grouped also makes the data contract
// visible at a glance.
const view = useMemo(() => {
const sum = compareSummary;
const totalSteps = sum.onTime + sum.late;
const deviations = compareDeltas.filter((d) => d.anomaly);
const delivered = compareDeltas.filter((d) =>
FINAL_STATUSES.has(String(d.orderstatus || '').toLowerCase())
).length;
const skipped = compareDeltas.filter((d) =>
SKIPPED_STATUSES.has(String(d.orderstatus || '').toLowerCase())
).length;
const stepDeltaPct =
sum.kmDeltaPct == null
? ''
: sum.kmDeltaPct > 25
? 'is-over'
: sum.kmDeltaPct < -5
? 'is-under'
: '';
// Compliance score (0-100): 60% delivered + 25% on-time + 15% no-deviation.
const totalForScore = compareDeltas.length || 1;
const onTimeForScore = sum.onTime + sum.late || 1;
const score = Math.round(
(delivered / totalForScore) * 60 +
(sum.onTime / onTimeForScore) * 25 +
((totalForScore - sum.anomalies) / totalForScore) * 15
);
const scoreColor = score >= 85 ? '#16a34a' : score >= 65 ? '#f59e0b' : '#dc2626';
const scoreLabel = score >= 85 ? 'Excellent' : score >= 65 ? 'Acceptable' : 'Needs review';
// KPIs derived from delivery timestamps.
const withActual = compareDeltas.filter((d) => d.actualTs);
const firstDelivery = withActual.reduce(
(acc, d) => (!acc || d.actualTs.isBefore(acc) ? d.actualTs : acc),
null
);
const lastDelivery = withActual.reduce(
(acc, d) => (!acc || d.actualTs.isAfter(acc) ? d.actualTs : acc),
null
);
const activeMin =
firstDelivery && lastDelivery
? Math.max(0, lastDelivery.diff(firstDelivery, 'minute'))
: 0;
const avgPerStop =
compareDeltas.length > 1
? Math.round(activeMin / (compareDeltas.length - 1))
: 0;
const avgSpeed =
activeMin > 0 ? (sum.actualKm / (activeMin / 60)).toFixed(1) : null;
// Best / worst step.
const readyDeltas = compareDeltas.filter(
(d) => !d.isLoading && d.coordsCount > 0
);
const bestStep =
readyDeltas
.filter((d) => d.timeDeltaMin != null && !d.anomaly)
.sort((a, b) => a.timeDeltaMin - b.timeDeltaMin)[0] || null;
const worstStep =
readyDeltas
.filter((d) => d.anomaly)
.sort((a, b) => {
const sa =
Math.abs(a.kmDeltaPct || 0) + (a.timeDeltaMin > 0 ? a.timeDeltaMin : 0);
const sb =
Math.abs(b.kmDeltaPct || 0) + (b.timeDeltaMin > 0 ? b.timeDeltaMin : 0);
return sb - sa;
})[0] || null;
// Route sequence — which actual-visit positions don't match the
// dispatch-planned step.
const outOfOrderSteps = actualOrdered.filter((d, i) => {
const planned = d.order?.step;
return planned != null && planned !== i + 1;
});
// Cascade-aware grouping of out-of-order steps: consecutive entries
// with the same `delta` collapse into one "N consecutive shifted +K"
// card so a single bad first stop doesn't paint 12 noisy rows.
const seqRuns = [];
outOfOrderSteps.forEach((d) => {
const planned = d.order?.step;
const actualPos =
actualOrdered.findIndex((x) => x.sequenceStep === d.sequenceStep) + 1;
const delta = actualPos - planned;
const last = seqRuns[seqRuns.length - 1];
if (last && last.delta === delta && last.lastActualPos + 1 === actualPos) {
last.items.push({ d, planned, actualPos, delta });
last.lastActualPos = actualPos;
} else {
seqRuns.push({
delta,
items: [{ d, planned, actualPos, delta }],
lastActualPos: actualPos
});
}
});
// Trip-by-trip rollup.
const tripBuckets = {};
focusedRider.orders.forEach((o) => {
const t = o.trip_number || 1;
if (!tripBuckets[t]) tripBuckets[t] = [];
tripBuckets[t].push(o);
});
const tripList = Object.entries(tripBuckets)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([tNum, tOrders]) => ({
tNum,
count: tOrders.length,
actualKm: tOrders.reduce(
(s, o) => s + parseFloat(o.actualkms || o.kms || 0),
0
),
delivered: tOrders.filter((o) =>
FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase())
).length
}));
return {
sum,
totalSteps,
deviations,
delivered,
skipped,
stepDeltaPct,
score,
scoreColor,
scoreLabel,
firstDelivery,
lastDelivery,
activeMin,
avgPerStop,
avgSpeed,
bestStep,
worstStep,
outOfOrderSteps,
seqRuns,
tripList
};
}, [focusedRider, compareDeltas, compareSummary, actualOrdered]);
const focused =
focusedCompareStep != null
? compareDeltas.find((d) => d.sequenceStep === focusedCompareStep)
: null;
const toggleSeqGroup = (idx) => {
setExpandedSeqGroups((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const focusStep = (sequenceStep) => {
setFocusedCompareStep((prev) => (prev === sequenceStep ? null : sequenceStep));
};
// Renders a single shifted-step diff card (used both stand-alone and
// nested under an expanded group).
const renderDiffRow = (item, focusable = true) => {
const { d, planned, actualPos, delta } = item;
return (
<li
key={`diff-${d.sequenceStep}`}
className={`cdp-seq-diff${
focusedCompareStep === d.sequenceStep ? ' is-focused' : ''
}${focusable ? '' : ' is-nested'}`}
onClick={() => focusStep(d.sequenceStep)}
>
<span
className="cdp-seq-diff-num"
style={{ background: stepColor((planned || d.sequenceStep) - 1) }}
>
{planned || d.sequenceStep}
</span>
<div className="cdp-seq-diff-body">
<div className="cdp-seq-diff-title">
{d.deliverycustomer || `Step ${planned || d.sequenceStep}`}
</div>
<div className="cdp-seq-diff-sub">
Visited <strong>{ordinal(actualPos)}</strong>{' '}
· planned <strong>{ordinal(planned)}</strong>
</div>
</div>
<span className="cdp-seq-diff-tag">
{delta > 0 ? `+${delta}` : `${delta}`}
</span>
</li>
);
};
const {
sum,
totalSteps,
deviations,
delivered,
skipped,
stepDeltaPct,
score,
scoreColor,
scoreLabel,
firstDelivery,
lastDelivery,
activeMin,
avgPerStop,
avgSpeed,
bestStep,
worstStep,
outOfOrderSteps,
seqRuns,
tripList
} = view;
return (
<aside id="compare-data-panel" className="compare-data-panel">
<div className="cdp-head">
<div className="cdp-head-title">
<span
className="cdp-rider-dot"
style={{ background: focusedRider.color }}
/>
<div className="cdp-head-text">
<div className="cdp-rider-name">{focusedRider.riderName}</div>
<div className="cdp-head-badge">PLANNED vs ACTUAL</div>
</div>
</div>
<button
type="button"
className="cdp-close"
onClick={onClose}
title="Exit compare"
aria-label="Exit compare"
>
<MdClose />
</button>
</div>
<div className="cdp-scroll">
{/* Compliance score — headline gauge blending delivery, on-time,
and route-fidelity into one number. */}
<section className="cdp-section cdp-score-section">
<div className="cdp-score-wrap">
<div
className="cdp-score-ring"
style={{
background: `conic-gradient(${scoreColor} ${score * 3.6}deg, rgba(15,23,42,0.08) 0deg)`
}}
>
<div className="cdp-score-inner">
<div className="cdp-score-value" style={{ color: scoreColor }}>
{score}
</div>
<div className="cdp-score-unit">/100</div>
</div>
</div>
<div className="cdp-score-body">
<div className="cdp-score-label" style={{ color: scoreColor }}>
{scoreLabel}
</div>
<div className="cdp-score-title">Compliance score</div>
<div className="cdp-score-sub">
{delivered}/{compareDeltas.length} delivered
{sum.anomalies > 0
? ` · ${sum.anomalies} deviation${sum.anomalies > 1 ? 's' : ''}`
: ''}
{sum.late > 0 ? ` · ${sum.late} late` : ''}
{skipped > 0 ? ` · ${skipped} skipped` : ''}
</div>
</div>
</div>
</section>
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdPublic /></span>
<span className="cdp-section-title">Day overview</span>
</div>
<div className="cdp-tiles">
<div className="cdp-tile">
<div className="cdp-tile-label">
<MdStraighten /> Distance
</div>
<div className="cdp-tile-value">
{sum.actualKm.toFixed(1)}
<span className="cdp-tile-unit">km</span>
</div>
<div className="cdp-tile-sub">actual</div>
</div>
<div className={`cdp-tile${sum.anomalies > 0 ? ' is-warn' : ''}`}>
<div className="cdp-tile-label">
<MdWarning /> Deviation
</div>
<div className={`cdp-tile-value ${stepDeltaPct}`}>
{sum.kmDeltaPct != null
? `${sum.kmDeltaPct > 0 ? '+' : ''}${sum.kmDeltaPct.toFixed(0)}%`
: '—'}
</div>
<div className="cdp-tile-sub">
{sum.anomalies > 0 ? `${sum.anomalies} flagged` : 'within plan'}
</div>
</div>
<div className={`cdp-tile${sum.late > 0 ? ' is-warn' : ''}`}>
<div className="cdp-tile-label">
<MdAccessTime /> On-time
</div>
<div className="cdp-tile-value">
{sum.onTime}
{totalSteps > 0 && (
<span className="cdp-tile-unit">/{totalSteps}</span>
)}
</div>
<div className="cdp-tile-sub">
{sum.late > 0 ? `${sum.late} late` : 'all on schedule'}
</div>
</div>
</div>
</section>
{/* Route sequence — collapsible, default open. Shows planned vs
actual visit order with cascade-aware diff grouping. */}
{compareDeltas.length > 0 && (
<section className="cdp-section cdp-seq-section">
<div
className="cdp-section-head cdp-section-head-clickable"
onClick={() => setSequenceOpen((v) => !v)}
role="button"
aria-expanded={sequenceOpen}
title={sequenceOpen ? 'Collapse route sequence' : 'Expand route sequence'}
>
<span className="cdp-section-icon">
<MdSwapHoriz />
</span>
<span className="cdp-section-title">Route sequence</span>
<span
className={`cdp-seq-status${outOfOrderSteps.length > 0 ? ' is-warn' : ' is-good'}`}
>
{outOfOrderSteps.length > 0
? `${outOfOrderSteps.length} out of order`
: 'In order'}
</span>
<span className={`cdp-seq-toggle${sequenceOpen ? ' is-open' : ''}`}>
<MdExpandMore />
</span>
</div>
{sequenceOpen && (
<div className="cdp-seq">
{outOfOrderSteps.length > 0 ? (
<ul className="cdp-seq-diffs">
{seqRuns.map((run, runIdx) => {
if (run.items.length === 1) {
return renderDiffRow(run.items[0]);
}
const first = run.items[0];
const last = run.items[run.items.length - 1];
const isOpen = expandedSeqGroups.has(runIdx);
const deltaStr =
run.delta > 0 ? `+${run.delta}` : `${run.delta}`;
const groupFocused = run.items.some(
(it) => it.d.sequenceStep === focusedCompareStep
);
return (
<React.Fragment key={`run-${runIdx}-${first.d.sequenceStep}`}>
<li
className={`cdp-seq-diff is-group${isOpen ? ' is-expanded' : ''}${groupFocused ? ' is-focused' : ''}`}
onClick={() => toggleSeqGroup(runIdx)}
aria-expanded={isOpen}
>
<span className="cdp-seq-group-num">
<span
className="cdp-seq-group-num-bg"
style={{
background: `linear-gradient(135deg, ${stepColor((first.planned || 1) - 1)}, ${stepColor((last.planned || 1) - 1)})`
}}
/>
<span className="cdp-seq-group-num-label">
{run.items.length}×
</span>
</span>
<div className="cdp-seq-diff-body">
<div className="cdp-seq-diff-title">
{run.items.length} consecutive steps shifted{' '}
<span className="cdp-seq-group-delta">{deltaStr}</span>
</div>
<div className="cdp-seq-diff-sub">
Planned {ordinal(first.planned)}{ordinal(last.planned)}{' '}
visited{' '}
<strong>
{ordinal(first.actualPos)}{ordinal(last.actualPos)}
</strong>
</div>
</div>
<span className="cdp-seq-diff-tag">{deltaStr}</span>
<span
className={`cdp-seq-group-toggle${isOpen ? ' is-open' : ''}`}
aria-hidden="true"
>
<MdExpandMore />
</span>
</li>
{isOpen && (
<li className="cdp-seq-group-children-wrap">
<ul className="cdp-seq-group-children">
{run.items.map((it) => renderDiffRow(it, false))}
</ul>
</li>
)}
</React.Fragment>
);
})}
</ul>
) : (
<div className="cdp-seq-good">
<MdCheckCircle /> Rider followed the planned route in order.
</div>
)}
</div>
)}
</section>
)}
{/* Timing — clock-style timeline. First/last delivery render as
digital clock faces flanking a duration centerpiece. Tiny
"Started" / "Finished" captions give the row a narrative.
Below: avg-per-stop with a dotted stops-row visualization,
and avg speed with a 0-60 gauge bar. */}
{(firstDelivery || lastDelivery) && (
<section className="cdp-section cdp-timing-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdTimer /></span>
<span className="cdp-section-title">Timing</span>
{activeMin > 0 && (
<span className="cdp-timing-active-tag">
<span className="cdp-timing-active-pulse" />
Day window
</span>
)}
</div>
<div className="cdp-timing-clock">
<div className="cdp-clock-card is-start">
<div className="cdp-clock-label">
<MdFlag /> First delivery
</div>
<div className="cdp-clock-face">
<span className="cdp-clock-time">
{firstDelivery ? firstDelivery.format('hh:mm') : '—'}
</span>
<span className="cdp-clock-period">
{firstDelivery ? firstDelivery.format('A') : ''}
</span>
</div>
<div className="cdp-clock-caption">Started</div>
</div>
<div className="cdp-clock-track" aria-hidden="true">
<span className="cdp-clock-track-line" />
<span className="cdp-clock-track-dot is-start" />
<span className="cdp-clock-track-dot is-end" />
<div className="cdp-clock-duration">
<span className="cdp-clock-duration-icon">
<MdHourglassBottom />
</span>
<span className="cdp-clock-duration-val">
{activeMin > 0
? activeMin >= 60
? `${Math.floor(activeMin / 60)}h ${activeMin % 60}m`
: `${activeMin}m`
: '—'}
</span>
<span className="cdp-clock-duration-sub">active</span>
</div>
</div>
<div className="cdp-clock-card is-end">
<div className="cdp-clock-label">
<MdCheckCircle /> Last delivery
</div>
<div className="cdp-clock-face">
<span className="cdp-clock-time">
{lastDelivery ? lastDelivery.format('hh:mm') : '—'}
</span>
<span className="cdp-clock-period">
{lastDelivery ? lastDelivery.format('A') : ''}
</span>
</div>
<div className="cdp-clock-caption">Finished</div>
</div>
</div>
<div className="cdp-timing-stats">
<div className="cdp-timing-stat">
<div className="cdp-timing-stat-head">
<div className="cdp-timing-stat-icon">
<MdAccessTime />
</div>
<div className="cdp-timing-stat-body">
<div className="cdp-timing-stat-value">
{avgPerStop > 0 ? `${avgPerStop}` : '—'}
{avgPerStop > 0 && (
<span className="cdp-timing-stat-unit">min</span>
)}
</div>
<div className="cdp-timing-stat-label">Avg / stop</div>
</div>
</div>
{compareDeltas.length > 0 && (
<div className="cdp-timing-stat-viz cdp-stops-dots" aria-hidden="true">
{Array.from({ length: Math.min(compareDeltas.length, 12) }).map((_, i) => (
<span key={`dot-${i}`} className="cdp-stop-dot" />
))}
<span className="cdp-timing-stat-viz-label">
{compareDeltas.length} stop{compareDeltas.length === 1 ? '' : 's'}
</span>
</div>
)}
</div>
{avgSpeed != null && (
<div className="cdp-timing-stat">
<div className="cdp-timing-stat-head">
<div className="cdp-timing-stat-icon">
<MdSpeed />
</div>
<div className="cdp-timing-stat-body">
<div className="cdp-timing-stat-value">
{avgSpeed}
<span className="cdp-timing-stat-unit">km/h</span>
</div>
<div className="cdp-timing-stat-label">Avg speed</div>
</div>
</div>
<div className="cdp-timing-stat-viz cdp-speed-gauge" aria-hidden="true">
<div className="cdp-speed-gauge-track">
<div
className="cdp-speed-gauge-fill"
style={{
width: `${Math.min(100, (parseFloat(avgSpeed) / 60) * 100)}%`
}}
/>
</div>
<div className="cdp-speed-gauge-scale">
<span>0</span>
<span>30</span>
<span>60 km/h</span>
</div>
</div>
</div>
)}
</div>
</section>
)}
{/* Highlights — best/worst step quick-pick. Full-width cards stacked
vertically: a colored rail on the left side encodes good/bad,
the customer name is the headline, the step number sits as a
right-aligned chip, and the metric line uses bold pills. */}
{(bestStep || worstStep) && (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdStar /></span>
<span className="cdp-section-title">Highlights</span>
</div>
<div className="cdp-highlights">
{bestStep && (
<div
className="cdp-highlight is-best"
onClick={() => focusStep(bestStep.sequenceStep)}
role="button"
title="Focus this step"
>
<span className="cdp-highlight-rail" aria-hidden="true" />
<div className="cdp-highlight-content">
<div className="cdp-highlight-top">
<span className="cdp-highlight-label">
<span className="cdp-highlight-chip">
<MdCheckCircle />
</span>
Fastest stop
</span>
<span
className="cdp-highlight-step-chip"
style={{
background: stepColor(bestStep.sequenceStep - 1)
}}
>
Step {bestStep.sequenceStep}
</span>
</div>
<div className="cdp-highlight-title">
{bestStep.deliverycustomer || `Step ${bestStep.sequenceStep}`}
</div>
<div className="cdp-highlight-meta">
<span className="cdp-highlight-pill is-good">
{bestStep.timeDeltaMin != null
? bestStep.timeDeltaMin === 0
? 'On schedule'
: `${bestStep.timeDeltaMin > 0 ? '+' : ''}${bestStep.timeDeltaMin} min vs plan`
: 'On schedule'}
</span>
</div>
</div>
</div>
)}
{worstStep && (
<div
className="cdp-highlight is-worst"
onClick={() => focusStep(worstStep.sequenceStep)}
role="button"
title="Focus this step"
>
<span className="cdp-highlight-rail" aria-hidden="true" />
<div className="cdp-highlight-content">
<div className="cdp-highlight-top">
<span className="cdp-highlight-label">
<span className="cdp-highlight-chip">
<MdWarning />
</span>
Biggest deviation
</span>
<span
className="cdp-highlight-step-chip"
style={{
background: stepColor(worstStep.sequenceStep - 1)
}}
>
Step {worstStep.sequenceStep}
</span>
</div>
<div className="cdp-highlight-title">
{worstStep.deliverycustomer || `Step ${worstStep.sequenceStep}`}
</div>
<div className="cdp-highlight-meta">
{worstStep.kmDeltaPct != null && (
<span className="cdp-highlight-pill is-bad">
{worstStep.kmDeltaPct > 0 ? '+' : ''}
{worstStep.kmDeltaPct.toFixed(0)}% route
</span>
)}
{worstStep.timeDeltaMin != null && worstStep.timeDeltaMin > 0 && (
<span className="cdp-highlight-pill is-bad">
+{worstStep.timeDeltaMin}m late
</span>
)}
</div>
</div>
</div>
)}
</div>
</section>
)}
{/* Trip breakdown — only when rider ran >1 trip. */}
{tripList.length > 1 && (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdSwapHoriz /></span>
<span className="cdp-section-title">Trips ({tripList.length})</span>
</div>
<div className="cdp-trips">
{tripList.map((t) => (
<div key={`trip-${t.tNum}`} className="cdp-trip">
<div className="cdp-trip-head">
<span className="cdp-trip-badge">Trip {t.tNum}</span>
<span className="cdp-trip-meta">
{t.delivered}/{t.count} delivered
</span>
</div>
<div className="cdp-trip-stats">
<span title="Distance">
<MdStraighten />
{t.actualKm.toFixed(1)}km
</span>
</div>
</div>
))}
</div>
</section>
)}
{/* Focused-step deep-dive — appears only when a step is selected. */}
{focused && (() => {
const color = stepColor(focused.sequenceStep - 1);
const timeDeltaCls =
focused.timeDeltaMin != null
? focused.timeDeltaMin > 10
? 'is-over'
: focused.timeDeltaMin < -2
? 'is-under'
: ''
: '';
const statusStyle = getStatusStyle(focused.orderstatus);
return (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdSwapHoriz /></span>
<span className="cdp-section-title">
Step {focused.sequenceStep} details
</span>
<button
type="button"
className="cdp-section-clear"
onClick={() => setFocusedCompareStep(null)}
title="Clear step focus"
>
Show all
</button>
</div>
<div className={`compare-delta${focused.anomaly ? ' is-anomaly' : ''}`}>
<div className="compare-delta-title">
<span
className="compare-delta-step-badge"
style={{ background: color }}
>
{focused.sequenceStep}
</span>
<div className="compare-delta-title-text">
<div className="compare-delta-title-main">
{focused.deliverycustomer || `Step ${focused.sequenceStep}`}
</div>
<div className="compare-delta-title-sub">
{focused.pickupcustomer ? `from ${focused.pickupcustomer} · ` : ''}
Order #{focused.orderid}
</div>
</div>
{focused.orderstatus && (
<span
className="compare-delta-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
</div>
<div className="compare-delta-grid">
<div className={`compare-delta-cell${focused.anomaly ? ' is-anomaly' : ''}`}>
<span className="compare-delta-cell-label">Distance</span>
<span className="compare-delta-cell-val">
{focused.actualKm.toFixed(2)}{' '}
<span className="compare-delta-cell-unit">km</span>
</span>
<span className="compare-delta-cell-sub">actual</span>
</div>
<div className="compare-delta-cell">
<span className="compare-delta-cell-label">Time</span>
<span className={`compare-delta-cell-val ${timeDeltaCls}`}>
{focused.timeDeltaMin != null
? `${focused.timeDeltaMin > 0 ? '+' : ''}${focused.timeDeltaMin} min`
: '—'}
</span>
<span className="compare-delta-cell-sub">
{focused.actualTs && focused.expectedTs
? `${focused.actualTs.format('HH:mm')} vs ${focused.expectedTs.format('HH:mm')}`
: focused.actualTs
? `delivered ${focused.actualTs.format('HH:mm')}`
: 'in flight'}
</span>
</div>
</div>
</div>
</section>
);
})()}
{/* Deviations list — anomaly-only steps. */}
{deviations.length > 0 && (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon cdp-icon-warn">
<MdErrorOutline />
</span>
<span className="cdp-section-title">
Deviations ({deviations.length})
</span>
</div>
<ul className="cdp-dev-list">
{deviations.map((d) => {
const color = stepColor(d.sequenceStep - 1);
const kmSign = d.kmDelta >= 0 ? '+' : '';
return (
<li
key={`dev-${d.sequenceStep}`}
className={`cdp-dev-item${focusedCompareStep === d.sequenceStep ? ' is-focused' : ''}`}
onClick={() => focusStep(d.sequenceStep)}
>
<span className="cdp-dev-num" style={{ background: color }}>
{d.sequenceStep}
</span>
<div className="cdp-dev-body">
<div className="cdp-dev-title">
{d.deliverycustomer || `Step ${d.sequenceStep}`}
</div>
<div className="cdp-dev-meta">
{d.kmDeltaPct != null && (
<span className="cdp-dev-chip is-over">
{kmSign}{d.kmDeltaPct.toFixed(0)}% route
</span>
)}
{d.timeDeltaMin != null && d.timeDeltaMin > 10 && (
<span className="cdp-dev-chip is-over">
+{d.timeDeltaMin}m late
</span>
)}
</div>
</div>
</li>
);
})}
</ul>
</section>
)}
{/* Full step list. */}
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon">
<MdFormatListBulleted />
</span>
<span className="cdp-section-title">
Steps ({compareDeltas.length})
</span>
<span className="cdp-section-sub">
{delivered}/{compareDeltas.length} delivered
</span>
</div>
<ul className="cdp-step-list">
{compareDeltas.map((d) => {
const color = stepColor(d.sequenceStep - 1);
const statusLow = String(d.orderstatus || '').toLowerCase();
const isDelivered = FINAL_STATUSES.has(statusLow);
const isSkipped = SKIPPED_STATUSES.has(statusLow);
const isCorrect = isDelivered && !d.anomaly;
const isFocused = focusedCompareStep === d.sequenceStep;
const statusStyle = getStatusStyle(d.orderstatus);
const timeCls =
d.timeDeltaMin != null
? d.timeDeltaMin > 10
? 'is-over'
: d.timeDeltaMin < -2
? 'is-under'
: ''
: '';
const stepCls = [
'cdp-step',
isFocused ? 'is-focused' : '',
d.anomaly ? 'is-anomaly' : '',
isCorrect ? 'is-correct' : '',
isSkipped ? 'is-skipped' : '',
d.isLoading ? 'is-loading' : ''
].filter(Boolean).join(' ');
return (
<li
key={`step-${d.sequenceStep}`}
className={stepCls}
onClick={() => focusStep(d.sequenceStep)}
>
<span className="cdp-step-num" style={{ background: color }}>
{d.sequenceStep}
{isCorrect && (
<span className="cdp-step-check">
<MdCheckCircle />
</span>
)}
{d.anomaly && (
<span className="cdp-step-flag">
<MdErrorOutline />
</span>
)}
</span>
<div className="cdp-step-body">
<div className="cdp-step-title-row">
<span className="cdp-step-title">
{d.deliverycustomer || `Step ${d.sequenceStep}`}
</span>
{d.orderstatus && (
<span
className="cdp-step-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
</div>
<div className="cdp-step-sub">
{d.pickupcustomer ? `from ${d.pickupcustomer} · ` : ''}
Order #{d.orderid}
</div>
<div className="cdp-step-deltas">
<span className="cdp-step-delta" title="Distance">
<MdStraighten />
{d.actualKm.toFixed(1)}km
</span>
<span className={`cdp-step-delta ${timeCls}`} title="Delivery time">
<MdAccessTime />
{d.actualTs ? d.actualTs.format('HH:mm') : '—'}
{d.timeDeltaMin != null && (
<small>
{' '}{d.timeDeltaMin > 0 ? '+' : ''}{d.timeDeltaMin}m
</small>
)}
</span>
</div>
</div>
</li>
);
})}
</ul>
</section>
</div>
</aside>
);
}
export default CompareDataPanel;