upates on the dispatch comparsion new page updates

This commit is contained in:
2026-05-23 01:16:35 +05:30
parent f4b545322b
commit 13309c1d24
4 changed files with 3395 additions and 1114 deletions

View File

@@ -0,0 +1,956 @@
import React, { useMemo } from 'react';
import {
MdPublic,
MdSwapHoriz,
MdExpandMore,
MdCheckCircle,
MdAccessTime,
MdAccountBalanceWallet,
MdStraighten,
MdTrendingUp,
MdTrendingDown,
MdErrorOutline,
MdFormatListBulleted,
MdTimer,
MdWarning,
MdClose
} 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 planned/actual deltas (see useMemo
// in Dispatch.js)
// compareSummary — day rollup: plannedKm/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 totalProfit = focusedRider.orders.reduce(
(s, o) => s + parseFloat(o.profit || 0),
0
);
const isLoss = totalProfit < 0;
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,
plannedKm: tOrders.reduce((s, o) => s + parseFloat(o.kms || 0), 0),
actualKm: tOrders.reduce(
(s, o) => s + parseFloat(o.actualkms || o.kms || 0),
0
),
profit: tOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
delivered: tOrders.filter((o) =>
FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase())
).length
}));
return {
sum,
totalSteps,
totalProfit,
isLoss,
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,
totalProfit,
isLoss,
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">
planned {sum.plannedKm.toFixed(1)} km
</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 className={`cdp-tile ${isLoss ? 'is-loss' : 'is-gain'}`}>
<div className="cdp-tile-label">
{isLoss ? <MdTrendingDown /> : <MdTrendingUp />}{' '}
{isLoss ? 'Loss' : 'Profit'}
</div>
<div className="cdp-tile-value">
{isLoss ? '-' : ''}{Math.abs(totalProfit).toFixed(0)}
</div>
<div className="cdp-tile-sub">
{focusedRider.orders.length}{' '}
{focusedRider.orders.length === 1 ? 'order' : 'orders'}
{' · '}
{delivered} delivered
</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 KPIs */}
{(firstDelivery || lastDelivery) && (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdTimer /></span>
<span className="cdp-section-title">Timing KPIs</span>
</div>
<div className="cdp-kpi-row">
<div className="cdp-kpi">
<div className="cdp-kpi-label">First delivery</div>
<div className="cdp-kpi-value">
{firstDelivery ? firstDelivery.format('hh:mm A') : '—'}
</div>
</div>
<div className="cdp-kpi">
<div className="cdp-kpi-label">Last delivery</div>
<div className="cdp-kpi-value">
{lastDelivery ? lastDelivery.format('hh:mm A') : '—'}
</div>
</div>
<div className="cdp-kpi">
<div className="cdp-kpi-label">Active time</div>
<div className="cdp-kpi-value">
{activeMin > 0
? activeMin >= 60
? `${Math.floor(activeMin / 60)}h ${activeMin % 60}m`
: `${activeMin}m`
: '—'}
</div>
</div>
<div className="cdp-kpi">
<div className="cdp-kpi-label">Avg / stop</div>
<div className="cdp-kpi-value">
{avgPerStop > 0 ? `${avgPerStop}m` : '—'}
</div>
</div>
{avgSpeed != null && (
<div className="cdp-kpi">
<div className="cdp-kpi-label">Avg speed</div>
<div className="cdp-kpi-value">
{avgSpeed}
<span className="cdp-kpi-unit">km/h</span>
</div>
</div>
)}
</div>
</section>
)}
{/* Highlights — best/worst step quick-pick. */}
{(bestStep || worstStep) && (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdTrendingUp /></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-num"
style={{ background: stepColor(bestStep.sequenceStep - 1) }}
>
{bestStep.sequenceStep}
</span>
<div className="cdp-highlight-body">
<div className="cdp-highlight-label">
<MdCheckCircle /> Fastest stop
</div>
<div className="cdp-highlight-title">
{bestStep.deliverycustomer || `Step ${bestStep.sequenceStep}`}
</div>
<div className="cdp-highlight-meta">
{bestStep.timeDeltaMin != null
? `${bestStep.timeDeltaMin > 0 ? '+' : ''}${bestStep.timeDeltaMin} min vs plan`
: 'on schedule'}
</div>
</div>
</div>
)}
{worstStep && (
<div
className="cdp-highlight is-worst"
onClick={() => focusStep(worstStep.sequenceStep)}
role="button"
title="Focus this step"
>
<span
className="cdp-highlight-num"
style={{ background: stepColor(worstStep.sequenceStep - 1) }}
>
{worstStep.sequenceStep}
</span>
<div className="cdp-highlight-body">
<div className="cdp-highlight-label">
<MdWarning /> Biggest deviation
</div>
<div className="cdp-highlight-title">
{worstStep.deliverycustomer || `Step ${worstStep.sequenceStep}`}
</div>
<div className="cdp-highlight-meta">
{worstStep.kmDeltaPct != null
? `${worstStep.kmDeltaPct > 0 ? '+' : ''}${worstStep.kmDeltaPct.toFixed(0)}% route`
: ''}
{worstStep.timeDeltaMin != null && worstStep.timeDeltaMin > 0
? ` · +${worstStep.timeDeltaMin}m late`
: ''}
</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) => {
const tripLoss = t.profit < 0;
return (
<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
<small> / {t.plannedKm.toFixed(1)}</small>
</span>
<span
className={tripLoss ? 'is-over' : ''}
title={tripLoss ? 'Loss' : 'Profit'}
>
<MdAccountBalanceWallet />
{tripLoss ? '-' : ''}{Math.abs(t.profit).toFixed(0)}
</span>
</div>
</div>
);
})}
</div>
</section>
)}
{/* Focused-step deep-dive — appears only when a step is selected. */}
{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);
const profit = parseFloat(focused.order?.profit || 0);
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">
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>
{!Number.isNaN(profit) && profit !== 0 && (
<div className={`compare-delta-cell${profit < 0 ? ' is-anomaly' : ''}`}>
<span className="compare-delta-cell-label">
{profit < 0 ? 'Loss' : 'Profit'}
</span>
<span className={`compare-delta-cell-val ${profit < 0 ? 'is-over' : 'is-under'}`}>
{profit < 0 ? '-' : ''}{Math.abs(profit).toFixed(0)}
</span>
<span className="compare-delta-cell-sub">order revenue</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 profit = parseFloat(d.order?.profit || 0);
const kmSign = d.kmDelta >= 0 ? '+' : '';
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
{d.plannedKm > 0 && (
<small className={d.anomaly ? 'is-over' : ''}>
{' '}{kmSign}{d.kmDelta.toFixed(1)}
</small>
)}
</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>
{!Number.isNaN(profit) && profit !== 0 && (
<span
className={`cdp-step-delta${profit < 0 ? ' is-over' : ''}`}
title={profit < 0 ? 'Loss' : 'Profit'}
>
<MdAccountBalanceWallet />
{profit < 0 ? '-' : ''}{Math.abs(profit).toFixed(0)}
</span>
)}
</div>
</div>
</li>
);
})}
</ul>
</section>
</div>
</aside>
);
}
export default CompareDataPanel;

