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

2
.gitignore vendored
View File

@@ -79,7 +79,7 @@ Thumbs.db
# Gatsby
.cache/
public/
# VuePress
.vuepress/dist

View File

@@ -5,8 +5,10 @@ import React, { forwardRef, useEffect, useRef, useState } from 'react';
import { styled, useTheme } from '@mui/material/styles';
import {
Box,
Button,
Checkbox,
Chip,
CircularProgress,
FormControl,
Grid,
ListItemText,
@@ -493,20 +495,36 @@ SortingSelect.propTypes = {
// ==============================|| CSV EXPORT ||============================== //
export const CSVExport = ({ data, filename, headers }) => {
export const CSVExport = ({ data, filename, headers, label, style, btnLoading, onClick }) => {
return (
<CSVLink data={data} filename={filename} headers={headers}>
<Tooltip title="Download CSV">
<DownloadOutlined style={{ fontSize: '24px', color: 'gray', marginTop: 4, marginRight: 4, marginLeft: 4 }} />
<Tooltip title="CSV Export">
<Button
startIcon={!btnLoading && <DownloadOutlined />}
variant={btnLoading ? 'outlined' : 'contained'}
sx={{ ...style }}
disabled={btnLoading}
onClick={(e) => {
onClick?.(e);
}}
>
{btnLoading ? <CircularProgress size={20} thickness={5} /> : label || 'Download'}
</Button>
</Tooltip>
</CSVLink>
);
};
export default CSVExport;
CSVExport.propTypes = {
data: PropTypes.array,
headers: PropTypes.any,
filename: PropTypes.string
filename: PropTypes.string,
label: PropTypes.node,
style: PropTypes.object,
btnLoading: PropTypes.bool,
onClick: PropTypes.func
};
// ==============================|| EMPTY TABLE - NO DATA ||============================== //

View File

@@ -5,7 +5,7 @@ export const facebookColor = '#3b5998';
export const linkedInColor = '#0e76a8';
// export const APP_DEFAULT_PATH = '/sample-page';
export const APP_DEFAULT_PATH = '/nearle/orders';
export const APP_DEFAULT_PATH = '/nearle/dispatch';
export const HORIZONTAL_MAX_ITEM = 6;
export const DRAWER_WIDTH = 260;

View File

@@ -5,6 +5,7 @@ import { AiOutlineDashboard } from 'react-icons/ai';
import { TbListDetails } from 'react-icons/tb';
import { LiaFileInvoiceSolid } from 'react-icons/lia';
import DirectionsBikeOutlinedIcon from '@mui/icons-material/DirectionsBikeOutlined';
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
// assets
import {
@@ -49,6 +50,13 @@ const nearle = {
title: <FormattedMessage id="MENU" />,
type: 'group',
children: [
{
id: 'dispatch',
title: <FormattedMessage id="Dispatch" />,
type: 'item',
url: '/nearle/dispatch',
icon: RouteOutlinedIcon
},
{
id: 'orders',
title: <FormattedMessage id="Orders" />,

View File

@@ -184,10 +184,12 @@ export const gettenantlocations = async ({ queryKey }) => {
const [, searchLocation] = queryKey;
try {
const response = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantlocations?tenantid=${tenid}&keyword=${searchLocation}`);
return response.data?.details || []; // safe fallback
return response.data?.details || [];
} catch (error) {
// Must return an array — downstream consumers do `.map`/`.length` and a
// string here crashes the entire Locations page.
console.error('Error fetching tenant locations:', error);
return error.message;
return [];
}
};
@@ -306,3 +308,126 @@ export const fetchRidersLogs = async ({ queryKey }) => {
console.log('fetchRidersLogs', riderLogsResponse.data.details);
return riderLogsResponse.data.details;
};
// ==============================|| Dispatch / Preview APIs (ported from xpressconsole) ||============================== //
// Returns the rider's latest periodic log entry — battery, GPS, status,
// current order. Used by the Rider Info modal on the Dispatch page.
export const getRiderPeriodicLogs = async (userid) => {
const url = `${process.env.REACT_APP_URL}/utils/getriderperiodiclogs${userid ? `?userid=${userid}` : ''}`;
const response = await axios.get(url);
if (response.data && response.data.status) return response.data.data;
return null;
};
// Fetches the riders dropdown list for an app location.
export const fetchRidersList = async ({ queryKey }) => {
try {
const [, appId] = queryKey;
const { data } = await axios.get(`${process.env.REACT_APP_URL}/partners/getriders/?applocationid=${appId}`);
const response = data?.details
? data.details.map((val) => ({
...val,
label: `${val.firstname} ${val.lastname} | ${val.contactno}`
}))
: [];
return response;
} catch (err) {
OpenToast(err.message, 'error', 2000);
throw err;
}
};
// Optimise the orders (bike solver).
export const createOptimisationDeliveries = async (deliveryData) => {
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/createdeliveries`, deliveryData.deliveries);
return response.data;
};
// Server-side step reconciliation after manual edits in Preview.
export const reconcileSteps = async ({ riders }) => {
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/reconcile-steps`, { riders });
return response.data;
};
// Batch efficiency analysis — batch ∈ 'morning' | 'afternoon' | 'evening'.
export const fetchBatchEfficiency = async ({ batch, tenantId }) => {
const response = await axios.post(
`https://routes.workolik.com/api/v1/batch/efficiency`,
{ batch, tenant_id: tenantId },
{
headers: { 'Content-Type': 'application/json' },
validateStatus: () => true
}
);
return response.data;
};
// Final commit of dispatched deliveries — coerces userid/rider_id to int at
// the boundary so a string upstream can't cause a 500 unmarshal error.
export const finalCreatedeliveries = async (deliveryData) => {
const toInt = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : v;
};
const deliveries = (deliveryData.deliveries || []).map((d) => ({
...d,
userid: toInt(d.userid),
rider_id: toInt(d.rider_id)
}));
const response = await axios.post(`https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries`, deliveries);
return response.data;
};
// Auto rider assignment via either the bike solver or the auto/multi-trip
// solver. Body shape differs per mode; absent_riders is merged through.
export const createAutomationDeliveries = async (variables) => {
const absentRiders = Array.isArray(variables.absent_riders) ? variables.absent_riders : [];
const url =
variables.selectedMode.value == 1
? `https://routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=${variables.hypertuning_params}`
: `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip`;
const body =
variables.selectedMode.value == 1
? { deliveries: variables.deliveries, absent_riders: absentRiders }
: { ...(variables.data || {}), absent_riders: absentRiders };
const response = await axios.post(url, body);
return response.data;
};
// Push a notification to a rider after assignment.
export const notifyRider = async (riderToken) => {
if (!riderToken) {
throw new Error('Invalid rider token');
}
const response = await axios.post(`${process.env.REACT_APP_URL}/utils/notifyuser`, {
token: riderToken,
notification: {
title: 'NearleXpress',
body: 'Orders have been placed for delivery. Kindly accept and process deliveries',
sound: 'ring',
image: ''
}
});
return response.data;
};
// Paginated deliveries fetch — supports both "All zones" (appId === 0) and
// per-zone scoping. Returns { rows, nextPage } for useInfiniteQuery.
export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
let [, appId, userid, currentStatus, startdate, enddate, rowsPerPage, searchword, tenantid, locationid, riderid] = queryKey;
currentStatus = currentStatus == 'All' ? 'all' : currentStatus;
const url =
appId === 0
? `${process.env.REACT_APP_URL}/deliveries/getdeliveries/?appuserid=${userid}&status=${currentStatus}&fromdate=${startdate}&todate=${enddate}&pageno=${pageParam}&pagesize=${rowsPerPage}&keyword=${searchword}&tenantid=${tenantid}&locationid=${locationid}&userid=${riderid}`
: `${process.env.REACT_APP_URL}/deliveries/getdeliveries/?applocationid=${appId}&status=${currentStatus}&fromdate=${startdate}&todate=${enddate}&pageno=${pageParam}&pagesize=${rowsPerPage}&keyword=${searchword}&tenantid=${tenantid}&locationid=${locationid}&userid=${riderid}`;
const response = await axios.get(url);
return {
rows: response.data.details,
nextPage: response.data.details.length === Number(rowsPerPage) ? pageParam + 1 : undefined
};
};

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]);
};

File diff suppressed because it is too large Load Diff

View File

@@ -131,8 +131,17 @@ const ResponsiveLocationDrawer = () => {
queryKey: ['locations', debouncedSearchLocation],
queryFn: gettenantlocations
});
// Auto-pick a sensible default whenever the locations list changes:
// • Nothing selected yet → pick the first item.
// • Current selection has been filtered out → also pick the first item
// (otherwise the orders panel queries a locationid that's no longer
// in the visible list, returning nothing and confusing the operator).
useEffect(() => {
if (!searchLocation) locations?.length > 0 ? setSelectedLocation(locations[0]) : null;
if (!Array.isArray(locations) || locations.length === 0) return;
const stillVisible =
selectedLocation &&
locations.some((l) => l.locationid === selectedLocation.locationid);
if (!stillVisible) setSelectedLocation(locations[0]);
}, [locations]);
const {
@@ -334,7 +343,7 @@ const ResponsiveLocationDrawer = () => {
color: 'white' // text color
}}
>
{row.locationname[0].toUpperCase()}
{row.locationname?.[0]?.toUpperCase() || '?'}
</Avatar>{' '}
</ListItemAvatar>
<ListItemText primary={row.locationname} secondary={row.suburb} />
@@ -535,8 +544,8 @@ const ResponsiveLocationDrawer = () => {
<Stack>
<Typography variant="caption">{row.pickupcustomer}</Typography>
<Typography variant="caption">{row.pickupcontactno}</Typography>
<Tooltip title={row.pickupaddress}>
<Typography variant="caption">{row.pickupsuburb || row.pickupaddress.slice(0, 20)}</Typography>
<Tooltip title={row.pickupaddress || ''}>
<Typography variant="caption">{row.pickupsuburb || row.pickupaddress?.slice(0, 20) || '—'}</Typography>
</Tooltip>
</Stack>
</Stack>
@@ -549,8 +558,8 @@ const ResponsiveLocationDrawer = () => {
<Stack>
<Typography variant="caption">{row.deliverycustomer}</Typography>
<Typography variant="caption">{row.deliverycontactno}</Typography>
<Tooltip title={row.deliveryaddress}>
<Typography variant="caption">{row.deliverysuburb || row.deliveryaddress.slice(0, 20)}</Typography>
<Tooltip title={row.deliveryaddress || ''}>
<Typography variant="caption">{row.deliverysuburb || row.deliveryaddress?.slice(0, 20) || '—'}</Typography>
</Tooltip>
</Stack>
</Stack>

View File

@@ -42,7 +42,7 @@ const Login = () => {
useEffect(() => {
if (localStorage.getItem('authname')) {
navigate('/nearle/orders');
navigate('/nearle/dispatch');
}
}, []);
@@ -79,7 +79,7 @@ const Login = () => {
setSubmitting(false);
setLoading(false);
navigate('/nearle/orders');
navigate('/nearle/dispatch');
} else {
OpenToast(res.data.message, 'warning', 2000);
setLoading(false);

View File

@@ -0,0 +1,288 @@
/* OrdersRedesign.css — premium aesthetics + micro-animations
Ported from xpressconsole; trimmed to only the classes the
redesigned multipleOrders.js (and its peers) consume here. */
/* ---------- Page background ---------- */
.orders-workspace-bg {
background: linear-gradient(135deg, #f8fafc 0%, #edf2f7 100%) !important;
}
/* ---------- Cards ---------- */
.orders-card {
background: #ffffff !important;
border: 1px solid #eef2f6 !important;
border-radius: 12px !important;
box-shadow: 0 10px 25px -5px rgba(62, 73, 84, 0.04),
0 4px 12px -2px rgba(62, 73, 84, 0.02) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
overflow: hidden;
}
.orders-card:hover {
box-shadow: 0 16px 35px -8px rgba(62, 73, 84, 0.08),
0 6px 16px -3px rgba(62, 73, 84, 0.03) !important;
}
.orders-card .MuiOutlinedInput-root {
font-size: 13px !important;
border-radius: 10px !important;
}
.orders-card .MuiOutlinedInput-input {
font-size: 13px !important;
padding-top: 9px !important;
padding-bottom: 9px !important;
}
.orders-card .MuiInputLabel-root {
font-size: 13px !important;
}
.orders-card .MuiInputLabel-root.MuiInputLabel-shrink {
font-size: 11.5px !important;
}
/* ---------- Section title bar (gradient strip + h-title) ---------- */
.section-title-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.section-title-bar::before {
content: '';
width: 4px;
height: 22px;
border-radius: 4px;
background: linear-gradient(180deg, #1890ff, #096dd9);
flex-shrink: 0;
}
.section-title-bar--accent::before {
background: linear-gradient(180deg, #a855f7, #65387a);
}
.section-title-bar .MuiTypography-root {
margin-bottom: 0 !important;
line-height: 1.2;
}
/* ---------- Delivery preferences card ---------- */
.delivery-prefs-card {
background: linear-gradient(135deg, #ffffff 0%, #fbfcff 100%) !important;
border: 1px solid #eef2f6 !important;
border-radius: 14px !important;
}
.delivery-prefs-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.delivery-prefs-title {
font-size: 14px !important;
font-weight: 700 !important;
color: #1e293b !important;
letter-spacing: -0.01em;
line-height: 1.2;
}
.delivery-prefs-sub {
font-size: 10.5px !important;
font-weight: 500 !important;
color: #94a3b8 !important;
text-transform: uppercase;
letter-spacing: 0.55px;
text-align: right;
line-height: 1.2;
}
.delivery-prefs-row {
display: flex;
flex-direction: column;
gap: 10px;
}
.delivery-prefs-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.delivery-prefs-label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.55px;
text-transform: uppercase;
color: #64748b;
}
/* ---------- Pricing summary card ---------- */
.pricing-summary-card {
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%) !important;
border: 1px solid #eef2f6 !important;
border-radius: 14px !important;
padding: 14px 16px !important;
}
.pricing-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.pricing-title {
font-size: 14px !important;
font-weight: 700 !important;
color: #1e293b !important;
letter-spacing: -0.01em;
}
.pricing-subtitle {
font-size: 11px !important;
font-weight: 500 !important;
color: #94a3b8 !important;
text-transform: uppercase;
letter-spacing: 0.6px;
}
/* ---------- Total charge badge ---------- */
.total-charge-badge {
background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(101, 56, 122, 0.10) 100%);
border: 1px solid rgba(101, 56, 122, 0.18);
border-radius: 10px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
.total-charge-left {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.total-charge-icon {
font-size: 13px;
color: #65387A;
flex-shrink: 0;
}
.total-charge-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: #65387A;
}
.total-charge-val {
font-size: 20px;
font-weight: 800;
color: #65387A;
line-height: 1.1;
letter-spacing: -0.01em;
white-space: nowrap;
}
/* ---------- Gradient action button ---------- */
.gradient-btn-create {
background: linear-gradient(135deg, #1890ff 0%, #65387a 100%) !important;
color: #ffffff !important;
font-weight: 600 !important;
font-size: 13px !important;
letter-spacing: 0.01em !important;
text-transform: none !important;
border-radius: 10px !important;
padding: 8px 18px !important;
min-height: 38px !important;
box-shadow: 0 4px 12px -3px rgba(24, 144, 255, 0.30),
0 2px 4px rgba(101, 56, 122, 0.10) !important;
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1) !important;
border: none !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
}
.gradient-btn-create .MuiButton-startIcon,
.gradient-btn-create .MuiButton-endIcon {
margin: 0 !important;
}
.gradient-btn-create:hover {
filter: brightness(1.04);
box-shadow: 0 8px 18px -4px rgba(24, 144, 255, 0.40),
0 3px 8px rgba(101, 56, 122, 0.18) !important;
}
.gradient-btn-create:active { filter: brightness(0.98); }
.gradient-btn-create.Mui-disabled,
.gradient-btn-create:disabled {
background: #e2e8f0 !important;
color: #94a3b8 !important;
box-shadow: none !important;
cursor: not-allowed !important;
}
/* ---------- Compact header inputs (Location/Client/Business) ---------- */
.header-compact-tf .MuiOutlinedInput-root {
border-radius: 10px !important;
height: 40px !important;
padding-left: 10px !important;
font-size: 12.5px !important;
background: #ffffff;
}
.header-compact-tf .MuiOutlinedInput-input {
padding-top: 6px !important;
padding-bottom: 6px !important;
font-size: 12.5px !important;
}
.header-compact-tf .MuiInputLabel-root {
font-size: 11.5px !important;
letter-spacing: 0.02em;
font-weight: 600;
color: #64748b !important;
}
.header-compact-tf .MuiInputLabel-shrink {
transform: translate(12px, -7px) scale(0.82) !important;
background: #ffffff;
padding: 0 4px;
}
.header-compact-tf .MuiOutlinedInput-notchedOutline { border-color: #e2e8f0; }
.header-compact-tf:hover .MuiOutlinedInput-notchedOutline { border-color: #cbd5e1; }
.header-compact-tf .Mui-focused .MuiOutlinedInput-notchedOutline { border-width: 1.5px !important; }
.header-compact-input .MuiAutocomplete-endAdornment {
top: 50%;
transform: translateY(-50%);
right: 8px;
display: inline-flex;
align-items: center;
height: auto;
gap: 2px;
}
.header-compact-input .MuiAutocomplete-endAdornment .MuiSvgIcon-root {
font-size: 16px;
display: block;
}
.header-compact-input .MuiAutocomplete-clearIndicator,
.header-compact-input .MuiAutocomplete-popupIndicator {
padding: 3px !important;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8 !important;
}
.header-compact-input .MuiAutocomplete-clearIndicator:hover,
.header-compact-input .MuiAutocomplete-popupIndicator:hover {
background: rgba(148, 163, 184, 0.12) !important;
color: #475569 !important;
}
.header-compact-input .MuiAutocomplete-popupIndicator { margin-right: 0; }
.header-compact-input .MuiOutlinedInput-root {
padding-top: 0 !important;
padding-bottom: 0 !important;
padding-right: 60px !important;
}
.header-compact-input .MuiAutocomplete-input {
padding: 4px 4px 4px 0 !important;
height: auto !important;
}

File diff suppressed because it is too large Load Diff

View File

@@ -97,7 +97,91 @@ import {
// DeleteTwoTone
} from '@ant-design/icons';
import { enqueueSnackbar } from 'notistack';
import {
MdLocalShipping,
MdHourglassEmpty,
MdCheckCircle,
MdCancel,
MdAccessTime,
MdHistoryToggleOff,
MdAssignmentTurnedIn,
MdEdit,
MdArrowBack,
MdReceiptLong
} from 'react-icons/md';
import { Paper, Box } from '@mui/material';
// ============================================================================
// Design tokens — shared with the rest of the redesigned operator pages.
// ============================================================================
const DT = {
radiusPill: 999,
radiusCard: 16,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
const dtA = (c, suffix) => `${c}${suffix}`;
const tint = (c) => dtA(c, '08');
const soft = (c) => dtA(c, '18');
const ring = (c) => dtA(c, '26');
const edge = (c) => dtA(c, '55');
const BRAND = '#662582';
const BRAND_LIGHT = '#9255AB';
// Semantic per-status palette — also drives StatusBadge.
const STATUS_META = {
pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
assigned: { label: 'Assigned', color: '#0ea5e9', icon: MdAssignmentTurnedIn },
confirmed: { label: 'Confirmed', color: '#10b981', icon: MdCheckCircle },
modified: { label: 'Modified', color: '#06b6d4', icon: MdHistoryToggleOff },
processing: { label: 'Processing', color: BRAND, icon: MdAccessTime },
active: { label: 'Active', color: '#8b5cf6', icon: MdLocalShipping },
closed: { label: 'Closed', color: '#06b6d4', icon: MdCheckCircle },
completed: { label: 'Completed', color: '#10b981', icon: MdCheckCircle },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
};
const StatusBadge = ({ status, size = 'md' }) => {
if (!status) return null;
const meta = STATUS_META[String(status).toLowerCase()] || {
label: status,
color: DT.textMuted,
icon: MdHistoryToggleOff
};
const Icon = meta.icon;
const px = size === 'lg' ? 1.25 : 1;
const py = size === 'lg' ? 0.5 : 0.375;
const fs = size === 'lg' ? 12 : 11;
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px,
py,
borderRadius: 999,
bgcolor: tint(meta.color),
border: `1px solid ${edge(meta.color)}`,
color: meta.color,
fontSize: fs,
fontWeight: 800,
whiteSpace: 'nowrap'
}}
>
<Icon size={12} /> {meta.label}
</Box>
);
};
const Details = () => {
// const [searchParams] = useSearchParams();
@@ -854,59 +938,105 @@ const Details = () => {
open={open}
onClose={() => handleClose(false)}
maxWidth="xs"
PaperProps={{ sx: { borderRadius: 3 } }}
>
<DialogContent sx={{ mt: 2, my: 1 }}>
<Stack alignItems="center" spacing={3.5}>
<Avatar color="error" sx={{ width: 72, height: 72, fontSize: '1.75rem' }}>
<Box
sx={{
p: 2.5,
background: `linear-gradient(135deg, ${tint('#ef4444')} 0%, ${tint('#f59e0b')} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ bgcolor: '#ef4444', color: '#fff', width: 40, height: 40 }}>
<DeleteFilled />
</Avatar>
<Grid >
<Chip label={orderid.slice(4)} variant="combined" color='warning' size='small' />
</Grid>
<Stack spacing={2}>
{/* <Typography variant="h4" align="center">
Are you sure you want to cancel this order?
</Typography> */}
{(invoiceeligible) &&
<Alert color="warning" variant="border" icon={<WarningFilled />}>
<AlertTitle>Order is within 24Hrs time frame. The order will be invoiced with standard pricing as agreed.</AlertTitle>
{/* <Typography variant="h6"> This is an warning alert.</Typography> */}
<Link href='https://thelegendarystaff.com/' target='_blank' >Terms & Condition link</Link>
</Alert>
}
<Typography variant="h4" align="center">
Please type in the order number to confirm.
<Stack>
<Typography variant="h5" sx={{ fontWeight: 800, color: DT.textPrimary }}>
Cancel Order
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Confirm to permanently cancel this order
</Typography>
<TextField
type='text'
onChange={(e) => {
console.log(e.target.value)
setDeletepassword(e.target.value)
}}
error={deletepassword !== orderid.slice(4)}
// error={true}
value={deletepassword}
/>
</Stack>
</Stack>
</Box>
<DialogContent sx={{ pt: 3 }}>
<Stack alignItems="center" spacing={2.5}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: tint('#f59e0b'),
border: `1px solid ${edge('#f59e0b')}`,
color: '#f59e0b',
fontSize: 12,
fontWeight: 800
}}
>
<MdReceiptLong size={12} /> {orderid.slice(4)}
</Box>
<Stack direction="row" spacing={2} sx={{ width: 1 }}>
<Button fullWidth color="error" variant="contained" onClick={() => {
if (deletepassword === orderid.slice(4)) {
cancelorder();
handleClose(true);
}
{(invoiceeligible) &&
<Alert color="warning" variant="border" icon={<WarningFilled />}>
<AlertTitle>Order is within 24Hrs time frame. The order will be invoiced with standard pricing as agreed.</AlertTitle>
<Link href='https://thelegendarystaff.com/' target='_blank' >Terms & Condition link</Link>
</Alert>
}
}} autoFocus>
Yes, Cancel
</Button>
<Button fullWidth onClick={() => handleClose(false)} color="secondary" variant="outlined">
<Typography variant="body1" align="center" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Please type in the order number to confirm.
</Typography>
<TextField
type="text"
fullWidth
onChange={(e) => setDeletepassword(e.target.value)}
error={deletepassword !== orderid.slice(4)}
value={deletepassword}
placeholder={orderid.slice(4)}
/>
<Stack direction="row" spacing={1.5} sx={{ width: 1 }}>
<Button
fullWidth
onClick={() => handleClose(false)}
variant="outlined"
sx={{
borderRadius: 999,
py: 1,
borderColor: DT.borderSubtle,
color: DT.textSecondary,
fontWeight: 700,
'&:hover': { borderColor: DT.textSecondary, bgcolor: DT.surfaceAlt }
}}
>
No
</Button>
<Button
fullWidth
variant="contained"
autoFocus
onClick={() => {
if (deletepassword === orderid.slice(4)) {
cancelorder();
handleClose(true);
}
}}
sx={{
borderRadius: 999,
py: 1,
bgcolor: '#ef4444',
fontWeight: 700,
boxShadow: `0 6px 18px ${ring('#ef4444')}`,
'&:hover': { bgcolor: '#dc2626' }
}}
>
Yes, Cancel
</Button>
</Stack>
</Stack>
</DialogContent>
@@ -925,40 +1055,116 @@ const Details = () => {
// fullScreen
TransitionComponent={PopupTransition}>
<DialogTitle>
<Stack direction={'row'} justifyContent={'space-between'}>
<Stack direction={{ sm: 'row', xs: 'column' }} spacing={2} alignItems={'center'}>
<Typography variant='h3'>Assign Roles</Typography>
<Chip label={clientname} variant="light" color="primary" />
<DialogTitle
sx={{
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`,
p: { xs: 2, sm: 2.5 }
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }} spacing={1.5}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ bgcolor: BRAND, color: '#fff', width: 40, height: 40, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
<MdAssignmentTurnedIn size={20} />
</Avatar>
<Stack>
<Typography variant="h4" sx={{ fontWeight: 800, color: DT.textPrimary, lineHeight: 1.1 }}>
Assign Roles
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
{clientname} · {currentrole}
</Typography>
</Stack>
</Stack>
<Chip label={orderid} variant="combined" color='warning' size={'large'} />
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: tint('#f59e0b'),
border: `1px solid ${edge('#f59e0b')}`,
color: '#f59e0b',
fontSize: 12,
fontWeight: 800
}}
>
<MdReceiptLong size={12} /> {orderid}
</Box>
</Stack>
<Grid container sx={{ p: 1 }} spacing={2}>
<Grid container sx={{ pt: 2 }} spacing={1.5}>
<Grid item sm={6} xs={12}>
{/* <Chip label={currentrole} variant="combined" color="primary" size='normal' /> */}
<Tabs
value={tabstatus}
// onChange={handleChangetab}
onChange={() => setTabstatus((e) => (e === 0) ? 1 : 0)}
variant="scrollable" scrollButtons="auto" >
{/* <Tab label="All" /> */}
<Tab label={currentrole} />
</Tabs>
<Box
onClick={() => setTabstatus((e) => (e === 0 ? 1 : 0))}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.625,
borderRadius: 999,
cursor: 'pointer',
bgcolor: BRAND,
color: '#fff',
fontWeight: 800,
fontSize: 12,
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<Avatar sx={{ width: 22, height: 22, bgcolor: 'rgba(255,255,255,0.22)', color: '#fff' }}>
<MdAssignmentTurnedIn size={12} />
</Avatar>
{currentrole || 'Role'}
</Box>
</Grid>
<Grid item sm={6} xs={12}>
<Stack direction={'row'} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}
// alignItems={{xs:'flex-end',sm:'center'}}
sx={{ height: '100%' }} spacing={2}>
<Chip sx={{ width: '130px' }} label={`Required:${currentshiftobj.shifts}`} variant="combined" color='primary' size='normal' />
<Chip sx={{ width: '130px' }} label={`Assigned: ${currentshiftobj.assigned}`} variant="combined" color='success' size='normal' />
<Chip sx={{ width: '130px' }} label={`Remaining: ${currentshiftobj.remaining}`} variant="combined" color='error' size='normal' />
<Stack direction="row" justifyContent={{ xs: 'flex-start', sm: 'flex-end' }} spacing={1} flexWrap="wrap" useFlexGap>
{[
{ label: 'Required', value: currentshiftobj.shifts, color: BRAND },
{ label: 'Assigned', value: currentshiftobj.assigned, color: '#10b981' },
{ label: 'Remaining', value: currentshiftobj.remaining, color: '#ef4444' }
].map((c) => (
<Box
key={c.label}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.625,
px: 1,
py: 0.5,
borderRadius: 999,
bgcolor: tint(c.color),
border: `1px solid ${edge(c.color)}`,
color: c.color,
fontSize: 11.5,
fontWeight: 800
}}
>
{c.label}
<Box
sx={{
minWidth: 22,
height: 18,
px: 0.5,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
fontSize: 11,
fontWeight: 800,
bgcolor: '#fff',
color: c.color,
border: `1px solid ${edge(c.color)}`
}}
>
{c.value ?? 0}
</Box>
</Box>
))}
</Stack>
</Grid>
</Grid>
</DialogTitle>
@@ -1256,215 +1462,194 @@ const Details = () => {
<Typography variant="h3">Details</Typography>
</Grid> */}
<CardActions
<Paper
elevation={0}
sx={{
position: 'sticky',
top: '60px',
// top:0,
bgcolor: theme.palette.background.default,
zIndex: 1,
// borderBottom: `1px solid ${theme.palette.divider}`,
width: '100%'
zIndex: 5,
mb: 2,
p: { xs: 1.5, sm: 2, md: 2.5 },
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
boxShadow: DT.shadowMd
}}
>
<Grid item xs={12} >
<Stack direction={{ md: 'row', xs: 'column' }} justifyContent="space-between" alignItems="flex-end"
sx={{ width: '100%', p: 1 }}
>
<Stack direction='row' spacing={2} alignItems='center'
justifyContent='flex-start'
sx={{ width: { xs: '100%', md: '0' } }}
<Stack
direction={{ xs: 'column', md: 'row' }}
alignItems={{ xs: 'flex-start', md: 'center' }}
justifyContent="space-between"
spacing={{ xs: 1.5, md: 2 }}
>
<Stack direction="row" alignItems="center" spacing={{ xs: 1.25, sm: 1.75 }}>
<IconButton
onClick={() => history.back()}
sx={{
bgcolor: '#fff',
border: `1px solid ${DT.borderSubtle}`,
borderRadius: 999,
color: DT.textPrimary,
'&:hover': { bgcolor: tint(BRAND), borderColor: edge(BRAND), color: BRAND }
}}
>
<IconButton
onClick={() => history.back()}
// onClick={()=>}
<MdArrowBack size={18} />
</IconButton>
<Avatar
sx={{
width: { xs: 40, sm: 48 },
height: { xs: 40, sm: 48 },
bgcolor: BRAND,
color: '#fff',
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<MdReceiptLong size={22} />
</Avatar>
<Stack>
<Typography
variant="h3"
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' }
}}
>
<ArrowBackIcon />
</IconButton>
{/* <Link to="/dashboard">Test me</Link> */}
<Stack direction='column' alignItems='flex-start'>
<Typography variant="h3">Details</Typography>
<Stack direction="row" spacing={1}>
{/* <Typography noWrap color="secondary"></Typography> */}
<Chip label={(orderid === '') ? <Skeleton sx={{ width: '80px', bgcolor: '#fff9c4' }} animation="wave" /> : orderid} variant="combined" color='warning' size='small' />
{/* <Typography variant="subtitle1">Date</Typography> */}
{/* <Typography color="secondary">{orderdate}</Typography> */}
<Chip label={(orderdate === '') ? <Skeleton sx={{ width: '80px', bgcolor: '#b3e5fc' }} animation="wave" /> : orderdate} variant="combined" color="primary" size='small' />
{(orderstatus === 'pending') &&
<Chip label="Pending" color="error" size="small" />
}
{(orderstatus === 'cancelled') &&
<Chip label="Cancelled" color="secondary" size="small" />
}
{(orderstatus === 'completed') &&
<Chip label="Completed" color="primary" size="small" />
}
{(orderstatus === 'processing') &&
<Chip label="Processing" color="primary" size="small" />
}
{(orderstatus === 'assigned') &&
<Chip label="Assigned" color="warning" size="small" />
}
{(orderstatus === 'confirmed') &&
<Chip label="Confirmed" color="success" size="small" />
}
{(orderstatus === 'active') &&
<Chip label="Active" color="info" size="small" />
}
{(orderstatus === 'closed') &&
<Chip label="Closed" color="info" size="small" />
}
{(orderstatus === 'modified') &&
<Chip label="Modified"
color='secondary' size="small" variant='contained' />
}
</Stack>
</Stack>
</Stack>
<Stack direction="row" spacing={2}
sx={{ mt: { md: 0, xs: 2 } }}
>
{/* <Typography>{dayjs(startdate).$d.toString()}</Typography> */}
{/* <Typography>{startdate}</Typography> */}
{/* <Typography> {dayjs().$d.toString()}</Typography> */}
{(((orderstatus === 'pending')
|| (orderstatus === 'assigned')
|| (orderstatus === 'confirmed')
|| (orderstatus === 'modified'))
// && (dayjs(startdate).$d > dayjs().$d)
) &&
<Tooltip title='Edit'>
<Button1
variant="outlined"
color="info"
sx={{ borderRadius: '40px' }}
startIcon={<BorderColorIcon color='info' />}
onClick={(e) => {
e.stopPropagation();
// if (dayjs(startdate).$d > dayjs().$d) {
if (dayjs(dayjs().format('MM-DD-YYYY')).isBefore(dayjs(dayjs(startdate).format('MM-DD-YYYY')))) {
navigate(`/editorder`
, {
state: {
orderheaderid: orderheaderid,
tenantid: tenantid
}
}
)
} else {
enqueueSnackbar('Order cannot be edited.\n Order date is not valid at this time',
{
variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 4000,
style: { whiteSpace: "pre-line" }
})
}
}}
>
Edit Order
</Button1>
</Tooltip>
}
{/* {(((orderstatus === 'pending')
|| (orderstatus === 'modified'))
&& assignedpendingcount === 0) &&
<>
<Button1
variant="outlined"
color="primary"
sx={{ borderRadius: '40px' }}
startIcon={<SendIcon />}
onClick={() => {
fetchassignedstaffs();
}}
>
Notify Staff
</Button1>
</>
} */}
{(orderstatus !== 'cancelled' && orderstatus !== '' && orderstatus !== 'completed' && orderstatus !== 'closed') &&
<>
<Button1
variant="outlined"
color="error"
onClick={() => {
console.log(dayjs(startdate).diff(dayjs(), 'm') / 60)
if ((dayjs(startdate).diff(dayjs(), 'm') / 60) > 24) {
setInvoiceeligible(false)
setOpen(true)
} else {
setInvoiceeligible(true)
setOpen(true)
}
}}
sx={{ borderRadius: '40px', mt: { xs: 2, sm: 0 } }}
startIcon={<CancelOutlinedIcon />}
>
Cancel Order
</Button1>
</>
}
{(orderstatus === 'cancelled') &&
<>
<Chip label={`Order Cancelled on ${cancelleddate}`} variant="combined" color='error' />
</>
}
{/* {(orderstatus === 'completed') &&
<Button
variant="outlined"
color="error"
onClick={() => {
navigate(`/invoice/create`, {
state: {
orderheaderid: orderheaderid,
tenantid: tenantid
}
})
}
}
sx={{ borderRadius: '40px', mt: { xs: 2, sm: 0 } }}
Order Details
</Typography>
<Stack direction="row" alignItems="center" spacing={0.75} flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#f59e0b'),
border: `1px solid ${edge('#f59e0b')}`,
color: '#f59e0b',
fontSize: 11,
fontWeight: 800
}}
>
Raise Invoice
</Button>
} */}
<MdReceiptLong size={11} />
{orderid === '' ? <Skeleton sx={{ width: 80 }} animation="wave" /> : orderid}
</Box>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: tint(BRAND),
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontSize: 11,
fontWeight: 800
}}
>
<MdAccessTime size={11} />
{orderdate === '' ? <Skeleton sx={{ width: 80 }} animation="wave" /> : orderdate}
</Box>
<StatusBadge status={orderstatus} />
</Stack>
</Stack>
</Stack>
</Grid>
</CardActions>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
{((orderstatus === 'pending') ||
(orderstatus === 'assigned') ||
(orderstatus === 'confirmed') ||
(orderstatus === 'modified')) && (
<Tooltip title="Edit">
<Button1
variant="outlined"
sx={{
borderRadius: 999,
borderColor: edge(BRAND),
color: BRAND,
fontWeight: 700,
px: 2,
'&:hover': { borderColor: BRAND, bgcolor: tint(BRAND), boxShadow: `0 0 0 3px ${ring(BRAND)}` }
}}
startIcon={<MdEdit size={16} />}
onClick={(e) => {
e.stopPropagation();
if (dayjs(dayjs().format('MM-DD-YYYY')).isBefore(dayjs(dayjs(startdate).format('MM-DD-YYYY')))) {
navigate(`/editorder`, {
state: {
orderheaderid: orderheaderid,
tenantid: tenantid
}
});
} else {
enqueueSnackbar('Order cannot be edited.\n Order date is not valid at this time', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 4000,
style: { whiteSpace: 'pre-line' }
});
}
}}
>
Edit Order
</Button1>
</Tooltip>
)}
{orderstatus !== 'cancelled' && orderstatus !== '' && orderstatus !== 'completed' && orderstatus !== 'closed' && (
<Button1
variant="outlined"
sx={{
borderRadius: 999,
borderColor: edge('#ef4444'),
color: '#ef4444',
fontWeight: 700,
px: 2,
'&:hover': { borderColor: '#ef4444', bgcolor: tint('#ef4444'), boxShadow: `0 0 0 3px ${ring('#ef4444')}` }
}}
onClick={() => {
if ((dayjs(startdate).diff(dayjs(), 'm') / 60) > 24) {
setInvoiceeligible(false);
setOpen(true);
} else {
setInvoiceeligible(true);
setOpen(true);
}
}}
startIcon={<CancelOutlinedIcon />}
>
Cancel Order
</Button1>
)}
{orderstatus === 'cancelled' && (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: tint('#ef4444'),
border: `1px solid ${edge('#ef4444')}`,
color: '#ef4444',
fontSize: 12,
fontWeight: 800
}}
>
<MdCancel size={12} /> Cancelled on {cancelleddate}
</Box>
)}
</Stack>
</Stack>
</Paper>
{/* Dialog window */}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,100 @@
import React, { useState, useEffect, Fragment } from 'react';
import React, { useState, useEffect, useRef, Fragment } from 'react';
import {
Box,
Drawer,
IconButton,
Toolbar,
Typography,
Avatar,
AppBar,
useMediaQuery,
Divider,
List,
ListItem,
ListItemText,
useTheme,
ListItemAvatar,
Stack,
Backdrop,
Box,
Button,
Checkbox,
Divider,
Drawer,
IconButton,
InputBase,
List,
ListItem,
Paper,
Skeleton,
Backdrop,
Chip
Stack,
Toolbar,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import SearchBar from 'components/nearle_components/SearchBar';
import { useQuery } from '@tanstack/react-query';
import Loader from 'components/Loader';
import dayjs from 'dayjs';
import {
MdMenu,
MdSearch,
MdClear,
MdRefresh,
MdLocalShipping,
MdCheckCircle,
MdHighlightOff,
MdGroups,
MdAccessTime,
MdLocationOn,
MdMyLocation
} from 'react-icons/md';
import RiderLocationMap from './RiderLocationMap';
import MainCard from 'components/MainCard';
import dayjs from 'dayjs';
import TaskAltIcon from '@mui/icons-material/TaskAlt';
import error500 from 'assets/images/maintenance/Error500.png';
import { fetchRidersLogs } from '../api/api';
import CircularLoader from 'components/nearle_components/CircularLoader';
import error500 from 'assets/images/maintenance/Error500.png';
const drawerWidth = 350;
// ============================================================================
// Design tokens — shared with the rest of the redesigned operator pages.
// ============================================================================
const DT = {
radiusPill: 999,
radiusCard: 16,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
const dtA = (c, suffix) => `${c}${suffix}`;
const tint = (c) => dtA(c, '08');
const soft = (c) => dtA(c, '18');
const ring = (c) => dtA(c, '26');
const edge = (c) => dtA(c, '55');
const BRAND = '#662582';
const BRAND_LIGHT = '#9255AB';
const C_ACTIVE = '#10b981';
const C_INACTIVE = '#ef4444';
const drawerWidth = 360;
// Soft pill used for status / count chips throughout the page.
const SoftPill = ({ color, icon, children, sx = {} }) => (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 0.875,
py: 0.25,
borderRadius: 999,
bgcolor: tint(color),
border: `1px solid ${edge(color)}`,
color,
fontSize: 11,
fontWeight: 800,
whiteSpace: 'nowrap',
...sx
}}
>
{icon}
{children}
</Box>
);
const RidersLogs = () => {
const theme = useTheme();
@@ -43,7 +102,9 @@ const RidersLogs = () => {
const [open, setOpen] = useState(false);
const [selectedRiders, setSelectedRiders] = useState([]);
const [riderSearch, setRiderSearch] = useState('');
const searchRef = useRef(null);
const appId = 1;
const {
data: riders,
isLoading: ridersIsLoading,
@@ -57,35 +118,58 @@ const RidersLogs = () => {
});
useEffect(() => {
console.log('riders', riders);
// const sortedRiders = riders?.sort((a, b) => a.firstname.localeCompare(b.firstname));
setSelectedRiders(riders);
}, [riders]);
useEffect(() => {
console.log('selectedRiders', selectedRiders);
}, [selectedRiders]);
useEffect(() => {
setOpen(isDesktop);
}, [isDesktop]);
// Ctrl/Cmd+K focuses the rider search.
useEffect(() => {
const handleKeyPress = (event) => {
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
searchRef.current && searchRef.current.focus();
}
if (event.key === 'Escape' && document.activeElement === searchRef.current) {
searchRef.current.blur();
}
};
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, []);
// Counts shown in the drawer header.
const totalRiders = riders?.length || 0;
const activeCount = (riders || []).filter((r) => r.status === 'active').length;
const inactiveCount = totalRiders - activeCount;
const isAllSelected = totalRiders > 0 && selectedRiders?.length === totalRiders;
return (
<Fragment>
{
<Backdrop
sx={{
color: '#fff',
zIndex: (theme) => theme.zIndex.drawer + 1
}}
open={ridersIsLoading || riderIsFetching} // when loader = true, backdrop covers the page
>
<CircularLoader color="inherit" />
</Backdrop>
}
<MainCard content={false}>
<Backdrop
sx={{
color: '#fff',
zIndex: (t) => t.zIndex.drawer + 1
}}
open={ridersIsLoading || riderIsFetching}
>
<CircularLoader color="inherit" />
</Backdrop>
<Paper
elevation={0}
sx={{
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
overflow: 'hidden',
background: '#fff'
}}
>
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative' }}>
{/* Drawer */}
{/* ============================================= || Drawer || ============================================= */}
<Drawer
variant={isDesktop ? 'persistent' : 'temporary'}
open={open}
@@ -100,144 +184,335 @@ const RidersLogs = () => {
height: '100%',
overflowY: 'auto',
transition: 'transform 0.35s ease-in-out',
zIndex: 13
zIndex: 13,
borderRight: `1px solid ${DT.borderSubtle}`,
backgroundColor: '#fff'
}
}}
>
{/* Search */}
<Box sx={{ position: 'sticky', top: 0, zIndex: 1 }}>
<SearchBar
value={riderSearch}
placeholder="Search Rider"
onChange={(e) => setRiderSearch(e.target.value)}
sx={{
height: 60,
bgcolor: 'white',
'& .MuiOutlinedInput-notchedOutline': {
borderBottom: '1px solid',
borderColor: theme.palette.secondary.light
}
}}
/>
<List>
<ListItem sx={{ cursor: 'pointer', '&:hover': { bgcolor: theme.palette.secondary.lighter }, bgcolor: 'white', mt: -1 }}>
<ListItemAvatar>
<Checkbox
checked={riders?.length == selectedRiders?.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedRiders(riders);
}
{/* ===== Drawer header — gradient strip with title + count pills ===== */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 2,
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`,
p: 1.75
}}
>
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.25 }}>
<Avatar
sx={{
width: 38,
height: 38,
bgcolor: BRAND,
color: '#fff',
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<MdGroups size={20} />
</Avatar>
<Stack sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{
fontWeight: 800,
color: DT.textPrimary,
fontSize: { xs: '1rem', sm: '1.1rem' },
lineHeight: 1.1
}}
noWrap
>
Riders
</Typography>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.25 }}>
<Box
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: C_ACTIVE,
boxShadow: `0 0 0 3px ${ring(C_ACTIVE)}`
}}
/>
</ListItemAvatar>
<ListItemText primary="All" />
</ListItem>
<Divider />
</List>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Updated · {dayjs().format('hh:mm A')}
</Typography>
</Stack>
</Stack>
</Stack>
{/* Count pills */}
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap sx={{ mb: 1.25 }}>
<SoftPill color={BRAND} icon={<MdGroups size={11} />}>
Total · {totalRiders}
</SoftPill>
<SoftPill color={C_ACTIVE} icon={<MdCheckCircle size={11} />}>
Active · {activeCount}
</SoftPill>
<SoftPill color={C_INACTIVE} icon={<MdHighlightOff size={11} />}>
Inactive · {inactiveCount}
</SoftPill>
</Stack>
{/* Pill search */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: '#fff',
border: `1.5px solid ${edge(BRAND)}`,
transition: 'all 0.18s',
'&:focus-within': {
borderColor: BRAND,
boxShadow: `0 0 0 3px ${ring(BRAND)}`
}
}}
>
<MdSearch size={16} style={{ color: BRAND, flexShrink: 0 }} />
<InputBase
inputRef={searchRef}
placeholder="Search rider (ctrl+k)"
value={riderSearch}
onChange={(e) => setRiderSearch(e.target.value)}
autoComplete="off"
sx={{
flex: 1,
fontSize: 13,
fontWeight: 600,
color: DT.textPrimary,
'& input::placeholder': { color: DT.textMuted, opacity: 1 }
}}
/>
{riderSearch && (
<IconButton size="small" onClick={() => setRiderSearch('')} sx={{ p: 0.25, color: BRAND }}>
<MdClear size={14} />
</IconButton>
)}
</Box>
</Box>
{/* Rider List */}
<List>
{/* Individuals */}
{/* ===== "All" selection pill ===== */}
<Box sx={{ p: 1.25, pb: 0.5 }}>
<Box
onClick={() => {
if (!isAllSelected) {
setSelectedRiders(riders);
}
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1,
py: 0.75,
borderRadius: 999,
cursor: 'pointer',
border: `1.5px solid ${isAllSelected ? BRAND : edge(BRAND)}`,
bgcolor: isAllSelected ? BRAND : tint(BRAND),
color: isAllSelected ? '#fff' : BRAND,
transition: 'all 0.18s',
boxShadow: isAllSelected ? `0 6px 18px ${ring(BRAND)}` : 'none',
'&:hover': {
borderColor: BRAND,
boxShadow: `0 0 0 3px ${ring(BRAND)}`
}
}}
>
<Checkbox
size="small"
checked={isAllSelected}
onChange={(e) => {
if (e.target.checked) {
setSelectedRiders(riders);
}
}}
sx={{
p: 0,
color: isAllSelected ? '#fff' : BRAND,
'&.Mui-checked': { color: '#fff' }
}}
/>
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: 12.5 }}>
Show All Riders
</Typography>
<Box
sx={{
ml: 'auto',
minWidth: 24,
height: 20,
px: 0.625,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
fontSize: 11,
fontWeight: 800,
bgcolor: isAllSelected ? 'rgba(255,255,255,0.22)' : '#fff',
color: isAllSelected ? '#fff' : BRAND,
border: isAllSelected ? 'none' : `1px solid ${edge(BRAND)}`
}}
>
{totalRiders}
</Box>
</Box>
</Box>
{/* ===== Rider list ===== */}
<List sx={{ px: 1, py: 0.5 }}>
{ridersIsLoading || riderIsFetching
? Array.from({ length: 10 }).map((_, index) => (
<Fragment key={index}>
<ListItem sx={{ py: 1.5, px: 2 }}>
<ListItemAvatar>
<Skeleton variant="circular" width={24} height={24} />
</ListItemAvatar>
<ListItemText
primary={<Skeleton variant="text" width="60%" height={22} />}
secondary={<Skeleton variant="text" width="40%" height={18} />}
/>
<Stack spacing={0.5} textAlign="right">
<Skeleton variant="text" width={50} height={18} />
<Skeleton variant="text" width={80} height={16} />
? Array.from({ length: 8 }).map((_, index) => (
<ListItem key={index} sx={{ px: 1, py: 1 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ width: '100%' }}>
<Skeleton variant="circular" width={32} height={32} />
<Stack sx={{ flex: 1 }}>
<Skeleton variant="text" width="60%" height={18} />
<Skeleton variant="text" width="40%" height={14} />
</Stack>
</ListItem>
<Divider />
</Fragment>
<Stack spacing={0.5}>
<Skeleton variant="text" width={60} height={16} />
<Skeleton variant="text" width={80} height={12} />
</Stack>
</Stack>
</ListItem>
))
: riders?.map((row) => {
const isSelected = selectedRiders?.length === 1 && selectedRiders[0]?.userid === row?.userid;
const statusColor = row.status === 'active' ? C_ACTIVE : C_INACTIVE;
const initial = (row.firstname || row.username || '?').charAt(0).toUpperCase();
const name =
row.username && row.username.length > 0
? `${row.username.slice(0, 25)}${row.username.length > 25 ? '…' : ''}`
: `${row.firstname || ''}${row.lastname ? ` ${row.lastname}` : ''}`.trim() || 'Rider';
return (
<Fragment key={row.userid}>
<ListItem
sx={{
cursor: 'pointer',
py: 1,
px: 2,
borderRadius: 1,
'&:hover': { bgcolor: theme.palette.secondary.lighter }
}}
secondaryAction={
<Stack textAlign="right" spacing={0.5}>
<Typography variant="body2" noWrap sx={{ color: row.status == 'active' ? 'success.main' : 'error.main' }}>
{row.userid}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{dayjs(row.logdate).format('DD/MM/YYYY hh:mm A')}
</Typography>
</Stack>
<Box
key={row.userid}
onClick={() => {
// Tapping the card selects this rider in isolation.
setSelectedRiders([row]);
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
mb: 0.625,
borderRadius: 2,
cursor: 'pointer',
border: `1px solid ${isSelected ? edge(BRAND) : DT.divider}`,
bgcolor: isSelected ? tint(BRAND) : '#fff',
transition: 'all 0.15s',
'&:hover': {
borderColor: edge(BRAND),
bgcolor: isSelected ? soft(BRAND) : DT.surfaceAlt,
boxShadow: DT.shadowSoft
}
}}
>
<Checkbox
size="small"
checked={isSelected}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
if (e.target.checked) {
setSelectedRiders([row]);
} else {
setSelectedRiders(riders);
}
}}
sx={{
p: 0.5,
color: edge(BRAND),
'&.Mui-checked': { color: BRAND }
}}
/>
<Avatar
sx={{
width: 36,
height: 36,
bgcolor: soft(statusColor),
color: statusColor,
fontWeight: 800,
fontSize: 14,
position: 'relative'
}}
>
<ListItemAvatar>
<Checkbox
sx={{
color: row.status == 'active' ? 'green' : 'red',
'&.Mui-checked': {
color: row.status == 'active' ? 'green' : 'red'
}
}}
checked={
// INDIVIDUAL CHECKED CONDITION
selectedRiders?.length === 1 && selectedRiders[0]?.userid === row?.userid
}
onChange={(e) => {
if (e.target.checked) {
// SELECT ONE RIDER
setSelectedRiders([row]);
} else {
// UNCHECK -> SELECT ALL
setSelectedRiders(riders);
}
}}
/>
</ListItemAvatar>
<ListItemText
primary={
row.username ? (
<Typography noWrap>
{row.username?.slice(0, 25) || ''}
{row.username?.length > 25 && '...'}
{/* {row.status === 'active' && <TaskAltIcon fontSize="small" color="success" sx={{ ml: 1 }} />} */}
</Typography>
) : (
<Typography noWrap>
{row.firstname || ''}
{row.lastname ? ` ${row.lastname}` : ''}
</Typography>
)
}
secondary={
<Typography variant="caption" color="text.secondary" noWrap>
{row.contactno || '##########'}
</Typography>
}
{initial}
<Box
sx={{
position: 'absolute',
bottom: -1,
right: -1,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: statusColor,
border: '2px solid #fff'
}}
/>
</ListItem>
</Avatar>
<Divider />
</Fragment>
<Stack sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{
fontWeight: 700,
color: DT.textPrimary,
fontSize: 13,
lineHeight: 1.15
}}
noWrap
>
{name}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.25 }}>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontSize: 11 }} noWrap>
{row.contactno || '##########'}
</Typography>
</Stack>
</Stack>
<Stack alignItems="flex-end" spacing={0.5} sx={{ minWidth: 0 }}>
<SoftPill
color={statusColor}
icon={row.status === 'active' ? <MdCheckCircle size={10} /> : <MdHighlightOff size={10} />}
sx={{ fontSize: 10 }}
>
{row.status === 'active' ? 'Active' : 'Inactive'}
</SoftPill>
<Stack direction="row" alignItems="center" spacing={0.375}>
<MdAccessTime size={10} style={{ color: DT.textMuted }} />
<Typography sx={{ fontSize: 10, color: DT.textMuted, fontWeight: 700 }} noWrap>
{row.logdate ? dayjs(row.logdate).format('DD/MM · hh:mm A') : '—'}
</Typography>
</Stack>
</Stack>
</Box>
);
})}
{!ridersIsLoading && !riderIsFetching && totalRiders === 0 && (
<Stack alignItems="center" spacing={1.25} sx={{ py: 6, px: 2 }}>
<Avatar sx={{ width: 56, height: 56, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdGroups size={24} />
</Avatar>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
No riders to show
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center' }}>
{riderSearch ? 'Try a different rider name.' : 'Pull-to-refresh once you have rider activity.'}
</Typography>
</Stack>
)}
</List>
</Drawer>
{/* AppBar */}
{/* ============================================= || AppBar || ============================================= */}
<AppBar
elevation={0}
position="absolute"
@@ -246,50 +521,104 @@ const RidersLogs = () => {
left: open && isDesktop ? `${drawerWidth}px` : 0,
width: open && isDesktop ? `calc(100% - ${drawerWidth}px)` : '100%',
transition: 'left 0.3s ease, width 0.3s ease',
backgroundColor: 'white',
borderBottom: '1px solid',
borderColor: theme.palette.secondary.light
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`,
color: DT.textPrimary
}}
>
<Toolbar>
<Toolbar sx={{ minHeight: { xs: 60, md: 68 } }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
<Stack direction="row" alignItems="center">
<IconButton color="primary" onClick={() => setOpen(!open)}>
<MenuIcon />
<Stack direction="row" alignItems="center" spacing={1.25}>
<IconButton
onClick={() => setOpen(!open)}
sx={{
bgcolor: '#fff',
border: `1px solid ${DT.borderSubtle}`,
borderRadius: 999,
color: BRAND,
'&:hover': { bgcolor: tint(BRAND), borderColor: edge(BRAND) }
}}
>
<MdMenu size={18} />
</IconButton>
<Typography variant="h5" color="primary" sx={{ ml: 2 }}>
Riders Locations
</Typography>
<Avatar
sx={{
width: 36,
height: 36,
bgcolor: BRAND,
color: '#fff',
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<MdLocationOn size={18} />
</Avatar>
<Stack>
<Typography
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '0.95rem', sm: '1.1rem', md: '1.25rem' }
}}
>
Riders Locations
</Typography>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.25 }}>
<Box
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: C_ACTIVE,
boxShadow: `0 0 0 3px ${ring(C_ACTIVE)}`
}}
/>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Live · {selectedRiders?.length || 0} of {totalRiders} on map
</Typography>
</Stack>
</Stack>
</Stack>
<Button
variant="outlined"
color="primary"
onClick={() => {
riderLogsRefetch();
}}
>
Refresh
</Button>
<Stack direction="row" alignItems="center" spacing={1}>
<SoftPill color={BRAND} icon={<MdMyLocation size={11} />} sx={{ display: { xs: 'none', sm: 'inline-flex' } }}>
{dayjs().format('DD MMM YYYY')}
</SoftPill>
<Button
variant="contained"
onClick={() => riderLogsRefetch()}
startIcon={<MdRefresh size={16} />}
sx={{
borderRadius: 999,
px: 2,
bgcolor: BRAND,
fontWeight: 700,
boxShadow: `0 6px 18px ${ring(BRAND)}`,
textTransform: 'none',
'&:hover': { bgcolor: '#4D1C61' }
}}
>
Refresh
</Button>
</Stack>
</Stack>
</Toolbar>
</AppBar>
{/* Map */}
{/* ============================================= || Map area || ============================================= */}
<Box
sx={{
flexGrow: 1,
overflow: 'auto',
pt: '64px',
pt: { xs: '60px', md: '68px' },
pl: open && isDesktop ? `${drawerWidth}px` : 0,
transition: 'padding-left 0.3s ease',
minHeight: '80vh'
minHeight: '80vh',
bgcolor: DT.surfaceAlt
}}
>
{(ridersIsLoading || riderIsFetching) && (
<Box position="relative" width="100%" height="80vh" display="grid" placeItems="center">
{/* <CircularLoader /> */}
<Skeleton
variant="rectangular"
width="100%"
@@ -299,22 +628,68 @@ const RidersLogs = () => {
position: 'absolute',
top: 0,
left: 0,
borderRadius: 1,
borderRadius: 0,
zIndex: 1
}}
/>
</Box>
)}
{selectedRiders?.length > 0 && <RiderLocationMap riderLocations={selectedRiders} />}
{selectedRiders?.length > 0 && !riderLogsError && <RiderLocationMap riderLocations={selectedRiders} />}
{riderLogsError && (
<Box sx={{ width: '100% ', height: '100%' }}>
<img src={error500} alt="mantis" style={{ height: '100%', width: '100%' }} />
</Box>
<Stack alignItems="center" justifyContent="center" sx={{ p: 4, height: '70vh' }}>
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: edge(C_INACTIVE),
bgcolor: tint(C_INACTIVE),
maxWidth: 520,
textAlign: 'center'
}}
>
<Avatar sx={{ bgcolor: C_INACTIVE, color: '#fff', width: 56, height: 56, mx: 'auto', mb: 1.5 }}>
<MdHighlightOff size={26} />
</Avatar>
<Typography variant="h6" sx={{ fontWeight: 800, color: DT.textPrimary, mb: 0.5 }}>
Couldnt load rider logs
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
The map is unavailable right now. Try refreshing in a moment.
</Typography>
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
onClick={() => riderLogsRefetch()}
startIcon={<MdRefresh size={16} />}
sx={{
borderRadius: 999,
px: 3,
bgcolor: BRAND,
fontWeight: 700,
boxShadow: `0 6px 18px ${ring(BRAND)}`,
textTransform: 'none',
'&:hover': { bgcolor: '#4D1C61' }
}}
>
Retry
</Button>
</Box>
</Paper>
<Box
component="img"
src={error500}
alt="error"
sx={{ mt: 2, maxWidth: 240, opacity: 0.6, filter: 'grayscale(0.2)' }}
/>
</Stack>
)}
</Box>
</Box>
</MainCard>
</Paper>
</Fragment>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,9 @@ const RiderLogs = Loadable(lazy(() => import('pages/nearle/reports/riderLogs')))
const Invoice = Loadable(lazy(() => import('pages/nearle/invoice/invoice')));
const InvoicePreview = Loadable(lazy(() => import('../pages/nearle/invoice/invoicePreview')));
const Dispatch = Loadable(lazy(() => import('pages/nearle/dispatch/Dispatch')));
const DispatchPreview = Loadable(lazy(() => import('pages/nearle/dispatch/Preview')));
// ==============================|| MAIN ROUTING ||============================== //
const MainRoutes = {
@@ -123,6 +126,19 @@ const MainRoutes = {
{
path: 'locations',
element: <Locations />
},
{
path: 'dispatch',
children: [
{
path: '',
element: <Dispatch />
},
{
path: 'preview',
element: <DispatchPreview />
}
]
}
]
},

