updates on the dispatch page
This commit is contained in:
@@ -140,6 +140,36 @@ export const createOptimisationDeliveries = async (deliveryData) => {
|
||||
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/createdeliveries`, deliveryData.deliveries);
|
||||
return response.data;
|
||||
};
|
||||
// ==============================|| reconcileSteps (Preview - validate rider/order step assignments) ||============================== //
|
||||
|
||||
export const reconcileSteps = async ({ riders }) => {
|
||||
const response = await axios.post(
|
||||
`https://routes.workolik.com/api/v1/optimization/reconcile-steps`,
|
||||
{ riders }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| fetchBatchEfficiency (Dispatch - Analysis view) ||============================== //
|
||||
// Calls POST /api/v1/batch/efficiency with a JSON body { batch, tenant_id }.
|
||||
// `batch` is one of: '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' },
|
||||
// Let success:false envelopes flow through so the UI can surface the
|
||||
// server's own error message (instead of axios throwing on 4xx/5xx).
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
// ==============================|| finalCreatedeliveries (orders) ||============================== //
|
||||
|
||||
export const finalCreatedeliveries = async (deliveryData) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
717
src/pages/nearle/dispatch/Preview.js
Normal file
717
src/pages/nearle/dispatch/Preview.js
Normal file
@@ -0,0 +1,717 @@
|
||||
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/third-party/OpenToast';
|
||||
import CSVExport from 'components/third-party/ReactTable';
|
||||
import CircularLoader from '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();
|
||||
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);
|
||||
entry.orders.push(...(orders || []));
|
||||
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) => {
|
||||
(r.orders || []).forEach((o) => {
|
||||
out.push({
|
||||
...o,
|
||||
rider_id: r.rider_id,
|
||||
userid: r.rider_id,
|
||||
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) {
|
||||
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 = newOrdersByRider.get(key);
|
||||
newOrdersByRider.delete(key);
|
||||
} else {
|
||||
// Rider wasn't in the response — keep their existing orders untouched.
|
||||
}
|
||||
});
|
||||
});
|
||||
// Any response riders we didn't place attach to the first zone.
|
||||
if (newOrdersByRider.size) {
|
||||
const target = next.zones[0];
|
||||
target.riders = target.riders || [];
|
||||
newOrdersByRider.forEach((orders, riderKey) => {
|
||||
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);
|
||||
const [csvExportData, setCsvExportData] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const [reconcileLoading, setReconcileLoading] = useState(false);
|
||||
const [hasReconciled, setHasReconciled] = useState(false);
|
||||
|
||||
// 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);
|
||||
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)) {
|
||||
setDispatchPreviewData((prev) => applyReconcileResponse(prev, data));
|
||||
setHasReconciled(true);
|
||||
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;
|
||||
}
|
||||
// Payload built straight from the cache — whatever Change Rider edited
|
||||
// is what gets sent.
|
||||
reconcileMutation.mutate({
|
||||
riders: reconcileRiders.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;
|
||||
const newRiderId = selectedNewRider.userid;
|
||||
const newRiderName =
|
||||
selectedNewRider.label ||
|
||||
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
|
||||
`Rider ${newRiderId}`;
|
||||
|
||||
setDispatchPreviewData((prev) =>
|
||||
moveOrderInPreviewData(prev, {
|
||||
orderId: selectedOrder.orderid,
|
||||
oldRiderId: selectedOldRiderId,
|
||||
newRiderId,
|
||||
newRiderName
|
||||
})
|
||||
);
|
||||
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}
|
||||
sx={{ minWidth: 220, borderRadius: '10px', textTransform: 'none', fontWeight: 700 }}
|
||||
>
|
||||
{reconcileLoading ? 'Reconciling...' : 'Reconcile'}
|
||||
</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;
|
||||
@@ -1096,13 +1096,11 @@
|
||||
}
|
||||
|
||||
.gradient-btn-create:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
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 {
|
||||
transform: translateY(0) !important;
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -142,6 +142,10 @@ const Orders = () => {
|
||||
const [finaldeliveryList, setFinalDeliveryList] = useState([]);
|
||||
|
||||
const aiModeRef = useRef(0);
|
||||
// Caches the inputs of the most recent AI Assign call so we can forward them
|
||||
// to the /nearle/dispatch/preview page via navigate state (the page re-uses
|
||||
// them for its Re-Assign button).
|
||||
const aiMutationContextRef = useRef(null);
|
||||
const rowsPerPage = 100;
|
||||
|
||||
const transportOptions = [
|
||||
@@ -417,19 +421,25 @@ const Orders = () => {
|
||||
setZoneData(data?.zones);
|
||||
setMetaData(data?.meta);
|
||||
setDispatchPreviewData(data);
|
||||
setAiDialog(true);
|
||||
// navigate('/nearle/orders/optimisedpreview', {
|
||||
// state: {
|
||||
// zoneSummary: data?.zone_analysis,
|
||||
// deliverylist: data?.details, // to deliveryDetails
|
||||
// zoneData: data?.zones,
|
||||
// metaData: data?.meta,
|
||||
// riderToken: rider.userfcmtoken,
|
||||
// appId,
|
||||
// aiMode: aiModeRef.current,
|
||||
// reassignOrders
|
||||
// }
|
||||
// });
|
||||
// Route the AI Assign result to the dedicated Preview page (Dispatch
|
||||
// view + Reconcile tab + Change Rider). The previous in-page dialog
|
||||
// (aiDialog) is left intact but no longer opened — the page replaces
|
||||
// it. All inputs needed for Re-Assign / Assign Orders are forwarded.
|
||||
const ctx = aiMutationContextRef.current || {};
|
||||
navigate('/nearle/dispatch/preview', {
|
||||
state: {
|
||||
dispatchPreviewData: data,
|
||||
aiMode: ctx.aiMode ?? aiModeRef.current,
|
||||
selectedMode: ctx.selectedMode || selectedMode,
|
||||
deliveryData: ctx.deliveryData || [],
|
||||
autoRiders: ctx.autoRiders || autoRiders || [],
|
||||
absentRidersPayload: ctx.absentRidersPayload || [],
|
||||
rider: ctx.rider || rider,
|
||||
appId: ctx.appId ?? appId,
|
||||
tenantId: ctx.tenantid ?? tenantid,
|
||||
startdate
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -562,6 +572,19 @@ const Orders = () => {
|
||||
`Rider ${r.userid}`
|
||||
}));
|
||||
|
||||
// Remember the inputs so the Preview page can re-run Re-Assign with the
|
||||
// same payload without us having to re-derive it from current Orders state.
|
||||
aiMutationContextRef.current = {
|
||||
deliveryData,
|
||||
absentRidersPayload,
|
||||
autoRiders,
|
||||
selectedMode,
|
||||
aiMode: aiModeRef.current,
|
||||
rider,
|
||||
appId,
|
||||
tenantid
|
||||
};
|
||||
|
||||
if (aiModeRef.current == 0) {
|
||||
// manual assign
|
||||
createDeliveryMutation.mutate({
|
||||
|
||||
@@ -49,6 +49,7 @@ const Riders = Loadable(lazy(() => import('pages/nearle/riders/riders')));
|
||||
const Createrider = Loadable(lazy(() => import('pages/nearle/riders/createrider')));
|
||||
const EditRider = Loadable(lazy(() => import('pages/nearle/riders/editRider')));
|
||||
const Dispatch = Loadable(lazy(() => import('pages/nearle/dispatch/Dispatch')));
|
||||
const DispatchPreview = Loadable(lazy(() => import('pages/nearle/dispatch/Preview')));
|
||||
|
||||
|
||||
// ==============================|| MAIN ROUTING ||============================== //
|
||||
@@ -170,6 +171,10 @@ const MainRoutes = {
|
||||
{
|
||||
path: 'dispatch',
|
||||
element: <Dispatch />
|
||||
},
|
||||
{
|
||||
path: 'dispatch/preview',
|
||||
element: <DispatchPreview />
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user