import React, { useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import logger from '../../../utils/logger'; import { Autocomplete, Backdrop, Box, Button, Card, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Stack, Tab, Tabs, TextField, Tooltip, Typography, useMediaQuery, useTheme } 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, MdWarning } from 'react-icons/md'; import { buildMilerLookup, createAutomationDeliveries, createOptimisationDeliveries, fetchRidersList, finalCreatedeliveries, notifyRider, reconcileSteps, resolveMilerForOrder } 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; // A rider left with zero orders after this move is a ghost entry — // Dispatch's rider list renders every zone.riders[] entry // unconditionally, so it would keep showing as a clickable // 0-trips/0km/₹0 card with nothing inside once its last order is // reassigned elsewhere. Drop it from the zone entirely. if (r.orders.length === 0) { zone.riders.splice(ri, 1); } } } } 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 }); } }); // Same ghost-rider cleanup as moveOrderInPreviewData: if the reconcile // response came back with an empty orders[] for a rider (every stop it // had got reassigned elsewhere during reconciliation), don't leave that // rider sitting in the tree as a 0-trips/0km/₹0 card with nothing inside. next.zones.forEach((zone) => { if (!Array.isArray(zone.riders)) return; zone.riders = zone.riders.filter((r) => Array.isArray(r.orders) && r.orders.length > 0); }); } 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 || {}; const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); // 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('/doormile/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 || []; // fetchRidersList() takes no params — GET /admin/milers is tenant-scoped // server-side from the auth token, not by appId. Gating this query on // `!!appId` was wrong: orders.js (the entry point into this page) always // navigates here with a hardcoded appId of 0 (it has no zone picker at // all), which made this query permanently disabled unless a stale // `applocationid` happened to be cached in localStorage from a previous // Dispatch.js visit — the Change Rider dropdown showed "no riders" for // anyone who reached this page the normal way. const { data: ridersList } = useQuery({ queryKey: ['ridersList'], queryFn: fetchRidersList, 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]); // Which orders carry a rider id the AI solver assigned that doesn't match // any real Doormile miler (userid/milerprofileid/name — same rule // finalCreatedeliveries uses server-side, see api.js). Bike-hypertuning // mode never sends the solver a rider pool at all, so it assigns from its // own internal roster — one that predates the Doormile migration and can // return ids with no live counterpart. Surfacing this here, before // commit, lets the operator fix it via the existing Change Rider flow // instead of the batch silently failing (or worse, notifying/assigning // the wrong person) after Assign is clicked. const unverifiedOrderIds = useMemo(() => { const lookup = buildMilerLookup(ridersList || []); const ids = new Set(); finaldeliveryList.forEach((order) => { if (!resolveMilerForOrder(order, lookup)) ids.add(String(order.orderid)); }); return ids; }, [finaldeliveryList, ridersList]); 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) => { // doormileAxios's response interceptor rewrites a failed request's // rejection to `error.response.data` directly (see utils/doormileAxios.js), // so `error` here IS the backend's JSON body, not an axios Error — its // `.message` key only exists if the backend happened to name the field // that. Logging the raw object is the only reliable way to see what a // 400 actually complained about (e.g. "no device token", "invalid // miler") instead of a blank/undefined toast. logger.error('notifyRiderMutation failed:', error); OpenToast(error?.message || error?.error || 'Failed to notify rider — see console for details', '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({ // finalCreatedeliveries now assigns each order directly on Doormile's // own booking record (POST /admin/bookings/:id/assign-miler per order, // resolved internally) instead of posting to jupiter — see api.js for // why: jupiter's createdeliveries wrote into jupiter's own orphaned // database, which neither the Orders "pending" list nor the Deliveries // "dispatched" filter ever read (both come from GET /admin/bookings). mutationFn: finalCreatedeliveries, onSuccess: (data) => { OpenToast('Delivery Created Successfully', 'success', 2000); setIsLoading(false); // stateData.rider (a single rider forwarded via navigate() from the // Orders page) is never actually populated in the real flow — that // page's navigate() call doesn't include a `rider` key at all — so // this was a permanent no-op and no rider ever got notified after // assignment. Notify every rider finalCreatedeliveries actually // resolved and assigned (data.resolvedMilerProfileIds — real // milerprofileids from GET /admin/milers, deduped there). Previously // this notified using order.rider_id/userid directly, which is the // solver's own internal rider numbering — confirmed live to NOT be a // real Doormile userid or milerprofileid (see api.js's // finalCreatedeliveries) — so every notification went out with a // bogus id and likely silently failed server-side. (data?.resolvedMilerProfileIds || []).forEach((milerprofileid) => { notifyRiderMutation.mutate(milerprofileid); }); navigate('/doormile/deliveries'); }, onError: (error) => { OpenToast(error.message, 'error', 4000); setIsLoading(false); }, onSettled: () => setIsLoading(false) }); const reconcileMutation = useMutation({ mutationFn: (payload) => { logger.debug('reconcile: sending payload', payload); return reconcileSteps(payload); }, onMutate: () => setReconcileLoading(true), onSuccess: (data) => { logger.debug('reconcile: response', 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))); logger.debug('reconcile: dirtyRiderIds after clearing reconciled riders', [...next]); return next; }); OpenToast('Steps reconciled — preview updated', 'success', 2000); } else { logger.error('reconcile: response had no riders array — dirtyRiderIds NOT cleared, Assign Orders stays disabled', data); OpenToast('Reconcile returned no rider data', 'warning', 3000); } }, onError: (error) => { logger.error('reconcile: request failed', error?.response?.status, error?.response?.data || error?.message); 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; } // "Change Rider" and "Reconcile" were two independent buttons with // nothing linking them — an operator could edit a rider's steps and hit // Assign Orders without ever pressing Reconcile. Committing stale step // ordering corrupts route sequences server-side (see this folder's // CLAUDE.md §3, called out as the single biggest production risk here). // dirtyRiderIds only ever holds riders edited since the last successful // reconcile of that specific rider, so size > 0 means real unreconciled // edits are pending. if (dirtyRiderIds.size > 0) { OpenToast(`Reconcile ${dirtyRiderIds.size} edited rider(s) before assigning`, 'warning', 4000); return; } // Same reasoning as the button's disabled state — belt-and-suspenders // in case this ever fires from somewhere other than that button. if (unverifiedOrderIds.size > 0) { OpenToast(`${unverifiedOrderIds.size} order(s) have an unrecognized rider — use Change Rider to fix them first`, 'warning', 4000); return; } setIsLoading(true); createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList }); }; const handleReconcile = () => { logger.debug('handleReconcile: dirtyRiderIds', [...dirtyRiderIds], 'reconcileRiders ids', reconcileRiders.map((r) => r.rider_id)); 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) { logger.error( 'handleReconcile: dirtyRiderIds is non-empty but none of them match a rider currently in reconcileRiders — nothing to send, Assign Orders stays disabled', [...dirtyRiderIds] ); 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}`; const moved = moveOrderInPreviewData(dispatchPreviewData, { orderId: selectedOrder.orderid, oldRiderId: selectedOldRiderId, newRiderId, newRiderName }); setDispatchPreviewData(moved); // If that was the old rider's LAST order, moveOrderInPreviewData's own // ghost-rider cleanup already removed them from the tree entirely (see // that function). There is nothing left of theirs to reconcile — and // marking them dirty anyway is a real bug, not just unnecessary: the // reconcile response can only ever echo back riders that were actually // sent to it, handleReconcile only sends riders still present in // reconcileRiders (derived from this same tree), so a rider who no // longer exists here can NEVER be sent, NEVER come back in the // response, and therefore NEVER get cleared from dirtyRiderIds — // permanently stuck at size > 0, permanently disabling Assign Orders. // Confirmed via logging (reconcile: dirtyRiderIds after clearing // reconciled riders) that this is exactly what happens. const oldRiderStillExists = Array.isArray(moved?.zones) ? moved.zones.some((z) => (z.riders || []).some((r) => String(r.rider_id ?? r.userid) === String(selectedOldRiderId))) : false; logger.debug('confirmChangeRider: old rider still has orders after move?', oldRiderStillExists, 'oldRiderId', selectedOldRiderId); // 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 — unless the old rider is gone. setDirtyRiderIds((prev) => { const next = new Set(prev); if (selectedOldRiderId != null) { if (oldRiderStillExists) next.add(String(selectedOldRiderId)); else next.delete(String(selectedOldRiderId)); } if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId)); logger.debug('confirmChangeRider: dirtyRiderIds after change', [...next]); return next; }); setHasReconciled(false); setChangeDialogOpen(false); OpenToast('Rider changed — click Reconcile to verify steps', 'info', 2500); }; return ( theme.zIndex.modal + 1 }} open={isLoading} > navigate('/doormile/orders')} sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }} > Assign Orders option.type} sx={{ minWidth: { xs: 0, sm: 250 }, maxWidth: 600, flex: 1, width: { xs: '100%', sm: 'auto' } }} renderInput={(params) => } onChange={(e, val, reason) => { if (reason === 'clear') handleCreateDelivery(null); else handleCreateDelivery(val.value); }} /> setTabValue(v)} variant={isMobile ? 'fullWidth' : 'standard'} 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); const isUnverified = unverifiedOrderIds.has(String(o.orderid)); return (
Order #{o.orderid}
{o.deliveryaddress || o.deliverysuburb || ''}
{isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider'}
} > openChangeRider(r, o)} sx={{ position: 'relative', width: 36, height: 36, borderRadius: '50%', bgcolor: isUnverified ? '#ef4444' : color, color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 14, cursor: 'pointer', boxShadow: isUnverified ? '0 0 0 2px #fff, 0 0 0 4px #ef4444, 0 1px 3px rgba(15,23,42,0.15)' : '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} {isUnverified && ( )} ); })} ); })} )}
)}
0 ? `Reconcile ${dirtyRiderIds.size} edited rider(s) first` : unverifiedOrderIds.size > 0 ? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first` : '' } > setChangeDialogOpen(false)} maxWidth="xs" fullWidth fullScreen={isMobile} PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }} > 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;