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 (
  • focusStep(d.sequenceStep)} > {planned || d.sequenceStep}
    {d.deliverycustomer || `Step ${planned || d.sequenceStep}`}
    Visited {ordinal(actualPos)}{' '} · planned {ordinal(planned)}
    {delta > 0 ? `+${delta}` : `${delta}`}
  • ); }; const { sum, totalSteps, deviations, delivered, skipped, stepDeltaPct, score, scoreColor, scoreLabel, firstDelivery, lastDelivery, activeMin, avgPerStop, avgSpeed, bestStep, worstStep, outOfOrderSteps, seqRuns, tripList } = view; return ( ); } export default CompareDataPanel;