upates on the dispatch comparsion new page updates
This commit is contained in:
956
src/pages/nearle/dispatch/CompareDataPanel.js
Normal file
956
src/pages/nearle/dispatch/CompareDataPanel.js
Normal 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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
65
src/pages/nearle/dispatch/dispatchShared.js
Normal file
65
src/pages/nearle/dispatch/dispatchShared.js
Normal 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]);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user