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
|
# Gatsby
|
||||||
.cache/
|
.cache/
|
||||||
public/
|
|
||||||
|
|
||||||
# VuePress
|
# VuePress
|
||||||
.vuepress/dist
|
.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 { styled, useTheme } from '@mui/material/styles';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
Grid,
|
Grid,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
@@ -493,20 +495,36 @@ SortingSelect.propTypes = {
|
|||||||
|
|
||||||
// ==============================|| CSV EXPORT ||============================== //
|
// ==============================|| CSV EXPORT ||============================== //
|
||||||
|
|
||||||
export const CSVExport = ({ data, filename, headers }) => {
|
export const CSVExport = ({ data, filename, headers, label, style, btnLoading, onClick }) => {
|
||||||
return (
|
return (
|
||||||
<CSVLink data={data} filename={filename} headers={headers}>
|
<CSVLink data={data} filename={filename} headers={headers}>
|
||||||
<Tooltip title="Download CSV">
|
<Tooltip title="CSV Export">
|
||||||
<DownloadOutlined style={{ fontSize: '24px', color: 'gray', marginTop: 4, marginRight: 4, marginLeft: 4 }} />
|
<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>
|
</Tooltip>
|
||||||
</CSVLink>
|
</CSVLink>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default CSVExport;
|
||||||
|
|
||||||
CSVExport.propTypes = {
|
CSVExport.propTypes = {
|
||||||
data: PropTypes.array,
|
data: PropTypes.array,
|
||||||
headers: PropTypes.any,
|
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 ||============================== //
|
// ==============================|| EMPTY TABLE - NO DATA ||============================== //
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const facebookColor = '#3b5998';
|
|||||||
export const linkedInColor = '#0e76a8';
|
export const linkedInColor = '#0e76a8';
|
||||||
|
|
||||||
// export const APP_DEFAULT_PATH = '/sample-page';
|
// 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 HORIZONTAL_MAX_ITEM = 6;
|
||||||
export const DRAWER_WIDTH = 260;
|
export const DRAWER_WIDTH = 260;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AiOutlineDashboard } from 'react-icons/ai';
|
|||||||
import { TbListDetails } from 'react-icons/tb';
|
import { TbListDetails } from 'react-icons/tb';
|
||||||
import { LiaFileInvoiceSolid } from 'react-icons/lia';
|
import { LiaFileInvoiceSolid } from 'react-icons/lia';
|
||||||
import DirectionsBikeOutlinedIcon from '@mui/icons-material/DirectionsBikeOutlined';
|
import DirectionsBikeOutlinedIcon from '@mui/icons-material/DirectionsBikeOutlined';
|
||||||
|
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
||||||
|
|
||||||
// assets
|
// assets
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +50,13 @@ const nearle = {
|
|||||||
title: <FormattedMessage id="MENU" />,
|
title: <FormattedMessage id="MENU" />,
|
||||||
type: 'group',
|
type: 'group',
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
id: 'dispatch',
|
||||||
|
title: <FormattedMessage id="Dispatch" />,
|
||||||
|
type: 'item',
|
||||||
|
url: '/nearle/dispatch',
|
||||||
|
icon: RouteOutlinedIcon
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'orders',
|
id: 'orders',
|
||||||
title: <FormattedMessage id="Orders" />,
|
title: <FormattedMessage id="Orders" />,
|
||||||
|
|||||||
@@ -184,10 +184,12 @@ export const gettenantlocations = async ({ queryKey }) => {
|
|||||||
const [, searchLocation] = queryKey;
|
const [, searchLocation] = queryKey;
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantlocations?tenantid=${tenid}&keyword=${searchLocation}`);
|
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) {
|
} 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);
|
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);
|
console.log('fetchRidersLogs', riderLogsResponse.data.details);
|
||||||
return 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],
|
queryKey: ['locations', debouncedSearchLocation],
|
||||||
queryFn: gettenantlocations
|
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(() => {
|
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]);
|
}, [locations]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -334,7 +343,7 @@ const ResponsiveLocationDrawer = () => {
|
|||||||
color: 'white' // text color
|
color: 'white' // text color
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.locationname[0].toUpperCase()}
|
{row.locationname?.[0]?.toUpperCase() || '?'}
|
||||||
</Avatar>{' '}
|
</Avatar>{' '}
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={row.locationname} secondary={row.suburb} />
|
<ListItemText primary={row.locationname} secondary={row.suburb} />
|
||||||
@@ -535,8 +544,8 @@ const ResponsiveLocationDrawer = () => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Typography variant="caption">{row.pickupcustomer}</Typography>
|
<Typography variant="caption">{row.pickupcustomer}</Typography>
|
||||||
<Typography variant="caption">{row.pickupcontactno}</Typography>
|
<Typography variant="caption">{row.pickupcontactno}</Typography>
|
||||||
<Tooltip title={row.pickupaddress}>
|
<Tooltip title={row.pickupaddress || ''}>
|
||||||
<Typography variant="caption">{row.pickupsuburb || row.pickupaddress.slice(0, 20)}</Typography>
|
<Typography variant="caption">{row.pickupsuburb || row.pickupaddress?.slice(0, 20) || '—'}</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -549,8 +558,8 @@ const ResponsiveLocationDrawer = () => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Typography variant="caption">{row.deliverycustomer}</Typography>
|
<Typography variant="caption">{row.deliverycustomer}</Typography>
|
||||||
<Typography variant="caption">{row.deliverycontactno}</Typography>
|
<Typography variant="caption">{row.deliverycontactno}</Typography>
|
||||||
<Tooltip title={row.deliveryaddress}>
|
<Tooltip title={row.deliveryaddress || ''}>
|
||||||
<Typography variant="caption">{row.deliverysuburb || row.deliveryaddress.slice(0, 20)}</Typography>
|
<Typography variant="caption">{row.deliverysuburb || row.deliveryaddress?.slice(0, 20) || '—'}</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const Login = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem('authname')) {
|
if (localStorage.getItem('authname')) {
|
||||||
navigate('/nearle/orders');
|
navigate('/nearle/dispatch');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ const Login = () => {
|
|||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
navigate('/nearle/orders');
|
navigate('/nearle/dispatch');
|
||||||
} else {
|
} else {
|
||||||
OpenToast(res.data.message, 'warning', 2000);
|
OpenToast(res.data.message, 'warning', 2000);
|
||||||
setLoading(false);
|
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
|
// DeleteTwoTone
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
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 Details = () => {
|
||||||
// const [searchParams] = useSearchParams();
|
// const [searchParams] = useSearchParams();
|
||||||
@@ -854,59 +938,105 @@ const Details = () => {
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={() => handleClose(false)}
|
onClose={() => handleClose(false)}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
|
PaperProps={{ sx: { borderRadius: 3 } }}
|
||||||
>
|
>
|
||||||
<DialogContent sx={{ mt: 2, my: 1 }}>
|
<Box
|
||||||
<Stack alignItems="center" spacing={3.5}>
|
sx={{
|
||||||
<Avatar color="error" sx={{ width: 72, height: 72, fontSize: '1.75rem' }}>
|
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 />
|
<DeleteFilled />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Grid >
|
<Stack>
|
||||||
<Chip label={orderid.slice(4)} variant="combined" color='warning' size='small' />
|
<Typography variant="h5" sx={{ fontWeight: 800, color: DT.textPrimary }}>
|
||||||
</Grid>
|
Cancel Order
|
||||||
<Stack spacing={2}>
|
</Typography>
|
||||||
{/* <Typography variant="h4" align="center">
|
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
|
||||||
Are you sure you want to cancel this order?
|
Confirm to permanently 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.
|
|
||||||
</Typography>
|
</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>
|
||||||
|
</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 }}>
|
{(invoiceeligible) &&
|
||||||
<Button fullWidth color="error" variant="contained" onClick={() => {
|
<Alert color="warning" variant="border" icon={<WarningFilled />}>
|
||||||
if (deletepassword === orderid.slice(4)) {
|
<AlertTitle>Order is within 24Hrs time frame. The order will be invoiced with standard pricing as agreed.</AlertTitle>
|
||||||
cancelorder();
|
<Link href='https://thelegendarystaff.com/' target='_blank' >Terms & Condition link</Link>
|
||||||
handleClose(true);
|
</Alert>
|
||||||
}
|
}
|
||||||
|
|
||||||
}} autoFocus>
|
<Typography variant="body1" align="center" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
|
||||||
Yes, Cancel
|
Please type in the order number to confirm.
|
||||||
</Button>
|
</Typography>
|
||||||
<Button fullWidth onClick={() => handleClose(false)} color="secondary" variant="outlined">
|
<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
|
No
|
||||||
</Button>
|
</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>
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -925,40 +1055,116 @@ const Details = () => {
|
|||||||
// fullScreen
|
// fullScreen
|
||||||
TransitionComponent={PopupTransition}>
|
TransitionComponent={PopupTransition}>
|
||||||
|
|
||||||
<DialogTitle>
|
<DialogTitle
|
||||||
<Stack direction={'row'} justifyContent={'space-between'}>
|
sx={{
|
||||||
<Stack direction={{ sm: 'row', xs: 'column' }} spacing={2} alignItems={'center'}>
|
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
|
||||||
<Typography variant='h3'>Assign Roles</Typography>
|
borderBottom: `1px solid ${DT.borderSubtle}`,
|
||||||
|
p: { xs: 2, sm: 2.5 }
|
||||||
<Chip label={clientname} variant="light" color="primary" />
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
</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>
|
</Stack>
|
||||||
<Grid container sx={{ p: 1 }} spacing={2}>
|
|
||||||
|
|
||||||
|
<Grid container sx={{ pt: 2 }} spacing={1.5}>
|
||||||
<Grid item sm={6} xs={12}>
|
<Grid item sm={6} xs={12}>
|
||||||
|
<Box
|
||||||
{/* <Chip label={currentrole} variant="combined" color="primary" size='normal' /> */}
|
onClick={() => setTabstatus((e) => (e === 0 ? 1 : 0))}
|
||||||
<Tabs
|
sx={{
|
||||||
value={tabstatus}
|
display: 'inline-flex',
|
||||||
// onChange={handleChangetab}
|
alignItems: 'center',
|
||||||
onChange={() => setTabstatus((e) => (e === 0) ? 1 : 0)}
|
gap: 0.75,
|
||||||
variant="scrollable" scrollButtons="auto" >
|
px: 1.25,
|
||||||
{/* <Tab label="All" /> */}
|
py: 0.625,
|
||||||
<Tab label={currentrole} />
|
borderRadius: 999,
|
||||||
</Tabs>
|
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>
|
||||||
<Grid item sm={6} xs={12}>
|
<Grid item sm={6} xs={12}>
|
||||||
<Stack direction={'row'} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}
|
<Stack direction="row" justifyContent={{ xs: 'flex-start', sm: 'flex-end' }} spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
// alignItems={{xs:'flex-end',sm:'center'}}
|
{[
|
||||||
sx={{ height: '100%' }} spacing={2}>
|
{ label: 'Required', value: currentshiftobj.shifts, color: BRAND },
|
||||||
<Chip sx={{ width: '130px' }} label={`Required:${currentshiftobj.shifts}`} variant="combined" color='primary' size='normal' />
|
{ label: 'Assigned', value: currentshiftobj.assigned, color: '#10b981' },
|
||||||
<Chip sx={{ width: '130px' }} label={`Assigned: ${currentshiftobj.assigned}`} variant="combined" color='success' size='normal' />
|
{ label: 'Remaining', value: currentshiftobj.remaining, color: '#ef4444' }
|
||||||
<Chip sx={{ width: '130px' }} label={`Remaining: ${currentshiftobj.remaining}`} variant="combined" color='error' size='normal' />
|
].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>
|
</Stack>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@@ -1256,215 +1462,194 @@ const Details = () => {
|
|||||||
<Typography variant="h3">Details</Typography>
|
<Typography variant="h3">Details</Typography>
|
||||||
</Grid> */}
|
</Grid> */}
|
||||||
|
|
||||||
<CardActions
|
<Paper
|
||||||
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: '60px',
|
top: '60px',
|
||||||
// top:0,
|
zIndex: 5,
|
||||||
bgcolor: theme.palette.background.default,
|
mb: 2,
|
||||||
zIndex: 1,
|
p: { xs: 1.5, sm: 2, md: 2.5 },
|
||||||
// borderBottom: `1px solid ${theme.palette.divider}`,
|
borderRadius: DT.radiusCard / 8,
|
||||||
width: '100%'
|
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
|
||||||
<Stack direction={{ md: 'row', xs: 'column' }} justifyContent="space-between" alignItems="flex-end"
|
direction={{ xs: 'column', md: 'row' }}
|
||||||
sx={{ width: '100%', p: 1 }}
|
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||||
>
|
justifyContent="space-between"
|
||||||
|
spacing={{ xs: 1.5, md: 2 }}
|
||||||
<Stack direction='row' spacing={2} alignItems='center'
|
>
|
||||||
justifyContent='flex-start'
|
<Stack direction="row" alignItems="center" spacing={{ xs: 1.25, sm: 1.75 }}>
|
||||||
sx={{ width: { xs: '100%', md: '0' } }}
|
<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 }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<MdArrowBack size={18} />
|
||||||
<IconButton
|
</IconButton>
|
||||||
onClick={() => history.back()}
|
<Avatar
|
||||||
// onClick={()=>}
|
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 />
|
Order Details
|
||||||
</IconButton>
|
</Typography>
|
||||||
{/* <Link to="/dashboard">Test me</Link> */}
|
<Stack direction="row" alignItems="center" spacing={0.75} flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
|
||||||
<Stack direction='column' alignItems='flex-start'>
|
<Box
|
||||||
<Typography variant="h3">Details</Typography>
|
sx={{
|
||||||
<Stack direction="row" spacing={1}>
|
display: 'inline-flex',
|
||||||
{/* <Typography noWrap color="secondary"></Typography> */}
|
alignItems: 'center',
|
||||||
<Chip label={(orderid === '') ? <Skeleton sx={{ width: '80px', bgcolor: '#fff9c4' }} animation="wave" /> : orderid} variant="combined" color='warning' size='small' />
|
gap: 0.5,
|
||||||
{/* <Typography variant="subtitle1">Date</Typography> */}
|
px: 1,
|
||||||
{/* <Typography color="secondary">{orderdate}</Typography> */}
|
py: 0.375,
|
||||||
<Chip label={(orderdate === '') ? <Skeleton sx={{ width: '80px', bgcolor: '#b3e5fc' }} animation="wave" /> : orderdate} variant="combined" color="primary" size='small' />
|
borderRadius: 999,
|
||||||
|
bgcolor: tint('#f59e0b'),
|
||||||
|
border: `1px solid ${edge('#f59e0b')}`,
|
||||||
|
color: '#f59e0b',
|
||||||
{(orderstatus === 'pending') &&
|
fontSize: 11,
|
||||||
|
fontWeight: 800
|
||||||
<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 } }}
|
|
||||||
>
|
>
|
||||||
Raise Invoice
|
<MdReceiptLong size={11} />
|
||||||
</Button>
|
{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>
|
||||||
|
|
||||||
|
|
||||||
</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 */}
|
{/* 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 {
|
import {
|
||||||
Box,
|
Avatar,
|
||||||
Drawer,
|
|
||||||
IconButton,
|
|
||||||
Toolbar,
|
|
||||||
Typography,
|
|
||||||
AppBar,
|
AppBar,
|
||||||
useMediaQuery,
|
Backdrop,
|
||||||
Divider,
|
Box,
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
useTheme,
|
|
||||||
ListItemAvatar,
|
|
||||||
Stack,
|
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
InputBase,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Paper,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Backdrop,
|
Stack,
|
||||||
Chip
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
|
||||||
import SearchBar from 'components/nearle_components/SearchBar';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 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 { fetchRidersLogs } from '../api/api';
|
||||||
import CircularLoader from 'components/nearle_components/CircularLoader';
|
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 RidersLogs = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -43,7 +102,9 @@ const RidersLogs = () => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [selectedRiders, setSelectedRiders] = useState([]);
|
const [selectedRiders, setSelectedRiders] = useState([]);
|
||||||
const [riderSearch, setRiderSearch] = useState('');
|
const [riderSearch, setRiderSearch] = useState('');
|
||||||
|
const searchRef = useRef(null);
|
||||||
const appId = 1;
|
const appId = 1;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: riders,
|
data: riders,
|
||||||
isLoading: ridersIsLoading,
|
isLoading: ridersIsLoading,
|
||||||
@@ -57,35 +118,58 @@ const RidersLogs = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('riders', riders);
|
|
||||||
// const sortedRiders = riders?.sort((a, b) => a.firstname.localeCompare(b.firstname));
|
|
||||||
setSelectedRiders(riders);
|
setSelectedRiders(riders);
|
||||||
}, [riders]);
|
}, [riders]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('selectedRiders', selectedRiders);
|
|
||||||
}, [selectedRiders]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(isDesktop);
|
setOpen(isDesktop);
|
||||||
}, [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 (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{
|
<Backdrop
|
||||||
<Backdrop
|
sx={{
|
||||||
sx={{
|
color: '#fff',
|
||||||
color: '#fff',
|
zIndex: (t) => t.zIndex.drawer + 1
|
||||||
zIndex: (theme) => theme.zIndex.drawer + 1
|
}}
|
||||||
}}
|
open={ridersIsLoading || riderIsFetching}
|
||||||
open={ridersIsLoading || riderIsFetching} // when loader = true, backdrop covers the page
|
>
|
||||||
>
|
<CircularLoader color="inherit" />
|
||||||
<CircularLoader color="inherit" />
|
</Backdrop>
|
||||||
</Backdrop>
|
|
||||||
}
|
<Paper
|
||||||
<MainCard content={false}>
|
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' }}>
|
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative' }}>
|
||||||
{/* Drawer */}
|
{/* ============================================= || Drawer || ============================================= */}
|
||||||
<Drawer
|
<Drawer
|
||||||
variant={isDesktop ? 'persistent' : 'temporary'}
|
variant={isDesktop ? 'persistent' : 'temporary'}
|
||||||
open={open}
|
open={open}
|
||||||
@@ -100,144 +184,335 @@ const RidersLogs = () => {
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
transition: 'transform 0.35s ease-in-out',
|
transition: 'transform 0.35s ease-in-out',
|
||||||
zIndex: 13
|
zIndex: 13,
|
||||||
|
borderRight: `1px solid ${DT.borderSubtle}`,
|
||||||
|
backgroundColor: '#fff'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search */}
|
{/* ===== Drawer header — gradient strip with title + count pills ===== */}
|
||||||
<Box sx={{ position: 'sticky', top: 0, zIndex: 1 }}>
|
<Box
|
||||||
<SearchBar
|
sx={{
|
||||||
value={riderSearch}
|
position: 'sticky',
|
||||||
placeholder="Search Rider"
|
top: 0,
|
||||||
onChange={(e) => setRiderSearch(e.target.value)}
|
zIndex: 2,
|
||||||
sx={{
|
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
|
||||||
height: 60,
|
borderBottom: `1px solid ${DT.borderSubtle}`,
|
||||||
bgcolor: 'white',
|
p: 1.75
|
||||||
'& .MuiOutlinedInput-notchedOutline': {
|
}}
|
||||||
borderBottom: '1px solid',
|
>
|
||||||
borderColor: theme.palette.secondary.light
|
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.25 }}>
|
||||||
}
|
<Avatar
|
||||||
}}
|
sx={{
|
||||||
/>
|
width: 38,
|
||||||
<List>
|
height: 38,
|
||||||
<ListItem sx={{ cursor: 'pointer', '&:hover': { bgcolor: theme.palette.secondary.lighter }, bgcolor: 'white', mt: -1 }}>
|
bgcolor: BRAND,
|
||||||
<ListItemAvatar>
|
color: '#fff',
|
||||||
<Checkbox
|
boxShadow: `0 6px 18px ${ring(BRAND)}`
|
||||||
checked={riders?.length == selectedRiders?.length}
|
}}
|
||||||
onChange={(e) => {
|
>
|
||||||
if (e.target.checked) {
|
<MdGroups size={20} />
|
||||||
setSelectedRiders(riders);
|
</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>
|
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
|
||||||
<ListItemText primary="All" />
|
Updated · {dayjs().format('hh:mm A')}
|
||||||
</ListItem>
|
</Typography>
|
||||||
<Divider />
|
</Stack>
|
||||||
</List>
|
</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>
|
</Box>
|
||||||
{/* Rider List */}
|
|
||||||
<List>
|
{/* ===== "All" selection pill ===== */}
|
||||||
{/* Individuals */}
|
<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
|
{ridersIsLoading || riderIsFetching
|
||||||
? Array.from({ length: 10 }).map((_, index) => (
|
? Array.from({ length: 8 }).map((_, index) => (
|
||||||
<Fragment key={index}>
|
<ListItem key={index} sx={{ px: 1, py: 1 }}>
|
||||||
<ListItem sx={{ py: 1.5, px: 2 }}>
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ width: '100%' }}>
|
||||||
<ListItemAvatar>
|
<Skeleton variant="circular" width={32} height={32} />
|
||||||
<Skeleton variant="circular" width={24} height={24} />
|
<Stack sx={{ flex: 1 }}>
|
||||||
</ListItemAvatar>
|
<Skeleton variant="text" width="60%" height={18} />
|
||||||
|
<Skeleton variant="text" width="40%" height={14} />
|
||||||
<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} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</ListItem>
|
<Stack spacing={0.5}>
|
||||||
|
<Skeleton variant="text" width={60} height={16} />
|
||||||
<Divider />
|
<Skeleton variant="text" width={80} height={12} />
|
||||||
</Fragment>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
))
|
))
|
||||||
: riders?.map((row) => {
|
: 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 (
|
return (
|
||||||
<Fragment key={row.userid}>
|
<Box
|
||||||
<ListItem
|
key={row.userid}
|
||||||
sx={{
|
onClick={() => {
|
||||||
cursor: 'pointer',
|
// Tapping the card selects this rider in isolation.
|
||||||
py: 1,
|
setSelectedRiders([row]);
|
||||||
px: 2,
|
}}
|
||||||
borderRadius: 1,
|
sx={{
|
||||||
'&:hover': { bgcolor: theme.palette.secondary.lighter }
|
display: 'flex',
|
||||||
}}
|
alignItems: 'center',
|
||||||
secondaryAction={
|
gap: 1,
|
||||||
<Stack textAlign="right" spacing={0.5}>
|
p: 1,
|
||||||
<Typography variant="body2" noWrap sx={{ color: row.status == 'active' ? 'success.main' : 'error.main' }}>
|
mb: 0.625,
|
||||||
{row.userid}
|
borderRadius: 2,
|
||||||
</Typography>
|
cursor: 'pointer',
|
||||||
<Typography variant="caption" color="text.secondary" noWrap>
|
border: `1px solid ${isSelected ? edge(BRAND) : DT.divider}`,
|
||||||
{dayjs(row.logdate).format('DD/MM/YYYY hh:mm A')}
|
bgcolor: isSelected ? tint(BRAND) : '#fff',
|
||||||
</Typography>
|
transition: 'all 0.15s',
|
||||||
</Stack>
|
'&: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>
|
{initial}
|
||||||
<Checkbox
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
color: row.status == 'active' ? 'green' : 'red',
|
position: 'absolute',
|
||||||
'&.Mui-checked': {
|
bottom: -1,
|
||||||
color: row.status == 'active' ? 'green' : 'red'
|
right: -1,
|
||||||
}
|
width: 10,
|
||||||
}}
|
height: 10,
|
||||||
checked={
|
borderRadius: '50%',
|
||||||
// INDIVIDUAL CHECKED CONDITION
|
bgcolor: statusColor,
|
||||||
selectedRiders?.length === 1 && selectedRiders[0]?.userid === row?.userid
|
border: '2px solid #fff'
|
||||||
}
|
}}
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</Avatar>
|
||||||
|
|
||||||
<Divider />
|
<Stack sx={{ flex: 1, minWidth: 0 }}>
|
||||||
</Fragment>
|
<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>
|
</List>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* AppBar */}
|
{/* ============================================= || AppBar || ============================================= */}
|
||||||
<AppBar
|
<AppBar
|
||||||
elevation={0}
|
elevation={0}
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@@ -246,50 +521,104 @@ const RidersLogs = () => {
|
|||||||
left: open && isDesktop ? `${drawerWidth}px` : 0,
|
left: open && isDesktop ? `${drawerWidth}px` : 0,
|
||||||
width: open && isDesktop ? `calc(100% - ${drawerWidth}px)` : '100%',
|
width: open && isDesktop ? `calc(100% - ${drawerWidth}px)` : '100%',
|
||||||
transition: 'left 0.3s ease, width 0.3s ease',
|
transition: 'left 0.3s ease, width 0.3s ease',
|
||||||
backgroundColor: 'white',
|
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
|
||||||
borderBottom: '1px solid',
|
borderBottom: `1px solid ${DT.borderSubtle}`,
|
||||||
borderColor: theme.palette.secondary.light
|
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" justifyContent="space-between" sx={{ width: '100%' }}>
|
||||||
<Stack direction="row" alignItems="center">
|
<Stack direction="row" alignItems="center" spacing={1.25}>
|
||||||
<IconButton color="primary" onClick={() => setOpen(!open)}>
|
<IconButton
|
||||||
<MenuIcon />
|
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>
|
</IconButton>
|
||||||
|
<Avatar
|
||||||
<Typography variant="h5" color="primary" sx={{ ml: 2 }}>
|
sx={{
|
||||||
Riders Locations
|
width: 36,
|
||||||
</Typography>
|
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>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
variant="outlined"
|
<SoftPill color={BRAND} icon={<MdMyLocation size={11} />} sx={{ display: { xs: 'none', sm: 'inline-flex' } }}>
|
||||||
color="primary"
|
{dayjs().format('DD MMM YYYY')}
|
||||||
onClick={() => {
|
</SoftPill>
|
||||||
riderLogsRefetch();
|
<Button
|
||||||
}}
|
variant="contained"
|
||||||
>
|
onClick={() => riderLogsRefetch()}
|
||||||
Refresh
|
startIcon={<MdRefresh size={16} />}
|
||||||
</Button>
|
sx={{
|
||||||
|
borderRadius: 999,
|
||||||
|
px: 2,
|
||||||
|
bgcolor: BRAND,
|
||||||
|
fontWeight: 700,
|
||||||
|
boxShadow: `0 6px 18px ${ring(BRAND)}`,
|
||||||
|
textTransform: 'none',
|
||||||
|
'&:hover': { bgcolor: '#4D1C61' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
{/* Map */}
|
{/* ============================================= || Map area || ============================================= */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
pt: '64px',
|
pt: { xs: '60px', md: '68px' },
|
||||||
pl: open && isDesktop ? `${drawerWidth}px` : 0,
|
pl: open && isDesktop ? `${drawerWidth}px` : 0,
|
||||||
transition: 'padding-left 0.3s ease',
|
transition: 'padding-left 0.3s ease',
|
||||||
minHeight: '80vh'
|
minHeight: '80vh',
|
||||||
|
bgcolor: DT.surfaceAlt
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(ridersIsLoading || riderIsFetching) && (
|
{(ridersIsLoading || riderIsFetching) && (
|
||||||
<Box position="relative" width="100%" height="80vh" display="grid" placeItems="center">
|
<Box position="relative" width="100%" height="80vh" display="grid" placeItems="center">
|
||||||
{/* <CircularLoader /> */}
|
|
||||||
<Skeleton
|
<Skeleton
|
||||||
variant="rectangular"
|
variant="rectangular"
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -299,22 +628,68 @@ const RidersLogs = () => {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
borderRadius: 1,
|
borderRadius: 0,
|
||||||
zIndex: 1
|
zIndex: 1
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedRiders?.length > 0 && <RiderLocationMap riderLocations={selectedRiders} />}
|
{selectedRiders?.length > 0 && !riderLogsError && <RiderLocationMap riderLocations={selectedRiders} />}
|
||||||
|
|
||||||
{riderLogsError && (
|
{riderLogsError && (
|
||||||
<Box sx={{ width: '100% ', height: '100%' }}>
|
<Stack alignItems="center" justifyContent="center" sx={{ p: 4, height: '70vh' }}>
|
||||||
<img src={error500} alt="mantis" style={{ height: '100%', width: '100%' }} />
|
<Paper
|
||||||
</Box>
|
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>
|
||||||
</Box>
|
</Box>
|
||||||
</MainCard>
|
</Paper>
|
||||||
</Fragment>
|
</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 Invoice = Loadable(lazy(() => import('pages/nearle/invoice/invoice')));
|
||||||
const InvoicePreview = Loadable(lazy(() => import('../pages/nearle/invoice/invoicePreview')));
|
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 ||============================== //
|
// ==============================|| MAIN ROUTING ||============================== //
|
||||||
|
|
||||||
const MainRoutes = {
|
const MainRoutes = {
|
||||||
@@ -123,6 +126,19 @@ const MainRoutes = {
|
|||||||
{
|
{
|
||||||
path: 'locations',
|
path: 'locations',
|
||||||
element: <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"
|
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||||
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
|
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:
|
function-bind@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
|
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user