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);
|
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/createdeliveries`, deliveryData.deliveries);
|
||||||
return response.data;
|
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) ||============================== //
|
// ==============================|| finalCreatedeliveries (orders) ||============================== //
|
||||||
|
|
||||||
export const finalCreatedeliveries = async (deliveryData) => {
|
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 {
|
.gradient-btn-create:hover {
|
||||||
transform: translateY(-1px) !important;
|
|
||||||
filter: brightness(1.04);
|
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;
|
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 {
|
.gradient-btn-create:active {
|
||||||
transform: translateY(0) !important;
|
|
||||||
filter: brightness(0.98);
|
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 [finaldeliveryList, setFinalDeliveryList] = useState([]);
|
||||||
|
|
||||||
const aiModeRef = useRef(0);
|
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 rowsPerPage = 100;
|
||||||
|
|
||||||
const transportOptions = [
|
const transportOptions = [
|
||||||
@@ -417,19 +421,25 @@ const Orders = () => {
|
|||||||
setZoneData(data?.zones);
|
setZoneData(data?.zones);
|
||||||
setMetaData(data?.meta);
|
setMetaData(data?.meta);
|
||||||
setDispatchPreviewData(data);
|
setDispatchPreviewData(data);
|
||||||
setAiDialog(true);
|
// Route the AI Assign result to the dedicated Preview page (Dispatch
|
||||||
// navigate('/nearle/orders/optimisedpreview', {
|
// view + Reconcile tab + Change Rider). The previous in-page dialog
|
||||||
// state: {
|
// (aiDialog) is left intact but no longer opened — the page replaces
|
||||||
// zoneSummary: data?.zone_analysis,
|
// it. All inputs needed for Re-Assign / Assign Orders are forwarded.
|
||||||
// deliverylist: data?.details, // to deliveryDetails
|
const ctx = aiMutationContextRef.current || {};
|
||||||
// zoneData: data?.zones,
|
navigate('/nearle/dispatch/preview', {
|
||||||
// metaData: data?.meta,
|
state: {
|
||||||
// riderToken: rider.userfcmtoken,
|
dispatchPreviewData: data,
|
||||||
// appId,
|
aiMode: ctx.aiMode ?? aiModeRef.current,
|
||||||
// aiMode: aiModeRef.current,
|
selectedMode: ctx.selectedMode || selectedMode,
|
||||||
// reassignOrders
|
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) => {
|
onError: (error) => {
|
||||||
@@ -562,6 +572,19 @@ const Orders = () => {
|
|||||||
`Rider ${r.userid}`
|
`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) {
|
if (aiModeRef.current == 0) {
|
||||||
// manual assign
|
// manual assign
|
||||||
createDeliveryMutation.mutate({
|
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 Createrider = Loadable(lazy(() => import('pages/nearle/riders/createrider')));
|
||||||
const EditRider = Loadable(lazy(() => import('pages/nearle/riders/editRider')));
|
const EditRider = Loadable(lazy(() => import('pages/nearle/riders/editRider')));
|
||||||
const Dispatch = Loadable(lazy(() => import('pages/nearle/dispatch/Dispatch')));
|
const Dispatch = Loadable(lazy(() => import('pages/nearle/dispatch/Dispatch')));
|
||||||
|
const DispatchPreview = Loadable(lazy(() => import('pages/nearle/dispatch/Preview')));
|
||||||
|
|
||||||
|
|
||||||
// ==============================|| MAIN ROUTING ||============================== //
|
// ==============================|| MAIN ROUTING ||============================== //
|
||||||
@@ -170,6 +171,10 @@ const MainRoutes = {
|
|||||||
{
|
{
|
||||||
path: 'dispatch',
|
path: 'dispatch',
|
||||||
element: <Dispatch />
|
element: <Dispatch />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dispatch/preview',
|
||||||
|
element: <DispatchPreview />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user