updates on the fix
This commit is contained in:
240
doormile-flow.md
Normal file
240
doormile-flow.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Doormile — order to delivery
|
||||
|
||||
Every endpoint in the DailyGrubs path, the payload that goes in, and what
|
||||
actually changes when it does. Base URL `https://api.doormile.com/api/v1`.
|
||||
|
||||
Payload shapes are taken from the request structs in this repo, not from
|
||||
documentation — where the two disagree, the code is right. Where a field is
|
||||
optional it says so.
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
Two separate logins.
|
||||
|
||||
**Console.** The token carries `tenantid`, which is what scopes a client to
|
||||
their own data. A client passing another tenant's `?tenantid=` gets 403; reading
|
||||
another tenant's resource by id gets 404, so ids aren't probeable.
|
||||
|
||||
```
|
||||
POST /admin/login
|
||||
{ "email": "info@dailygrubs.com", "password": "admin" }
|
||||
|
||||
→ { "token": "eyJ…", "user": { "id": 43, "tenantid": 13 } }
|
||||
```
|
||||
|
||||
**Rider.** Two calls, and `configid` must be **1001** on both — a Doormile login
|
||||
partition with no jupiter equivalent. Omitting it is the most common reason a
|
||||
rider looks like they don't exist.
|
||||
|
||||
```
|
||||
POST /miler/login
|
||||
{ "phone": "9787698259", "configid": 1001 }
|
||||
|
||||
POST /miler/verify-pin
|
||||
{ "phone": "9787698259", "pin": "1234", "configid": 1001, "device_token": "fcm-…" }
|
||||
|
||||
→ { "token": "eyJ…" }
|
||||
```
|
||||
|
||||
`POST /miler/reset-pin` is **admin-only** despite sitting under `/miler`. A phone
|
||||
number is the login identifier, not a secret — left open, reset-then-verify takes
|
||||
over any rider account in two calls. The rider app must not call it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create a booking
|
||||
|
||||
```
|
||||
POST /admin/expressbooking
|
||||
{
|
||||
"tenantid": 13,
|
||||
"tenantlocationid": 20, // the kitchen. optional — inferred if omitted
|
||||
"customer_phone": "9876500011",
|
||||
"customer_name": "Priya R",
|
||||
"pickupaddress": "DailyGrubs RS Puram Kitchen",
|
||||
"pickuppincode": "641002",
|
||||
"pickuplatitude": 11.004500, "pickuplongitude": 76.961200,
|
||||
"deliveryaddress": "12 Bharathi Rd, Peelamedu",
|
||||
"deliverypincode": "641004",
|
||||
"deliverylatitude": 11.051000, "deliverylongitude": 76.930000,
|
||||
"service_option": "Fast",
|
||||
"finalprice": 65,
|
||||
"parcels": [ { "itemcategory": "Food", "weight": 1.2 } ]
|
||||
}
|
||||
|
||||
→ { "bookingid": 118, "bookingno": "DM-BK-…", "status": "Created" }
|
||||
```
|
||||
|
||||
Writes a `pickupbookings` row plus its parcels and price.
|
||||
|
||||
**On `tenantlocationid`.** This is the client's own site — the kitchen. Omit it
|
||||
and it's resolved from the pickup coordinates: nearest stored site within 150m,
|
||||
falling back to an address match, nil when unsure. It is what per-kitchen
|
||||
reporting groups by.
|
||||
|
||||
`pickuplocationid` is accepted only as a legacy alias and is never stored as
|
||||
given — the column of that name foreign-keys to `appcustomerlocations` (a B2C
|
||||
customer's saved address), so writing a client site id into it fails the insert.
|
||||
|
||||
**CityGate.** The pickup pincode prefix must be an open city: `641` Coimbatore,
|
||||
`600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil. Anything else
|
||||
is refused at creation. jupiter had no such gate.
|
||||
|
||||
## 2. Or create them in bulk
|
||||
|
||||
```
|
||||
POST /admin/expressbooking/bulk
|
||||
{ "bookings": [ { …same shape… }, { … } ] } // max 200
|
||||
|
||||
→ { "results": [
|
||||
{ "index": 0, "success": true, "bookingid": 119, "bookingno": "DM-BK-…" },
|
||||
{ "index": 1, "success": false, "error": "pickup pincode not serviceable" }
|
||||
] }
|
||||
```
|
||||
|
||||
Per-row results, never all-or-nothing — one bad address doesn't lose the other
|
||||
199. Each booking is its own transaction. A client login cannot use bulk to
|
||||
smuggle in another tenant's id; `tenantid` is pinned to the caller's own.
|
||||
|
||||
## 3. A rider is found — automatic
|
||||
|
||||
No call needed. Creation publishes `booking.assignment_requested` to JetStream
|
||||
after the transaction commits. A worker searches riders within 10km via Redis
|
||||
GEO, scores them through the AI layer, and commits the assignment. If nobody is
|
||||
available it retries **5 times, 2 minutes apart**, then publishes
|
||||
`booking.assignment_failed` for the dispatch agent.
|
||||
|
||||
The retry state lives in NATS, not in process memory, so a pod restart no longer
|
||||
loses a booking mid-wait.
|
||||
|
||||
To assign by hand instead:
|
||||
|
||||
```
|
||||
POST /admin/bookings/:id/assign-miler
|
||||
{ "mileruserid": 38 }
|
||||
```
|
||||
|
||||
## 4. Clear a hub queue, and order the stops
|
||||
|
||||
```
|
||||
POST /hub/bookings/batch-assign
|
||||
{ "bookingids": [118, 119, 120], "max_per_rider": 5 }
|
||||
|
||||
→ { "assigned": 3, "skipped": 0, "riderssequenced": 1,
|
||||
"results": [ { "bookingid": 118, "assigned": true,
|
||||
"mileruserid": 38, "distance_km": 1.4 } ] }
|
||||
```
|
||||
|
||||
**This is the only place stops get ordered.** After assigning, each affected
|
||||
rider's whole active set is sent to the Route Optimization API
|
||||
(`POST /api/v1/optimization/doormile/sequence` on `routes.workolik.com`), which
|
||||
returns a road-network sequence via Valhalla — not straight-line distance. The
|
||||
step, per-leg distance, cumulative distance and ETA are written onto
|
||||
`bookingassignments`.
|
||||
|
||||
**Assignment picks _who_; sequencing picks _what order_.** They are separate,
|
||||
and sequencing can only run once a rider is known — which is why bulk *creation*
|
||||
cannot sequence anything, however many bookings you send. jupiter got away with
|
||||
sequencing inside `createdeliveries` because that payload was already one
|
||||
rider's run; Doormile's bulk endpoint is up to 200 bookings across many riders.
|
||||
|
||||
Sequencing is best-effort and runs after the assignments commit: the optimizer
|
||||
is a separate service over the network, and it being down must leave bookings
|
||||
assigned but unordered, never undo the batch.
|
||||
|
||||
## 5. The rider runs the route
|
||||
|
||||
```
|
||||
GET /miler/assignments
|
||||
|
||||
→ [ { "bookingassignmentid": 555, "bookingid": 120,
|
||||
"step": 1, "previouskms": 4.0, "cumulativekms": 4.0,
|
||||
"etaminutes": 14, "cumulativeeta": 14 }, … ]
|
||||
```
|
||||
|
||||
Returned in **step order**. `step: 0` means *not sequenced* — never *first* — and
|
||||
sorts to the end. A rider with one stop is never sequenced, so 0 is common.
|
||||
|
||||
Then, per booking:
|
||||
|
||||
```
|
||||
POST /miler/assignments/:id/accept
|
||||
POST /miler/bookings/:bookingid/reached
|
||||
|
||||
POST /miler/bookings/:bookingid/parcel
|
||||
{ "parcels": [ { "weight": 1.4, "length": 20, "width": 15, "height": 10 } ] }
|
||||
|
||||
POST /miler/bookings/:bookingid/payment
|
||||
{ "amount": 65, "paymentmode": "Cash", "transactionref": "" }
|
||||
|
||||
POST /miler/bookings/:bookingid/pickup-complete
|
||||
```
|
||||
|
||||
`pickup-complete` is the pivot the old system had no concept of. It converts the
|
||||
booking into a **consignment**, recomputes chargeable weight from the dimensions
|
||||
the rider actually measured, carries the kitchen across, and decides routing —
|
||||
matching 3-digit pincode prefixes go straight to `Out_for_Delivery` (hyperlocal),
|
||||
everything else routes via a hub.
|
||||
|
||||
Other rider actions: `vehicle-required` (needs a bigger vehicle), `cancel`
|
||||
(before pickup).
|
||||
|
||||
## 6. Deliver
|
||||
|
||||
```
|
||||
POST /miler/consignments/:id/deliver
|
||||
{ "deliveredtoname": "Priya R",
|
||||
"photourl": "https://…", "receiversignatureurl": "https://…",
|
||||
"lat": 11.051, "lon": 76.93,
|
||||
"otp": "418317" } // only when the tenant requires it
|
||||
```
|
||||
|
||||
Marks the consignment delivered and writes a history row. **Delivery OTP is
|
||||
opt-in per tenant** (`Tenant.Requiredeliveryotp`, default off — off for
|
||||
DailyGrubs). When on it is verified server-side and never serialised outward:
|
||||
returning it would hand the rider the code they are meant to be told.
|
||||
|
||||
Couldn't deliver? `POST /miler/consignments/:id/skip` increments `attemptcount`
|
||||
rather than failing the parcel.
|
||||
|
||||
---
|
||||
|
||||
## Watching it
|
||||
|
||||
| What | Endpoint |
|
||||
|---|---|
|
||||
| One booking end to end | `GET /admin/bookings/:id/track` |
|
||||
| A parcel's GPS trail + history | `GET /admin/consignments/:id/logs` |
|
||||
| Riders today | `GET /admin/milers/summary?from=&to=` |
|
||||
| One rider's logs | `GET /admin/milers/:id/logs` |
|
||||
| Per kitchen | `GET /admin/locations/summary?tenantid=&locationid=` |
|
||||
| Reports | `GET /admin/reports?from=&to=&tenantid=&locationid=&hubid=` |
|
||||
|
||||
Admin miler endpoints key on **`milerprofileid`**, while `assign-miler` takes a
|
||||
**`mileruserid`** in the body — different identity spaces on adjacent endpoints.
|
||||
Worth checking which one you have.
|
||||
|
||||
---
|
||||
|
||||
## State
|
||||
|
||||
| Capability | State |
|
||||
|---|---|
|
||||
| Booking create, bulk, tracking, reports | deployed |
|
||||
| Tenant scoping for client logins | deployed |
|
||||
| Per-kitchen attribution | deployed |
|
||||
| Durable assignment retry (JetStream) | built, **not deployed** |
|
||||
| Stop sequencing (Doormile side) | built, **not deployed** |
|
||||
| `/optimization/doormile/sequence` (routes.workolik.com) | built, **not deployed** |
|
||||
| Multi-stop optimizer service itself | live |
|
||||
|
||||
**Sequencing needs two deploys, not one** — the endpoint in the route-optimizer
|
||||
service (docker-compose on `31.97.228.132`, behind Traefik) and Doormile's
|
||||
client that calls it. Ship one without the other and sequencing fails quietly:
|
||||
bookings stay assigned but unordered, and riders choose their own order.
|
||||
|
||||
Nothing on the client side has moved. The rider app and express console still
|
||||
call `jupiter.nearle.app`. These endpoints exist and are tested; no production
|
||||
traffic uses them yet.
|
||||
@@ -27,7 +27,12 @@ const LocationAutocomplete = forwardRef(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem('applocations') || '[]'));
|
||||
// Tenant-scoped cache key — fetchAppLocations() now narrows the hub list
|
||||
// to the logged-in tenant's own city, so caching under one shared flat
|
||||
// key would leak a previous tenant's list into a different tenant's
|
||||
// session on the same browser if it ever skipped a full logout clear.
|
||||
const applocationsCacheKey = `applocations_${localStorage.getItem('tenantid') || 'staff'}`;
|
||||
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem(applocationsCacheKey) || '[]'));
|
||||
|
||||
useEffect(() => {
|
||||
// Zones are derived from GET /admin/hubs (the new API has no dedicated
|
||||
@@ -35,7 +40,7 @@ const LocationAutocomplete = forwardRef(
|
||||
const fetchLocations = async () => {
|
||||
try {
|
||||
const updatedLocations = await fetchAppLocations();
|
||||
localStorage.setItem('applocations', JSON.stringify(updatedLocations));
|
||||
localStorage.setItem(applocationsCacheKey, JSON.stringify(updatedLocations));
|
||||
setLocations(updatedLocations);
|
||||
} catch (err) {
|
||||
console.error('Error fetching locations in LocationAutocomplete:', err);
|
||||
@@ -45,7 +50,7 @@ const LocationAutocomplete = forwardRef(
|
||||
if (locations.length === 0) {
|
||||
fetchLocations();
|
||||
}
|
||||
}, [locations.length]);
|
||||
}, [locations.length, applocationsCacheKey]);
|
||||
|
||||
// Helpers (only used by pill variant) — match the deliveries page's
|
||||
// token shorthand so the same opacity ramp is applied here.
|
||||
|
||||
@@ -38,15 +38,33 @@ import {
|
||||
// lifecycle keys (pending/accepted/arrived/picked/active/skipped/delivered/
|
||||
// cancelled) for its tabs, chip counts, and row badges. The new booking
|
||||
// status enum uses different strings entirely (Pending_Pickup,
|
||||
// Converted_To_Consignment, Out_for_Delivery, ...) — only Pending_Pickup,
|
||||
// Converted_To_Consignment (from a real sample) and Out_for_Delivery (named
|
||||
// in express-console-api.md's CityGate note) are confirmed; the rest of this
|
||||
// mapping is a best-effort guess. Unmapped statuses pass through lowercased,
|
||||
// which the page's own fallback renders as an "unknown" badge rather than
|
||||
// crashing. There's no confirmed equivalent for arrived/picked/skipped at
|
||||
// all, so those tabs will show a 0 count until the real enum is confirmed.
|
||||
// Miler_Assigned, Pickup_Scheduled, Converted_To_Consignment, Out_for_Delivery,
|
||||
// ...) — Pending_Pickup, Converted_To_Consignment, Out_for_Delivery (named in
|
||||
// express-console-api.md's CityGate note), and now Miler_Assigned /
|
||||
// Pickup_Scheduled (confirmed live via a real booking-status breakdown logged
|
||||
// right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS
|
||||
// comment) are confirmed; the rest of this mapping is still a best-effort
|
||||
// guess. Unmapped statuses pass through lowercased, which the page's own
|
||||
// fallback renders as an "unknown" badge rather than crashing. There's no
|
||||
// confirmed equivalent for arrived/picked/skipped at all, so those tabs will
|
||||
// show a 0 count until the real enum is confirmed.
|
||||
//
|
||||
// Miler_Assigned is deliberately kept on 'pending', NOT bumped to 'accepted'.
|
||||
// Assigning a rider is an OPERATOR action (POST /admin/bookings/:id/assign-miler
|
||||
// from this console); "Accepted" is meant to reflect the RIDER's own action
|
||||
// (doormile-flow.md's POST /miler/assignments/:id/accept). Conflating the two
|
||||
// made a just-assigned, not-yet-acknowledged order look already-accepted —
|
||||
// wrong from an ops standpoint (per explicit product requirement: stays
|
||||
// "Pending" in the operator's eyes until the rider actually accepts).
|
||||
// Pickup_Scheduled — the status that appears once a rider has accepted and
|
||||
// the pickup is on their route — is the best available proxy for "rider
|
||||
// accepted" in the currently-confirmed enum, so that one maps to 'accepted'.
|
||||
// If the backend turns out to have a distinct status specifically for the
|
||||
// accept action, add it here rather than reusing Miler_Assigned for it.
|
||||
const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||
pending_pickup: 'pending',
|
||||
miler_assigned: 'pending',
|
||||
pickup_scheduled: 'accepted',
|
||||
converted_to_consignment: 'accepted',
|
||||
out_for_delivery: 'active',
|
||||
delivered: 'delivered',
|
||||
@@ -101,6 +119,19 @@ export const getRiderPeriodicLogs = async (userid) => {
|
||||
// ==============================|| fetchAppLocations (zone/location picker) ||============================== //
|
||||
// The new API has no "zones" resource — applocationid only exists as a field
|
||||
// on Hubs. Derive a zone picker list from the distinct cities in GET /admin/hubs.
|
||||
//
|
||||
// Scoped per tenant: Hub has no tenantid field at all (confirmed against
|
||||
// express-console-api.md), so GET /admin/hubs always returns every hub
|
||||
// nationwide with no server-side tenant filtering possible. A client-tenant
|
||||
// login should only see their own city's hub(s) — inferred from the
|
||||
// tenant's own GET /admin/tenants/:id/locations (city is free text there,
|
||||
// and on the hub side too, so matched via normalized exact-then-substring
|
||||
// comparison, not a clean foreign key). Staff logins (tenantid falsy) keep
|
||||
// seeing every hub, unchanged. Fails open (full list) whenever the tenant's
|
||||
// city can't be determined or nothing matches, rather than ever locking an
|
||||
// operator out with an empty picker.
|
||||
const normCity = (s) => String(s || '').trim().toLowerCase();
|
||||
|
||||
export const fetchAppLocations = async () => {
|
||||
try {
|
||||
const hubs = await getHubs();
|
||||
@@ -110,7 +141,36 @@ export const fetchAppLocations = async () => {
|
||||
seen.set(hub.applocationid, { applocationid: hub.applocationid, locationname: hub.city || hub.hubname });
|
||||
}
|
||||
});
|
||||
return [...seen.values(), { locationname: 'All', applocationid: 0 }];
|
||||
const allLocations = [...seen.values()];
|
||||
|
||||
const tenantid = localStorage.getItem('tenantid');
|
||||
const isStaff = !tenantid || tenantid === '0';
|
||||
if (isStaff) {
|
||||
return [...allLocations, { locationname: 'All', applocationid: 0 }];
|
||||
}
|
||||
|
||||
const tenantLocations = await gettenantlocations(tenantid);
|
||||
const tenantCities = new Set((tenantLocations || []).map((loc) => normCity(loc.city)).filter(Boolean));
|
||||
|
||||
if (tenantCities.size === 0) {
|
||||
OpenToast("Could not determine your tenant's city — showing all zones.", 'warning', 3000);
|
||||
return [...allLocations, { locationname: 'All', applocationid: 0 }];
|
||||
}
|
||||
|
||||
let matched = allLocations.filter((loc) => tenantCities.has(normCity(loc.locationname)));
|
||||
if (matched.length === 0) {
|
||||
matched = allLocations.filter((loc) => {
|
||||
const hubNorm = normCity(loc.locationname);
|
||||
return [...tenantCities].some((cityNorm) => hubNorm.includes(cityNorm) || cityNorm.includes(hubNorm));
|
||||
});
|
||||
}
|
||||
|
||||
if (matched.length === 0) {
|
||||
OpenToast("No zones matched your tenant's city — showing all zones.", 'warning', 3000);
|
||||
return [...allLocations, { locationname: 'All', applocationid: 0 }];
|
||||
}
|
||||
|
||||
return matched;
|
||||
} catch (err) {
|
||||
OpenToast(err.message, 'error', 2000);
|
||||
return [{ locationname: 'All', applocationid: 0 }];
|
||||
@@ -175,10 +235,18 @@ export const fetchPaymentType = async () => [];
|
||||
export const fetchRidersList = async () => {
|
||||
try {
|
||||
const milers = await getMilers();
|
||||
return (milers || []).map((val) => ({
|
||||
...val,
|
||||
label: `${val.displayname || val.authname || ''} | ${val.contactno || ''}`
|
||||
}));
|
||||
return (milers || []).map((val) => {
|
||||
const name = val.displayname || val.authname || '';
|
||||
return {
|
||||
...val,
|
||||
// Only append " | phone" when a phone actually exists — an
|
||||
// unconditional template literal left a dangling " | " on every
|
||||
// rider missing contactno, rendering literally in every dropdown
|
||||
// that falls back to this default label (e.g. OrdersPreview.js,
|
||||
// Preview.js's Change Rider dialog).
|
||||
label: val.contactno ? `${name} | ${val.contactno}` : name
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
// Was `throw`ing after already toasting here — deliveries.js also wires
|
||||
// its own onError toast on this same query, so a real failure showed
|
||||
@@ -201,7 +269,16 @@ export const createOptimisationDeliveries = async (deliveryData) => {
|
||||
// ==============================|| reconcileSteps (Preview - validate rider/order step assignments) ||============================== //
|
||||
|
||||
export const reconcileSteps = async ({ riders }) => {
|
||||
logger.debug(`reconcileSteps: posting ${riders?.length ?? 0} rider(s)`, riders);
|
||||
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/reconcile-steps`, { riders });
|
||||
// Diagnostic: this is an external, unverified solver contract (see this
|
||||
// area's CLAUDE.md) — Preview.js's reconcileMutation only clears the
|
||||
// dirty-rider set (which is what re-enables Assign Orders) when
|
||||
// response.data.riders is an array. If the solver's real response shape
|
||||
// is different (wrapped in an envelope, a different key name, etc.),
|
||||
// that check silently fails every time and Assign Orders can never
|
||||
// re-enable — logging the exact raw shape here settles it either way.
|
||||
logger.debug('reconcileSteps: raw response.data', response.data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -232,6 +309,49 @@ export const fetchBatchEfficiency = async ({ batch, tenantId }) => {
|
||||
// (that one creates a brand-new booking from scratch; wrong here, since
|
||||
// every order already exists as a Doormile booking pulled from
|
||||
// GET /admin/bookings).
|
||||
// Shared by finalCreatedeliveries (here) and Preview.js's pre-commit
|
||||
// verification UI, so both use the IDENTICAL matching rule rather than two
|
||||
// copies that could silently drift apart. userid/milerprofileid matching is
|
||||
// tried first (the solver's rider pool is fed full miler objects for
|
||||
// Auto/multi-trip mode — see Preview.js's handleCreateDelivery -> `riders:
|
||||
// autoRiders`, raw GET /admin/milers data carrying both fields per rider)
|
||||
// but confirmed live that for Bike hypertuning mode NEITHER matches: that
|
||||
// solver never receives a rider pool at all and assigns from its own
|
||||
// internal roster, seeded against jupiter rider ids when the integration
|
||||
// was first built — disconnected from Doormile's id space entirely (see
|
||||
// jupiter2doormile.md comparison). Falls back to matching the rider's NAME
|
||||
// (also echoed by the solver, see flattenRiders' rider_name) against each
|
||||
// miler's displayname/authname — the only other correlatable field.
|
||||
const normMilerName = (s) => String(s || '').trim().toLowerCase();
|
||||
|
||||
export const buildMilerLookup = (milers) => {
|
||||
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m]));
|
||||
const byProfileId = new Map((milers || []).map((m) => [String(m.milerprofileid), m]));
|
||||
const byName = new Map();
|
||||
(milers || []).forEach((m) => {
|
||||
[m.displayname, m.authname].forEach((n) => {
|
||||
const key = normMilerName(n);
|
||||
if (key && !byName.has(key)) byName.set(key, m);
|
||||
});
|
||||
});
|
||||
return { byUserId, byProfileId, byName };
|
||||
};
|
||||
|
||||
export const resolveMilerForOrder = (order, lookup) => {
|
||||
const riderUserId = order.rider_id ?? order.userid;
|
||||
const riderName = order.rider_name ?? order.rider;
|
||||
const matchedVia = lookup.byUserId.has(String(riderUserId))
|
||||
? 'userid'
|
||||
: lookup.byProfileId.has(String(riderUserId))
|
||||
? 'milerprofileid'
|
||||
: lookup.byName.has(normMilerName(riderName))
|
||||
? 'name'
|
||||
: null;
|
||||
const rider =
|
||||
lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName));
|
||||
return rider?.milerprofileid ? { rider, matchedVia } : null;
|
||||
};
|
||||
|
||||
export const finalCreatedeliveries = async (deliveryData) => {
|
||||
const deliveries = deliveryData.deliveries || [];
|
||||
logger.debug(`finalCreatedeliveries: ${deliveries.length} order(s) to assign`);
|
||||
@@ -253,19 +373,6 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
});
|
||||
});
|
||||
|
||||
// assign-miler needs a milerprofileid. userid/milerprofileid matching was
|
||||
// tried first (the solver's rider pool is fed full miler objects — see
|
||||
// Preview.js's handleCreateDelivery -> `riders: autoRiders`, raw
|
||||
// GET /admin/milers data carrying both fields per rider) but confirmed
|
||||
// live that NEITHER matches: the solver's rider_id/userid (e.g. "883")
|
||||
// isn't in GET /admin/milers under either field. This matches
|
||||
// Dispatch.js's own Analysis-panel comment — "the workolik solver doesn't
|
||||
// have our auth/users table" — so its numeric rider ids are its own
|
||||
// internal numbering, disconnected from Doormile's id space entirely.
|
||||
// Falls back to matching the rider's NAME (also echoed by the solver,
|
||||
// see flattenRiders' rider_name) against each miler's displayname/
|
||||
// authname — the only other correlatable field. Logs the full roster
|
||||
// alongside so a further mismatch is fully diagnosable from one run.
|
||||
let milers = [];
|
||||
try {
|
||||
milers = (await getMilers()) || [];
|
||||
@@ -276,47 +383,62 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
`finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`,
|
||||
milers.map((m) => ({ userid: m.userid, milerprofileid: m.milerprofileid, name: m.displayname || m.authname }))
|
||||
);
|
||||
const milerByUserId = new Map(milers.map((m) => [String(m.userid), m]));
|
||||
const milerByProfileId = new Map(milers.map((m) => [String(m.milerprofileid), m]));
|
||||
const normName = (s) => String(s || '').trim().toLowerCase();
|
||||
const milerByName = new Map();
|
||||
milers.forEach((m) => {
|
||||
[m.displayname, m.authname].forEach((n) => {
|
||||
const key = normName(n);
|
||||
if (key && !milerByName.has(key)) milerByName.set(key, m);
|
||||
});
|
||||
});
|
||||
const lookup = buildMilerLookup(milers);
|
||||
|
||||
// Booking id resolution: confirmed live that guessing a single field name
|
||||
// (bookingid first, on the assumption the solver passes unknown fields
|
||||
// through untouched) sends assign-miler a wrong id — a small, sequential-
|
||||
// looking number that 404s. The AI solver is a separate, unverified
|
||||
// service (see this area's CLAUDE.md); there's no reliable way to know
|
||||
// which candidate field it actually preserves. Instead of guessing,
|
||||
// fetch the tenant's real current booking list once and VALIDATE each
|
||||
// candidate against it, using whichever one actually matches a real
|
||||
// booking — this is correct regardless of which field the solver happens
|
||||
// to preserve, and regardless of which one changes in a future solver
|
||||
// update.
|
||||
let realBookingIds = new Set();
|
||||
try {
|
||||
const realBookings = (await getBookings(1, 1000)) || [];
|
||||
realBookingIds = new Set(realBookings.map((b) => String(b.bookingid)));
|
||||
logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`);
|
||||
} catch (err) {
|
||||
logger.error('finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', err.response?.status, err.response?.data || err.message);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
deliveries.map(async (d) => {
|
||||
// Booking id: the solver is a separate, unverified service (see this
|
||||
// area's CLAUDE.md) — falls back through every plausible field name
|
||||
// an order might carry it under, `bookingid` first since that's what
|
||||
// orders.js actually sends in and most solvers pass unknown fields
|
||||
// through untouched.
|
||||
const bookingId = d.bookingid ?? d.orderheaderid ?? d.deliveryid ?? d.orderid;
|
||||
const candidates = [d.bookingid, d.orderheaderid, d.deliveryid, d.orderid];
|
||||
const bookingId = candidates.find((c) => c != null && realBookingIds.has(String(c)));
|
||||
const riderUserId = d.rider_id ?? d.userid;
|
||||
const riderName = d.rider_name ?? d.rider;
|
||||
const matchedVia = milerByUserId.has(String(riderUserId))
|
||||
? 'userid'
|
||||
: milerByProfileId.has(String(riderUserId))
|
||||
? 'milerprofileid'
|
||||
: milerByName.has(normName(riderName))
|
||||
? 'name'
|
||||
: null;
|
||||
const rider =
|
||||
milerByUserId.get(String(riderUserId)) ?? milerByProfileId.get(String(riderUserId)) ?? milerByName.get(normName(riderName));
|
||||
if (bookingId == null || !rider?.milerprofileid) {
|
||||
const resolved = resolveMilerForOrder(d, lookup);
|
||||
if (bookingId == null || !resolved) {
|
||||
const reason =
|
||||
bookingId == null
|
||||
? 'no booking id resolved on the order object'
|
||||
? `no candidate id matched a real booking (tried bookingid=${d.bookingid}, orderheaderid=${d.orderheaderid}, deliveryid=${d.deliveryid}, orderid=${d.orderid})`
|
||||
: `no miler found for rider id ${riderUserId} / name "${riderName}" (checked userid, milerprofileid, name)`;
|
||||
logger.error(`finalCreatedeliveries: skipping order — ${reason}`);
|
||||
throw new Error(`order ${d.orderid ?? bookingId ?? '?'}: ${reason}`);
|
||||
}
|
||||
const { rider, matchedVia } = resolved;
|
||||
logger.debug(`finalCreatedeliveries: order booking ${bookingId} -> rider ${riderUserId} ("${riderName}") matched via ${matchedVia}`);
|
||||
try {
|
||||
return await assignMilerToBooking(bookingId, { milerid: rider.milerprofileid });
|
||||
// doormile-flow.md (confirmed current, authoritative): assign-miler's
|
||||
// body key is `mileruserid`, and it's the miler's userid — a
|
||||
// DIFFERENT identity space than milerprofileid, which is what admin
|
||||
// miler endpoints (notify, block, etc.) key on instead. Sending
|
||||
// { milerid: rider.milerprofileid } was wrong on both the key name
|
||||
// and the value's identity space — the actual root cause of the
|
||||
// persistent 404s on this call.
|
||||
await assignMilerToBooking(bookingId, { mileruserid: rider.userid });
|
||||
// Return the REAL resolved milerprofileid, not the solver's own
|
||||
// rider_id/userid — the caller (Preview.js) needs this for
|
||||
// notifyRider, which takes a milerprofileid specifically. Before
|
||||
// this, Preview.js notified using the raw solver id directly, which
|
||||
// is neither a real userid nor a milerprofileid (see the matching
|
||||
// comment above) — so rider push notifications were going out with
|
||||
// a bogus id and very likely silently failing server-side.
|
||||
return { milerprofileid: rider.milerprofileid };
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`finalCreatedeliveries: assign-miler failed for booking ${bookingId}`,
|
||||
@@ -337,7 +459,12 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
if (failed.length) {
|
||||
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000);
|
||||
}
|
||||
return { success: true, assigned: deliveries.length - failed.length, failed: failed.length };
|
||||
const resolvedMilerProfileIds = [
|
||||
...new Set(
|
||||
results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid)
|
||||
)
|
||||
];
|
||||
return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds };
|
||||
};
|
||||
// ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== //
|
||||
// Also part of the optimiser pipeline (routes.workolik.com / routemate.workolik.com) — untouched.
|
||||
@@ -486,7 +613,16 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
|
||||
return {
|
||||
rows,
|
||||
nextPage: rows.length === Number(rowsPerPage) ? pageParam + 1 : undefined
|
||||
// Whether to fetch another page must be based on the RAW bookings page
|
||||
// (bookings.length), not the post-filter `rows.length` — most bookings on
|
||||
// any given page are still pending, not dispatched, so the filtered count
|
||||
// almost never equals rowsPerPage. Comparing the filtered count against
|
||||
// rowsPerPage (the previous logic) made pagination stop after page 1 in
|
||||
// virtually every real dataset, silently hiding dispatched/assigned
|
||||
// orders that live beyond the first `rowsPerPage` bookings — e.g. a
|
||||
// booking just created and assigned wouldn't show on the Deliveries page
|
||||
// at all once the tenant has more than one page's worth of bookings.
|
||||
nextPage: (bookings || []).length === Number(rowsPerPage) ? pageParam + 1 : undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -541,19 +677,27 @@ export const cancelDeliveryAPI = async (selectedRow, cancelFeed) =>
|
||||
export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid);
|
||||
|
||||
// ==============================|| changeRiderAPI (deliveries) ||============================== //
|
||||
// POST /admin/bookings/:id/assign-miler has no documented body — `milerid` as
|
||||
// the JSON key is a guess (unverified, this is a write endpoint we didn't
|
||||
// test live), but the VALUE must be the miler's milerprofileid regardless of
|
||||
// key name: selectedRider comes straight from getMilers(), whose own .userid
|
||||
// field is a different resource (confirmed live via GET /admin/milers/:id).
|
||||
// doormile-flow.md (confirmed current, authoritative) settles this: the body
|
||||
// is { "mileruserid": <miler's userid> } — the previous guess here (`milerid`
|
||||
// key, `milerprofileid` value) was wrong on both counts. Admin miler
|
||||
// endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the
|
||||
// one exception that wants userid instead — "different identity spaces on
|
||||
// adjacent endpoints," per that doc's own wording. selectedRider comes
|
||||
// straight from getMilers(), which carries both fields on the same object.
|
||||
export const changeRiderAPI = async (selectedRider, selectedRow) =>
|
||||
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { milerid: selectedRider.milerprofileid });
|
||||
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { mileruserid: selectedRider.userid });
|
||||
|
||||
// ==============================|| updateDeliveryAPI (deliveries) ||============================== //
|
||||
// No amount/notes field exists on PUT /admin/consignments/:id/status — closest
|
||||
// available write is a status update. Free-text amount/notes edits have no home
|
||||
// in the new API yet.
|
||||
export const updateDeliveryAPI = async (orderData) => updateConsignmentStatus(orderData.deliveryid ?? orderData.consignmentid, orderData);
|
||||
// Target endpoint is consignment-scoped (/admin/consignments/:id/status), so
|
||||
// this needs the real consignmentid, not a booking id. deliveryid on a
|
||||
// deliveries-page row is always b.bookingid (see fetchDeliveries) — always
|
||||
// truthy, so `deliveryid ?? consignmentid` never actually fell through to
|
||||
// consignmentid even when it was present, silently calling the endpoint
|
||||
// with the wrong kind of id on every Update Status submit.
|
||||
export const updateDeliveryAPI = async (orderData) => updateConsignmentStatus(orderData.consignmentid ?? orderData.deliveryid, orderData);
|
||||
|
||||
// ==============================|| getalltenants (tenants) ||============================== //
|
||||
|
||||
|
||||
@@ -346,6 +346,20 @@ export const assignMilerToBooking = async (id, data) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Doormile-native bulk assign: picks real milers via Redis GEO + AI scoring, no
|
||||
// external solver involved. Commits server-side in this one call — unlike the
|
||||
// workolik solver flow, there's no separate preview/reconcile/commit step.
|
||||
// Sequencing (POST /optimization/doormile/sequence) is not deployed yet, so
|
||||
// riders with more than one stop come back unsequenced (step: 0) — see
|
||||
// doormile-flow.md's State table.
|
||||
export const batchAssignBookings = async (bookingIds, maxPerRider = 5) => {
|
||||
const response = await doormileAxios.post('/hub/bookings/batch-assign', {
|
||||
bookingids: bookingIds,
|
||||
max_per_rider: maxPerRider
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const assignVehicleToBooking = async (id, data) => {
|
||||
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data);
|
||||
return response.data;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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't store an address against a customer yet — these fields aren't required and won'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 }}>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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='© 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
22
src/utils/doormileTimestamp.js
Normal file
22
src/utils/doormileTimestamp.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Doormile timestamps are IST (Asia/Kolkata) wall-clock stored in Postgres
|
||||
// `timestamp without time zone` columns (express-console-api.md, "Conventions
|
||||
// across every endpoint"). Some responses come back with a trailing Z/offset
|
||||
// anyway — a known Go+pgx footgun where a naive DB timestamp loads into
|
||||
// time.Time under UTC location and gets marshaled with a false "Z" suffix.
|
||||
// dayjs treats a Z-suffixed string as a real UTC instant and converts it to
|
||||
// the browser's local time on display/bucketing, adding a spurious +5:30 on
|
||||
// top of digits that were already correct IST.
|
||||
//
|
||||
// Stripping any trailing zone marker before parsing makes both cases (truly
|
||||
// naive, or naive-with-false-Z) render/bucket identically as the raw
|
||||
// wall-clock digits. Shared by orders.js (row date cells), deliveries.js and
|
||||
// Dispatch.js (`assigntime`-based batch bucketing — CLAUDE.md requires both
|
||||
// pages agree on which batch a row belongs to, so both must use the same
|
||||
// parse).
|
||||
export const parseDoormileTimestamp = (raw) => {
|
||||
if (!raw) return dayjs(null);
|
||||
const stripped = String(raw).replace(/(Z|[+-]\d{2}:?\d{2})$/, '');
|
||||
return dayjs(stripped);
|
||||
};
|
||||
Reference in New Issue
Block a user