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(); // 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 ( theme.zIndex.modal + 1 }} open={isLoading} > navigate('/nearle/orders')} sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }} > Assign Orders option.type} sx={{ minWidth: 250, maxWidth: 600, flex: 1 }} renderInput={(params) => } onChange={(e, val, reason) => { if (reason === 'clear') handleCreateDelivery(null); else handleCreateDelivery(val.value); }} /> setTabValue(v)} sx={{ minHeight: 40 }}> {tabValue === 0 && dispatchPreviewData && ( openChangeRider(focusedRider, order)} /> )} {tabValue === 1 && ( {reconcileRiders.length === 0 ? ( No rider data available to reconcile. ) : ( {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.'} {reconcileRiders.map((r) => { const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0); return ( {r.rider_name} ID: {r.rider_id} {r.orders.map((o, idx) => { const stepNum = o.step ?? idx + 1; const color = stepColor(Number(stepNum) - 1); return (
Order #{o.orderid}
{o.deliveryaddress || o.deliverysuburb || ''}
Click to change rider
} > 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} ); })} ); })} )}
)}
setChangeDialogOpen(false)} maxWidth="xs" fullWidth> Change Rider Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to: o?.label || `${o?.firstname || ''} ${o?.lastname || ''}`.trim() || '' } value={selectedNewRider} onChange={(e, val) => setSelectedNewRider(val)} renderInput={(params) => } /> ); }; // 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;