View File

@@ -0,0 +1,154 @@
// Vendored from leaflet-polylineoffset@1.1.1 (MIT).
//
// Why this lives in-tree instead of being an npm dep:
// • The published package would require --legacy-peer-deps because of an
// unrelated React-17 peer-dep conflict elsewhere in the project, and we
// don't want a renderer plugin to force a global resolver flag.
// • It's frozen upstream (no meaningful updates since 2020), tiny, and
// has zero runtime deps besides leaflet (already in package.json).
//
// What it does:
// Monkey-patches L.Polyline so that any path passed with a numeric
// `offset` in pathOptions is rendered shifted perpendicular to its
// direction of travel by that many pixels (positive = right of travel,
// negative = left). Used by Dispatch.js's Compare → Combined view to
// render planned + actual as parallel rails when they share the same
// road geometry; without this they overlap and read as one polyline.
//
// Import once for the side effect:
// import '../../../utils/leafletPolylineOffset';
//
// Then add to any pathOptions:
// pathOptions={{ ..., offset: 5 }}
//
// Plays nicely with both SVG and Canvas renderers.
import L from 'leaflet';
L.PolylineOffset = {
translatePoint(pt, dist, radians) {
return L.point(pt.x + dist * Math.cos(radians), pt.y + dist * Math.sin(radians));
},
offsetPointLine(points, distance) {
const l = points.length;
if (l < 2) {
throw new Error('Line should be defined by at least 2 points');
}
let a = points[0];
let b;
const offsetAngle = Math.PI / 2;
const offsetSegments = [];
for (let i = 1; i < l; i++) {
b = points[i];
// Each segment's offset angle is perpendicular to its direction.
const segAngle = Math.atan2(b.y - a.y, b.x - a.x);
offsetSegments.push({
offsetAngle: segAngle - offsetAngle,
original: [a, b],
offset: [
this.translatePoint(a, distance, segAngle - offsetAngle),
this.translatePoint(b, distance, segAngle - offsetAngle)
]
});
a = b;
}
return offsetSegments;
},
// Find the intersection of two segments by extending them to infinity
// along their direction, then walking along segment 1 by parameter t.
// Returns null when the segments are parallel (no intersection).
intersection(l1a, l1b, l2a, l2b) {
const line1 = this.segmentAsVector(l1a, l1b);
const line2 = this.segmentAsVector(l2a, l2b);
const denom = -line2.x * line1.y + line1.x * line2.y;
if (denom === 0) return null;
const s = (-line1.y * (l1a.x - l2a.x) + line1.x * (l1a.y - l2a.y)) / denom;
const t = (line2.x * (l1a.y - l2a.y) - line2.y * (l1a.x - l2a.x)) / denom;
if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
return L.point(l1a.x + t * line1.x, l1a.y + t * line1.y);
}
return null;
},
segmentAsVector(a, b) {
return L.point(b.x - a.x, b.y - a.y);
},
// Walk the offset segments and join adjacent ones at their intersection
// points (mitered corners). When two consecutive segments don't intersect
// within their bounds (sharp turn, or co-linear), fall back to the offset
// endpoint so the polyline doesn't gap.
joinLineSegments(segments) {
const joined = [];
let last = segments[0].offset;
joined.push(last[0]);
for (let i = 1; i < segments.length; i++) {
const next = segments[i].offset;
const inter = this.intersection(last[0], last[1], next[0], next[1]);
if (inter) {
joined.push(inter);
} else {
joined.push(last[1]);
}
last = next;
}
joined.push(last[1]);
return joined;
},
offsetPoints(points, offset) {
if (!points || points.length < 2) return points;
const offsets = this.offsetPointLine(points, offset);
return this.joinLineSegments(offsets);
},
// Operates on a ring of LatLngs by projecting → offsetting → unprojecting,
// since leaflet polyline math is in screen pixels but our points are LatLng.
offsetLatLngs(map, latlngs, offset) {
const points = latlngs.map((ll) => map.latLngToLayerPoint(ll));
const offsetPts = this.offsetPoints(points, offset);
return offsetPts.map((p) => map.layerPointToLatLng(p));
}
};
// Patch Polyline._projectLatlngs (used by both SVG and Canvas renderers) so
// that when an offset is set, the projected ring is offset before clipping.
// We keep the original on _projectLatlngsOriginal so we can call through.
const originalProject = L.Polyline.prototype._projectLatlngs;
L.Polyline.prototype._projectLatlngs = function patchedProject(latlngs, result, projectedBounds) {
const offset = this.options.offset;
if (!offset || typeof offset !== 'number') {
return originalProject.call(this, latlngs, result, projectedBounds);
}
// Recurse for multi-ring polylines (shouldn't happen for simple lines,
// but the leaflet API allows it).
const flat = latlngs[0] instanceof L.LatLng;
if (!flat) {
for (let i = 0; i < latlngs.length; i++) {
this._projectLatlngs(latlngs[i], result, projectedBounds);
}
return undefined;
}
const projected = latlngs.map((ll) => this._map.latLngToLayerPoint(ll));
const offsetted = L.PolylineOffset.offsetPoints(projected, offset);
// Update projectedBounds with each offset point so the renderer's
// viewport-clipping check still works.
for (let i = 0; i < offsetted.length; i++) {
projectedBounds.extend(offsetted[i]);
}
result.push(offsetted);
return undefined;
};
export default L;

