updates on the fix

This commit is contained in:
2026-08-12 12:55:18 +05:30
parent bf28249528
commit fabb74c326
15 changed files with 1478 additions and 528 deletions

View File

@@ -85,7 +85,7 @@ const soft = (c) => a(c, '18');
const ring = (c) => a(c, '26');
const edge = (c) => a(c, '55');
const BRAND = '#662582';
const BRAND = '#C01227';
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
const SoftPaper = (props) => (
@@ -199,20 +199,30 @@ const ClientsPricing = () => {
const tenantMap = useMemo(() => new Map((tenants || []).map((t) => [t.tenantid, t])), [tenants]);
const locationMap = useMemo(
() => new Map((locations || []).filter((l) => l.applocationid).map((l) => [l.applocationid, l])),
() => new Map((locations || []).filter((l) => l.applocationid).map((l) => [String(l.applocationid), l])),
[locations]
);
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [locations]);
const tenantName = (row) => row.tenantname || tenantMap.get(row.tenantid)?.tenantname || (row.tenantid ? `Tenant #${row.tenantid}` : '—');
const zoneName = (row) =>
row.applocation || locationMap.get(row.applocationid)?.locationname || (row.applocationid ? `Zone #${row.applocationid}` : '—');
row.applocation ||
locationMap.get(String(row.applocationid))?.locationname ||
(row.applocationid ? `Zone #${row.applocationid}` : '—');
// getallpricing() takes no zone arg (see api.js) — the LocationAutocomplete
// only ever changed the header subtitle text, not which rows were shown.
// Filter client-side instead; pricing rows already carry applocationid
// (used by zoneName above), so this is the same real field, just applied.
const zonePricing = useMemo(() => (appId ? pricing.filter((r) => r.applocationid === appId) : pricing), [pricing, appId]);
// Compared as strings — applocationid on a pricing row (GET /admin/pricing)
// and on a hub/location (GET /admin/hubs, what the picker's appId comes
// from) are two independently-serialized resources with no guarantee they
// agree on number-vs-string; a raw === silently zeroed out every row
// whenever they didn't match, making the table look empty for any zone.
const zonePricing = useMemo(
() => (appId ? pricing.filter((r) => String(r.applocationid) === String(appId)) : pricing),
[pricing, appId]
);
const rows = useMemo(() => {
if (!debouncedSearch) return zonePricing;

View File

@@ -75,13 +75,15 @@ const Createcustomer = () => {
});
// LocationAutocomplete only reports back applocationid/locationname — it
// caches the full hub list (with lat/lng) in localStorage('applocations')
// caches the full hub list (with lat/lng) in localStorage under a
// tenant-scoped key (applocations_<tenantid>, see LocationAutocomplete.js)
// as a side effect, so pull the coordinates for the address-search bias
// from there rather than duplicating the GET /admin/hubs call.
useEffect(() => {
if (!appId) return;
try {
const hubs = JSON.parse(localStorage.getItem('applocations') || '[]');
const applocationsCacheKey = `applocations_${localStorage.getItem('tenantid') || 'staff'}`;
const hubs = JSON.parse(localStorage.getItem(applocationsCacheKey) || '[]');
const hub = hubs.find((h) => h.applocationid === appId);
if (hub?.latitude) {
setAppLocaLat(hub.latitude);

View File

@@ -9,7 +9,6 @@ import {
Button,
Grid,
IconButton,
InputLabel,
Paper,
Stack,
Table,
@@ -24,6 +23,7 @@ import {
useMediaQuery,
useTheme
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import {
MdPeopleAlt,
MdOutlinePeopleAlt,
@@ -43,6 +43,7 @@ import Loader from 'components/Loader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
@@ -54,6 +55,13 @@ import { enqueueSnackbar } from 'notistack';
// all. PATCH /admin/customers/:id is the only mutation the API exposes;
// there is no create/delete here by design ("Tenant-scoped through their
// bookings" — a customer only exists once they've ordered through a tenant).
//
// The edit dialog replicates nearle_console_express's customer edit dialog
// field-for-field (Name, Contact, Address, Location, City, State, Postcode,
// Landmark, Latitude, Longitude) — same layout, same AddressAutocomplete
// wiring — but only `name/phone/email` are ever sent in the PATCH body; the
// address fields have nowhere to persist to on this API (same "collected but
// not persisted" pattern as `pages/nearle/clients/createCustomer.js`).
// ============================================================================
const DT = {
@@ -97,6 +105,9 @@ const Customers = () => {
const [debouncedSearch, setDebouncedSearch] = useState('');
const [editRow, setEditRow] = useState(null);
const [form, setForm] = useState({});
const [addressInput, setAddressInput] = useState('');
const [pickAddress, setPickAddress] = useState({});
const [latLng, setLatLng] = useState({ latitude: '', longitude: '' });
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
@@ -139,6 +150,44 @@ const Customers = () => {
phone: row.phone || '',
email: row.email || ''
});
setAddressInput(row.address || '');
setPickAddress({
doorno: row.doorno || '',
suburb: row.suburb || '',
city: row.city || '',
state: row.state || '',
postcode: row.postcode || '',
landmark: row.landmark || ''
});
setLatLng({ latitude: row.latitude || '', longitude: row.longitude || '' });
};
// Same address_components parsing as createCustomer.js's handlePlaceSelected —
// AddressAutocomplete shapes Nominatim results to look like a Google Places `place`.
const handleAddressPlaceSelected = (place) => {
setLatLng({ latitude: place.geometry.location.lat(), longitude: place.geometry.location.lng() });
const parsed = { suburb: '', city: '', state: '', postcode: '' };
place.address_components.forEach((component) => {
component.types.forEach((type) => {
switch (type) {
case 'sublocality_level_1':
case 'sublocality':
parsed.suburb = component.long_name;
break;
case 'locality':
parsed.city = component.long_name;
break;
case 'administrative_area_level_1':
parsed.state = component.long_name;
break;
case 'postal_code':
parsed.postcode = component.long_name;
break;
}
});
});
setPickAddress((p) => ({ ...p, ...parsed }));
setAddressInput(place.formatted_address);
};
return (
@@ -188,13 +237,21 @@ const Customers = () => {
background: '#fff'
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ xs: 'stretch', sm: 'center' }} justifyContent="space-between" spacing={1.25}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'stretch', sm: 'center' }}
justifyContent="space-between"
spacing={1.25}
>
<Stack direction="row" alignItems="center" spacing={1.25}>
<AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={18} />
</AccentAvatar>
<Stack>
<Typography variant="caption" sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}>
<Typography
variant="caption"
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
>
Directory
</Typography>
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
@@ -388,7 +445,14 @@ const Customers = () => {
)}
</Paper>
<Dialog open={!!editRow} onClose={() => setEditRow(null)} maxWidth="xs" fullWidth PaperProps={{ sx: { borderRadius: 3 } }}>
<Dialog
open={!!editRow}
onClose={() => setEditRow(null)}
maxWidth="lg"
fullWidth
fullScreen={isMobile}
PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }}
>
<DialogTitle
sx={{
background: `linear-gradient(135deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)`,
@@ -401,7 +465,9 @@ const Customers = () => {
<FaRegEdit style={{ fontSize: 18 }} />
</Avatar>
<Stack>
<Typography sx={{ fontSize: 11, fontWeight: 700, opacity: 0.85, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1 }}>
<Typography
sx={{ fontSize: 11, fontWeight: 700, opacity: 0.85, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1 }}
>
Customer
</Typography>
<Typography sx={{ fontWeight: 800, fontSize: '1.1rem', lineHeight: 1.2, mt: 0.25 }}>
@@ -411,16 +477,22 @@ const Customers = () => {
</Stack>
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 2 }}>
<Stack spacing={1}>
<InputLabel>Name</InputLabel>
<TextField fullWidth value={form.name || ''} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</Stack>
<Stack spacing={1}>
<InputLabel>Phone Number</InputLabel>
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Customer Name</Typography>
<TextField
variant="outlined"
fullWidth
value={form.name || ''}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Contact Number</Typography>
<Stack direction="row" spacing={1}>
<TextField value="+91" disabled sx={{ width: 64, cursor: 'not-allowed' }} />
<TextField variant="outlined" value="+91" disabled sx={{ width: 64, cursor: 'not-allowed' }} />
<TextField
variant="outlined"
fullWidth
value={form.phone || ''}
inputProps={{ maxLength: 10, inputMode: 'numeric', pattern: '[0-9]*' }}
@@ -428,18 +500,107 @@ const Customers = () => {
InputProps={{ startAdornment: <MdOutlinePhoneAndroid size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/>
</Stack>
</Stack>
<Stack spacing={1}>
<InputLabel>Email</InputLabel>
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Email</Typography>
<TextField
variant="outlined"
fullWidth
type="email"
value={form.email || ''}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
InputProps={{ startAdornment: <MdMail size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" sx={{ color: DT.textMuted, fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved
on submit.
</Typography>
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Address</Typography>
<AddressAutocomplete
id="edit-customer-address"
fullWidth
value={addressInput}
onChange={(text) => setAddressInput(text)}
onPlaceSelected={handleAddressPlaceSelected}
TextFieldProps={{
variant: 'outlined',
InputProps: {
endAdornment: (
<IconButton
onClick={() => {
setAddressInput('');
setPickAddress((p) => ({ ...p, suburb: '', city: '', state: '', postcode: '' }));
setLatLng({ latitude: '', longitude: '' });
}}
size="small"
>
<CloseIcon fontSize="small" />
</IconButton>
)
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Location</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.suburb || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, suburb: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>City</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.city || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, city: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>State</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.state || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, state: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Postcode</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.postcode || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, postcode: e.target.value }))}
/>
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Landmark</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.landmark || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, landmark: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6} sx={{ cursor: 'not-allowed' }}>
<Typography sx={{ mb: 1 }}>Latitude</Typography>
<TextField variant="outlined" fullWidth disabled value={latLng.latitude || ''} sx={{ cursor: 'not-allowed' }} />
</Grid>
<Grid item xs={12} sm={6} sx={{ cursor: 'not-allowed' }}>
<Typography sx={{ mb: 1 }}>Longitude</Typography>
<TextField variant="outlined" fullWidth disabled value={latLng.longitude || ''} sx={{ cursor: 'not-allowed' }} />
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setEditRow(null)} sx={{ color: DT.textSecondary }}>

View File

@@ -5,7 +5,9 @@ import { useState, useEffect, Fragment, useRef, useMemo } from 'react';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
import { notifyMiler } from 'pages/api/doormileApi';
import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi';
import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { useTheme } from '@mui/material/styles';
import {
MdOutlineDateRange,
@@ -213,7 +215,12 @@ const getRowBatchId = (row) => {
// so a row that lands in Evening Batch on the dispatch page must also land
// in Evening Batch here — otherwise an operator running in IST/PST/etc.
// sees different totals on the two pages for the same underlying rows.
const d = dayjs(t);
// parseDoormileTimestamp (not bare dayjs()) strips a false trailing Z some
// Doormile timestamps carry (see utils/doormileTimestamp.js) — without
// this, a booking assigned "just now" could get shifted +5:30 (IST) past
// every batch window into a gap, silently vanishing from the table even
// though it was correctly assigned server-side.
const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null;
const h = d.hour() + d.minute() / 60;
for (const b of BATCH_OPTIONS) {
@@ -280,6 +287,58 @@ const Deliveries = () => {
const [deliveryamount, setDeliveryamount] = useState();
const [notes, setNotes] = useState('');
const [currentorder, setCurrentorder] = useState({});
// Real Actual KMs for the Update Status dialog, computed from real GPS
// ping history instead of leaving the field permanently blank (there is
// no backend-stored actual-distance field — see fetchDeliveries in
// api.js). Same sort -> Kalman-smooth -> polyline-length pipeline
// Dispatch.js's Compare feature already uses to show a real "actual km"
// figure, just reused here (see dispatchShared.js). Keyed off
// consignmentid, NOT deliveryid/bookingid — GET /admin/consignments/:id/logs
// is consignment-scoped (the same id-class mix-up already fixed in
// updateDeliveryAPI this session).
const { data: computedActualKm } = useQuery({
queryKey: ['deliveryActualKm', currentorder?.consignmentid],
queryFn: async () => {
let rows = [];
try {
rows = (await getConsignmentLogs(currentorder.consignmentid)) || [];
} catch {
return null;
}
const sorted = rows
.map((r) => {
const ts = r?.logdate ? dayjs(r.logdate) : null;
return {
lat: parseFloat(r?.latitude ?? r?.lat),
lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon),
logdate: r?.logdate,
_ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER
};
})
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))
.sort((a, b) => a._ts - b._ts);
// Fewer than 2 usable pings (order not yet delivered, or no telemetry
// recorded) — a real, expected case, not an error. Leave the field
// to its existing blank/manual-entry behavior.
if (sorted.length < 2) return null;
const smoothed = kalmanSmoothGps(sorted);
const km = polylineLengthKm(smoothed.map((p) => [p.lat, p.lng]));
return km > 0 ? km : null;
},
enabled: dialogopen && !!currentorder?.consignmentid,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false
});
// Only pre-fill while the field is still empty — never overwrite an
// operator's own edit (or an earlier pre-fill) if this query happens to
// re-run while the dialog is already open.
useEffect(() => {
if (computedActualKm != null && !cumulativekms) {
setCumulativeKms(Number(computedActualKm.toFixed(2)));
}
}, [computedActualKm]);
const [deliverylat, setDeliverylat] = useState('');
const [deliverylong, setDeliverylong] = useState('');
const [currentStatus, setCurrentStatus] = useState('pending');
@@ -311,7 +370,6 @@ const Deliveries = () => {
// batch matching the current UTC hour (the operator is most likely curious
// about "now"); never `null` since there's no longer an "All" option.
const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId);
const roleid = localStorage.getItem('roleid');
useEffect(() => {
setTenantid(0);
@@ -534,7 +592,21 @@ const Deliveries = () => {
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
});
const countSourceRows = useMemo(() => (countSourceData?.pages || []).flatMap((p) => p.rows || []), [countSourceData]);
// Newest activity first — an order just created and assigned should be the
// very first row an operator sees, not buried wherever GET /admin/bookings
// happens to return it (unconfirmed/unspecified server-side order). Every
// row here has been dispatched at least once (fetchDeliveries filters to
// that), so assigntime (booking.updatedat) is the more recent of the two
// timestamps on a fresh assignment; orderdate (createdat) covers the rare
// row missing it. parseDoormileTimestamp (not bare dayjs()) avoids the
// false-Z parsing bug documented in utils/doormileTimestamp.js.
const countSourceRows = useMemo(() => {
const rows = (countSourceData?.pages || []).flatMap((p) => p.rows || []);
return [...rows].sort(
(a, b) =>
parseDoormileTimestamp(b.assigntime || b.orderdate).valueOf() - parseDoormileTimestamp(a.assigntime || a.orderdate).valueOf()
);
}, [countSourceData]);
useEffect(() => {
if (countHasNext && !countIsFetchingNext) countFetchNext();
@@ -1097,7 +1169,7 @@ const Deliveries = () => {
<Autocomplete
disablePortal
options={locationlist || []}
getOptionLabel={(option) => `${option.locationname} (${option.suburb})` || ''}
getOptionLabel={(option) => (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')}
value={locationValue}
PaperComponent={SoftPaper}
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
@@ -1144,7 +1216,10 @@ const Deliveries = () => {
disabled={riderListIsLoading}
options={ridersList}
PaperComponent={SoftPaper}
getOptionLabel={(option) => `${option.displayname || option.authname || ''} (${option.contactno || ''})`}
getOptionLabel={(option) => {
const name = option?.displayname || option?.authname || '';
return option?.contactno ? `${name} (${option.contactno})` : name;
}}
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
onChange={(e, value, reason) => {
if (reason === 'clear') setRiderid(0);
@@ -1494,7 +1569,7 @@ const Deliveries = () => {
<MobileFieldGrid>
<MobileField label="Order / Location" full>
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap>
{`${row.locationname}-(${row.locationsuburb})`}
{row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.orderid} · {row.deliveryid}
@@ -1546,8 +1621,8 @@ const Deliveries = () => {
</MobileField>
<MobileField label="Kms · plan / act">
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
<Box sx={chipSx('#ef4444')}>{row.kms || 0} km</Box>
<Box sx={chipSx('#10b981')}>{row.cumulativekms || 0} km</Box>
<Box sx={chipSx('#ef4444')}>{Number(row.kms || 0).toFixed(2)} km</Box>
<Box sx={chipSx('#10b981')}>{Number(row.cumulativekms || 0).toFixed(2)} km</Box>
</Stack>
</MobileField>
<MobileField label="Amount · chg / amt">
@@ -1833,7 +1908,7 @@ const Deliveries = () => {
<TableCell align="left">
<Tooltip title="Location Name-Suburb" placement="top">
<Typography variant="subtitle1" noWrap>
{`${row.locationname}-(${row.locationsuburb})`}
{row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
</Typography>
</Tooltip>
@@ -1844,12 +1919,18 @@ const Deliveries = () => {
{row.orderid}
</Typography>
</Tooltip>
{/* express-console-api.md: Doormile timestamps are IST
wall-clock with no timezone marker — .utc() was
reinterpreting that naive string as local time and
shifting it back 5:30, showing the wrong time (and
sometimes the wrong day). Parse bare, matching the
already-correct convention in getRowBatchId above. */}
<Tooltip title="Ordered date" placement="top">
<Typography noWrap sx={{ fontSize: '12px' }}>
{dayjs(row.orderdate).utc().format('DD/MM/YYYY')}
{dayjs(row.orderdate).format('DD/MM/YYYY')}
</Typography>
<Typography noWrap sx={{ fontSize: '11px' }}>
{dayjs(row.orderdate).utc().format('hh:mm A')}
{dayjs(row.orderdate).format('hh:mm A')}
</Typography>
</Tooltip>
</Stack>
@@ -1862,44 +1943,62 @@ const Deliveries = () => {
</Tooltip>
<Tooltip title="Delivery date" placement="top">
<Typography noWrap sx={{ fontSize: '12px' }}>
{dayjs(row.deliverydate).utc().format('DD/MM/YYYY')}
{dayjs(row.deliverydate).format('DD/MM/YYYY')}
</Typography>
<Typography noWrap sx={{ fontSize: '11px' }}>
{dayjs(row.deliverydate).utc().format('hh:mm A')}
{dayjs(row.deliverydate).format('hh:mm A')}
</Typography>
</Tooltip>
</Stack>
</Stack>
</TableCell>
{/* pickup */}
<TableCell align="left">
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{/* pickup — capped to the same maxWidth as drop below, with CSS
ellipsis truncation, so one long address can't stretch this
column wider than its neighbour. row.pickuplocation is just
the full raw address (see fetchDeliveries in api.js), not a
pre-shortened name, so the old `.slice(0, 14)` fallback never
actually ran — it was always overridden by the untruncated
value on its left. Full text still available via the tooltip. */}
<TableCell align="left" sx={{ maxWidth: 190 }}>
<Stack sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{row.pickupcustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.pickupcontactno}
</Typography>
<Tooltip title={row.Pickupaddress} sx={{ whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
{row.pickuplocation || (row.Pickupaddress ? row.Pickupaddress.slice(0, 14) + '…' : '—')}
<Tooltip title={row.Pickupaddress || ''}>
<Typography
variant="caption"
sx={{ color: DT.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}
>
{row.pickuplocation || row.Pickupaddress || '—'}
</Typography>
</Tooltip>
</Stack>
</TableCell>
{/* drop */}
<TableCell align="left">
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{/* drop — same maxWidth/truncation treatment as pickup above. */}
<TableCell align="left" sx={{ maxWidth: 190 }}>
<Stack sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{row.deliverycustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.deliverycontactno}
</Typography>
<Tooltip title={row.deliveryaddress}>
<Typography variant="caption" sx={{ color: DT.textMuted, whiteSpace: 'nowrap' }}>
{row.deliverylocation || (row.deliveryaddress ? row.deliveryaddress.slice(0, 14) + '…' : '—')}
<Tooltip title={row.deliveryaddress || ''}>
<Typography
variant="caption"
sx={{ color: DT.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}
>
{row.deliverylocation || row.deliveryaddress || '—'}
</Typography>
</Tooltip>
</Stack>
@@ -1973,7 +2072,7 @@ const Deliveries = () => {
minWidth: 75
}}
>
{row.kms || 0} km
{Number(row.kms || 0).toFixed(2)} km
</Box>
</Tooltip>
<Tooltip title="Actual KMS" placement="top">
@@ -1994,7 +2093,7 @@ const Deliveries = () => {
minWidth: 75
}}
>
{row.cumulativekms || 0} km
{Number(row.cumulativekms || 0).toFixed(2)} km
</Box>
</Tooltip>
</Stack>
@@ -2371,27 +2470,29 @@ const Deliveries = () => {
Change Rider
</MenuItem>
)}
{(roleid == 1 || roleid == 2) && (
<MenuItem
onClick={() => {
setKms(selectedRow.kms);
setCumulativeKms(selectedRow.cumulativekms);
setDeliverylat(selectedRow.droplat);
setDeliverylong(selectedRow.droplon);
setNotes(selectedRow.notes);
setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount);
setUpdateStatus(selectedRow.orderstatus || 'delivered');
setCurrentorder(selectedRow);
setDialogopen(true);
handleMenuClose();
}}
>
<AccentAvatar color="#10b981" size={22}>
<MdCheckCircle size={13} />
</AccentAvatar>
Update Status
</MenuItem>
)}
{/* Was gated to roleid 1/2 (staff-only), matching the reference app's
identical gate — opened to every login per explicit decision, so
tenant operators can reach the same Update Status fields staff
already could. */}
<MenuItem
onClick={() => {
setKms(selectedRow.kms);
setCumulativeKms(selectedRow.cumulativekms);
setDeliverylat(selectedRow.droplat);
setDeliverylong(selectedRow.droplon);
setNotes(selectedRow.notes);
setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount);
setUpdateStatus(selectedRow.orderstatus || 'delivered');
setCurrentorder(selectedRow);
setDialogopen(true);
handleMenuClose();
}}
>
<AccentAvatar color="#10b981" size={22}>
<MdCheckCircle size={13} />
</AccentAvatar>
Update Status
</MenuItem>
{selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && (
<MenuItem
sx={{ color: '#ef4444 !important' }}
@@ -2539,7 +2640,10 @@ const Deliveries = () => {
fullWidth
options={ridersList}
PaperComponent={SoftPaper}
getOptionLabel={(option) => `${option.displayname || option.authname || ''} (${option.contactno || ''})`}
getOptionLabel={(option) => {
const name = option?.displayname || option?.authname || '';
return option?.contactno ? `${name} (${option.contactno})` : name;
}}
onChange={(e, value) => {
setSelectedRider(value);
logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None');
@@ -2922,6 +3026,7 @@ const Deliveries = () => {
} else {
updateDeliveryMutation.mutate({
deliveryid: currentorder.deliveryid,
consignmentid: currentorder.consignmentid,
orderheaderid: currentorder.orderheaderid,
orderstatus: updateStatus,
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),

View File

@@ -58,6 +58,7 @@ import ProfitabilitySection from './ProfitabilitySection';
import ActiveSection from './ActiveSection';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
STATUS_STYLES,
getStatusStyle,
@@ -66,7 +67,10 @@ import {
STEP_PALETTE,
stepColor,
isActiveDelivery,
getActiveOrder
getActiveOrder,
haversineKm,
polylineLengthKm,
kalmanSmoothGps
} from './dispatchShared';
import CompareDataPanel from './CompareDataPanel';
import './Dispatch.css';
@@ -224,7 +228,12 @@ const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => {
const str = String(t).trim();
// Skip bare date strings — no time component, would always parse to midnight.
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
const d = dayjs(t);
// parseDoormileTimestamp strips a false trailing Z some Doormile timestamps
// carry (see utils/doormileTimestamp.js) — bare dayjs(t) would treat that as
// a real UTC instant and shift it +5:30 (IST), potentially bucketing a
// just-assigned row into the wrong slot or out of every slot entirely.
// deliveries.js's getRowBatchId uses the same parse so both pages agree.
const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null;
// Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which
// starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would
@@ -273,274 +282,9 @@ function MapAutoResize({ trigger }) {
return null;
}
// Haversine distance between two [lat, lng] points in kilometers. Good to
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
// polyline so the Compare delta panel can show "actual km" without depending
// on the backend's actualkms field (which can be stale or missing).
function haversineKm(a, b) {
const R = 6371; // km
const toRad = (d) => (d * Math.PI) / 180;
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
function polylineLengthKm(points) {
if (!Array.isArray(points) || points.length < 2) return 0;
let total = 0;
for (let i = 1; i < points.length; i++) {
total += haversineKm(points[i - 1], points[i]);
}
return total;
}
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
//
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
// velocity dynamics with random acceleration as process noise; measurement
// model H = [1, 0] (we measure position only).
//
// Pipeline:
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
// forward filter even with the in-loop Mahalanobis gate enabled.
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
// innovation exceeds the gate are not used to update; the prediction
// is kept as the posterior. Stores prior + posterior moments at each
// step so the backward pass can run.
// 3. Backward RTS smoother — refines every step using ALL future
// observations. Logs are fetched in one shot (not streamed) so we
// can afford the second pass; the accuracy lift is biggest near the
// start of the trail and through turns the forward pass under-corrects.
//
// Tuning (all in degrees² since pings are in lat/lng):
// processNoise (q) — random-acceleration variance (deg²/s²). Default
// tuned for urban two-wheelers (~1 m/s² accel).
// Lower = smoother but slower to follow sharp turns.
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
// which matches consumer GPS in open urban areas.
// Bump for dense canyons.
// outlierGate — Mahalanobis² threshold for in-loop rejection.
// 9.0 = 3σ (≈ 99.7% of inliers pass).
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
// 120 km/h covers any legal two-wheeler movement
// plus margin; anything above is GPS error.
function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return [];
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
const cleanedPings = pings.filter(p =>
Number.isFinite(p.lat) &&
Number.isFinite(p.lng) &&
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
);
if (cleanedPings.length === 0) return [];
if (cleanedPings.length === 1) {
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
}
const processNoise =
options.processNoise != null ? options.processNoise : 1e-10;
const measurementNoise =
options.measurementNoise != null ? options.measurementNoise : 2e-9;
const outlierGate =
options.outlierGate != null ? options.outlierGate : 9.0;
const maxSpeedKmh =
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
const tsOf = (p) =>
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
// 2. Scan forward to find the first valid starting anchor
let startIdx = 0;
while (startIdx < cleanedPings.length - 1) {
const p0 = cleanedPings[startIdx];
const p1 = cleanedPings[startIdx + 1];
const ts0 = tsOf(p0);
const ts1 = tsOf(p1) || ts0 + 1000;
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh <= maxSpeedKmh) {
break;
} else {
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
if (startIdx + 2 < cleanedPings.length) {
const p2 = cleanedPings[startIdx + 2];
const ts2 = tsOf(p2) || ts1 + 1000;
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
const speedKmh12 = (km12 / dtSec12) * 3600;
if (speedKmh12 <= maxSpeedKmh) {
startIdx = startIdx + 1;
continue;
}
}
startIdx++;
}
}
// 3. Teleport filter starting from the valid anchor
const accepted = [cleanedPings[startIdx]];
let lastTs = tsOf(cleanedPings[startIdx]);
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
const p = cleanedPings[i];
const ts = tsOf(p) || lastTs + 1000;
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
const prev = accepted[accepted.length - 1];
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh > maxSpeedKmh) continue;
accepted.push(p);
lastTs = ts;
}
if (accepted.length < 2) {
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
}
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
// positions parallel to `accepted`.
const smoothAxis = (axisKey) => {
const N = accepted.length;
// Per-step storage for the backward RTS pass.
const xPost = new Array(N); // [pos, vel] posterior after update
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
const xPrior = new Array(N); // predicted mean before update
const pPrior = new Array(N); // predicted cov before update
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
// Initial state: position = first measurement, velocity from the first
// two pings (better than 0 — keeps the start of the trail from lagging
// behind the rider's actual motion). Initial position covariance = r
// (we just measured it); initial velocity covariance is loose so it
// can be refined quickly.
const ts0 = tsOf(accepted[0]);
const ts1 = tsOf(accepted[1]);
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
xPost[0] = [accepted[0][axisKey], v0];
pPost[0] = [measurementNoise, 0, 0, 1];
xPrior[0] = xPost[0].slice();
pPrior[0] = pPost[0].slice();
dtArr[0] = 0;
let prevTs = ts0;
for (let i = 1; i < N; i++) {
const ts = tsOf(accepted[i]) || prevTs + 1000;
const dt = Math.max(0.1, (ts - prevTs) / 1000);
prevTs = ts;
dtArr[i] = dt;
// ─── Predict ───
// x' = F x where F = [[1, dt], [0, 1]]
const [xPrev, vPrev] = xPost[i - 1];
const xPredPos = xPrev + vPrev * dt;
const xPredVel = vPrev;
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
const dt2 = dt * dt;
const dt3 = dt2 * dt;
const dt4 = dt3 * dt;
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
const np11 = pp11 + dt2 * processNoise;
xPrior[i] = [xPredPos, xPredVel];
pPrior[i] = [np00, np01, np10, np11];
// ─── Update (with Mahalanobis gating) ───
// y = z − Hx' (innovation)
// S = H P' H^T + R (innovation covariance)
// Reject the measurement if mahal² = y²/S exceeds the gate. The
// prediction then carries forward as the posterior — the trail stays
// continuous instead of being yanked toward a bad fix.
const z = accepted[i][axisKey];
const y = z - xPredPos;
const S = np00 + measurementNoise;
const mahal2 = (y * y) / S;
if (mahal2 > outlierGate) {
xPost[i] = [xPredPos, xPredVel];
pPost[i] = [np00, np01, np10, np11];
continue;
}
// K = P' H^T / S
const K0 = np00 / S;
const K1 = np10 / S;
// x = x' + K y
const newPos = xPredPos + K0 * y;
const newVel = xPredVel + K1 * y;
// P = (I − K H) P'
xPost[i] = [newPos, newVel];
pPost[i] = [
(1 - K0) * np00,
(1 - K0) * np01,
np10 - K1 * np00,
np11 - K1 * np01
];
}
// ─── Backward RTS smoother ─────────────────────────────────────────
// x_smooth[N-1] = x_post[N-1]
// For i = N-2 … 0:
// C = P_post[i] · F^T · inv(P_prior[i+1])
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
// P_post · F^T = [[p00 + dt·p01, p01],
// [p10 + dt·p11, p11]]
const xSmooth = new Array(N);
xSmooth[N - 1] = xPost[N - 1].slice();
for (let i = N - 2; i >= 0; i--) {
const dt = dtArr[i + 1];
const [pp00, pp01, pp10, pp11] = pPost[i];
const a = pp00 + dt * pp01;
const b = pp01;
const c = pp10 + dt * pp11;
const d = pp11;
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
const [q00, q01, q10, q11] = pPrior[i + 1];
const det = q00 * q11 - q01 * q10;
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
xSmooth[i] = xPost[i].slice();
continue;
}
const inv00 = q11 / det;
const inv01 = -q01 / det;
const inv10 = -q10 / det;
const inv11 = q00 / det;
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
const c00 = a * inv00 + b * inv10;
const c01 = a * inv01 + b * inv11;
const c10 = c * inv00 + d * inv10;
const c11 = c * inv01 + d * inv11;
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
xSmooth[i] = [
xPost[i][0] + c00 * dxPos + c01 * dxVel,
xPost[i][1] + c10 * dxPos + c11 * dxVel
];
}
return xSmooth.map((s) => s[0]);
};
const lats = smoothAxis('lat');
const lngs = smoothAxis('lng');
return accepted.map((p, i) => ({
lat: lats[i],
lng: lngs[i],
logdate: p.logdate,
_ts: p._ts
}));
}
// haversineKm/polylineLengthKm/kalmanSmoothGps moved to dispatchShared.js so
// deliveries.js's Update Status dialog can compute the same real, GPS-based
// Actual KMs figure instead of leaving that field permanently blank.
// Splits a routed OSRM polyline into per-step segments by finding the
// polyline index closest to each drop waypoint. Returns an array of
@@ -594,7 +338,7 @@ const formatTimeOnly = (t) => {
if (!t) return null;
const d = dayjs(t);
if (!d.isValid()) return String(t);
return d.format('HH:mm:ss');
return d.format('hh:mm A');
};
// Stages the popup walks through, top → bottom, in real-world delivery order.
@@ -981,14 +725,6 @@ const ANALYSIS_BATCH_WINDOWS = [
{ key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' }
];
// Tolerant field-name lookup so the Analysis card still renders cleanly even
// if the API response uses slightly different keys than expected.
const analysisPick = (obj, keys) => {
for (const k of keys) {
if (obj && obj[k] != null && obj[k] !== '') return obj[k];
}
return null;
};
const analysisFormatNum = (v) => {
if (v == null) return '—';
if (typeof v === 'number') return v.toLocaleString('en-IN');
@@ -996,14 +732,6 @@ const analysisFormatNum = (v) => {
if (Number.isFinite(n)) return n.toLocaleString('en-IN');
return String(v);
};
const analysisFormatKm = (v) => (v == null ? '—' : `${parseFloat(v).toFixed(1)} km`);
const analysisFormatRupees = (v) => (v == null ? '—' : `₹${parseFloat(v).toFixed(0)}`);
const analysisFormatPct = (v) => {
if (v == null) return '—';
const n = parseFloat(v);
if (!Number.isFinite(n)) return '—';
return `${n > 1 ? n.toFixed(1) : (n * 100).toFixed(1)}%`;
};
// Parse "HH:mm:ss" or "HH:mm" → seconds since midnight. Returns null when the
// string is missing or malformed. Used to compute gantt percentages for the
// rider timelines on the Analysis page — the API ships those fields as bare
@@ -3507,13 +3235,6 @@ const Dispatch = ({
return routes;
};
const toggleRider = (rid) => {
const newActive = new Set(activeRiders);
if (newActive.has(rid)) newActive.delete(rid);
else newActive.add(rid);
setActiveRiders(newActive);
};
return (
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
{!embedded && (
@@ -4617,7 +4338,7 @@ const Dispatch = ({
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
@@ -4766,7 +4487,7 @@ const Dispatch = ({
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
@@ -4913,7 +4634,7 @@ const Dispatch = ({
<div className="zone-order-stats">
<span className="zone-order-chip" title="Distance">
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
</span>
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
@@ -5120,6 +4841,14 @@ const Dispatch = ({
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" />
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
{compareOpen && (
<CompareMapClickUnpin
onUnpin={() => {
pinnedPopupsRef.current.clear();
setCenterPopupOrder(null);
}}
/>
)}
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
{kitchens

View File

@@ -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

View File

@@ -85,3 +85,274 @@ export const getActiveOrder = (orders) => {
});
return sorted.find(isActiveDelivery) || null;
};
// Haversine distance between two [lat, lng] points in kilometers. Good to
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
// polyline so the Compare delta panel can show "actual km" without depending
// on the backend's actualkms field (which can be stale or missing). Also
// reused by deliveries.js's Update Status dialog to compute a real Actual
// KMs figure from GET /admin/consignments/:id/logs — see polylineLengthKm.
export function haversineKm(a, b) {
const R = 6371; // km
const toRad = (d) => (d * Math.PI) / 180;
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
export function polylineLengthKm(points) {
if (!Array.isArray(points) || points.length < 2) return 0;
let total = 0;
for (let i = 1; i < points.length; i++) {
total += haversineKm(points[i - 1], points[i]);
}
return total;
}
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
//
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
// velocity dynamics with random acceleration as process noise; measurement
// model H = [1, 0] (we measure position only).
//
// Pipeline:
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
// forward filter even with the in-loop Mahalanobis gate enabled.
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
// innovation exceeds the gate are not used to update; the prediction
// is kept as the posterior. Stores prior + posterior moments at each
// step so the backward pass can run.
// 3. Backward RTS smoother — refines every step using ALL future
// observations. Logs are fetched in one shot (not streamed) so we
// can afford the second pass; the accuracy lift is biggest near the
// start of the trail and through turns the forward pass under-corrects.
//
// Tuning (all in degrees² since pings are in lat/lng):
// processNoise (q) — random-acceleration variance (deg²/s²). Default
// tuned for urban two-wheelers (~1 m/s² accel).
// Lower = smoother but slower to follow sharp turns.
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
// which matches consumer GPS in open urban areas.
// Bump for dense canyons.
// outlierGate — Mahalanobis² threshold for in-loop rejection.
// 9.0 = 3σ (≈ 99.7% of inliers pass).
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
// 120 km/h covers any legal two-wheeler movement
// plus margin; anything above is GPS error.
export function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return [];
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
const cleanedPings = pings.filter(p =>
Number.isFinite(p.lat) &&
Number.isFinite(p.lng) &&
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
);
if (cleanedPings.length === 0) return [];
if (cleanedPings.length === 1) {
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
}
const processNoise =
options.processNoise != null ? options.processNoise : 1e-10;
const measurementNoise =
options.measurementNoise != null ? options.measurementNoise : 2e-9;
const outlierGate =
options.outlierGate != null ? options.outlierGate : 9.0;
const maxSpeedKmh =
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
const tsOf = (p) =>
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
// 2. Scan forward to find the first valid starting anchor
let startIdx = 0;
while (startIdx < cleanedPings.length - 1) {
const p0 = cleanedPings[startIdx];
const p1 = cleanedPings[startIdx + 1];
const ts0 = tsOf(p0);
const ts1 = tsOf(p1) || ts0 + 1000;
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh <= maxSpeedKmh) {
break;
} else {
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
if (startIdx + 2 < cleanedPings.length) {
const p2 = cleanedPings[startIdx + 2];
const ts2 = tsOf(p2) || ts1 + 1000;
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
const speedKmh12 = (km12 / dtSec12) * 3600;
if (speedKmh12 <= maxSpeedKmh) {
startIdx = startIdx + 1;
continue;
}
}
startIdx++;
}
}
// 3. Teleport filter starting from the valid anchor
const accepted = [cleanedPings[startIdx]];
let lastTs = tsOf(cleanedPings[startIdx]);
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
const p = cleanedPings[i];
const ts = tsOf(p) || lastTs + 1000;
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
const prev = accepted[accepted.length - 1];
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh > maxSpeedKmh) continue;
accepted.push(p);
lastTs = ts;
}
if (accepted.length < 2) {
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
}
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
// positions parallel to `accepted`.
const smoothAxis = (axisKey) => {
const N = accepted.length;
// Per-step storage for the backward RTS pass.
const xPost = new Array(N); // [pos, vel] posterior after update
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
const xPrior = new Array(N); // predicted mean before update
const pPrior = new Array(N); // predicted cov before update
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
// Initial state: position = first measurement, velocity from the first
// two pings (better than 0 — keeps the start of the trail from lagging
// behind the rider's actual motion). Initial position covariance = r
// (we just measured it); initial velocity covariance is loose so it
// can be refined quickly.
const ts0 = tsOf(accepted[0]);
const ts1 = tsOf(accepted[1]);
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
xPost[0] = [accepted[0][axisKey], v0];
pPost[0] = [measurementNoise, 0, 0, 1];
xPrior[0] = xPost[0].slice();
pPrior[0] = pPost[0].slice();
dtArr[0] = 0;
let prevTs = ts0;
for (let i = 1; i < N; i++) {
const ts = tsOf(accepted[i]) || prevTs + 1000;
const dt = Math.max(0.1, (ts - prevTs) / 1000);
prevTs = ts;
dtArr[i] = dt;
// ─── Predict ───
// x' = F x where F = [[1, dt], [0, 1]]
const [xPrev, vPrev] = xPost[i - 1];
const xPredPos = xPrev + vPrev * dt;
const xPredVel = vPrev;
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
const dt2 = dt * dt;
const dt3 = dt2 * dt;
const dt4 = dt3 * dt;
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
const np11 = pp11 + dt2 * processNoise;
xPrior[i] = [xPredPos, xPredVel];
pPrior[i] = [np00, np01, np10, np11];
// ─── Update (with Mahalanobis gating) ───
// y = z − Hx' (innovation)
// S = H P' H^T + R (innovation covariance)
// Reject the measurement if mahal² = y²/S exceeds the gate. The
// prediction then carries forward as the posterior — the trail stays
// continuous instead of being yanked toward a bad fix.
const z = accepted[i][axisKey];
const y = z - xPredPos;
const S = np00 + measurementNoise;
const mahal2 = (y * y) / S;
if (mahal2 > outlierGate) {
xPost[i] = [xPredPos, xPredVel];
pPost[i] = [np00, np01, np10, np11];
continue;
}
// K = P' H^T / S
const K0 = np00 / S;
const K1 = np10 / S;
// x = x' + K y
const newPos = xPredPos + K0 * y;
const newVel = xPredVel + K1 * y;
// P = (I − K H) P'
xPost[i] = [newPos, newVel];
pPost[i] = [
(1 - K0) * np00,
(1 - K0) * np01,
np10 - K1 * np00,
np11 - K1 * np01
];
}
// ─── Backward RTS smoother ─────────────────────────────────────────
// x_smooth[N-1] = x_post[N-1]
// For i = N-2 … 0:
// C = P_post[i] · F^T · inv(P_prior[i+1])
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
// P_post · F^T = [[p00 + dt·p01, p01],
// [p10 + dt·p11, p11]]
const xSmooth = new Array(N);
xSmooth[N - 1] = xPost[N - 1].slice();
for (let i = N - 2; i >= 0; i--) {
const dt = dtArr[i + 1];
const [pp00, pp01, pp10, pp11] = pPost[i];
const a = pp00 + dt * pp01;
const b = pp01;
const c = pp10 + dt * pp11;
const d = pp11;
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
const [q00, q01, q10, q11] = pPrior[i + 1];
const det = q00 * q11 - q01 * q10;
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
xSmooth[i] = xPost[i].slice();
continue;
}
const inv00 = q11 / det;
const inv01 = -q01 / det;
const inv10 = -q10 / det;
const inv11 = q00 / det;
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
const c00 = a * inv00 + b * inv10;
const c01 = a * inv01 + b * inv11;
const c10 = c * inv00 + d * inv10;
const c11 = c * inv01 + d * inv11;
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
xSmooth[i] = [
xPost[i][0] + c00 * dxPos + c01 * dxVel,
xPost[i][1] + c10 * dxPos + c11 * dxVel
];
}
return xSmooth.map((s) => s[0]);
};
const lats = smoothAxis('lat');
const lngs = smoothAxis('lng');
return accepted.map((p, i) => ({
lat: lats[i],
lng: lngs[i],
logdate: p.logdate,
_ts: p._ts
}));
}

View File

@@ -167,7 +167,7 @@ const MobileOrdersList = ({ list, aiMode }) => {
<MobileField label="KMS">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top">
<Chip size="small" label={`${val.kms} km`} color="error" />
<Chip size="small" label={`${Number(val.kms || 0).toFixed(2)} km`} color="error" />
</Tooltip>
<Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
@@ -559,7 +559,7 @@ const OrdersPreview = () => {
<TableCell align="center">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top">
<Chip size="small" label={`${val.kms} km`} color="error" />
<Chip size="small" label={`${Number(val.kms || 0).toFixed(2)} km`} color="error" />
</Tooltip>
<Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
@@ -668,7 +668,7 @@ const OrdersPreview = () => {
<TableCell align="center">
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
<Tooltip title="KMS" placement="top">
<Chip size="small" label={`${val.kms} km`} color="error" />
<Chip size="small" label={`${Number(val.kms || 0).toFixed(2)} km`} color="error" />
</Tooltip>
<Tooltip title="Cumulative Kms" placement="right">
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />

View File

@@ -2,6 +2,7 @@
import * as React from 'react';
import { enqueueSnackbar } from 'notistack';
import { useState, useEffect, useMemo, useCallback, Fragment, useRef } from 'react';
import logger from '../../../utils/logger';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
@@ -53,7 +54,8 @@ import {
MdNotes,
MdMyLocation,
MdOutlineFlag,
MdPerson
MdPerson,
MdFlashOn
} from 'react-icons/md';
import { DeleteOutlined } from '@ant-design/icons';
import Loader from 'components/Loader';
@@ -62,8 +64,9 @@ import CircularLoader from 'components/CircularLoader';
import AiImage from '../../../assets/images/aiImage.png';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate, useLocation } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders } from '../../api/api';
import { getBookings, cancelBooking, getAdminCustomers, getBookingTrack } from 'pages/api/doormileApi';
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
import { getBookings, cancelBooking, getAdminCustomers, getBookingTrack, batchAssignBookings, getMilers } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
// ============================================================================
// Design tokens — shared with the rest of the redesigned operator pages.
@@ -97,13 +100,17 @@ const BRAND = '#C01227';
const BRAND_LIGHT = '#D25463';
// Semantic per-row status palette, keyed by GET /admin/bookings' real `status`
// enum (lowercased — StatusBadge lowercases before lookup). Only
// pending_pickup and converted_to_consignment have been observed against the
// live API so far; the rest are reasonable guesses at likely sibling states
// with a safe unknown-status fallback in StatusBadge below.
// enum (lowercased — StatusBadge lowercases before lookup). pending_pickup,
// miler_assigned, pickup_scheduled, converted_to_consignment and cancelled are
// now confirmed live (see the ORDERS_STATUS_TABS comment above — a real
// booking status breakdown logged from an actual assign-miler call); the rest
// remain reasonable guesses at likely sibling states with a safe
// unknown-status fallback in StatusBadge below.
const ROW_STATUS_META = {
pending_pickup: { label: 'Pending Pickup', color: '#f59e0b', icon: MdHourglassEmpty },
pending_assignment: { label: 'Pending Assignment', color: '#f59e0b', icon: MdHourglassEmpty },
miler_assigned: { label: 'Rider Assigned', color: '#6366f1', icon: MdCheckCircle },
pickup_scheduled: { label: 'Pickup Scheduled', color: '#6366f1', icon: MdCheckCircle },
converted_to_consignment: { label: 'Converted', color: '#6366f1', icon: MdCheckCircle },
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
@@ -115,11 +122,42 @@ const ROW_STATUS_META = {
// below. `status` values here are the raw, lowercased GET /admin/bookings
// enum (matches ROW_STATUS_META's keys), not api.js's separate generic
// pending/accepted/delivered mapping used by the Deliveries page.
//
// The "Assigned" tab originally matched only `converted_to_consignment` — the
// ONLY other status this file's author had actually observed live at the
// time (see ROW_STATUS_META's comment). Confirmed live now (booking status
// breakdown logged from a real assign-miler call): a booking moves
// pending_pickup -> miler_assigned immediately on assignment, not straight to
// converted_to_consignment (that only happens later, when the rider marks
// pickup-complete). `miler_assigned` matched nothing in this tab list, so an
// assigned-but-not-yet-picked-up order vanished from every tab.
//
// Deliberately DIFFERENT rule from api.js's BOOKING_STATUS_TO_DELIVERY_STATUS
// (Deliveries page): this Orders page tracks the OPERATOR's own workflow —
// "Assigned" means the operator has picked a rider for it, full stop, the
// moment assign-miler / batch-assign succeeds. The Deliveries page instead
// tracks the RIDER's engagement (its "Accepted" tab specifically means the
// rider accepted, not just that one was assigned) — a deliberately later,
// narrower bar, which is why that page keeps miler_assigned on 'pending'
// while THIS page moves the same status straight to "Assigned". Don't
// "fix" this to match Deliveries again — it was tried and reverted per
// explicit product direction: on Orders, Assigned = operator action; on
// Deliveries, Accepted = rider action. `statuses` (plural) is the actual
// match set; each tab's singular `status` stays the tab's
// primary/representative value for `currentStatus`, the React key, and the
// few `currentStatus === 'pending_pickup'` checks elsewhere in this file.
const ORDERS_STATUS_TABS = [
{ idx: 0, status: 'pending_pickup', label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
{ idx: 1, status: 'converted_to_consignment', label: 'Assigned', color: '#6366f1', icon: MdCheckCircle },
{ idx: 2, status: 'delivered', label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
{ idx: 3, status: 'cancelled', label: 'Cancelled', color: '#ef4444', icon: MdCancel }
{ idx: 0, status: 'pending_pickup', statuses: ['pending_pickup'], label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
{
idx: 1,
status: 'converted_to_consignment',
statuses: ['converted_to_consignment', 'miler_assigned', 'pickup_scheduled'],
label: 'Assigned',
color: '#6366f1',
icon: MdCheckCircle
},
{ idx: 2, status: 'delivered', statuses: ['delivered'], label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
{ idx: 3, status: 'cancelled', statuses: ['cancelled'], label: 'Cancelled', color: '#ef4444', icon: MdCancel }
];
// Haversine straight-line distance in km — GET /admin/bookings has no `kms`
@@ -360,10 +398,30 @@ const Orders = () => {
return counts;
}, [allBookings]);
// Diagnostic: a newly-created booking not showing on the Pending tab could
// mean either (a) Doormile's background auto-assignment (see
// express-console-api.md — fires after commit, 5 retries 2 min apart) has
// already moved it out of pending_pickup by the time the operator looks,
// which is expected behavior, not a bug, or (b) a brand-new booking's
// actual status string doesn't match any of the tabs at all (an unmapped
// value BOOKING_STATUS_TO_DELIVERY_STATUS in api.js has never observed).
// This makes that distinguishable from the console instead of guessing.
useEffect(() => {
logger.debug('orders.js: booking status breakdown', statusCounts);
}, [statusCounts]);
// The active tab's full status match set (see ORDERS_STATUS_TABS' comment) —
// falls back to [currentStatus] so this still works if currentStatus is
// ever set to something outside the tab list.
const activeTabStatuses = useMemo(
() => ORDERS_STATUS_TABS.find((t) => t.status === currentStatus)?.statuses || [currentStatus],
[currentStatus]
);
const filteredRows = useMemo(() => {
const q = debouncedSearch.trim().toLowerCase();
return allBookings.filter((b) => {
if (String(b.status || '').toLowerCase() !== currentStatus) return false;
if (!activeTabStatuses.includes(String(b.status || '').toLowerCase())) return false;
if (!q) return true;
return (
b.bookingno?.toLowerCase().includes(q) ||
@@ -372,7 +430,7 @@ const Orders = () => {
b.notes?.toLowerCase().includes(q)
);
});
}, [allBookings, currentStatus, debouncedSearch]);
}, [allBookings, activeTabStatuses, debouncedSearch]);
// "Load more" now reveals more of the already-fetched, already-filtered
// set instead of requesting another server page (the bulk fetch above
@@ -473,9 +531,12 @@ const Orders = () => {
return {
...val,
deliveryid: 0,
deliverydate: dayjs(val.serviceoptions?.[0]?.estimateddeliveryat || val.createdat)
.utc()
.format('YYYY-MM-DD HH:mm:ss'),
// Doormile timestamps are naive IST wall-clock (express-console-api.md) —
// .utc() was reinterpreting that as local time and shifting it back
// 5:30, sending the solver a wrong delivery deadline. Parse bare,
// matching assigntime right below (and deliveries.js's own fix for
// the same bug class in its date-display cells).
deliverydate: dayjs(val.serviceoptions?.[0]?.estimateddeliveryat || val.createdat).format('YYYY-MM-DD HH:mm:ss'),
assigntime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
orderstatus: 'pending',
orderamount: charge,
@@ -498,6 +559,70 @@ const Orders = () => {
});
};
// Doormile-native alternative to the external solver above — assigns real
// milers directly via POST /hub/bookings/batch-assign (Redis GEO + AI
// scoring), no external solver rider pool involved, so there's no chance of
// the stale-id mismatch that made the "unverified rider" flow on
// /dispatch/preview necessary for the solver path. Commits server-side in
// this one call — no preview/reconcile step exists for this path because
// there is nothing to reconcile. Sequencing (/optimization/doormile/sequence)
// isn't deployed yet, so riders with more than one stop come back
// unsequenced (step: 0) — an accepted, visible trade-off for this opt-in
// "Quick Assign" button; operators who need solver-quality sequencing keep
// using "Assign Orders" above. See doormile-flow.md and the plan this was
// built from.
const quickAssignMutation = useMutation({
mutationFn: () => batchAssignBookings(selectedOrders.map((o) => o.bookingid)),
onSuccess: async (data) => {
const results = data?.results || data?.data?.results || [];
const assignedCount = data?.assigned ?? data?.data?.assigned ?? results.filter((r) => r.assigned).length;
const skipped = results.filter((r) => !r.assigned);
enqueueSnackbar(`Quick Assign: ${assignedCount} assigned${skipped.length ? `, ${skipped.length} skipped` : ''}`, {
variant: skipped.length ? 'warning' : 'success',
autoHideDuration: 3000,
anchorOrigin: { vertical: 'top', horizontal: 'right' }
});
if (skipped.length) {
logger.debug(
'quickAssignMutation: skipped bookings',
skipped.map((r) => r.bookingid)
);
}
// batch-assign returns mileruserid per result — resolve to
// milerprofileid (what notifyRider/notify actually needs) the same way
// finalCreatedeliveries does, then notify each assigned rider.
try {
const milers = (await getMilers()) || [];
const lookup = buildMilerLookup(milers);
const notifiedProfileIds = new Set();
results
.filter((r) => r.assigned)
.forEach((r) => {
const rider = lookup.byUserId.get(String(r.mileruserid));
if (rider?.milerprofileid && !notifiedProfileIds.has(rider.milerprofileid)) {
notifiedProfileIds.add(rider.milerprofileid);
notifyRider(rider.milerprofileid).catch((err) => logger.error('quickAssignMutation: notifyRider failed', err.message));
}
});
} catch (err) {
logger.error('quickAssignMutation: rider notify resolution failed', err.message);
}
refetchOrders();
setSelectedOrders([]);
},
onError: (error) => {
enqueueSnackbar(error.message, { variant: 'error', autoHideDuration: 4000 });
}
});
const handleQuickAssign = () => {
if (selectedOrders.length === 0) return;
quickAssignMutation.mutate();
};
// KPI tile definitions.
const kpiCards = [
{
@@ -561,6 +686,39 @@ const Orders = () => {
</Button>
)}
{selectedOrders.length > 0 && currentStatus === 'pending_pickup' && (
<Tooltip title="Assigns real Doormile riders directly (no external solver). Stops aren't sequenced yet — best for single-stop or same-hub batches.">
<Button
variant="contained"
onClick={handleQuickAssign}
disabled={quickAssignMutation.isPending}
sx={{
position: 'fixed',
bottom: 32,
right: 24 + 200,
bgcolor: BRAND_LIGHT,
color: '#fff',
px: 3,
py: 1.5,
borderRadius: 999,
fontWeight: 850,
boxShadow: `0 10px 30px ${ring(BRAND_LIGHT)}`,
zIndex: 1000,
textTransform: 'none',
fontSize: '14px',
gap: 1,
'&:hover': {
bgcolor: BRAND,
transform: 'translateY(-2px)'
}
}}
startIcon={<MdFlashOn size={18} />}
>
Quick Assign
</Button>
</Tooltip>
)}
{/* ============================================= || Header (compact) || ============================================= */}
<Paper
elevation={0}
@@ -658,7 +816,7 @@ const Orders = () => {
{ORDERS_STATUS_TABS.map((t) => {
const Icon = t.icon;
const active = tabvalue === t.idx;
const count = statusCounts[t.status] ?? 0;
const count = t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0);
return (
<Box
key={t.status}
@@ -903,7 +1061,7 @@ const Orders = () => {
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.125 }}>
<MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
{(() => {
const dateObj = dayjs(row.createdat);
const dateObj = parseDoormileTimestamp(row.createdat);
return (
<>
<Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
@@ -1012,28 +1170,6 @@ const Orders = () => {
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Track">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setTrackBookingId(row.bookingid);
}}
sx={{
bgcolor: tint('#0ea5e9'),
border: `1px solid ${edge('#0ea5e9')}`,
color: '#0ea5e9',
borderRadius: 999,
p: 0.75,
'&:hover': {
bgcolor: soft('#0ea5e9'),
borderColor: '#0ea5e9'
}
}}
>
<MdMyLocation size={14} />
</IconButton>
</Tooltip>
{currentStatus === 'pending_pickup' && isCancellable && (
<Tooltip title="Cancel Order">
<IconButton

View File

@@ -673,7 +673,7 @@ export default function OrdersDetails() {
<Grid item xs={12} sm={6} md={3}>
<Autocomplete
options={locationlist || []}
getOptionLabel={(option) => (option ? `${option.locationname} (${option.suburb})` : '')}
getOptionLabel={(option) => (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')}
value={locationValue}
PaperComponent={SoftPaper}
onOpen={(event) => {
@@ -725,7 +725,10 @@ export default function OrdersDetails() {
<Autocomplete
options={ridersList || []}
value={riderValue}
getOptionLabel={(option) => `${option.firstname} ${option.lastname}`}
getOptionLabel={(option) => {
const name = option?.displayname || option?.authname || '';
return option?.contactno ? `${name} (${option.contactno})` : name;
}}
PaperComponent={SoftPaper}
onOpen={() => {
if (!appId) {