977 lines
38 KiB
JavaScript
977 lines
38 KiB
JavaScript
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;
|