66
src/utils/logger.js Normal file
View File

@@ -0,0 +1,66 @@
const LOG_LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
};
// Default log level based on environment
const currentEnv = process.env.NODE_ENV || 'development';
const isDev = currentEnv === 'development';
const GLOBAL_LOG_LEVEL = isDev ? LOG_LEVELS.DEBUG : LOG_LEVELS.WARN;
const style = (bg, color) => `background: ${bg}; color: ${color}; padding: 2px 5px; border-radius: 4px; font-weight: bold;`;
const PREFIX = '%c[NearlExpress]';
const PREFIX_STYLE = style('#2563eb', '#ffffff');
// Capture original console methods before any global overrides occur
const originalLog = console.log;
const originalWarn = console.warn || console.log;
const originalError = console.error || console.log;
const print = (levelName, args, labelStyle) => {
const levelValue = LOG_LEVELS[levelName];
if (levelValue < GLOBAL_LOG_LEVEL) return;
const [message, ...extra] = args;
const isMessageString = typeof message === 'string';
const formatPrefix = `${PREFIX}%c ${levelName}`;
const styles = [PREFIX_STYLE, labelStyle];
const consoleMethod =
levelName === 'ERROR' ? originalError :
levelName === 'WARN' ? originalWarn :
originalLog;
if (isMessageString) {
consoleMethod(
`${formatPrefix}%c ${message}`,
...styles,
'color: inherit;',
...extra
);
} else {
// If first argument is an object/array, preserve raw interactive log
consoleMethod(
`${formatPrefix}`,
...styles,
message,
...extra
);
}
};
const logger = {
debug: (...args) => print('DEBUG', args, style('#64748b', '#ffffff')),
info: (...args) => print('INFO', args, style('#10b981', '#ffffff')),
warn: (...args) => print('WARN', args, style('#f59e0b', '#ffffff')),
error: (...args) => {
print('ERROR', args, style('#ef4444', '#ffffff'));
// Future expansion hook: e.g., Sentry.captureException(args[0]);
},
};
export default logger;

View File

@@ -5795,11 +5795,6 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
fsevents@^2.3.2, fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"