updates on the dispatch page and redesigned the maximum pages

This commit is contained in:
2026-06-02 13:09:29 +05:30
parent c882dbdcdd
commit 8d0c796ba5
25 changed files with 24405 additions and 4133 deletions

View 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;

View File

@@ -0,0 +1,65 @@
// Shared constants and pure helpers for the Dispatch page and its
// extracted sub-components (CompareDataPanel, etc.). Lives outside
// Dispatch.js so we don't create a circular import between the host
// component and the child views.
// Status palette — single source of truth for the status pill colors
// rendered on rider cards, order rows, step lists, and tooltips.
export const STATUS_STYLES = {
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' },
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' },
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' },
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' },
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' },
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' },
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }
};
export const getStatusStyle = (status) =>
STATUS_STYLES[String(status || '').toLowerCase()] || {
label: status || 'Unknown',
bg: '#64748b',
fg: '#fff'
};
// Order-status sets used for completion / skipped decisions across the
// rider list, the planned-route renderer, and the compare data panel.
export const FINAL_STATUSES = new Set(['delivered']);
export const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
// Per-step palette — wider and more deliberately spaced than the rider
// palette so a 10-stop day reads as 10 distinct colors on the compare
// map's polylines + pins.
export const STEP_PALETTE = [
'#2563eb', // blue-600
'#dc2626', // red-600
'#16a34a', // green-600
'#ea580c', // orange-600
'#9333ea', // purple-600
'#0891b2', // cyan-600
'#ca8a04', // yellow-600
'#db2777', // pink-600
'#0f766e', // teal-700
'#7c3aed', // violet-600
'#65a30d', // lime-600
'#0284c7', // sky-600
'#b91c1c', // red-700
'#15803d', // green-700
'#a16207', // yellow-700
'#86198f' // fuchsia-800
];
export const stepColor = (i) =>
STEP_PALETTE[((i % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length];
// Pure helper — converts 1, 2, 3, 21 → "1st", "2nd", "3rd", "21st". Used
// by the compare data panel for the route-sequence diff list ("Visited
// 4th · planned 2nd").
export const ordinal = (n) => {
if (n == null) return '';
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
};