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