updates on the dispatch page and redesigned the maximum pages
This commit is contained in:
976
src/pages/nearle/dispatch/CompareDataPanel.js
Normal file
976
src/pages/nearle/dispatch/CompareDataPanel.js
Normal file
@@ -0,0 +1,976 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
MdPublic,
|
||||
MdSwapHoriz,
|
||||
MdExpandMore,
|
||||
MdCheckCircle,
|
||||
MdAccessTime,
|
||||
MdStraighten,
|
||||
MdErrorOutline,
|
||||
MdFormatListBulleted,
|
||||
MdTimer,
|
||||
MdWarning,
|
||||
MdClose,
|
||||
MdSpeed,
|
||||
MdStar,
|
||||
MdFlag,
|
||||
MdHourglassBottom
|
||||
} from 'react-icons/md';
|
||||
import {
|
||||
stepColor,
|
||||
getStatusStyle,
|
||||
FINAL_STATUSES,
|
||||
SKIPPED_STATUSES,
|
||||
ordinal
|
||||
} from './dispatchShared';
|
||||
|
||||
// Right-side data panel rendered in Compare mode. Pure presentation +
|
||||
// memoized derivations: feed it the comparison state from Dispatch and
|
||||
// the panel handles its own layout (compliance score, day overview, route
|
||||
// sequence with cascade grouping, KPIs, highlights, trips, focused-step
|
||||
// details, deviations, full step list).
|
||||
//
|
||||
// Props:
|
||||
// focusedRider — the rider whose day is being compared
|
||||
// compareDeltas — per-step actual deltas (see useMemo
|
||||
// in Dispatch.js)
|
||||
// compareSummary — day rollup: actualKm/onTime/etc
|
||||
// actualOrdered — compareDeltas sorted by sequenceStep (visit
|
||||
// order). Used by the route-sequence section.
|
||||
// focusedCompareStep — currently focused step (1..N) or null
|
||||
// setFocusedCompareStep — setter; pass a function-updater for toggle
|
||||
// sequenceOpen — whether the "Route sequence" section is open
|
||||
// setSequenceOpen — setter for sequenceOpen
|
||||
// expandedSeqGroups — Set of expanded sequence-diff group indices
|
||||
// setExpandedSeqGroups — setter (Set state)
|
||||
// onClose — called when the user clicks the × header btn
|
||||
function CompareDataPanel({
|
||||
focusedRider,
|
||||
compareDeltas,
|
||||
compareSummary,
|
||||
actualOrdered,
|
||||
focusedCompareStep,
|
||||
setFocusedCompareStep,
|
||||
sequenceOpen,
|
||||
setSequenceOpen,
|
||||
expandedSeqGroups,
|
||||
setExpandedSeqGroups,
|
||||
onClose
|
||||
}) {
|
||||
// All derivations live in a single useMemo so the cost of re-running
|
||||
// them is paid only when an upstream input actually changes — not on
|
||||
// every parent render (e.g. cursor moving over the map, sync toggle
|
||||
// toggling, etc.). Keeping them grouped also makes the data contract
|
||||
// visible at a glance.
|
||||
const view = useMemo(() => {
|
||||
const sum = compareSummary;
|
||||
const totalSteps = sum.onTime + sum.late;
|
||||
const deviations = compareDeltas.filter((d) => d.anomaly);
|
||||
const delivered = compareDeltas.filter((d) =>
|
||||
FINAL_STATUSES.has(String(d.orderstatus || '').toLowerCase())
|
||||
).length;
|
||||
const skipped = compareDeltas.filter((d) =>
|
||||
SKIPPED_STATUSES.has(String(d.orderstatus || '').toLowerCase())
|
||||
).length;
|
||||
const stepDeltaPct =
|
||||
sum.kmDeltaPct == null
|
||||
? ''
|
||||
: sum.kmDeltaPct > 25
|
||||
? 'is-over'
|
||||
: sum.kmDeltaPct < -5
|
||||
? 'is-under'
|
||||
: '';
|
||||
|
||||
// Compliance score (0-100): 60% delivered + 25% on-time + 15% no-deviation.
|
||||
const totalForScore = compareDeltas.length || 1;
|
||||
const onTimeForScore = sum.onTime + sum.late || 1;
|
||||
const score = Math.round(
|
||||
(delivered / totalForScore) * 60 +
|
||||
(sum.onTime / onTimeForScore) * 25 +
|
||||
((totalForScore - sum.anomalies) / totalForScore) * 15
|
||||
);
|
||||
const scoreColor = score >= 85 ? '#16a34a' : score >= 65 ? '#f59e0b' : '#dc2626';
|
||||
const scoreLabel = score >= 85 ? 'Excellent' : score >= 65 ? 'Acceptable' : 'Needs review';
|
||||
|
||||
// KPIs derived from delivery timestamps.
|
||||
const withActual = compareDeltas.filter((d) => d.actualTs);
|
||||
const firstDelivery = withActual.reduce(
|
||||
(acc, d) => (!acc || d.actualTs.isBefore(acc) ? d.actualTs : acc),
|
||||
null
|
||||
);
|
||||
const lastDelivery = withActual.reduce(
|
||||
(acc, d) => (!acc || d.actualTs.isAfter(acc) ? d.actualTs : acc),
|
||||
null
|
||||
);
|
||||
const activeMin =
|
||||
firstDelivery && lastDelivery
|
||||
? Math.max(0, lastDelivery.diff(firstDelivery, 'minute'))
|
||||
: 0;
|
||||
const avgPerStop =
|
||||
compareDeltas.length > 1
|
||||
? Math.round(activeMin / (compareDeltas.length - 1))
|
||||
: 0;
|
||||
const avgSpeed =
|
||||
activeMin > 0 ? (sum.actualKm / (activeMin / 60)).toFixed(1) : null;
|
||||
|
||||
// Best / worst step.
|
||||
const readyDeltas = compareDeltas.filter(
|
||||
(d) => !d.isLoading && d.coordsCount > 0
|
||||
);
|
||||
const bestStep =
|
||||
readyDeltas
|
||||
.filter((d) => d.timeDeltaMin != null && !d.anomaly)
|
||||
.sort((a, b) => a.timeDeltaMin - b.timeDeltaMin)[0] || null;
|
||||
const worstStep =
|
||||
readyDeltas
|
||||
.filter((d) => d.anomaly)
|
||||
.sort((a, b) => {
|
||||
const sa =
|
||||
Math.abs(a.kmDeltaPct || 0) + (a.timeDeltaMin > 0 ? a.timeDeltaMin : 0);
|
||||
const sb =
|
||||
Math.abs(b.kmDeltaPct || 0) + (b.timeDeltaMin > 0 ? b.timeDeltaMin : 0);
|
||||
return sb - sa;
|
||||
})[0] || null;
|
||||
|
||||
// Route sequence — which actual-visit positions don't match the
|
||||
// dispatch-planned step.
|
||||
const outOfOrderSteps = actualOrdered.filter((d, i) => {
|
||||
const planned = d.order?.step;
|
||||
return planned != null && planned !== i + 1;
|
||||
});
|
||||
|
||||
// Cascade-aware grouping of out-of-order steps: consecutive entries
|
||||
// with the same `delta` collapse into one "N consecutive shifted +K"
|
||||
// card so a single bad first stop doesn't paint 12 noisy rows.
|
||||
const seqRuns = [];
|
||||
outOfOrderSteps.forEach((d) => {
|
||||
const planned = d.order?.step;
|
||||
const actualPos =
|
||||
actualOrdered.findIndex((x) => x.sequenceStep === d.sequenceStep) + 1;
|
||||
const delta = actualPos - planned;
|
||||
const last = seqRuns[seqRuns.length - 1];
|
||||
if (last && last.delta === delta && last.lastActualPos + 1 === actualPos) {
|
||||
last.items.push({ d, planned, actualPos, delta });
|
||||
last.lastActualPos = actualPos;
|
||||
} else {
|
||||
seqRuns.push({
|
||||
delta,
|
||||
items: [{ d, planned, actualPos, delta }],
|
||||
lastActualPos: actualPos
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Trip-by-trip rollup.
|
||||
const tripBuckets = {};
|
||||
focusedRider.orders.forEach((o) => {
|
||||
const t = o.trip_number || 1;
|
||||
if (!tripBuckets[t]) tripBuckets[t] = [];
|
||||
tripBuckets[t].push(o);
|
||||
});
|
||||
const tripList = Object.entries(tripBuckets)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([tNum, tOrders]) => ({
|
||||
tNum,
|
||||
count: tOrders.length,
|
||||
actualKm: tOrders.reduce(
|
||||
(s, o) => s + parseFloat(o.actualkms || o.kms || 0),
|
||||
0
|
||||
),
|
||||
delivered: tOrders.filter((o) =>
|
||||
FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase())
|
||||
).length
|
||||
}));
|
||||
|
||||
return {
|
||||
sum,
|
||||
totalSteps,
|
||||
deviations,
|
||||
delivered,
|
||||
skipped,
|
||||
stepDeltaPct,
|
||||
score,
|
||||
scoreColor,
|
||||
scoreLabel,
|
||||
firstDelivery,
|
||||
lastDelivery,
|
||||
activeMin,
|
||||
avgPerStop,
|
||||
avgSpeed,
|
||||
bestStep,
|
||||
worstStep,
|
||||
outOfOrderSteps,
|
||||
seqRuns,
|
||||
tripList
|
||||
};
|
||||
}, [focusedRider, compareDeltas, compareSummary, actualOrdered]);
|
||||
|
||||
const focused =
|
||||
focusedCompareStep != null
|
||||
? compareDeltas.find((d) => d.sequenceStep === focusedCompareStep)
|
||||
: null;
|
||||
|
||||
const toggleSeqGroup = (idx) => {
|
||||
setExpandedSeqGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const focusStep = (sequenceStep) => {
|
||||
setFocusedCompareStep((prev) => (prev === sequenceStep ? null : sequenceStep));
|
||||
};
|
||||
|
||||
// Renders a single shifted-step diff card (used both stand-alone and
|
||||
// nested under an expanded group).
|
||||
const renderDiffRow = (item, focusable = true) => {
|
||||
const { d, planned, actualPos, delta } = item;
|
||||
return (
|
||||
<li
|
||||
key={`diff-${d.sequenceStep}`}
|
||||
className={`cdp-seq-diff${
|
||||
focusedCompareStep === d.sequenceStep ? ' is-focused' : ''
|
||||
}${focusable ? '' : ' is-nested'}`}
|
||||
onClick={() => focusStep(d.sequenceStep)}
|
||||
>
|
||||
<span
|
||||
className="cdp-seq-diff-num"
|
||||
style={{ background: stepColor((planned || d.sequenceStep) - 1) }}
|
||||
>
|
||||
{planned || d.sequenceStep}
|
||||
</span>
|
||||
<div className="cdp-seq-diff-body">
|
||||
<div className="cdp-seq-diff-title">
|
||||
{d.deliverycustomer || `Step ${planned || d.sequenceStep}`}
|
||||
</div>
|
||||
<div className="cdp-seq-diff-sub">
|
||||
Visited <strong>{ordinal(actualPos)}</strong>{' '}
|
||||
· planned <strong>{ordinal(planned)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<span className="cdp-seq-diff-tag">
|
||||
{delta > 0 ? `+${delta}` : `${delta}`}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const {
|
||||
sum,
|
||||
totalSteps,
|
||||
deviations,
|
||||
delivered,
|
||||
skipped,
|
||||
stepDeltaPct,
|
||||
score,
|
||||
scoreColor,
|
||||
scoreLabel,
|
||||
firstDelivery,
|
||||
lastDelivery,
|
||||
activeMin,
|
||||
avgPerStop,
|
||||
avgSpeed,
|
||||
bestStep,
|
||||
worstStep,
|
||||
outOfOrderSteps,
|
||||
seqRuns,
|
||||
tripList
|
||||
} = view;
|
||||
|
||||
return (
|
||||
<aside id="compare-data-panel" className="compare-data-panel">
|
||||
<div className="cdp-head">
|
||||
<div className="cdp-head-title">
|
||||
<span
|
||||
className="cdp-rider-dot"
|
||||
style={{ background: focusedRider.color }}
|
||||
/>
|
||||
<div className="cdp-head-text">
|
||||
<div className="cdp-rider-name">{focusedRider.riderName}</div>
|
||||
<div className="cdp-head-badge">PLANNED vs ACTUAL</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cdp-close"
|
||||
onClick={onClose}
|
||||
title="Exit compare"
|
||||
aria-label="Exit compare"
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cdp-scroll">
|
||||
{/* Compliance score — headline gauge blending delivery, on-time,
|
||||
and route-fidelity into one number. */}
|
||||
<section className="cdp-section cdp-score-section">
|
||||
<div className="cdp-score-wrap">
|
||||
<div
|
||||
className="cdp-score-ring"
|
||||
style={{
|
||||
background: `conic-gradient(${scoreColor} ${score * 3.6}deg, rgba(15,23,42,0.08) 0deg)`
|
||||
}}
|
||||
>
|
||||
<div className="cdp-score-inner">
|
||||
<div className="cdp-score-value" style={{ color: scoreColor }}>
|
||||
{score}
|
||||
</div>
|
||||
<div className="cdp-score-unit">/100</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cdp-score-body">
|
||||
<div className="cdp-score-label" style={{ color: scoreColor }}>
|
||||
{scoreLabel}
|
||||
</div>
|
||||
<div className="cdp-score-title">Compliance score</div>
|
||||
<div className="cdp-score-sub">
|
||||
{delivered}/{compareDeltas.length} delivered
|
||||
{sum.anomalies > 0
|
||||
? ` · ${sum.anomalies} deviation${sum.anomalies > 1 ? 's' : ''}`
|
||||
: ''}
|
||||
{sum.late > 0 ? ` · ${sum.late} late` : ''}
|
||||
{skipped > 0 ? ` · ${skipped} skipped` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="cdp-section">
|
||||
<div className="cdp-section-head">
|
||||
<span className="cdp-section-icon"><MdPublic /></span>
|
||||
<span className="cdp-section-title">Day overview</span>
|
||||
</div>
|
||||
<div className="cdp-tiles">
|
||||
<div className="cdp-tile">
|
||||
<div className="cdp-tile-label">
|
||||
<MdStraighten /> Distance
|
||||
</div>
|
||||
<div className="cdp-tile-value">
|
||||
{sum.actualKm.toFixed(1)}
|
||||
<span className="cdp-tile-unit">km</span>
|
||||
</div>
|
||||
<div className="cdp-tile-sub">actual</div>
|
||||
</div>
|
||||
<div className={`cdp-tile${sum.anomalies > 0 ? ' is-warn' : ''}`}>
|
||||
<div className="cdp-tile-label">
|
||||
<MdWarning /> Deviation
|
||||
</div>
|
||||
<div className={`cdp-tile-value ${stepDeltaPct}`}>
|
||||
{sum.kmDeltaPct != null
|
||||
? `${sum.kmDeltaPct > 0 ? '+' : ''}${sum.kmDeltaPct.toFixed(0)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="cdp-tile-sub">
|
||||
{sum.anomalies > 0 ? `${sum.anomalies} flagged` : 'within plan'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`cdp-tile${sum.late > 0 ? ' is-warn' : ''}`}>
|
||||
<div className="cdp-tile-label">
|
||||
<MdAccessTime /> On-time
|
||||
</div>
|
||||
<div className="cdp-tile-value">
|
||||
{sum.onTime}
|
||||
{totalSteps > 0 && (
|
||||
<span className="cdp-tile-unit">/{totalSteps}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cdp-tile-sub">
|
||||
{sum.late > 0 ? `${sum.late} late` : 'all on schedule'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Route sequence — collapsible, default open. Shows planned vs
|
||||
actual visit order with cascade-aware diff grouping. */}
|
||||
{compareDeltas.length > 0 && (
|
||||
<section className="cdp-section cdp-seq-section">
|
||||
<div
|
||||
className="cdp-section-head cdp-section-head-clickable"
|
||||
onClick={() => setSequenceOpen((v) => !v)}
|
||||
role="button"
|
||||
aria-expanded={sequenceOpen}
|
||||
title={sequenceOpen ? 'Collapse route sequence' : 'Expand route sequence'}
|
||||
>
|
||||
<span className="cdp-section-icon">
|
||||
<MdSwapHoriz />
|
||||
</span>
|
||||
<span className="cdp-section-title">Route sequence</span>
|
||||
<span
|
||||
className={`cdp-seq-status${outOfOrderSteps.length > 0 ? ' is-warn' : ' is-good'}`}
|
||||
>
|
||||
{outOfOrderSteps.length > 0
|
||||
? `${outOfOrderSteps.length} out of order`
|
||||
: 'In order'}
|
||||
</span>
|
||||
<span className={`cdp-seq-toggle${sequenceOpen ? ' is-open' : ''}`}>
|
||||
<MdExpandMore />
|
||||
</span>
|
||||
</div>
|
||||
{sequenceOpen && (
|
||||
<div className="cdp-seq">
|
||||
{outOfOrderSteps.length > 0 ? (
|
||||
<ul className="cdp-seq-diffs">
|
||||
{seqRuns.map((run, runIdx) => {
|
||||
if (run.items.length === 1) {
|
||||
return renderDiffRow(run.items[0]);
|
||||
}
|
||||
const first = run.items[0];
|
||||
const last = run.items[run.items.length - 1];
|
||||
const isOpen = expandedSeqGroups.has(runIdx);
|
||||
const deltaStr =
|
||||
run.delta > 0 ? `+${run.delta}` : `${run.delta}`;
|
||||
const groupFocused = run.items.some(
|
||||
(it) => it.d.sequenceStep === focusedCompareStep
|
||||
);
|
||||
return (
|
||||
<React.Fragment key={`run-${runIdx}-${first.d.sequenceStep}`}>
|
||||
<li
|
||||
className={`cdp-seq-diff is-group${isOpen ? ' is-expanded' : ''}${groupFocused ? ' is-focused' : ''}`}
|
||||
onClick={() => toggleSeqGroup(runIdx)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="cdp-seq-group-num">
|
||||
<span
|
||||
className="cdp-seq-group-num-bg"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${stepColor((first.planned || 1) - 1)}, ${stepColor((last.planned || 1) - 1)})`
|
||||
}}
|
||||
/>
|
||||
<span className="cdp-seq-group-num-label">
|
||||
{run.items.length}×
|
||||
</span>
|
||||
</span>
|
||||
<div className="cdp-seq-diff-body">
|
||||
<div className="cdp-seq-diff-title">
|
||||
{run.items.length} consecutive steps shifted{' '}
|
||||
<span className="cdp-seq-group-delta">{deltaStr}</span>
|
||||
</div>
|
||||
<div className="cdp-seq-diff-sub">
|
||||
Planned {ordinal(first.planned)}–{ordinal(last.planned)}{' '}
|
||||
visited{' '}
|
||||
<strong>
|
||||
{ordinal(first.actualPos)}–{ordinal(last.actualPos)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<span className="cdp-seq-diff-tag">{deltaStr}</span>
|
||||
<span
|
||||
className={`cdp-seq-group-toggle${isOpen ? ' is-open' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<MdExpandMore />
|
||||
</span>
|
||||
</li>
|
||||
{isOpen && (
|
||||
<li className="cdp-seq-group-children-wrap">
|
||||
<ul className="cdp-seq-group-children">
|
||||
{run.items.map((it) => renderDiffRow(it, false))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="cdp-seq-good">
|
||||
<MdCheckCircle /> Rider followed the planned route in order.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Timing — clock-style timeline. First/last delivery render as
|
||||
digital clock faces flanking a duration centerpiece. Tiny
|
||||
"Started" / "Finished" captions give the row a narrative.
|
||||
Below: avg-per-stop with a dotted stops-row visualization,
|
||||
and avg speed with a 0-60 gauge bar. */}
|
||||
{(firstDelivery || lastDelivery) && (
|
||||
<section className="cdp-section cdp-timing-section">
|
||||
<div className="cdp-section-head">
|
||||
<span className="cdp-section-icon"><MdTimer /></span>
|
||||
<span className="cdp-section-title">Timing</span>
|
||||
{activeMin > 0 && (
|
||||
<span className="cdp-timing-active-tag">
|
||||
<span className="cdp-timing-active-pulse" />
|
||||
Day window
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cdp-timing-clock">
|
||||
<div className="cdp-clock-card is-start">
|
||||
<div className="cdp-clock-label">
|
||||
<MdFlag /> First delivery
|
||||
</div>
|
||||
<div className="cdp-clock-face">
|
||||
<span className="cdp-clock-time">
|
||||
{firstDelivery ? firstDelivery.format('hh:mm') : '—'}
|
||||
</span>
|
||||
<span className="cdp-clock-period">
|
||||
{firstDelivery ? firstDelivery.format('A') : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cdp-clock-caption">Started</div>
|
||||
</div>
|
||||
<div className="cdp-clock-track" aria-hidden="true">
|
||||
<span className="cdp-clock-track-line" />
|
||||
<span className="cdp-clock-track-dot is-start" />
|
||||
<span className="cdp-clock-track-dot is-end" />
|
||||
<div className="cdp-clock-duration">
|
||||
<span className="cdp-clock-duration-icon">
|
||||
<MdHourglassBottom />
|
||||
</span>
|
||||
<span className="cdp-clock-duration-val">
|
||||
{activeMin > 0
|
||||
? activeMin >= 60
|
||||
? `${Math.floor(activeMin / 60)}h ${activeMin % 60}m`
|
||||
: `${activeMin}m`
|
||||
: '—'}
|
||||
</span>
|
||||
<span className="cdp-clock-duration-sub">active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cdp-clock-card is-end">
|
||||
<div className="cdp-clock-label">
|
||||
<MdCheckCircle /> Last delivery
|
||||
</div>
|
||||
<div className="cdp-clock-face">
|
||||
<span className="cdp-clock-time">
|
||||
{lastDelivery ? lastDelivery.format('hh:mm') : '—'}
|
||||
</span>
|
||||
<span className="cdp-clock-period">
|
||||
{lastDelivery ? lastDelivery.format('A') : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cdp-clock-caption">Finished</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cdp-timing-stats">
|
||||
<div className="cdp-timing-stat">
|
||||
<div className="cdp-timing-stat-head">
|
||||
<div className="cdp-timing-stat-icon">
|
||||
<MdAccessTime />
|
||||
</div>
|
||||
<div className="cdp-timing-stat-body">
|
||||
<div className="cdp-timing-stat-value">
|
||||
{avgPerStop > 0 ? `${avgPerStop}` : '—'}
|
||||
{avgPerStop > 0 && (
|
||||
<span className="cdp-timing-stat-unit">min</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cdp-timing-stat-label">Avg / stop</div>
|
||||
</div>
|
||||
</div>
|
||||
{compareDeltas.length > 0 && (
|
||||
<div className="cdp-timing-stat-viz cdp-stops-dots" aria-hidden="true">
|
||||
{Array.from({ length: Math.min(compareDeltas.length, 12) }).map((_, i) => (
|
||||
<span key={`dot-${i}`} className="cdp-stop-dot" />
|
||||
))}
|
||||
<span className="cdp-timing-stat-viz-label">
|
||||
{compareDeltas.length} stop{compareDeltas.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{avgSpeed != null && (
|
||||
<div className="cdp-timing-stat">
|
||||
<div className="cdp-timing-stat-head">
|
||||
<div className="cdp-timing-stat-icon">
|
||||
<MdSpeed />
|
||||
</div>
|
||||
<div className="cdp-timing-stat-body">
|
||||
<div className="cdp-timing-stat-value">
|
||||
{avgSpeed}
|
||||
<span className="cdp-timing-stat-unit">km/h</span>
|
||||
</div>
|
||||
<div className="cdp-timing-stat-label">Avg speed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cdp-timing-stat-viz cdp-speed-gauge" aria-hidden="true">
|
||||
<div className="cdp-speed-gauge-track">
|
||||
<div
|
||||
className="cdp-speed-gauge-fill"
|
||||
style={{
|
||||
width: `${Math.min(100, (parseFloat(avgSpeed) / 60) * 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="cdp-speed-gauge-scale">
|
||||
<span>0</span>
|
||||
<span>30</span>
|
||||
<span>60 km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Highlights — best/worst step quick-pick. Full-width cards stacked
|
||||
vertically: a colored rail on the left side encodes good/bad,
|
||||
the customer name is the headline, the step number sits as a
|
||||
right-aligned chip, and the metric line uses bold pills. */}
|
||||
{(bestStep || worstStep) && (
|
||||
<section className="cdp-section">
|
||||
<div className="cdp-section-head">
|
||||
<span className="cdp-section-icon"><MdStar /></span>
|
||||
<span className="cdp-section-title">Highlights</span>
|
||||
</div>
|
||||
<div className="cdp-highlights">
|
||||
{bestStep && (
|
||||
<div
|
||||
className="cdp-highlight is-best"
|
||||
onClick={() => focusStep(bestStep.sequenceStep)}
|
||||
role="button"
|
||||
title="Focus this step"
|
||||
>
|
||||
<span className="cdp-highlight-rail" aria-hidden="true" />
|
||||
<div className="cdp-highlight-content">
|
||||
<div className="cdp-highlight-top">
|
||||
<span className="cdp-highlight-label">
|
||||
<span className="cdp-highlight-chip">
|
||||
<MdCheckCircle />
|
||||
</span>
|
||||
Fastest stop
|
||||
</span>
|
||||
<span
|
||||
className="cdp-highlight-step-chip"
|
||||
style={{
|
||||
background: stepColor(bestStep.sequenceStep - 1)
|
||||
}}
|
||||
>
|
||||
Step {bestStep.sequenceStep}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cdp-highlight-title">
|
||||
{bestStep.deliverycustomer || `Step ${bestStep.sequenceStep}`}
|
||||
</div>
|
||||
<div className="cdp-highlight-meta">
|
||||
<span className="cdp-highlight-pill is-good">
|
||||
{bestStep.timeDeltaMin != null
|
||||
? bestStep.timeDeltaMin === 0
|
||||
? 'On schedule'
|
||||
: `${bestStep.timeDeltaMin > 0 ? '+' : ''}${bestStep.timeDeltaMin} min vs plan`
|
||||
: 'On schedule'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{worstStep && (
|
||||
<div
|
||||
className="cdp-highlight is-worst"
|
||||
onClick={() => focusStep(worstStep.sequenceStep)}
|
||||
role="button"
|
||||
title="Focus this step"
|
||||
>
|
||||
<span className="cdp-highlight-rail" aria-hidden="true" />
|
||||
<div className="cdp-highlight-content">
|
||||
<div className="cdp-highlight-top">
|
||||
<span className="cdp-highlight-label">
|
||||
<span className="cdp-highlight-chip">
|
||||
<MdWarning />
|
||||
</span>
|
||||
Biggest deviation
|
||||
</span>
|
||||
<span
|
||||
className="cdp-highlight-step-chip"
|
||||
style={{
|
||||
background: stepColor(worstStep.sequenceStep - 1)
|
||||
}}
|
||||
>
|
||||
Step {worstStep.sequenceStep}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cdp-highlight-title">
|
||||
{worstStep.deliverycustomer || `Step ${worstStep.sequenceStep}`}
|
||||
</div>
|
||||
<div className="cdp-highlight-meta">
|
||||
{worstStep.kmDeltaPct != null && (
|
||||
<span className="cdp-highlight-pill is-bad">
|
||||
{worstStep.kmDeltaPct > 0 ? '+' : ''}
|
||||
{worstStep.kmDeltaPct.toFixed(0)}% route
|
||||
</span>
|
||||
)}
|
||||
{worstStep.timeDeltaMin != null && worstStep.timeDeltaMin > 0 && (
|
||||
<span className="cdp-highlight-pill is-bad">
|
||||
+{worstStep.timeDeltaMin}m late
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Trip breakdown — only when rider ran >1 trip. */}
|
||||
{tripList.length > 1 && (
|
||||
<section className="cdp-section">
|
||||
<div className="cdp-section-head">
|
||||
<span className="cdp-section-icon"><MdSwapHoriz /></span>
|
||||
<span className="cdp-section-title">Trips ({tripList.length})</span>
|
||||
</div>
|
||||
<div className="cdp-trips">
|
||||
{tripList.map((t) => (
|
||||
<div key={`trip-${t.tNum}`} className="cdp-trip">
|
||||
<div className="cdp-trip-head">
|
||||
<span className="cdp-trip-badge">Trip {t.tNum}</span>
|
||||
<span className="cdp-trip-meta">
|
||||
{t.delivered}/{t.count} delivered
|
||||
</span>
|
||||
</div>
|
||||
<div className="cdp-trip-stats">
|
||||
<span title="Distance">
|
||||
<MdStraighten />
|
||||
{t.actualKm.toFixed(1)}km
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Focused-step deep-dive — appears only when a step is selected. */}
|
||||
{focused && (() => {
|
||||
const color = stepColor(focused.sequenceStep - 1);
|
||||
const timeDeltaCls =
|
||||
focused.timeDeltaMin != null
|
||||
? focused.timeDeltaMin > 10
|
||||
? 'is-over'
|
||||
: focused.timeDeltaMin < -2
|
||||
? 'is-under'
|
||||
: ''
|
||||
: '';
|
||||
const statusStyle = getStatusStyle(focused.orderstatus);
|
||||
return (
|
||||
<section className="cdp-section">
|
||||
<div className="cdp-section-head">
|
||||
<span className="cdp-section-icon"><MdSwapHoriz /></span>
|
||||
<span className="cdp-section-title">
|
||||
Step {focused.sequenceStep} details
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cdp-section-clear"
|
||||
onClick={() => setFocusedCompareStep(null)}
|
||||
title="Clear step focus"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
<div className={`compare-delta${focused.anomaly ? ' is-anomaly' : ''}`}>
|
||||
<div className="compare-delta-title">
|
||||
<span
|
||||
className="compare-delta-step-badge"
|
||||
style={{ background: color }}
|
||||
>
|
||||
{focused.sequenceStep}
|
||||
</span>
|
||||
<div className="compare-delta-title-text">
|
||||
<div className="compare-delta-title-main">
|
||||
{focused.deliverycustomer || `Step ${focused.sequenceStep}`}
|
||||
</div>
|
||||
<div className="compare-delta-title-sub">
|
||||
{focused.pickupcustomer ? `from ${focused.pickupcustomer} · ` : ''}
|
||||
Order #{focused.orderid}
|
||||
</div>
|
||||
</div>
|
||||
{focused.orderstatus && (
|
||||
<span
|
||||
className="compare-delta-status"
|
||||
style={{ background: statusStyle.bg, color: statusStyle.fg }}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="compare-delta-grid">
|
||||
<div className={`compare-delta-cell${focused.anomaly ? ' is-anomaly' : ''}`}>
|
||||
<span className="compare-delta-cell-label">Distance</span>
|
||||
<span className="compare-delta-cell-val">
|
||||
{focused.actualKm.toFixed(2)}{' '}
|
||||
<span className="compare-delta-cell-unit">km</span>
|
||||
</span>
|
||||
<span className="compare-delta-cell-sub">actual</span>
|
||||
</div>
|
||||
<div className="compare-delta-cell">
|
||||
<span className="compare-delta-cell-label">Time</span>
|
||||
<span className={`compare-delta-cell-val ${timeDeltaCls}`}>
|
||||
{focused.timeDeltaMin != null
|
||||
? `${focused.timeDeltaMin > 0 ? '+' : ''}${focused.timeDeltaMin} min`
|
||||
: '—'}
|
||||
</span>
|
||||
<span className="compare-delta-cell-sub">
|
||||
{focused.actualTs && focused.expectedTs
|
||||
? `${focused.actualTs.format('HH:mm')} vs ${focused.expectedTs.format('HH:mm')}`
|
||||
: focused.actualTs
|
||||
? `delivered ${focused.actualTs.format('HH:mm')}`
|
||||
: 'in flight'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Deviations list — anomaly-only steps. */}
|
||||
{deviations.length > 0 && (
|
||||
<section className="cdp-section">
|
||||
<div className="cdp-section-head">
|
||||
<span className="cdp-section-icon cdp-icon-warn">
|
||||
<MdErrorOutline />
|
||||
</span>
|
||||
<span className="cdp-section-title">
|
||||
Deviations ({deviations.length})
|
||||
</span>
|
||||
</div>
|
||||
<ul className="cdp-dev-list">
|
||||
{deviations.map((d) => {
|
||||
const color = stepColor(d.sequenceStep - 1);
|
||||
const kmSign = d.kmDelta >= 0 ? '+' : '';
|
||||
return (
|
||||
<li
|
||||
key={`dev-${d.sequenceStep}`}
|
||||
className={`cdp-dev-item${focusedCompareStep === d.sequenceStep ? ' is-focused' : ''}`}
|
||||
onClick={() => focusStep(d.sequenceStep)}
|
||||
>
|
||||
<span className="cdp-dev-num" style={{ background: color }}>
|
||||
{d.sequenceStep}
|
||||
</span>
|
||||
<div className="cdp-dev-body">
|
||||
<div className="cdp-dev-title">
|
||||
{d.deliverycustomer || `Step ${d.sequenceStep}`}
|
||||
</div>
|
||||
<div className="cdp-dev-meta">
|
||||
{d.kmDeltaPct != null && (
|
||||
<span className="cdp-dev-chip is-over">
|
||||
{kmSign}{d.kmDeltaPct.toFixed(0)}% route
|
||||
</span>
|
||||
)}
|
||||
{d.timeDeltaMin != null && d.timeDeltaMin > 10 && (
|
||||
<span className="cdp-dev-chip is-over">
|
||||
+{d.timeDeltaMin}m late
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Full step list. */}
|
||||
<section className="cdp-section">
|
||||
<div className="cdp-section-head">
|
||||
<span className="cdp-section-icon">
|
||||
<MdFormatListBulleted />
|
||||
</span>
|
||||
<span className="cdp-section-title">
|
||||
Steps ({compareDeltas.length})
|
||||
</span>
|
||||
<span className="cdp-section-sub">
|
||||
{delivered}/{compareDeltas.length} delivered
|
||||
</span>
|
||||
</div>
|
||||
<ul className="cdp-step-list">
|
||||
{compareDeltas.map((d) => {
|
||||
const color = stepColor(d.sequenceStep - 1);
|
||||
const statusLow = String(d.orderstatus || '').toLowerCase();
|
||||
const isDelivered = FINAL_STATUSES.has(statusLow);
|
||||
const isSkipped = SKIPPED_STATUSES.has(statusLow);
|
||||
const isCorrect = isDelivered && !d.anomaly;
|
||||
const isFocused = focusedCompareStep === d.sequenceStep;
|
||||
const statusStyle = getStatusStyle(d.orderstatus);
|
||||
const timeCls =
|
||||
d.timeDeltaMin != null
|
||||
? d.timeDeltaMin > 10
|
||||
? 'is-over'
|
||||
: d.timeDeltaMin < -2
|
||||
? 'is-under'
|
||||
: ''
|
||||
: '';
|
||||
const stepCls = [
|
||||
'cdp-step',
|
||||
isFocused ? 'is-focused' : '',
|
||||
d.anomaly ? 'is-anomaly' : '',
|
||||
isCorrect ? 'is-correct' : '',
|
||||
isSkipped ? 'is-skipped' : '',
|
||||
d.isLoading ? 'is-loading' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
return (
|
||||
<li
|
||||
key={`step-${d.sequenceStep}`}
|
||||
className={stepCls}
|
||||
onClick={() => focusStep(d.sequenceStep)}
|
||||
>
|
||||
<span className="cdp-step-num" style={{ background: color }}>
|
||||
{d.sequenceStep}
|
||||
{isCorrect && (
|
||||
<span className="cdp-step-check">
|
||||
<MdCheckCircle />
|
||||
</span>
|
||||
)}
|
||||
{d.anomaly && (
|
||||
<span className="cdp-step-flag">
|
||||
<MdErrorOutline />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="cdp-step-body">
|
||||
<div className="cdp-step-title-row">
|
||||
<span className="cdp-step-title">
|
||||
{d.deliverycustomer || `Step ${d.sequenceStep}`}
|
||||
</span>
|
||||
{d.orderstatus && (
|
||||
<span
|
||||
className="cdp-step-status"
|
||||
style={{ background: statusStyle.bg, color: statusStyle.fg }}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cdp-step-sub">
|
||||
{d.pickupcustomer ? `from ${d.pickupcustomer} · ` : ''}
|
||||
Order #{d.orderid}
|
||||
</div>
|
||||
<div className="cdp-step-deltas">
|
||||
<span className="cdp-step-delta" title="Distance">
|
||||
<MdStraighten />
|
||||
{d.actualKm.toFixed(1)}km
|
||||
</span>
|
||||
<span className={`cdp-step-delta ${timeCls}`} title="Delivery time">
|
||||
<MdAccessTime />
|
||||
{d.actualTs ? d.actualTs.format('HH:mm') : '—'}
|
||||
{d.timeDeltaMin != null && (
|
||||
<small>
|
||||
{' '}{d.timeDeltaMin > 0 ? '+' : ''}{d.timeDeltaMin}m
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompareDataPanel;
|
||||
9848
src/pages/nearle/dispatch/Dispatch.css
Normal file
9848
src/pages/nearle/dispatch/Dispatch.css
Normal file
File diff suppressed because it is too large
Load Diff
5445
src/pages/nearle/dispatch/Dispatch.js
Normal file
5445
src/pages/nearle/dispatch/Dispatch.js
Normal file
File diff suppressed because it is too large
Load Diff
806
src/pages/nearle/dispatch/Preview.js
Normal file
806
src/pages/nearle/dispatch/Preview.js
Normal file
@@ -0,0 +1,806 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Autocomplete,
|
||||
Backdrop,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { HiOutlineArrowLeft } from 'react-icons/hi';
|
||||
import { IoReload } from 'react-icons/io5';
|
||||
import { MdTwoWheeler, MdSwapHoriz } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
createAutomationDeliveries,
|
||||
createOptimisationDeliveries,
|
||||
fetchRidersList,
|
||||
finalCreatedeliveries,
|
||||
notifyRider,
|
||||
reconcileSteps
|
||||
} from '../api/api';
|
||||
import { OpenToast } from 'components/nearle_components/OpenToast';
|
||||
import CSVExport from 'components/third-party/ReactTable';
|
||||
import CircularLoader from 'components/nearle_components/CircularLoader';
|
||||
import Dispatch from './Dispatch';
|
||||
import { stepColor } from './dispatchShared';
|
||||
|
||||
const tuningTypes = [
|
||||
{ tuneid: 1, type: 'Balanced', value: 'balanced' },
|
||||
{ tuneid: 2, type: 'Aggressive Speed', value: 'aggressive_speed' },
|
||||
{ tuneid: 3, type: 'Fuel Saver', value: 'fuel_saver' },
|
||||
{ tuneid: 4, type: 'Zone Strict', value: 'zone_strict' }
|
||||
];
|
||||
|
||||
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
|
||||
// the Reconcile tab UI and the reconcile-API payload.
|
||||
const extractRiders = (previewData) => {
|
||||
if (!previewData) return [];
|
||||
const map = new Map();
|
||||
// De-dupe by orderid across the whole tree. A rider can legitimately appear
|
||||
// in multiple zones (one per delivery suburb), so the same rider_id is
|
||||
// visited more than once. Without this guard, any stale copy left behind
|
||||
// by applyReconcileResponse gets concatenated into the rider's orders and
|
||||
// the same orderid is sent twice to /deliveries/createdeliveries.
|
||||
const seenOrderIds = new Set();
|
||||
const push = (riderId, riderName, orders) => {
|
||||
if (riderId == null) return;
|
||||
const key = String(riderId);
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
|
||||
}
|
||||
const entry = map.get(key);
|
||||
(orders || []).forEach((o) => {
|
||||
const oid = o?.orderid != null ? String(o.orderid) : null;
|
||||
if (oid) {
|
||||
if (seenOrderIds.has(oid)) return;
|
||||
seenOrderIds.add(oid);
|
||||
}
|
||||
entry.orders.push(o);
|
||||
});
|
||||
if (!entry.rider_name && riderName) entry.rider_name = riderName;
|
||||
};
|
||||
|
||||
if (Array.isArray(previewData.zones) && previewData.zones.length) {
|
||||
previewData.zones.forEach((z) => {
|
||||
(z.riders || []).forEach((r) => {
|
||||
const id = r.rider_id ?? r.userid;
|
||||
const name = r.rider_name || r.username || `Rider ${id}`;
|
||||
push(id, name, r.orders);
|
||||
});
|
||||
});
|
||||
} else if (Array.isArray(previewData.details)) {
|
||||
previewData.details.forEach((o) => {
|
||||
const id = o.rider_id ?? o.userid;
|
||||
const name = o.rider_name || o.ridername || `Rider ${id}`;
|
||||
push(id, name, [o]);
|
||||
});
|
||||
}
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
// Reverse of extractRiders — flatten rider-grouped list into a details-style
|
||||
// array (used as the Assign Orders payload).
|
||||
const flattenRiders = (riders) => {
|
||||
const out = [];
|
||||
riders.forEach((r) => {
|
||||
// Go backend types Deliveries.userid as int — coerce here so any
|
||||
// upstream string (AI response, riders API, change-rider edit) gets
|
||||
// normalised before the JSON body is built.
|
||||
const ridNum = Number(r.rider_id);
|
||||
const rid = Number.isFinite(ridNum) ? ridNum : r.rider_id;
|
||||
(r.orders || []).forEach((o) => {
|
||||
out.push({
|
||||
...o,
|
||||
rider_id: rid,
|
||||
userid: rid,
|
||||
rider_name: r.rider_name,
|
||||
rider: r.rider_name
|
||||
});
|
||||
});
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
// Move one order from oldRiderId -> newRiderId inside dispatchPreviewData.
|
||||
// Mutates both the zones[].riders[].orders[] tree (so the Dispatch tab
|
||||
// renders the change) AND the flat details[] list (so Assign Orders picks
|
||||
// it up). Returns a NEW preview object (immutable update).
|
||||
const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName }) => {
|
||||
if (!preview) return preview;
|
||||
const next = JSON.parse(JSON.stringify(preview));
|
||||
|
||||
// 1) Update flat details list
|
||||
if (Array.isArray(next.details)) {
|
||||
next.details = next.details.map((o) =>
|
||||
String(o.orderid) === String(orderId)
|
||||
? { ...o, rider_id: newRiderId, userid: newRiderId, rider_name: newRiderName, rider: newRiderName }
|
||||
: o
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Move within zones[].riders[].orders[]
|
||||
if (Array.isArray(next.zones)) {
|
||||
let movedOrder = null;
|
||||
let homeZoneIdx = -1;
|
||||
|
||||
for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) {
|
||||
const zone = next.zones[zi];
|
||||
if (!Array.isArray(zone.riders)) continue;
|
||||
for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) {
|
||||
const r = zone.riders[ri];
|
||||
if (!Array.isArray(r.orders)) continue;
|
||||
const oi = r.orders.findIndex((o) => String(o.orderid) === String(orderId));
|
||||
if (oi !== -1) {
|
||||
movedOrder = r.orders[oi];
|
||||
r.orders.splice(oi, 1);
|
||||
homeZoneIdx = zi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (movedOrder) {
|
||||
const updated = {
|
||||
...movedOrder,
|
||||
rider_id: newRiderId,
|
||||
userid: newRiderId,
|
||||
rider_name: newRiderName,
|
||||
rider: newRiderName
|
||||
};
|
||||
let placed = false;
|
||||
for (const zone of next.zones) {
|
||||
if (!Array.isArray(zone.riders)) continue;
|
||||
const target = zone.riders.find(
|
||||
(r) => String(r.rider_id ?? r.userid) === String(newRiderId)
|
||||
);
|
||||
if (target) {
|
||||
target.orders = target.orders || [];
|
||||
target.orders.push(updated);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed && homeZoneIdx >= 0) {
|
||||
next.zones[homeZoneIdx].riders.push({
|
||||
rider_id: newRiderId,
|
||||
userid: newRiderId,
|
||||
rider_name: newRiderName,
|
||||
orders: [updated]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
// Merge a reconcile-API response { riders:[{rider_id, orders}] } back into
|
||||
// dispatchPreviewData. Replaces each rider's orders[] in zones (preserving
|
||||
// zone containment), then rebuilds the flat details list from the new tree.
|
||||
const applyReconcileResponse = (preview, response) => {
|
||||
if (!preview || !Array.isArray(response?.riders)) return preview;
|
||||
const next = JSON.parse(JSON.stringify(preview));
|
||||
|
||||
const newOrdersByRider = new Map(
|
||||
response.riders.map((r) => [String(r.rider_id), r.orders || []])
|
||||
);
|
||||
|
||||
if (Array.isArray(next.zones) && next.zones.length) {
|
||||
// Pass 1: wipe every existing copy of a responding rider's orders across
|
||||
// ALL zones. The server's reconciled list is the single source of truth,
|
||||
// and a rider can be present in multiple zones (one per delivery suburb).
|
||||
// The previous "update first match, delete from map" loop left stale
|
||||
// copies in the other zones, which extractRiders then concatenated into
|
||||
// duplicate orderids — surfacing as duplicate deliveries on Assign.
|
||||
next.zones.forEach((zone) => {
|
||||
if (!Array.isArray(zone.riders)) return;
|
||||
zone.riders.forEach((r) => {
|
||||
const key = String(r.rider_id ?? r.userid);
|
||||
if (newOrdersByRider.has(key)) r.orders = [];
|
||||
});
|
||||
});
|
||||
|
||||
// Pass 2: drop the reconciled orders onto the first zone that already
|
||||
// lists the rider. If the rider isn't anywhere in the tree, append a
|
||||
// fresh rider entry to zone[0].
|
||||
newOrdersByRider.forEach((orders, riderKey) => {
|
||||
let placed = false;
|
||||
for (const zone of next.zones) {
|
||||
if (!Array.isArray(zone.riders)) continue;
|
||||
const target = zone.riders.find(
|
||||
(r) => String(r.rider_id ?? r.userid) === riderKey
|
||||
);
|
||||
if (target) {
|
||||
target.orders = orders;
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
const target = next.zones[0];
|
||||
target.riders = target.riders || [];
|
||||
target.riders.push({
|
||||
rider_id: Number(riderKey) || riderKey,
|
||||
rider_name: orders[0]?.rider_name || `Rider ${riderKey}`,
|
||||
orders
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
next.zones = [
|
||||
{
|
||||
zone_name: 'Reconciled',
|
||||
riders: response.riders.map((r) => ({
|
||||
rider_id: r.rider_id,
|
||||
rider_name: r.rider_name || `Rider ${r.rider_id}`,
|
||||
orders: r.orders || []
|
||||
}))
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Rebuild flat details from the updated zones->riders->orders tree.
|
||||
const flatDetails = [];
|
||||
next.zones.forEach((zone) => {
|
||||
(zone.riders || []).forEach((r) => {
|
||||
(r.orders || []).forEach((o) => {
|
||||
flatDetails.push({
|
||||
...o,
|
||||
rider_id: r.rider_id,
|
||||
userid: r.rider_id,
|
||||
rider_name: r.rider_name,
|
||||
rider: r.rider_name
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
next.details = flatDetails;
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const Preview = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const stateData = location.state || {};
|
||||
|
||||
// SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes
|
||||
// through this state. The Dispatch tab renders from it, the Reconcile tab
|
||||
// derives its rider list from it, and Assign Orders sends a flattened copy
|
||||
// of it to the API.
|
||||
const [dispatchPreviewData, setDispatchPreviewData] = useState(stateData.dispatchPreviewData || null);
|
||||
|
||||
// The AI response arrives via location.state, which the browser stores in
|
||||
// history.state and persists across reloads. That means a reload of
|
||||
// /dispatch/preview would re-hydrate the stale snapshot — including any
|
||||
// pending edits the user thought they discarded. Bounce to /orders when
|
||||
// there's no fresh response, and wipe the history snapshot once consumed
|
||||
// so a later reload / back-forward also bounces instead of re-using it.
|
||||
useEffect(() => {
|
||||
if (!stateData.dispatchPreviewData) {
|
||||
navigate('/nearle/orders', { replace: true });
|
||||
return;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.history?.state) {
|
||||
window.history.replaceState({ ...window.history.state, usr: null }, '');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const [csvExportData, setCsvExportData] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const [reconcileLoading, setReconcileLoading] = useState(false);
|
||||
const [hasReconciled, setHasReconciled] = useState(false);
|
||||
|
||||
// Tracks riders whose orders have been edited since the last AI response
|
||||
// or successful reconcile. Only these are sent to the reconcile API — the
|
||||
// server-side step re-ordering only needs to see what actually changed.
|
||||
const [dirtyRiderIds, setDirtyRiderIds] = useState(() => new Set());
|
||||
|
||||
// Change-rider dialog state
|
||||
const [changeDialogOpen, setChangeDialogOpen] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
const [selectedOldRiderId, setSelectedOldRiderId] = useState(null);
|
||||
const [selectedNewRider, setSelectedNewRider] = useState(null);
|
||||
|
||||
const aiMode = stateData.aiMode ?? 1;
|
||||
const selectedMode = stateData.selectedMode || null;
|
||||
const deliveryData = stateData.deliveryData || [];
|
||||
const autoRiders = stateData.autoRiders || [];
|
||||
const absentRidersPayload = stateData.absentRidersPayload || [];
|
||||
const rider = stateData.rider || null;
|
||||
|
||||
const appId = useMemo(() => {
|
||||
if (stateData.appId) return stateData.appId;
|
||||
if (typeof window !== 'undefined') {
|
||||
const v = localStorage.getItem('applocationid');
|
||||
return v ? Number(v) : 0;
|
||||
}
|
||||
return 0;
|
||||
}, [stateData.appId]);
|
||||
|
||||
const { data: ridersList } = useQuery({
|
||||
queryKey: ['ridersList', appId],
|
||||
queryFn: fetchRidersList,
|
||||
enabled: !!appId,
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
|
||||
// Derived: rider list for the Reconcile tab. Recomputes whenever the cache
|
||||
// (dispatchPreviewData) changes — so Change Rider / Reconcile both reflect
|
||||
// here without a separate state.
|
||||
const reconcileRiders = useMemo(() => extractRiders(dispatchPreviewData), [dispatchPreviewData]);
|
||||
|
||||
// Derived: flat orders list used for the Assign Orders payload + CSV export.
|
||||
// Always reflects the latest cache state.
|
||||
const finaldeliveryList = useMemo(() => {
|
||||
const flat = flattenRiders(reconcileRiders);
|
||||
if (flat.length) return computeDeliveryAmounts(flat);
|
||||
if (Array.isArray(dispatchPreviewData?.details)) {
|
||||
return computeDeliveryAmounts(dispatchPreviewData.details);
|
||||
}
|
||||
return [];
|
||||
}, [reconcileRiders, dispatchPreviewData]);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = finaldeliveryList.map((item) => ({
|
||||
zone_name: item.zone_name,
|
||||
ordernotes: item.ordernotes,
|
||||
rider: item.rider,
|
||||
step: item.step,
|
||||
ordertype: item.ordertype,
|
||||
orderamount: item.orderamount,
|
||||
riderkms: item.riderkms,
|
||||
cumulativekms: item.cumulativekms,
|
||||
baseprice: item.baseprice,
|
||||
minkm: item.minkm,
|
||||
priceperkm: item.priceperkm,
|
||||
kms: item.kms,
|
||||
actualkms: item.actualkms,
|
||||
rider_charge: item.rider_charge,
|
||||
deliveryamt: item.deliveryamt,
|
||||
deliverycharges: item.deliverycharges,
|
||||
profit: item.profit
|
||||
}));
|
||||
setCsvExportData(filtered);
|
||||
}, [finaldeliveryList]);
|
||||
|
||||
const notifyRiderMutation = useMutation({
|
||||
mutationFn: notifyRider,
|
||||
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000),
|
||||
onError: (error) => OpenToast(error.message, 'error', 2000)
|
||||
});
|
||||
|
||||
const createDeliveryMutation = useMutation({
|
||||
mutationFn: aiMode == 0 ? createOptimisationDeliveries : createAutomationDeliveries,
|
||||
onSuccess: (data) => {
|
||||
OpenToast('Orders Optimised Successfully', 'success', 2000);
|
||||
// Brand new response = brand new source of truth.
|
||||
setDispatchPreviewData(data);
|
||||
setHasReconciled(false);
|
||||
setDirtyRiderIds(new Set());
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
OpenToast(error.message, 'error', 4000);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onSettled: () => setIsLoading(false)
|
||||
});
|
||||
|
||||
const createFinalDeliveryMutation = useMutation({
|
||||
mutationFn: finalCreatedeliveries,
|
||||
onSuccess: () => {
|
||||
OpenToast('Delivery Created Successfully', 'success', 2000);
|
||||
setIsLoading(false);
|
||||
if (rider?.userfcmtoken) notifyRiderMutation.mutate(rider.userfcmtoken);
|
||||
navigate('/nearle/deliveries');
|
||||
},
|
||||
onError: (error) => {
|
||||
OpenToast(error.message, 'error', 4000);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onSettled: () => setIsLoading(false)
|
||||
});
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: reconcileSteps,
|
||||
onMutate: () => setReconcileLoading(true),
|
||||
onSuccess: (data) => {
|
||||
if (Array.isArray(data?.riders)) {
|
||||
// Merge: applyReconcileResponse replaces orders for riders present
|
||||
// in the response and leaves the rest of the cache untouched.
|
||||
setDispatchPreviewData((prev) => applyReconcileResponse(prev, data));
|
||||
setHasReconciled(true);
|
||||
// Clear only the riders we just reconciled from the dirty set, so
|
||||
// any unrelated edits made meanwhile are preserved.
|
||||
setDirtyRiderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
data.riders.forEach((r) => next.delete(String(r.rider_id)));
|
||||
return next;
|
||||
});
|
||||
OpenToast('Steps reconciled — preview updated', 'success', 2000);
|
||||
} else {
|
||||
OpenToast('Reconcile returned no rider data', 'warning', 3000);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
OpenToast(error.message || 'Reconcile failed', 'error', 4000);
|
||||
},
|
||||
onSettled: () => setReconcileLoading(false)
|
||||
});
|
||||
|
||||
const handleCreateDelivery = (tune) => {
|
||||
setIsLoading(true);
|
||||
if (aiMode == 0) {
|
||||
createDeliveryMutation.mutate({ deliveries: deliveryData });
|
||||
} else if (selectedMode && selectedMode?.value == 1) {
|
||||
createDeliveryMutation.mutate({
|
||||
deliveries: deliveryData,
|
||||
hypertuning_params: tune || null,
|
||||
selectedMode,
|
||||
absent_riders: absentRidersPayload
|
||||
});
|
||||
} else {
|
||||
createDeliveryMutation.mutate({
|
||||
data: {
|
||||
orders: deliveryData,
|
||||
riders: autoRiders,
|
||||
config: { pay_type: 'hourly', base_pay: 300.0, strategy: 'multi_trip' },
|
||||
absent_riders: absentRidersPayload
|
||||
},
|
||||
selectedMode
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalCreateDelivery = () => {
|
||||
if (!finaldeliveryList?.length) {
|
||||
OpenToast('No deliveries to assign', 'error', 3000);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList });
|
||||
};
|
||||
|
||||
const handleReconcile = () => {
|
||||
if (!reconcileRiders.length) {
|
||||
OpenToast('No riders to reconcile', 'warning', 3000);
|
||||
return;
|
||||
}
|
||||
// Only send riders that were edited since the last AI response / reconcile.
|
||||
// Their step ordering is the only thing that can be stale — untouched
|
||||
// riders are skipped to keep the payload small.
|
||||
const dirty = reconcileRiders.filter((r) =>
|
||||
dirtyRiderIds.has(String(r.rider_id))
|
||||
);
|
||||
if (!dirty.length) {
|
||||
OpenToast('No edits to reconcile', 'info', 2500);
|
||||
return;
|
||||
}
|
||||
reconcileMutation.mutate({
|
||||
riders: dirty.map((r) => ({
|
||||
rider_id: r.rider_id,
|
||||
orders: r.orders
|
||||
}))
|
||||
});
|
||||
};
|
||||
|
||||
const openChangeRider = (oldRider, order) => {
|
||||
const oldId =
|
||||
oldRider?.rider_id ?? oldRider?.id ?? order?.rider_id ?? order?.userid ?? null;
|
||||
setSelectedOldRiderId(oldId);
|
||||
setSelectedOrder(order);
|
||||
setSelectedNewRider(null);
|
||||
setChangeDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmChangeRider = () => {
|
||||
if (!selectedNewRider || !selectedOrder) return;
|
||||
// Backend expects an int — coerce at the boundary so a string from the
|
||||
// riders API doesn't propagate into the Assign Orders payload.
|
||||
const newRiderId = Number(selectedNewRider.userid);
|
||||
const newRiderName =
|
||||
selectedNewRider.label ||
|
||||
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
|
||||
`Rider ${newRiderId}`;
|
||||
|
||||
setDispatchPreviewData((prev) =>
|
||||
moveOrderInPreviewData(prev, {
|
||||
orderId: selectedOrder.orderid,
|
||||
oldRiderId: selectedOldRiderId,
|
||||
newRiderId,
|
||||
newRiderName
|
||||
})
|
||||
);
|
||||
// Both riders' step sequences are now potentially stale: the old rider
|
||||
// lost a stop, the new rider gained one. Mark both as dirty so the next
|
||||
// Reconcile sends exactly these two.
|
||||
setDirtyRiderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selectedOldRiderId != null) next.add(String(selectedOldRiderId));
|
||||
if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
|
||||
return next;
|
||||
});
|
||||
setHasReconciled(false);
|
||||
setChangeDialogOpen(false);
|
||||
OpenToast('Rider changed — click Reconcile to verify steps', 'info', 2500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', position: 'relative' }}>
|
||||
<Backdrop
|
||||
sx={{ position: 'absolute', color: '#fff', zIndex: (theme) => theme.zIndex.modal + 1 }}
|
||||
open={isLoading}
|
||||
>
|
||||
<CircularLoader color="inherit" />
|
||||
</Backdrop>
|
||||
|
||||
<Box sx={{ py: 1.25, px: 2, borderBottom: '1px solid #eef2f6' }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Tooltip title="Back to orders" placement="top">
|
||||
<IconButton
|
||||
onClick={() => navigate('/nearle/orders')}
|
||||
sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }}
|
||||
>
|
||||
<HiOutlineArrowLeft size={20} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography variant="h3" fontWeight={600}>
|
||||
Assign Orders
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Autocomplete
|
||||
options={tuningTypes || []}
|
||||
getOptionLabel={(option) => option.type}
|
||||
sx={{ minWidth: 250, maxWidth: 600, flex: 1 }}
|
||||
renderInput={(params) => <TextField {...params} label="Hyper Tuning" />}
|
||||
onChange={(e, val, reason) => {
|
||||
if (reason === 'clear') handleCreateDelivery(null);
|
||||
else handleCreateDelivery(val.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<IoReload />}
|
||||
onClick={() => {
|
||||
setIsLoading(true);
|
||||
handleCreateDelivery('reshuffle');
|
||||
}}
|
||||
>
|
||||
Re-Assign
|
||||
</Button>
|
||||
<CSVExport
|
||||
data={csvExportData}
|
||||
filename={`Orders_Detail_${dayjs().format('YYYY-MM-DD_HHmmss')}.csv`}
|
||||
label=" CSV"
|
||||
style={{ m: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: '1px solid #eef2f6' }}>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ minHeight: 40 }}>
|
||||
<Tab label="Dispatch" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} />
|
||||
<Tab label="Reconcile" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{tabValue === 0 && dispatchPreviewData && (
|
||||
<Dispatch
|
||||
// The key forces a full re-mount when the cache reference changes
|
||||
// (after Change Rider / Reconcile / Re-Assign) so Dispatch's
|
||||
// internal state (focused rider, view mode, etc.) recomputes
|
||||
// against the new orders. Without this, internal memos can stick
|
||||
// to the previous data shape.
|
||||
key={dispatchPreviewData?.__cacheKey || JSON.stringify(reconcileRiders.length)}
|
||||
data={dispatchPreviewData}
|
||||
embedded
|
||||
onChangeRider={(order, focusedRider) => openChangeRider(focusedRider, order)}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 1 && (
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2, bgcolor: '#f8fafc' }}>
|
||||
{reconcileRiders.length === 0 ? (
|
||||
<Typography sx={{ color: '#94a3b8', textAlign: 'center', mt: 4 }}>
|
||||
No rider data available to reconcile.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.75}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: hasReconciled ? '#ecfdf5' : '#fffbeb',
|
||||
border: `1px solid ${hasReconciled ? '#a7f3d0' : '#fde68a'}`,
|
||||
color: hasReconciled ? '#065f46' : '#92400e',
|
||||
borderRadius: '10px',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
{hasReconciled
|
||||
? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.'
|
||||
: 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'}
|
||||
</Box>
|
||||
|
||||
{reconcileRiders.map((r) => {
|
||||
const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
|
||||
return (
|
||||
<Card key={r.rider_id} sx={{ p: 2, borderRadius: '12px', boxShadow: '0 1px 3px rgba(15,23,42,0.06)' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.25 }}>
|
||||
<Stack direction="row" alignItems="center" gap={1.25}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '8px',
|
||||
bgcolor: '#eef2ff',
|
||||
color: '#4f46e5',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<MdTwoWheeler size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: 14, color: '#1e293b' }}>
|
||||
{r.rider_name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: 11.5, color: '#64748b' }}>
|
||||
ID: {r.rider_id}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1}>
|
||||
<Chip size="small" label={`${r.orders.length} stops`} sx={{ fontWeight: 600 }} />
|
||||
<Chip size="small" label={`${totalKms.toFixed(1)} km`} variant="outlined" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={1.25} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{r.orders.map((o, idx) => {
|
||||
const stepNum = o.step ?? idx + 1;
|
||||
const color = stepColor(Number(stepNum) - 1);
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${o.orderid}-${idx}`}
|
||||
title={
|
||||
<Box>
|
||||
<div>Order #{o.orderid}</div>
|
||||
<div>{o.deliveryaddress || o.deliverysuburb || ''}</div>
|
||||
<div style={{ marginTop: 4, opacity: 0.8 }}>Click to change rider</div>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
onClick={() => openChangeRider(r, o)}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
bgcolor: color,
|
||||
color: '#fff',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 800,
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
boxShadow:
|
||||
'0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
|
||||
transition: 'transform 0.15s',
|
||||
'&:hover': { transform: 'scale(1.08)' }
|
||||
}}
|
||||
>
|
||||
{stepNum}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
startIcon={<MdSwapHoriz />}
|
||||
onClick={handleReconcile}
|
||||
disabled={reconcileLoading || dirtyRiderIds.size === 0}
|
||||
sx={{ minWidth: 220, borderRadius: '10px', textTransform: 'none', fontWeight: 700 }}
|
||||
>
|
||||
{reconcileLoading
|
||||
? 'Reconciling...'
|
||||
: dirtyRiderIds.size === 0
|
||||
? 'Reconcile'
|
||||
: `Reconcile (${dirtyRiderIds.size})`}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #eef2f6' }}>
|
||||
<Stack direction="row" gap={2} alignItems="center" justifyContent="end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleFinalCreateDelivery}>
|
||||
Assign Orders
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Dialog open={changeDialogOpen} onClose={() => setChangeDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Change Rider</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ mb: 2, fontSize: 13, color: 'text.secondary' }}>
|
||||
Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to:
|
||||
</Typography>
|
||||
<Autocomplete
|
||||
options={ridersList || []}
|
||||
getOptionLabel={(o) =>
|
||||
o?.label || `${o?.firstname || ''} ${o?.lastname || ''}`.trim() || ''
|
||||
}
|
||||
value={selectedNewRider}
|
||||
onChange={(e, val) => setSelectedNewRider(val)}
|
||||
renderInput={(params) => <TextField {...params} label="New rider" placeholder="Pick a rider" />}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setChangeDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" disabled={!selectedNewRider} onClick={confirmChangeRider}>
|
||||
Change Rider
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Mirrors the orders.js deliveryamt recalc — applied at render-time so the
|
||||
// Assign payload always reflects the current cache without a useEffect.
|
||||
function computeDeliveryAmounts(list) {
|
||||
return list.map((item) => {
|
||||
const cumulativeKms = Number(item.cumulativekms || 0);
|
||||
const minKm = Number(item.minkm || 0);
|
||||
const basePrice = Number(item.baseprice || 0);
|
||||
const pricePerKm = Number(item.priceperkm || 0);
|
||||
if (cumulativeKms <= minKm) return { ...item, deliveryamt: basePrice };
|
||||
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
|
||||
});
|
||||
}
|
||||
|
||||
export default Preview;
|
||||
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