File diff suppressed because it is too large Load Diff

View File

@@ -35,29 +35,29 @@ import {
MdSearch, MdSearch,
MdChevronLeft, MdChevronLeft,
MdChevronRight, MdChevronRight,
MdLocalMall MdLocalMall,
MdCheckCircle,
MdErrorOutline,
MdWarning,
MdClose,
MdFormatListBulleted,
MdTimer
} from 'react-icons/md'; } from 'react-icons/md';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api'; 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'; import './Dispatch.css';
// Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes. // 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 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 toNum = (v) => {
const n = parseFloat(v); const n = parseFloat(v);
return Number.isFinite(n) ? n : NaN; return Number.isFinite(n) ? n : NaN;
@@ -436,8 +436,6 @@ function splitPolylineByDrops(polyline, drops) {
return segments; return segments;
} }
const FINAL_STATUSES = new Set(['delivered']);
const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
// --- Marker popup helpers --- // --- Marker popup helpers ---
// Strip the date portion from an API timestamp — operators viewing today's // 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']; const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000'];
// Per-step palette used by the Compare map (polylines, timeline dots, drop // STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES,
// pins, delta panel). Wider and more deliberately spaced around the hue // STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the
// wheel than RIDER_COLORS so a 10-delivery rider day reads as 10 visibly // extracted CompareDataPanel component can import them without forcing
// different lines — operators don't have to second-guess which polyline // a circular dependency on Dispatch.js.
// 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];
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => { const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => {
const map = useMap(); const map = useMap();
@@ -973,7 +950,18 @@ const Dispatch = ({
// per-rider). Kept as a single state flag so we don't entangle it with // per-rider). Kept as a single state flag so we don't entangle it with
// viewMode/focused* logic. // viewMode/focused* logic.
const [compareOpen, setCompareOpen] = useState(false); 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 // In controlled mode the parent owns selectedRiderId, so handleRiderFocus only
// fires onRiderSelect — focusedRider won't update until the parent re-renders. // fires onRiderSelect — focusedRider won't update until the parent re-renders.
// This ref lets us defer setCompareOpen(true) until focusedRider is confirmed. // This ref lets us defer setCompareOpen(true) until focusedRider is confirmed.
@@ -1010,6 +998,20 @@ const Dispatch = ({
const syncSourceRef = useRef(null); const syncSourceRef = useRef(null);
const syncLastInputAtRef = useRef(0); 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. // Pull the partners/getriderlogs feed for the currently selected hub + date.
// This endpoint returns the exact live GPS position for every rider at the // This endpoint returns the exact live GPS position for every rider at the
// hub (latitude/longitude/logdate/status). We render those positions as // 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) ? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
: internalFocusedRider; : 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 // 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. // updates local state; in controlled mode it only notifies the parent.
const handleRiderFocus = useCallback( const handleRiderFocus = useCallback(
@@ -1566,6 +1578,18 @@ const Dispatch = ({
return { plannedKm, actualKm, kmDeltaPct, anomalies, late, onTime }; return { plannedKm, actualKm, kmDeltaPct, anomalies, late, onTime };
}, [compareDeltas]); }, [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 // When Compare is open and a step is focused, override MapController's
// focusedItem with a synthetic single-order item so the planned (left) // focusedItem with a synthetic single-order item so the planned (left)
// map zooms onto the same delivery the operator is scrutinizing. Without // 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. // stuck on a step that may not exist in their day.
useEffect(() => { useEffect(() => {
setFocusedCompareStep(null); setFocusedCompareStep(null);
setDaySummaryOpen(false); setExpandedSeqGroups(new Set());
}, [compareOpen, focusedRider?.id]); }, [compareOpen, focusedRider?.id]);
// Mirror pan/zoom from whichever map the user is driving. Called by // Mirror pan/zoom from whichever map the user is driving. Called by
@@ -2446,7 +2470,7 @@ const Dispatch = ({
}; };
return ( return (
<div className={`testing-container${embedded ? ' embedded' : ''}`}> <div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
{!embedded && ( {!embedded && (
<div id="hdr"> <div id="hdr">
<div className="logo"> <div className="logo">
@@ -3567,7 +3591,7 @@ const Dispatch = ({
scrollWheelZoom scrollWheelZoom
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
zoomControl={false} zoomControl={false}
preferCanvas renderer={plannedMapRendererRef.current}
inertia inertia
inertiaDeceleration={2400} inertiaDeceleration={2400}
inertiaMaxSpeed={2000} inertiaMaxSpeed={2000}
@@ -3864,23 +3888,46 @@ const Dispatch = ({
{compareSyncEnabled ? <MdGpsFixed /> : <MdMyLocation />} {compareSyncEnabled ? <MdGpsFixed /> : <MdMyLocation />}
{compareSyncEnabled ? 'Sync' : 'Unlinked'} {compareSyncEnabled ? 'Sync' : 'Unlinked'}
</button> </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>
</div> </div>
{/* Step timeline — every delivery as a tappable dot in chronological {/* Step timeline — every delivery as a tappable dot in chronological
order. The operator can drill into any step to scrutinize that order. The operator can drill into any step to scrutinize that
single delivery on both maps. Filled = delivered, ring = pending, 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-wrap">
<div className="compare-timeline"> <div className="compare-timeline-container">
{compareDeltas.map((d, i) => { <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 statusLow = String(d.orderstatus || '').toLowerCase();
const isDelivered = FINAL_STATUSES.has(statusLow); const isDelivered = FINAL_STATUSES.has(statusLow);
const isSkipped = SKIPPED_STATUSES.has(statusLow); const isSkipped = SKIPPED_STATUSES.has(statusLow);
const isFocused = focusedCompareStep === d.sequenceStep; const isFocused = focusedCompareStep === d.sequenceStep;
const isLoading = d.isLoading && d.coordsCount === 0; const isLoading = d.isLoading && d.coordsCount === 0;
const isNoData = !d.isLoading && d.coordsCount === 0; const isNoData = !d.isLoading && d.coordsCount === 0;
const color = stepColor(i); const plannedStepNum = d.order?.step || d.sequenceStep;
const color = stepColor(plannedStepNum - 1);
const cls = [ const cls = [
'compare-step', 'compare-step',
isFocused && 'is-focused', isFocused && 'is-focused',
@@ -3892,7 +3939,7 @@ const Dispatch = ({
d.anomaly && 'is-anomaly' d.anomaly && 'is-anomaly'
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
return ( return (
<React.Fragment key={`step-${d.deliveryid}`}> <React.Fragment key={`step-p-${d.deliveryid}`}>
{i > 0 && <span className="compare-step-spacer" />} {i > 0 && <span className="compare-step-spacer" />}
<button <button
type="button" type="button"
@@ -3904,14 +3951,62 @@ const Dispatch = ({
) )
} }
title={ title={
`Step ${d.sequenceStep}` + `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.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
(d.actualTs ? ` · ${d.actualTs.format('hh:mm A')}` : '') + (d.actualTs ? ` · ${d.actualTs.format('hh:mm A')}` : '') +
(d.anomaly ? ' · deviation flagged' : '') (d.anomaly ? ' · deviation flagged' : '')
} }
> >
<span className="compare-step-circle"> <span className="compare-step-circle">
{isLoading ? <span className="compare-step-spin" /> : d.sequenceStep} {isLoading ? <span className="compare-step-spin" /> : plannedStepNum}
</span> </span>
{d.actualTs && ( {d.actualTs && (
<span className="compare-step-tick"> <span className="compare-step-tick">
@@ -3924,6 +4019,8 @@ const Dispatch = ({
); );
})} })}
</div> </div>
</div>
</div>
<div className="compare-progress-strip"> <div className="compare-progress-strip">
<div className="compare-progress-bar-wrap"> <div className="compare-progress-bar-wrap">
<div <div
@@ -3970,10 +4067,20 @@ const Dispatch = ({
</div> </div>
); );
})()} })()}
</>
)}
</div> </div>
); );
})()} })()}
<div className="compare-map-area"> <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 <MapContainer
key={`compare-${focusedRider.id}`} key={`compare-${focusedRider.id}`}
center={[11.022, 76.982]} center={[11.022, 76.982]}
@@ -3981,7 +4088,7 @@ const Dispatch = ({
scrollWheelZoom scrollWheelZoom
style={{ flex: 1, minHeight: 0, width: '100%' }} style={{ flex: 1, minHeight: 0, width: '100%' }}
zoomControl={false} zoomControl={false}
preferCanvas renderer={actualMapRenderer}
inertia inertia
inertiaDeceleration={2400} inertiaDeceleration={2400}
inertiaMaxSpeed={2000} inertiaMaxSpeed={2000}
@@ -4262,193 +4369,31 @@ const Dispatch = ({
</div> </div>
</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>
);
})()}
</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> </div>
)} )}

View File

@@ -0,0 +1,65 @@
// Shared constants and pure helpers for the Dispatch page and its
// extracted sub-components (CompareDataPanel, etc.). Lives outside
// Dispatch.js so we don't create a circular import between the host
// component and the child views.
// Status palette — single source of truth for the status pill colors
// rendered on rider cards, order rows, step lists, and tooltips.
export 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' }
};
export const getStatusStyle = (status) =>
STATUS_STYLES[String(status || '').toLowerCase()] || {
label: status || 'Unknown',
bg: '#64748b',
fg: '#fff'
};
// Order-status sets used for completion / skipped decisions across the
// rider list, the planned-route renderer, and the compare data panel.
export const FINAL_STATUSES = new Set(['delivered']);
export const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
// Per-step palette — wider and more deliberately spaced than the rider
// palette so a 10-stop day reads as 10 distinct colors on the compare
// map's polylines + pins.
export 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
];
export const stepColor = (i) =>
STEP_PALETTE[((i % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length];
// Pure helper — converts 1, 2, 3, 21 → "1st", "2nd", "3rd", "21st". Used
// by the compare data panel for the route-sequence diff list ("Visited
// 4th · planned 2nd").
export const ordinal = (n) => {
if (n == null) return '';
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
};