updates on the fix
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import logger from '../../../utils/logger';
|
||||
import {
|
||||
Autocomplete,
|
||||
Backdrop,
|
||||
@@ -26,15 +27,17 @@ 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 { MdTwoWheeler, MdSwapHoriz, MdWarning } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
buildMilerLookup,
|
||||
createAutomationDeliveries,
|
||||
createOptimisationDeliveries,
|
||||
fetchRidersList,
|
||||
finalCreatedeliveries,
|
||||
notifyRider,
|
||||
reconcileSteps
|
||||
reconcileSteps,
|
||||
resolveMilerForOrder
|
||||
} from '../../api/api';
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import CSVExport from 'components/third-party/ReactTable';
|
||||
@@ -152,6 +155,14 @@ const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName })
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +254,15 @@ const applyReconcileResponse = (preview, response) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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 = [
|
||||
{
|
||||
@@ -329,19 +349,17 @@ const Preview = () => {
|
||||
const autoRiders = stateData.autoRiders || [];
|
||||
const absentRidersPayload = stateData.absentRidersPayload || [];
|
||||
|
||||
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]);
|
||||
|
||||
// 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', appId],
|
||||
queryKey: ['ridersList'],
|
||||
queryFn: fetchRidersList,
|
||||
enabled: !!appId,
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
|
||||
@@ -361,6 +379,24 @@ const Preview = () => {
|
||||
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,
|
||||
@@ -387,7 +423,17 @@ const Preview = () => {
|
||||
const notifyRiderMutation = useMutation({
|
||||
mutationFn: notifyRider,
|
||||
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000),
|
||||
onError: (error) => OpenToast(error.message, 'error', 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({
|
||||
@@ -415,26 +461,23 @@ const Preview = () => {
|
||||
// database, which neither the Orders "pending" list nor the Deliveries
|
||||
// "dispatched" filter ever read (both come from GET /admin/bookings).
|
||||
mutationFn: finalCreatedeliveries,
|
||||
onSuccess: () => {
|
||||
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 actually present in the committed
|
||||
// list instead. notifyRider expects a milerprofileid (not an FCM
|
||||
// token — the server looks the device up itself); rider_id/userid
|
||||
// here is the id this page already treats as canonical throughout
|
||||
// (see flattenRiders/moveOrderInPreviewData above) since it's the
|
||||
// only rider identifier the solver echoes back — unconfirmed whether
|
||||
// that's actually a milerprofileid by the time it reaches here.
|
||||
const notifiedRiderIds = new Set();
|
||||
finaldeliveryList.forEach((order) => {
|
||||
const riderId = order.rider_id ?? order.userid;
|
||||
if (riderId == null || notifiedRiderIds.has(String(riderId))) return;
|
||||
notifiedRiderIds.add(String(riderId));
|
||||
notifyRiderMutation.mutate(riderId);
|
||||
// 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');
|
||||
},
|
||||
@@ -446,9 +489,13 @@ const Preview = () => {
|
||||
});
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: reconcileSteps,
|
||||
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.
|
||||
@@ -459,14 +506,17 @@ const Preview = () => {
|
||||
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)
|
||||
@@ -513,11 +563,18 @@ const Preview = () => {
|
||||
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;
|
||||
@@ -529,6 +586,10 @@ const Preview = () => {
|
||||
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;
|
||||
}
|
||||
@@ -559,21 +620,42 @@ const Preview = () => {
|
||||
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
|
||||
`Rider ${newRiderId}`;
|
||||
|
||||
setDispatchPreviewData((prev) =>
|
||||
moveOrderInPreviewData(prev, {
|
||||
orderId: selectedOrder.orderid,
|
||||
oldRiderId: selectedOldRiderId,
|
||||
newRiderId,
|
||||
newRiderName
|
||||
})
|
||||
);
|
||||
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.
|
||||
// Reconcile sends exactly these two — unless the old rider is gone.
|
||||
setDirtyRiderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selectedOldRiderId != null) next.add(String(selectedOldRiderId));
|
||||
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);
|
||||
@@ -742,6 +824,7 @@ const Preview = () => {
|
||||
{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 (
|
||||
<Tooltip
|
||||
key={`${o.orderid}-${idx}`}
|
||||
@@ -749,17 +832,20 @@ const Preview = () => {
|
||||
<Box>
|
||||
<div>Order #{o.orderid}</div>
|
||||
<div>{o.deliveryaddress || o.deliverysuburb || ''}</div>
|
||||
<div style={{ marginTop: 4, opacity: 0.8 }}>Click to change rider</div>
|
||||
<div style={{ marginTop: 4, opacity: 0.8 }}>
|
||||
{isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider'}
|
||||
</div>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
onClick={() => openChangeRider(r, o)}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
bgcolor: color,
|
||||
bgcolor: isUnverified ? '#ef4444' : color,
|
||||
color: '#fff',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -767,13 +853,27 @@ const Preview = () => {
|
||||
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)',
|
||||
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 && (
|
||||
<MdWarning
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -5,
|
||||
right: -5,
|
||||
color: '#ef4444',
|
||||
background: '#fff',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -823,12 +923,20 @@ const Preview = () => {
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Tooltip title={dirtyRiderIds.size > 0 ? `Reconcile ${dirtyRiderIds.size} edited rider(s) first` : ''}>
|
||||
<Tooltip
|
||||
title={
|
||||
dirtyRiderIds.size > 0
|
||||
? `Reconcile ${dirtyRiderIds.size} edited rider(s) first`
|
||||
: unverifiedOrderIds.size > 0
|
||||
? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<span style={isMobile ? { width: '100%' } : undefined}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth={isMobile}
|
||||
disabled={dirtyRiderIds.size > 0}
|
||||
disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
|
||||
onClick={handleFinalCreateDelivery}
|
||||
>
|
||||
Assign Orders
|
||||
|
||||
Reference in New Issue
Block a user