upates on the dispatch comparsion new page updates
This commit is contained in:
@@ -35,29 +35,29 @@ import {
|
||||
MdSearch,
|
||||
MdChevronLeft,
|
||||
MdChevronRight,
|
||||
MdLocalMall
|
||||
MdLocalMall,
|
||||
MdCheckCircle,
|
||||
MdErrorOutline,
|
||||
MdWarning,
|
||||
MdClose,
|
||||
MdFormatListBulleted,
|
||||
MdTimer
|
||||
} from 'react-icons/md';
|
||||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api';
|
||||
import {
|
||||
STATUS_STYLES,
|
||||
getStatusStyle,
|
||||
FINAL_STATUSES,
|
||||
SKIPPED_STATUSES,
|
||||
STEP_PALETTE,
|
||||
stepColor
|
||||
} from './dispatchShared';
|
||||
import CompareDataPanel from './CompareDataPanel';
|
||||
import './Dispatch.css';
|
||||
|
||||
// Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes.
|
||||
const MOTORBIKE_SVG = `<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#fff" d="M200,112a40,40,0,0,0-12.07,1.86L161.6,72H200a8,8,0,0,1,8,8v8a8,8,0,0,0,16,0V80a24,24,0,0,0-24-24H160a8,8,0,0,0-6.79,3.77l-9.34,15.06L130.39,52A8,8,0,0,0,124,48H88a8,8,0,0,0,0,16h31.69l13.34,21.84L107.5,128H56A40,40,0,1,0,96,168.4V160a8,8,0,0,1,16,0v8.4a40.06,40.06,0,0,0,32,31.2V184a8,8,0,0,1,16,0v15.6A40,40,0,1,0,200,112ZM56,184a24,24,0,1,1,24-24A24,24,0,0,1,56,184Zm70.46-44.71h0L141,116.45,156.6,142h0a40,40,0,0,0-14,28h-16A40.16,40.16,0,0,0,126.46,139.29ZM200,184a24,24,0,1,1,24-24A24,24,0,0,1,200,184Z"/></svg>`;
|
||||
|
||||
const STATUS_STYLES = {
|
||||
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
|
||||
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' },
|
||||
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' },
|
||||
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' },
|
||||
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' },
|
||||
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' },
|
||||
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' },
|
||||
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
|
||||
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }
|
||||
};
|
||||
|
||||
const getStatusStyle = (status) =>
|
||||
STATUS_STYLES[String(status || '').toLowerCase()] || { label: status || 'Unknown', bg: '#64748b', fg: '#fff' };
|
||||
|
||||
const toNum = (v) => {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
@@ -436,8 +436,6 @@ function splitPolylineByDrops(polyline, drops) {
|
||||
return segments;
|
||||
}
|
||||
|
||||
const FINAL_STATUSES = new Set(['delivered']);
|
||||
const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
|
||||
|
||||
// --- Marker popup helpers ---
|
||||
// Strip the date portion from an API timestamp — operators viewing today's
|
||||
@@ -571,31 +569,10 @@ L.Icon.Default.mergeOptions({
|
||||
|
||||
const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000'];
|
||||
|
||||
// Per-step palette used by the Compare map (polylines, timeline dots, drop
|
||||
// pins, delta panel). Wider and more deliberately spaced around the hue
|
||||
// wheel than RIDER_COLORS so a 10-delivery rider day reads as 10 visibly
|
||||
// different lines — operators don't have to second-guess which polyline
|
||||
// is step 7 vs step 2. Tuned darker than RIDER_COLORS so the lines stay
|
||||
// legible on the light OSM tile background.
|
||||
const STEP_PALETTE = [
|
||||
'#2563eb', // blue-600
|
||||
'#dc2626', // red-600
|
||||
'#16a34a', // green-600
|
||||
'#ea580c', // orange-600
|
||||
'#9333ea', // purple-600
|
||||
'#0891b2', // cyan-600
|
||||
'#ca8a04', // yellow-600
|
||||
'#db2777', // pink-600
|
||||
'#0f766e', // teal-700
|
||||
'#7c3aed', // violet-600
|
||||
'#65a30d', // lime-600
|
||||
'#0284c7', // sky-600
|
||||
'#b91c1c', // red-700
|
||||
'#15803d', // green-700
|
||||
'#a16207', // yellow-700
|
||||
'#86198f' // fuchsia-800
|
||||
];
|
||||
const stepColor = (i) => STEP_PALETTE[((i % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length];
|
||||
// STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES,
|
||||
// STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the
|
||||
// extracted CompareDataPanel component can import them without forcing
|
||||
// a circular dependency on Dispatch.js.
|
||||
|
||||
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => {
|
||||
const map = useMap();
|
||||
@@ -973,7 +950,18 @@ const Dispatch = ({
|
||||
// per-rider). Kept as a single state flag so we don't entangle it with
|
||||
// viewMode/focused* logic.
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
const [daySummaryOpen, setDaySummaryOpen] = useState(false);
|
||||
// Default-open toggle for the "Route sequence" section in the compare data
|
||||
// panel — shows planned vs actual visit order with out-of-order steps flagged.
|
||||
const [sequenceOpen, setSequenceOpen] = useState(true);
|
||||
// Default-open toggle for the planned/actual timeline strip above the
|
||||
// compare actual-map. Lets the operator collapse the dual step rows when
|
||||
// they want maximum map real estate.
|
||||
const [compareTimelineOpen, setCompareTimelineOpen] = useState(true);
|
||||
// Set of route-sequence "diff group" indices that are currently expanded.
|
||||
// Cascade-aware: when N consecutive steps share the same shift amount,
|
||||
// they're collapsed into one summary row by default; clicking it adds the
|
||||
// run's index to this set so the individual steps reveal.
|
||||
const [expandedSeqGroups, setExpandedSeqGroups] = useState(() => new Set());
|
||||
// In controlled mode the parent owns selectedRiderId, so handleRiderFocus only
|
||||
// fires onRiderSelect — focusedRider won't update until the parent re-renders.
|
||||
// This ref lets us defer setCompareOpen(true) until focusedRider is confirmed.
|
||||
@@ -1010,6 +998,20 @@ const Dispatch = ({
|
||||
const syncSourceRef = useRef(null);
|
||||
const syncLastInputAtRef = useRef(0);
|
||||
|
||||
// Stable Canvas renderer for the planned (left) map. Leaflet's default
|
||||
// canvas (used when `preferCanvas` is true) only draws ~10% beyond the
|
||||
// viewport — the moment a drag carries a polyline edge outside that
|
||||
// padding, the off-area pixels are blank until `moveend` triggers a
|
||||
// redraw, which makes polylines look "broken" mid-drag. Bumping padding
|
||||
// to 1.5 makes the canvas ~4× viewport area, so dragging within a screen
|
||||
// of travel keeps every polyline drawn without re-raster gaps. The
|
||||
// actual (right) map's renderer is created later, after focusedRider is
|
||||
// in scope, since it remounts per rider.
|
||||
const plannedMapRendererRef = useRef(null);
|
||||
if (!plannedMapRendererRef.current) {
|
||||
plannedMapRendererRef.current = L.canvas({ padding: 1.5, tolerance: 5 });
|
||||
}
|
||||
|
||||
// Pull the partners/getriderlogs feed for the currently selected hub + date.
|
||||
// This endpoint returns the exact live GPS position for every rider at the
|
||||
// hub (latitude/longitude/logdate/status). We render those positions as
|
||||
@@ -1335,6 +1337,16 @@ const Dispatch = ({
|
||||
? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
|
||||
: internalFocusedRider;
|
||||
|
||||
// Per-rider canvas renderer for the actual (right) map in Compare mode.
|
||||
// The actual map remounts on rider change (via key={`compare-${rider.id}`})
|
||||
// so we recreate the renderer in lock-step to keep its map binding fresh.
|
||||
// Same padding as the planned map (1.5 → ~4× viewport area) so polylines
|
||||
// don't visually break at the canvas edge during drag.
|
||||
const actualMapRenderer = useMemo(
|
||||
() => L.canvas({ padding: 1.5, tolerance: 5 }),
|
||||
[focusedRider?.id]
|
||||
);
|
||||
|
||||
// Single setter used by every interactive site in the UI. In uncontrolled mode it
|
||||
// updates local state; in controlled mode it only notifies the parent.
|
||||
const handleRiderFocus = useCallback(
|
||||
@@ -1566,6 +1578,18 @@ const Dispatch = ({
|
||||
return { plannedKm, actualKm, kmDeltaPct, anomalies, late, onTime };
|
||||
}, [compareDeltas]);
|
||||
|
||||
const plannedOrdered = useMemo(() => {
|
||||
return [...compareDeltas].sort(
|
||||
(a, b) => (a.order?.step || a.sequenceStep) - (b.order?.step || b.sequenceStep)
|
||||
);
|
||||
}, [compareDeltas]);
|
||||
|
||||
const actualOrdered = useMemo(() => {
|
||||
return [...compareDeltas].sort(
|
||||
(a, b) => a.sequenceStep - b.sequenceStep
|
||||
);
|
||||
}, [compareDeltas]);
|
||||
|
||||
// When Compare is open and a step is focused, override MapController's
|
||||
// focusedItem with a synthetic single-order item so the planned (left)
|
||||
// map zooms onto the same delivery the operator is scrutinizing. Without
|
||||
@@ -1587,7 +1611,7 @@ const Dispatch = ({
|
||||
// stuck on a step that may not exist in their day.
|
||||
useEffect(() => {
|
||||
setFocusedCompareStep(null);
|
||||
setDaySummaryOpen(false);
|
||||
setExpandedSeqGroups(new Set());
|
||||
}, [compareOpen, focusedRider?.id]);
|
||||
|
||||
// Mirror pan/zoom from whichever map the user is driving. Called by
|
||||
@@ -2446,7 +2470,7 @@ const Dispatch = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`testing-container${embedded ? ' embedded' : ''}`}>
|
||||
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
|
||||
{!embedded && (
|
||||
<div id="hdr">
|
||||
<div className="logo">
|
||||
@@ -3567,7 +3591,7 @@ const Dispatch = ({
|
||||
scrollWheelZoom
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
zoomControl={false}
|
||||
preferCanvas
|
||||
renderer={plannedMapRendererRef.current}
|
||||
inertia
|
||||
inertiaDeceleration={2400}
|
||||
inertiaMaxSpeed={2000}
|
||||
@@ -3864,65 +3888,138 @@ const Dispatch = ({
|
||||
{compareSyncEnabled ? <MdGpsFixed /> : <MdMyLocation />}
|
||||
{compareSyncEnabled ? 'Sync' : 'Unlinked'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`compare-timeline-toggle${compareTimelineOpen ? ' is-open' : ''}`}
|
||||
onClick={() => setCompareTimelineOpen((v) => !v)}
|
||||
title={compareTimelineOpen
|
||||
? 'Hide planned/actual timeline'
|
||||
: 'Show planned/actual timeline'}
|
||||
aria-expanded={compareTimelineOpen}
|
||||
>
|
||||
<MdExpandMore />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step timeline — every delivery as a tappable dot in chronological
|
||||
order. The operator can drill into any step to scrutinize that
|
||||
single delivery on both maps. Filled = delivered, ring = pending,
|
||||
dim = cancelled/skipped, ring with spinner = GPS still loading. */}
|
||||
dim = cancelled/skipped, ring with spinner = GPS still loading.
|
||||
Whole strip (timeline + progress + legend) collapses via the
|
||||
header chevron — open by default. */}
|
||||
{compareTimelineOpen && (
|
||||
<>
|
||||
<div className="compare-timeline-wrap">
|
||||
<div className="compare-timeline">
|
||||
{compareDeltas.map((d, i) => {
|
||||
const statusLow = String(d.orderstatus || '').toLowerCase();
|
||||
const isDelivered = FINAL_STATUSES.has(statusLow);
|
||||
const isSkipped = SKIPPED_STATUSES.has(statusLow);
|
||||
const isFocused = focusedCompareStep === d.sequenceStep;
|
||||
const isLoading = d.isLoading && d.coordsCount === 0;
|
||||
const isNoData = !d.isLoading && d.coordsCount === 0;
|
||||
const color = stepColor(i);
|
||||
const cls = [
|
||||
'compare-step',
|
||||
isFocused && 'is-focused',
|
||||
isDelivered && 'is-delivered',
|
||||
isSkipped && 'is-skipped',
|
||||
!isDelivered && !isSkipped && 'is-pending',
|
||||
isLoading && 'is-loading',
|
||||
isNoData && 'is-no-data',
|
||||
d.anomaly && 'is-anomaly'
|
||||
].filter(Boolean).join(' ');
|
||||
return (
|
||||
<React.Fragment key={`step-${d.deliveryid}`}>
|
||||
{i > 0 && <span className="compare-step-spacer" />}
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
style={{ '--step-color': color }}
|
||||
onClick={() =>
|
||||
setFocusedCompareStep((prev) =>
|
||||
prev === d.sequenceStep ? null : d.sequenceStep
|
||||
)
|
||||
}
|
||||
title={
|
||||
`Step ${d.sequenceStep}` +
|
||||
(d.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
|
||||
(d.actualTs ? ` · ${d.actualTs.format('hh:mm A')}` : '') +
|
||||
(d.anomaly ? ' · deviation flagged' : '')
|
||||
}
|
||||
>
|
||||
<span className="compare-step-circle">
|
||||
{isLoading ? <span className="compare-step-spin" /> : d.sequenceStep}
|
||||
</span>
|
||||
{d.actualTs && (
|
||||
<span className="compare-step-tick">
|
||||
{d.actualTs.format('HH:mm')}
|
||||
</span>
|
||||
)}
|
||||
{d.anomaly && <span className="compare-step-flag" title="Deviation flagged" />}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<div className="compare-timeline-container">
|
||||
<div className="compare-timeline-labels">
|
||||
<div className="compare-timeline-label">Planned</div>
|
||||
<div className="compare-timeline-label">Actual</div>
|
||||
</div>
|
||||
<div className="compare-timeline-scrollable">
|
||||
{/* Planned Row */}
|
||||
<div className="compare-timeline-track is-planned">
|
||||
{plannedOrdered.map((d, i) => {
|
||||
const statusLow = String(d.orderstatus || '').toLowerCase();
|
||||
const isDelivered = FINAL_STATUSES.has(statusLow);
|
||||
const isSkipped = SKIPPED_STATUSES.has(statusLow);
|
||||
const isFocused = focusedCompareStep === d.sequenceStep;
|
||||
const isLoading = d.isLoading && d.coordsCount === 0;
|
||||
const isNoData = !d.isLoading && d.coordsCount === 0;
|
||||
const plannedStepNum = d.order?.step || d.sequenceStep;
|
||||
const color = stepColor(plannedStepNum - 1);
|
||||
const cls = [
|
||||
'compare-step',
|
||||
isFocused && 'is-focused',
|
||||
isDelivered && 'is-delivered',
|
||||
isSkipped && 'is-skipped',
|
||||
!isDelivered && !isSkipped && 'is-pending',
|
||||
isLoading && 'is-loading',
|
||||
isNoData && 'is-no-data',
|
||||
d.anomaly && 'is-anomaly'
|
||||
].filter(Boolean).join(' ');
|
||||
return (
|
||||
<React.Fragment key={`step-p-${d.deliveryid}`}>
|
||||
{i > 0 && <span className="compare-step-spacer" />}
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
style={{ '--step-color': color }}
|
||||
onClick={() =>
|
||||
setFocusedCompareStep((prev) =>
|
||||
prev === d.sequenceStep ? null : d.sequenceStep
|
||||
)
|
||||
}
|
||||
title={
|
||||
`Planned Step ${plannedStepNum}` +
|
||||
(d.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
|
||||
(d.anomaly ? ' · deviation flagged' : '')
|
||||
}
|
||||
>
|
||||
<span className="compare-step-circle">
|
||||
{isLoading ? <span className="compare-step-spin" /> : plannedStepNum}
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actual Row */}
|
||||
<div className="compare-timeline-track is-actual">
|
||||
{actualOrdered.map((d, i) => {
|
||||
const statusLow = String(d.orderstatus || '').toLowerCase();
|
||||
const isDelivered = FINAL_STATUSES.has(statusLow);
|
||||
const isSkipped = SKIPPED_STATUSES.has(statusLow);
|
||||
const isFocused = focusedCompareStep === d.sequenceStep;
|
||||
const isLoading = d.isLoading && d.coordsCount === 0;
|
||||
const isNoData = !d.isLoading && d.coordsCount === 0;
|
||||
const plannedStepNum = d.order?.step || d.sequenceStep;
|
||||
const color = stepColor(plannedStepNum - 1);
|
||||
const cls = [
|
||||
'compare-step',
|
||||
isFocused && 'is-focused',
|
||||
isDelivered && 'is-delivered',
|
||||
isSkipped && 'is-skipped',
|
||||
!isDelivered && !isSkipped && 'is-pending',
|
||||
isLoading && 'is-loading',
|
||||
isNoData && 'is-no-data',
|
||||
d.anomaly && 'is-anomaly'
|
||||
].filter(Boolean).join(' ');
|
||||
return (
|
||||
<React.Fragment key={`step-a-${d.deliveryid}`}>
|
||||
{i > 0 && <span className="compare-step-spacer" />}
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
style={{ '--step-color': color }}
|
||||
onClick={() =>
|
||||
setFocusedCompareStep((prev) =>
|
||||
prev === d.sequenceStep ? null : d.sequenceStep
|
||||
)
|
||||
}
|
||||
title={
|
||||
`Actual Visit ${i + 1} (Planned Step ${plannedStepNum})` +
|
||||
(d.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
|
||||
(d.actualTs ? ` · ${d.actualTs.format('hh:mm A')}` : '') +
|
||||
(d.anomaly ? ' · deviation flagged' : '')
|
||||
}
|
||||
>
|
||||
<span className="compare-step-circle">
|
||||
{isLoading ? <span className="compare-step-spin" /> : plannedStepNum}
|
||||
</span>
|
||||
{d.actualTs && (
|
||||
<span className="compare-step-tick">
|
||||
{d.actualTs.format('HH:mm')}
|
||||
</span>
|
||||
)}
|
||||
{d.anomaly && <span className="compare-step-flag" title="Deviation flagged" />}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="compare-progress-strip">
|
||||
<div className="compare-progress-bar-wrap">
|
||||
@@ -3970,10 +4067,20 @@ const Dispatch = ({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="compare-map-area">
|
||||
{/* Floating badge — mirrors the "Planned Route" pill on the
|
||||
bottom map. Tells the operator at a glance that the top
|
||||
map is the rider's actual GPS trail, not the planned
|
||||
route. The pulsing dot reinforces "live data". */}
|
||||
<div className="compare-actual-label">
|
||||
<span className="compare-actual-dot" />
|
||||
Actual GPS
|
||||
</div>
|
||||
<MapContainer
|
||||
key={`compare-${focusedRider.id}`}
|
||||
center={[11.022, 76.982]}
|
||||
@@ -3981,7 +4088,7 @@ const Dispatch = ({
|
||||
scrollWheelZoom
|
||||
style={{ flex: 1, minHeight: 0, width: '100%' }}
|
||||
zoomControl={false}
|
||||
preferCanvas
|
||||
renderer={actualMapRenderer}
|
||||
inertia
|
||||
inertiaDeceleration={2400}
|
||||
inertiaMaxSpeed={2000}
|
||||
@@ -4262,194 +4369,32 @@ const Dispatch = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delta panel — when a step is focused, shows planned-vs-actual
|
||||
numbers for just that delivery (distance, route deviation %, time
|
||||
variance). When no step is focused, rolls up the same numbers
|
||||
across the whole day. Anomaly cells turn red when a step ran
|
||||
>25% longer than planned or arrived >15 min late. */}
|
||||
{(() => {
|
||||
const focused =
|
||||
focusedCompareStep != null
|
||||
? compareDeltas.find((d) => d.sequenceStep === focusedCompareStep)
|
||||
: null;
|
||||
|
||||
if (focused) {
|
||||
const color = stepColor(focused.sequenceStep - 1);
|
||||
const kmDeltaSign = focused.kmDelta >= 0 ? '+' : '';
|
||||
const kmDeltaCls = focused.anomaly
|
||||
? 'is-over'
|
||||
: focused.kmDelta < -0.1
|
||||
? 'is-under'
|
||||
: '';
|
||||
const timeDeltaCls =
|
||||
focused.timeDeltaMin != null
|
||||
? focused.timeDeltaMin > 10
|
||||
? 'is-over'
|
||||
: focused.timeDeltaMin < -2
|
||||
? 'is-under'
|
||||
: ''
|
||||
: '';
|
||||
const statusStyle = getStatusStyle(focused.orderstatus);
|
||||
return (
|
||||
<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">
|
||||
planned {focused.plannedKm.toFixed(2)} km
|
||||
</span>
|
||||
</div>
|
||||
<div className="compare-delta-cell">
|
||||
<span className="compare-delta-cell-label">Δ Route</span>
|
||||
<span className={`compare-delta-cell-val ${kmDeltaCls}`}>
|
||||
{kmDeltaSign}
|
||||
{focused.kmDelta.toFixed(2)} km
|
||||
</span>
|
||||
<span className="compare-delta-cell-sub">
|
||||
{focused.kmDeltaPct != null
|
||||
? `${kmDeltaSign}${focused.kmDeltaPct.toFixed(0)}% vs plan`
|
||||
: 'no planned km'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const sum = compareSummary;
|
||||
const deltaCls =
|
||||
sum.kmDeltaPct == null
|
||||
? ''
|
||||
: sum.kmDeltaPct > 25
|
||||
? 'is-over'
|
||||
: sum.kmDeltaPct < -5
|
||||
? 'is-under'
|
||||
: '';
|
||||
const total = sum.onTime + sum.late;
|
||||
return (
|
||||
<div className={`compare-delta is-collapsible${daySummaryOpen ? ' is-expanded' : ' is-collapsed'}`}>
|
||||
<div
|
||||
className="compare-delta-title"
|
||||
onClick={() => setDaySummaryOpen((v) => !v)}
|
||||
style={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
title={daySummaryOpen ? 'Collapse Day Summary' : 'Expand Day Summary'}
|
||||
>
|
||||
<span
|
||||
className="compare-delta-step-badge"
|
||||
style={{ background: focusedRider.color }}
|
||||
>
|
||||
<MdPublic />
|
||||
</span>
|
||||
<div className="compare-delta-title-text">
|
||||
<div className="compare-delta-title-main" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
Day summary
|
||||
<span
|
||||
className="compare-delta-toggle-icon"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
transform: daySummaryOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}
|
||||
>
|
||||
<MdExpandMore />
|
||||
</span>
|
||||
</div>
|
||||
<div className="compare-delta-title-sub">
|
||||
{daySummaryOpen
|
||||
? 'Click to collapse summary'
|
||||
: 'Click to expand summary · Click any step above to scrutinize'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{daySummaryOpen && (
|
||||
<div className="compare-delta-grid">
|
||||
<div className="compare-delta-cell">
|
||||
<span className="compare-delta-cell-label">Total distance</span>
|
||||
<span className="compare-delta-cell-val">
|
||||
{sum.actualKm.toFixed(1)}{' '}
|
||||
<span className="compare-delta-cell-unit">km</span>
|
||||
</span>
|
||||
<span className="compare-delta-cell-sub">
|
||||
planned {sum.plannedKm.toFixed(1)} km
|
||||
</span>
|
||||
</div>
|
||||
<div className={`compare-delta-cell${sum.anomalies > 0 ? ' is-anomaly' : ''}`}>
|
||||
<span className="compare-delta-cell-label">Route deviation</span>
|
||||
<span className={`compare-delta-cell-val ${deltaCls}`}>
|
||||
{sum.kmDeltaPct != null
|
||||
? `${sum.kmDeltaPct > 0 ? '+' : ''}${sum.kmDeltaPct.toFixed(0)}%`
|
||||
: '—'}
|
||||
</span>
|
||||
<span className="compare-delta-cell-sub">
|
||||
{sum.anomalies > 0
|
||||
? `${sum.anomalies} step${sum.anomalies > 1 ? 's' : ''} flagged`
|
||||
: 'within plan'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="compare-delta-cell">
|
||||
<span className="compare-delta-cell-label">On-time</span>
|
||||
<span className="compare-delta-cell-val">
|
||||
{sum.onTime}
|
||||
{total > 0 && (
|
||||
<span className="compare-delta-cell-unit">/{total}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="compare-delta-cell-sub">
|
||||
{sum.late > 0 ? `${sum.late} late` : 'all on schedule'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right-side data panel — full-height column in Compare mode. Renders
|
||||
day-overview tiles (distance, deviation, on-time, profit), the
|
||||
focused-step delta when a step is selected, a deviations list of
|
||||
anomaly steps, and a per-step list showing whether each step was
|
||||
followed correctly (delivered + within plan), the delivery time
|
||||
vs expected, and the order profit. Clicking any step row mirrors
|
||||
the timeline click — sets focusedCompareStep so both maps zoom
|
||||
onto that delivery. */}
|
||||
{compareOpen && focusedRider && (
|
||||
<CompareDataPanel
|
||||
focusedRider={focusedRider}
|
||||
compareDeltas={compareDeltas}
|
||||
compareSummary={compareSummary}
|
||||
actualOrdered={actualOrdered}
|
||||
focusedCompareStep={focusedCompareStep}
|
||||
setFocusedCompareStep={setFocusedCompareStep}
|
||||
sequenceOpen={sequenceOpen}
|
||||
setSequenceOpen={setSequenceOpen}
|
||||
expandedSeqGroups={expandedSeqGroups}
|
||||
setExpandedSeqGroups={setExpandedSeqGroups}
|
||||
onClose={() => setCompareOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user