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
|
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(() => {
|
useEffect(() => {
|
||||||
// Zones are derived from GET /admin/hubs (the new API has no dedicated
|
// Zones are derived from GET /admin/hubs (the new API has no dedicated
|
||||||
@@ -35,7 +40,7 @@ const LocationAutocomplete = forwardRef(
|
|||||||
const fetchLocations = async () => {
|
const fetchLocations = async () => {
|
||||||
try {
|
try {
|
||||||
const updatedLocations = await fetchAppLocations();
|
const updatedLocations = await fetchAppLocations();
|
||||||
localStorage.setItem('applocations', JSON.stringify(updatedLocations));
|
localStorage.setItem(applocationsCacheKey, JSON.stringify(updatedLocations));
|
||||||
setLocations(updatedLocations);
|
setLocations(updatedLocations);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching locations in LocationAutocomplete:', err);
|
console.error('Error fetching locations in LocationAutocomplete:', err);
|
||||||
@@ -45,7 +50,7 @@ const LocationAutocomplete = forwardRef(
|
|||||||
if (locations.length === 0) {
|
if (locations.length === 0) {
|
||||||
fetchLocations();
|
fetchLocations();
|
||||||
}
|
}
|
||||||
}, [locations.length]);
|
}, [locations.length, applocationsCacheKey]);
|
||||||
|
|
||||||
// Helpers (only used by pill variant) — match the deliveries page's
|
// Helpers (only used by pill variant) — match the deliveries page's
|
||||||
// token shorthand so the same opacity ramp is applied here.
|
// token shorthand so the same opacity ramp is applied here.
|
||||||
|
|||||||
@@ -38,15 +38,33 @@ import {
|
|||||||
// lifecycle keys (pending/accepted/arrived/picked/active/skipped/delivered/
|
// lifecycle keys (pending/accepted/arrived/picked/active/skipped/delivered/
|
||||||
// cancelled) for its tabs, chip counts, and row badges. The new booking
|
// cancelled) for its tabs, chip counts, and row badges. The new booking
|
||||||
// status enum uses different strings entirely (Pending_Pickup,
|
// status enum uses different strings entirely (Pending_Pickup,
|
||||||
// Converted_To_Consignment, Out_for_Delivery, ...) — only Pending_Pickup,
|
// Miler_Assigned, Pickup_Scheduled, Converted_To_Consignment, Out_for_Delivery,
|
||||||
// Converted_To_Consignment (from a real sample) and Out_for_Delivery (named
|
// ...) — Pending_Pickup, Converted_To_Consignment, Out_for_Delivery (named in
|
||||||
// in express-console-api.md's CityGate note) are confirmed; the rest of this
|
// express-console-api.md's CityGate note), and now Miler_Assigned /
|
||||||
// mapping is a best-effort guess. Unmapped statuses pass through lowercased,
|
// Pickup_Scheduled (confirmed live via a real booking-status breakdown logged
|
||||||
// which the page's own fallback renders as an "unknown" badge rather than
|
// right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS
|
||||||
// crashing. There's no confirmed equivalent for arrived/picked/skipped at
|
// comment) are confirmed; the rest of this mapping is still a best-effort
|
||||||
// all, so those tabs will show a 0 count until the real enum is confirmed.
|
// 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 = {
|
const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||||
pending_pickup: 'pending',
|
pending_pickup: 'pending',
|
||||||
|
miler_assigned: 'pending',
|
||||||
|
pickup_scheduled: 'accepted',
|
||||||
converted_to_consignment: 'accepted',
|
converted_to_consignment: 'accepted',
|
||||||
out_for_delivery: 'active',
|
out_for_delivery: 'active',
|
||||||
delivered: 'delivered',
|
delivered: 'delivered',
|
||||||
@@ -101,6 +119,19 @@ export const getRiderPeriodicLogs = async (userid) => {
|
|||||||
// ==============================|| fetchAppLocations (zone/location picker) ||============================== //
|
// ==============================|| fetchAppLocations (zone/location picker) ||============================== //
|
||||||
// The new API has no "zones" resource — applocationid only exists as a field
|
// 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.
|
// 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 () => {
|
export const fetchAppLocations = async () => {
|
||||||
try {
|
try {
|
||||||
const hubs = await getHubs();
|
const hubs = await getHubs();
|
||||||
@@ -110,7 +141,36 @@ export const fetchAppLocations = async () => {
|
|||||||
seen.set(hub.applocationid, { applocationid: hub.applocationid, locationname: hub.city || hub.hubname });
|
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) {
|
} catch (err) {
|
||||||
OpenToast(err.message, 'error', 2000);
|
OpenToast(err.message, 'error', 2000);
|
||||||
return [{ locationname: 'All', applocationid: 0 }];
|
return [{ locationname: 'All', applocationid: 0 }];
|
||||||
@@ -175,10 +235,18 @@ export const fetchPaymentType = async () => [];
|
|||||||
export const fetchRidersList = async () => {
|
export const fetchRidersList = async () => {
|
||||||
try {
|
try {
|
||||||
const milers = await getMilers();
|
const milers = await getMilers();
|
||||||
return (milers || []).map((val) => ({
|
return (milers || []).map((val) => {
|
||||||
...val,
|
const name = val.displayname || val.authname || '';
|
||||||
label: `${val.displayname || val.authname || ''} | ${val.contactno || ''}`
|
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) {
|
} catch (err) {
|
||||||
// Was `throw`ing after already toasting here — deliveries.js also wires
|
// Was `throw`ing after already toasting here — deliveries.js also wires
|
||||||
// its own onError toast on this same query, so a real failure showed
|
// 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) ||============================== //
|
// ==============================|| reconcileSteps (Preview - validate rider/order step assignments) ||============================== //
|
||||||
|
|
||||||
export const reconcileSteps = async ({ riders }) => {
|
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 });
|
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;
|
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
|
// (that one creates a brand-new booking from scratch; wrong here, since
|
||||||
// every order already exists as a Doormile booking pulled from
|
// every order already exists as a Doormile booking pulled from
|
||||||
// GET /admin/bookings).
|
// 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) => {
|
export const finalCreatedeliveries = async (deliveryData) => {
|
||||||
const deliveries = deliveryData.deliveries || [];
|
const deliveries = deliveryData.deliveries || [];
|
||||||
logger.debug(`finalCreatedeliveries: ${deliveries.length} order(s) to assign`);
|
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 = [];
|
let milers = [];
|
||||||
try {
|
try {
|
||||||
milers = (await getMilers()) || [];
|
milers = (await getMilers()) || [];
|
||||||
@@ -276,47 +383,62 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
|||||||
`finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`,
|
`finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`,
|
||||||
milers.map((m) => ({ userid: m.userid, milerprofileid: m.milerprofileid, name: m.displayname || m.authname }))
|
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 lookup = buildMilerLookup(milers);
|
||||||
const milerByProfileId = new Map(milers.map((m) => [String(m.milerprofileid), m]));
|
|
||||||
const normName = (s) => String(s || '').trim().toLowerCase();
|
// Booking id resolution: confirmed live that guessing a single field name
|
||||||
const milerByName = new Map();
|
// (bookingid first, on the assumption the solver passes unknown fields
|
||||||
milers.forEach((m) => {
|
// through untouched) sends assign-miler a wrong id — a small, sequential-
|
||||||
[m.displayname, m.authname].forEach((n) => {
|
// looking number that 404s. The AI solver is a separate, unverified
|
||||||
const key = normName(n);
|
// service (see this area's CLAUDE.md); there's no reliable way to know
|
||||||
if (key && !milerByName.has(key)) milerByName.set(key, m);
|
// 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(
|
const results = await Promise.allSettled(
|
||||||
deliveries.map(async (d) => {
|
deliveries.map(async (d) => {
|
||||||
// Booking id: the solver is a separate, unverified service (see this
|
const candidates = [d.bookingid, d.orderheaderid, d.deliveryid, d.orderid];
|
||||||
// area's CLAUDE.md) — falls back through every plausible field name
|
const bookingId = candidates.find((c) => c != null && realBookingIds.has(String(c)));
|
||||||
// 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 riderUserId = d.rider_id ?? d.userid;
|
const riderUserId = d.rider_id ?? d.userid;
|
||||||
const riderName = d.rider_name ?? d.rider;
|
const riderName = d.rider_name ?? d.rider;
|
||||||
const matchedVia = milerByUserId.has(String(riderUserId))
|
const resolved = resolveMilerForOrder(d, lookup);
|
||||||
? 'userid'
|
if (bookingId == null || !resolved) {
|
||||||
: 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 reason =
|
const reason =
|
||||||
bookingId == null
|
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)`;
|
: `no miler found for rider id ${riderUserId} / name "${riderName}" (checked userid, milerprofileid, name)`;
|
||||||
logger.error(`finalCreatedeliveries: skipping order — ${reason}`);
|
logger.error(`finalCreatedeliveries: skipping order — ${reason}`);
|
||||||
throw new Error(`order ${d.orderid ?? bookingId ?? '?'}: ${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}`);
|
logger.debug(`finalCreatedeliveries: order booking ${bookingId} -> rider ${riderUserId} ("${riderName}") matched via ${matchedVia}`);
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`finalCreatedeliveries: assign-miler failed for booking ${bookingId}`,
|
`finalCreatedeliveries: assign-miler failed for booking ${bookingId}`,
|
||||||
@@ -337,7 +459,12 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
|||||||
if (failed.length) {
|
if (failed.length) {
|
||||||
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000);
|
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 ||============================== //
|
// ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== //
|
||||||
// Also part of the optimiser pipeline (routes.workolik.com / routemate.workolik.com) — untouched.
|
// 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 {
|
return {
|
||||||
rows,
|
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);
|
export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid);
|
||||||
|
|
||||||
// ==============================|| changeRiderAPI (deliveries) ||============================== //
|
// ==============================|| changeRiderAPI (deliveries) ||============================== //
|
||||||
// POST /admin/bookings/:id/assign-miler has no documented body — `milerid` as
|
// doormile-flow.md (confirmed current, authoritative) settles this: the body
|
||||||
// the JSON key is a guess (unverified, this is a write endpoint we didn't
|
// is { "mileruserid": <miler's userid> } — the previous guess here (`milerid`
|
||||||
// test live), but the VALUE must be the miler's milerprofileid regardless of
|
// key, `milerprofileid` value) was wrong on both counts. Admin miler
|
||||||
// key name: selectedRider comes straight from getMilers(), whose own .userid
|
// endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the
|
||||||
// field is a different resource (confirmed live via GET /admin/milers/:id).
|
// 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) =>
|
export const changeRiderAPI = async (selectedRider, selectedRow) =>
|
||||||
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { milerid: selectedRider.milerprofileid });
|
assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { mileruserid: selectedRider.userid });
|
||||||
|
|
||||||
// ==============================|| updateDeliveryAPI (deliveries) ||============================== //
|
// ==============================|| updateDeliveryAPI (deliveries) ||============================== //
|
||||||
// No amount/notes field exists on PUT /admin/consignments/:id/status — closest
|
// 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
|
// available write is a status update. Free-text amount/notes edits have no home
|
||||||
// in the new API yet.
|
// 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) ||============================== //
|
// ==============================|| getalltenants (tenants) ||============================== //
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,20 @@ export const assignMilerToBooking = async (id, data) => {
|
|||||||
return response.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) => {
|
export const assignVehicleToBooking = async (id, data) => {
|
||||||
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data);
|
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const soft = (c) => a(c, '18');
|
|||||||
const ring = (c) => a(c, '26');
|
const ring = (c) => a(c, '26');
|
||||||
const edge = (c) => a(c, '55');
|
const edge = (c) => a(c, '55');
|
||||||
|
|
||||||
const BRAND = '#662582';
|
const BRAND = '#C01227';
|
||||||
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
|
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
|
||||||
|
|
||||||
const SoftPaper = (props) => (
|
const SoftPaper = (props) => (
|
||||||
@@ -199,20 +199,30 @@ const ClientsPricing = () => {
|
|||||||
|
|
||||||
const tenantMap = useMemo(() => new Map((tenants || []).map((t) => [t.tenantid, t])), [tenants]);
|
const tenantMap = useMemo(() => new Map((tenants || []).map((t) => [t.tenantid, t])), [tenants]);
|
||||||
const locationMap = useMemo(
|
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]
|
[locations]
|
||||||
);
|
);
|
||||||
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [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 tenantName = (row) => row.tenantname || tenantMap.get(row.tenantid)?.tenantname || (row.tenantid ? `Tenant #${row.tenantid}` : '—');
|
||||||
const zoneName = (row) =>
|
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
|
// getallpricing() takes no zone arg (see api.js) — the LocationAutocomplete
|
||||||
// only ever changed the header subtitle text, not which rows were shown.
|
// only ever changed the header subtitle text, not which rows were shown.
|
||||||
// Filter client-side instead; pricing rows already carry applocationid
|
// Filter client-side instead; pricing rows already carry applocationid
|
||||||
// (used by zoneName above), so this is the same real field, just applied.
|
// (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(() => {
|
const rows = useMemo(() => {
|
||||||
if (!debouncedSearch) return zonePricing;
|
if (!debouncedSearch) return zonePricing;
|
||||||
|
|||||||
@@ -75,13 +75,15 @@ const Createcustomer = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// LocationAutocomplete only reports back applocationid/locationname — it
|
// 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
|
// as a side effect, so pull the coordinates for the address-search bias
|
||||||
// from there rather than duplicating the GET /admin/hubs call.
|
// from there rather than duplicating the GET /admin/hubs call.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appId) return;
|
if (!appId) return;
|
||||||
try {
|
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);
|
const hub = hubs.find((h) => h.applocationid === appId);
|
||||||
if (hub?.latitude) {
|
if (hub?.latitude) {
|
||||||
setAppLocaLat(hub.latitude);
|
setAppLocaLat(hub.latitude);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputLabel,
|
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import {
|
import {
|
||||||
MdPeopleAlt,
|
MdPeopleAlt,
|
||||||
MdOutlinePeopleAlt,
|
MdOutlinePeopleAlt,
|
||||||
@@ -43,6 +43,7 @@ import Loader from 'components/Loader';
|
|||||||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||||||
import PageHeader from 'components/nearle_components/PageHeader';
|
import PageHeader from 'components/nearle_components/PageHeader';
|
||||||
import StatCard from 'components/nearle_components/StatCard';
|
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 { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
||||||
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||||||
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
|
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;
|
// all. PATCH /admin/customers/:id is the only mutation the API exposes;
|
||||||
// there is no create/delete here by design ("Tenant-scoped through their
|
// there is no create/delete here by design ("Tenant-scoped through their
|
||||||
// bookings" — a customer only exists once they've ordered through a tenant).
|
// 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 = {
|
const DT = {
|
||||||
@@ -97,6 +105,9 @@ const Customers = () => {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [editRow, setEditRow] = useState(null);
|
const [editRow, setEditRow] = useState(null);
|
||||||
const [form, setForm] = useState({});
|
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 });
|
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
|
||||||
|
|
||||||
@@ -139,6 +150,44 @@ const Customers = () => {
|
|||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
email: row.email || ''
|
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 (
|
return (
|
||||||
@@ -188,13 +237,21 @@ const Customers = () => {
|
|||||||
background: '#fff'
|
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}>
|
<Stack direction="row" alignItems="center" spacing={1.25}>
|
||||||
<AccentAvatar color={BRAND} size={32}>
|
<AccentAvatar color={BRAND} size={32}>
|
||||||
<MdPeopleAlt size={18} />
|
<MdPeopleAlt size={18} />
|
||||||
</AccentAvatar>
|
</AccentAvatar>
|
||||||
<Stack>
|
<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
|
Directory
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
|
||||||
@@ -388,7 +445,14 @@ const Customers = () => {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</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
|
<DialogTitle
|
||||||
sx={{
|
sx={{
|
||||||
background: `linear-gradient(135deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)`,
|
background: `linear-gradient(135deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)`,
|
||||||
@@ -401,7 +465,9 @@ const Customers = () => {
|
|||||||
<FaRegEdit style={{ fontSize: 18 }} />
|
<FaRegEdit style={{ fontSize: 18 }} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Stack>
|
<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
|
Customer
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontWeight: 800, fontSize: '1.1rem', lineHeight: 1.2, mt: 0.25 }}>
|
<Typography sx={{ fontWeight: 800, fontSize: '1.1rem', lineHeight: 1.2, mt: 0.25 }}>
|
||||||
@@ -411,16 +477,22 @@ const Customers = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} sx={{ mt: 2 }}>
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
<Stack spacing={1}>
|
<Grid item xs={12} sm={6}>
|
||||||
<InputLabel>Name</InputLabel>
|
<Typography sx={{ mb: 1 }}>Customer Name</Typography>
|
||||||
<TextField fullWidth value={form.name || ''} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
<TextField
|
||||||
</Stack>
|
variant="outlined"
|
||||||
<Stack spacing={1}>
|
fullWidth
|
||||||
<InputLabel>Phone Number</InputLabel>
|
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}>
|
<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
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={form.phone || ''}
|
value={form.phone || ''}
|
||||||
inputProps={{ maxLength: 10, inputMode: 'numeric', pattern: '[0-9]*' }}
|
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 }} /> }}
|
InputProps={{ startAdornment: <MdOutlinePhoneAndroid size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Grid>
|
||||||
<Stack spacing={1}>
|
<Grid item xs={12}>
|
||||||
<InputLabel>Email</InputLabel>
|
<Typography sx={{ mb: 1 }}>Email</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
fullWidth
|
fullWidth
|
||||||
type="email"
|
type="email"
|
||||||
value={form.email || ''}
|
value={form.email || ''}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
InputProps={{ startAdornment: <MdMail size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
|
InputProps={{ startAdornment: <MdMail size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Grid>
|
||||||
</Stack>
|
|
||||||
|
<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>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<Button onClick={() => setEditRow(null)} sx={{ color: DT.textSecondary }}>
|
<Button onClick={() => setEditRow(null)} sx={{ color: DT.textSecondary }}>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { useState, useEffect, Fragment, useRef, useMemo } from 'react';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
var utc = require('dayjs/plugin/utc');
|
var utc = require('dayjs/plugin/utc');
|
||||||
dayjs.extend(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 { useTheme } from '@mui/material/styles';
|
||||||
import {
|
import {
|
||||||
MdOutlineDateRange,
|
MdOutlineDateRange,
|
||||||
@@ -213,7 +215,12 @@ const getRowBatchId = (row) => {
|
|||||||
// so a row that lands in Evening Batch on the dispatch page must also land
|
// 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.
|
// in Evening Batch here — otherwise an operator running in IST/PST/etc.
|
||||||
// sees different totals on the two pages for the same underlying rows.
|
// 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;
|
if (!d.isValid()) return null;
|
||||||
const h = d.hour() + d.minute() / 60;
|
const h = d.hour() + d.minute() / 60;
|
||||||
for (const b of BATCH_OPTIONS) {
|
for (const b of BATCH_OPTIONS) {
|
||||||
@@ -280,6 +287,58 @@ const Deliveries = () => {
|
|||||||
const [deliveryamount, setDeliveryamount] = useState();
|
const [deliveryamount, setDeliveryamount] = useState();
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [currentorder, setCurrentorder] = 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 [deliverylat, setDeliverylat] = useState('');
|
||||||
const [deliverylong, setDeliverylong] = useState('');
|
const [deliverylong, setDeliverylong] = useState('');
|
||||||
const [currentStatus, setCurrentStatus] = useState('pending');
|
const [currentStatus, setCurrentStatus] = useState('pending');
|
||||||
@@ -311,7 +370,6 @@ const Deliveries = () => {
|
|||||||
// batch matching the current UTC hour (the operator is most likely curious
|
// batch matching the current UTC hour (the operator is most likely curious
|
||||||
// about "now"); never `null` since there's no longer an "All" option.
|
// about "now"); never `null` since there's no longer an "All" option.
|
||||||
const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId);
|
const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId);
|
||||||
const roleid = localStorage.getItem('roleid');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTenantid(0);
|
setTenantid(0);
|
||||||
@@ -534,7 +592,21 @@ const Deliveries = () => {
|
|||||||
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
|
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(() => {
|
useEffect(() => {
|
||||||
if (countHasNext && !countIsFetchingNext) countFetchNext();
|
if (countHasNext && !countIsFetchingNext) countFetchNext();
|
||||||
@@ -1097,7 +1169,7 @@ const Deliveries = () => {
|
|||||||
<Autocomplete
|
<Autocomplete
|
||||||
disablePortal
|
disablePortal
|
||||||
options={locationlist || []}
|
options={locationlist || []}
|
||||||
getOptionLabel={(option) => `${option.locationname} (${option.suburb})` || ''}
|
getOptionLabel={(option) => (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')}
|
||||||
value={locationValue}
|
value={locationValue}
|
||||||
PaperComponent={SoftPaper}
|
PaperComponent={SoftPaper}
|
||||||
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
|
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
|
||||||
@@ -1144,7 +1216,10 @@ const Deliveries = () => {
|
|||||||
disabled={riderListIsLoading}
|
disabled={riderListIsLoading}
|
||||||
options={ridersList}
|
options={ridersList}
|
||||||
PaperComponent={SoftPaper}
|
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 } }}
|
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
|
||||||
onChange={(e, value, reason) => {
|
onChange={(e, value, reason) => {
|
||||||
if (reason === 'clear') setRiderid(0);
|
if (reason === 'clear') setRiderid(0);
|
||||||
@@ -1494,7 +1569,7 @@ const Deliveries = () => {
|
|||||||
<MobileFieldGrid>
|
<MobileFieldGrid>
|
||||||
<MobileField label="Order / Location" full>
|
<MobileField label="Order / Location" full>
|
||||||
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap>
|
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap>
|
||||||
{`${row.locationname}-(${row.locationsuburb})`}
|
{row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||||
{row.orderid} · {row.deliveryid}
|
{row.orderid} · {row.deliveryid}
|
||||||
@@ -1546,8 +1621,8 @@ const Deliveries = () => {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
<MobileField label="Kms · plan / act">
|
<MobileField label="Kms · plan / act">
|
||||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||||
<Box sx={chipSx('#ef4444')}>{row.kms || 0} km</Box>
|
<Box sx={chipSx('#ef4444')}>{Number(row.kms || 0).toFixed(2)} km</Box>
|
||||||
<Box sx={chipSx('#10b981')}>{row.cumulativekms || 0} km</Box>
|
<Box sx={chipSx('#10b981')}>{Number(row.cumulativekms || 0).toFixed(2)} km</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</MobileField>
|
</MobileField>
|
||||||
<MobileField label="Amount · chg / amt">
|
<MobileField label="Amount · chg / amt">
|
||||||
@@ -1833,7 +1908,7 @@ const Deliveries = () => {
|
|||||||
<TableCell align="left">
|
<TableCell align="left">
|
||||||
<Tooltip title="Location Name-Suburb" placement="top">
|
<Tooltip title="Location Name-Suburb" placement="top">
|
||||||
<Typography variant="subtitle1" noWrap>
|
<Typography variant="subtitle1" noWrap>
|
||||||
{`${row.locationname}-(${row.locationsuburb})`}
|
{row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -1844,12 +1919,18 @@ const Deliveries = () => {
|
|||||||
{row.orderid}
|
{row.orderid}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</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">
|
<Tooltip title="Ordered date" placement="top">
|
||||||
<Typography noWrap sx={{ fontSize: '12px' }}>
|
<Typography noWrap sx={{ fontSize: '12px' }}>
|
||||||
{dayjs(row.orderdate).utc().format('DD/MM/YYYY')}
|
{dayjs(row.orderdate).format('DD/MM/YYYY')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography noWrap sx={{ fontSize: '11px' }}>
|
<Typography noWrap sx={{ fontSize: '11px' }}>
|
||||||
{dayjs(row.orderdate).utc().format('hh:mm A')}
|
{dayjs(row.orderdate).format('hh:mm A')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1862,44 +1943,62 @@ const Deliveries = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Delivery date" placement="top">
|
<Tooltip title="Delivery date" placement="top">
|
||||||
<Typography noWrap sx={{ fontSize: '12px' }}>
|
<Typography noWrap sx={{ fontSize: '12px' }}>
|
||||||
{dayjs(row.deliverydate).utc().format('DD/MM/YYYY')}
|
{dayjs(row.deliverydate).format('DD/MM/YYYY')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography noWrap sx={{ fontSize: '11px' }}>
|
<Typography noWrap sx={{ fontSize: '11px' }}>
|
||||||
{dayjs(row.deliverydate).utc().format('hh:mm A')}
|
{dayjs(row.deliverydate).format('hh:mm A')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* pickup */}
|
{/* pickup — capped to the same maxWidth as drop below, with CSS
|
||||||
<TableCell align="left">
|
ellipsis truncation, so one long address can't stretch this
|
||||||
<Stack>
|
column wider than its neighbour. row.pickuplocation is just
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
|
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}
|
{row.pickupcustomer}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||||
{row.pickupcontactno}
|
{row.pickupcontactno}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title={row.Pickupaddress} sx={{ whiteSpace: 'nowrap' }}>
|
<Tooltip title={row.Pickupaddress || ''}>
|
||||||
<Typography variant="caption" sx={{ color: DT.textMuted }}>
|
<Typography
|
||||||
{row.pickuplocation || (row.Pickupaddress ? row.Pickupaddress.slice(0, 14) + '…' : '—')}
|
variant="caption"
|
||||||
|
sx={{ color: DT.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}
|
||||||
|
>
|
||||||
|
{row.pickuplocation || row.Pickupaddress || '—'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* drop */}
|
{/* drop — same maxWidth/truncation treatment as pickup above. */}
|
||||||
<TableCell align="left">
|
<TableCell align="left" sx={{ maxWidth: 190 }}>
|
||||||
<Stack>
|
<Stack sx={{ minWidth: 0 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||||
|
>
|
||||||
{row.deliverycustomer}
|
{row.deliverycustomer}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||||
{row.deliverycontactno}
|
{row.deliverycontactno}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title={row.deliveryaddress}>
|
<Tooltip title={row.deliveryaddress || ''}>
|
||||||
<Typography variant="caption" sx={{ color: DT.textMuted, whiteSpace: 'nowrap' }}>
|
<Typography
|
||||||
{row.deliverylocation || (row.deliveryaddress ? row.deliveryaddress.slice(0, 14) + '…' : '—')}
|
variant="caption"
|
||||||
|
sx={{ color: DT.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}
|
||||||
|
>
|
||||||
|
{row.deliverylocation || row.deliveryaddress || '—'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1973,7 +2072,7 @@ const Deliveries = () => {
|
|||||||
minWidth: 75
|
minWidth: 75
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.kms || 0} km
|
{Number(row.kms || 0).toFixed(2)} km
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Actual KMS" placement="top">
|
<Tooltip title="Actual KMS" placement="top">
|
||||||
@@ -1994,7 +2093,7 @@ const Deliveries = () => {
|
|||||||
minWidth: 75
|
minWidth: 75
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.cumulativekms || 0} km
|
{Number(row.cumulativekms || 0).toFixed(2)} km
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -2371,27 +2470,29 @@ const Deliveries = () => {
|
|||||||
Change Rider
|
Change Rider
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{(roleid == 1 || roleid == 2) && (
|
{/* Was gated to roleid 1/2 (staff-only), matching the reference app's
|
||||||
<MenuItem
|
identical gate — opened to every login per explicit decision, so
|
||||||
onClick={() => {
|
tenant operators can reach the same Update Status fields staff
|
||||||
setKms(selectedRow.kms);
|
already could. */}
|
||||||
setCumulativeKms(selectedRow.cumulativekms);
|
<MenuItem
|
||||||
setDeliverylat(selectedRow.droplat);
|
onClick={() => {
|
||||||
setDeliverylong(selectedRow.droplon);
|
setKms(selectedRow.kms);
|
||||||
setNotes(selectedRow.notes);
|
setCumulativeKms(selectedRow.cumulativekms);
|
||||||
setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount);
|
setDeliverylat(selectedRow.droplat);
|
||||||
setUpdateStatus(selectedRow.orderstatus || 'delivered');
|
setDeliverylong(selectedRow.droplon);
|
||||||
setCurrentorder(selectedRow);
|
setNotes(selectedRow.notes);
|
||||||
setDialogopen(true);
|
setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount);
|
||||||
handleMenuClose();
|
setUpdateStatus(selectedRow.orderstatus || 'delivered');
|
||||||
}}
|
setCurrentorder(selectedRow);
|
||||||
>
|
setDialogopen(true);
|
||||||
<AccentAvatar color="#10b981" size={22}>
|
handleMenuClose();
|
||||||
<MdCheckCircle size={13} />
|
}}
|
||||||
</AccentAvatar>
|
>
|
||||||
Update Status
|
<AccentAvatar color="#10b981" size={22}>
|
||||||
</MenuItem>
|
<MdCheckCircle size={13} />
|
||||||
)}
|
</AccentAvatar>
|
||||||
|
Update Status
|
||||||
|
</MenuItem>
|
||||||
{selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && (
|
{selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
sx={{ color: '#ef4444 !important' }}
|
sx={{ color: '#ef4444 !important' }}
|
||||||
@@ -2539,7 +2640,10 @@ const Deliveries = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
options={ridersList}
|
options={ridersList}
|
||||||
PaperComponent={SoftPaper}
|
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) => {
|
onChange={(e, value) => {
|
||||||
setSelectedRider(value);
|
setSelectedRider(value);
|
||||||
logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None');
|
logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None');
|
||||||
@@ -2922,6 +3026,7 @@ const Deliveries = () => {
|
|||||||
} else {
|
} else {
|
||||||
updateDeliveryMutation.mutate({
|
updateDeliveryMutation.mutate({
|
||||||
deliveryid: currentorder.deliveryid,
|
deliveryid: currentorder.deliveryid,
|
||||||
|
consignmentid: currentorder.consignmentid,
|
||||||
orderheaderid: currentorder.orderheaderid,
|
orderheaderid: currentorder.orderheaderid,
|
||||||
orderstatus: updateStatus,
|
orderstatus: updateStatus,
|
||||||
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import ProfitabilitySection from './ProfitabilitySection';
|
|||||||
import ActiveSection from './ActiveSection';
|
import ActiveSection from './ActiveSection';
|
||||||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
|
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
|
||||||
import { getConsignmentLogs } from 'pages/api/doormileApi';
|
import { getConsignmentLogs } from 'pages/api/doormileApi';
|
||||||
|
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||||
import {
|
import {
|
||||||
STATUS_STYLES,
|
STATUS_STYLES,
|
||||||
getStatusStyle,
|
getStatusStyle,
|
||||||
@@ -66,7 +67,10 @@ import {
|
|||||||
STEP_PALETTE,
|
STEP_PALETTE,
|
||||||
stepColor,
|
stepColor,
|
||||||
isActiveDelivery,
|
isActiveDelivery,
|
||||||
getActiveOrder
|
getActiveOrder,
|
||||||
|
haversineKm,
|
||||||
|
polylineLengthKm,
|
||||||
|
kalmanSmoothGps
|
||||||
} from './dispatchShared';
|
} from './dispatchShared';
|
||||||
import CompareDataPanel from './CompareDataPanel';
|
import CompareDataPanel from './CompareDataPanel';
|
||||||
import './Dispatch.css';
|
import './Dispatch.css';
|
||||||
@@ -224,7 +228,12 @@ const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => {
|
|||||||
const str = String(t).trim();
|
const str = String(t).trim();
|
||||||
// Skip bare date strings — no time component, would always parse to midnight.
|
// Skip bare date strings — no time component, would always parse to midnight.
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
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;
|
if (!d.isValid()) return null;
|
||||||
// Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which
|
// 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
|
// starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would
|
||||||
@@ -273,274 +282,9 @@ function MapAutoResize({ trigger }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Haversine distance between two [lat, lng] points in kilometers. Good to
|
// haversineKm/polylineLengthKm/kalmanSmoothGps moved to dispatchShared.js so
|
||||||
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
|
// deliveries.js's Update Status dialog can compute the same real, GPS-based
|
||||||
// polyline so the Compare delta panel can show "actual km" without depending
|
// Actual KMs figure instead of leaving that field permanently blank.
|
||||||
// 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
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Splits a routed OSRM polyline into per-step segments by finding the
|
// Splits a routed OSRM polyline into per-step segments by finding the
|
||||||
// polyline index closest to each drop waypoint. Returns an array of
|
// polyline index closest to each drop waypoint. Returns an array of
|
||||||
@@ -594,7 +338,7 @@ const formatTimeOnly = (t) => {
|
|||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
const d = dayjs(t);
|
const d = dayjs(t);
|
||||||
if (!d.isValid()) return String(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.
|
// 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' }
|
{ 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) => {
|
const analysisFormatNum = (v) => {
|
||||||
if (v == null) return '—';
|
if (v == null) return '—';
|
||||||
if (typeof v === 'number') return v.toLocaleString('en-IN');
|
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');
|
if (Number.isFinite(n)) return n.toLocaleString('en-IN');
|
||||||
return String(v);
|
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
|
// 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
|
// 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
|
// rider timelines on the Analysis page — the API ships those fields as bare
|
||||||
@@ -3507,13 +3235,6 @@ const Dispatch = ({
|
|||||||
return routes;
|
return routes;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRider = (rid) => {
|
|
||||||
const newActive = new Set(activeRiders);
|
|
||||||
if (newActive.has(rid)) newActive.delete(rid);
|
|
||||||
else newActive.add(rid);
|
|
||||||
setActiveRiders(newActive);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
|
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
@@ -4617,7 +4338,7 @@ const Dispatch = ({
|
|||||||
|
|
||||||
<div className="zone-order-stats">
|
<div className="zone-order-stats">
|
||||||
<span className="zone-order-chip" title="Distance">
|
<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>
|
||||||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||||||
@@ -4766,7 +4487,7 @@ const Dispatch = ({
|
|||||||
|
|
||||||
<div className="zone-order-stats">
|
<div className="zone-order-stats">
|
||||||
<span className="zone-order-chip" title="Distance">
|
<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>
|
||||||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||||||
@@ -4913,7 +4634,7 @@ const Dispatch = ({
|
|||||||
|
|
||||||
<div className="zone-order-stats">
|
<div className="zone-order-stats">
|
||||||
<span className="zone-order-chip" title="Distance">
|
<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>
|
||||||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
<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' />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© OpenStreetMap contributors' />
|
||||||
<ZoomControl position="bottomright" />
|
<ZoomControl position="bottomright" />
|
||||||
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
|
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
|
||||||
|
{compareOpen && (
|
||||||
|
<CompareMapClickUnpin
|
||||||
|
onUnpin={() => {
|
||||||
|
pinnedPopupsRef.current.clear();
|
||||||
|
setCenterPopupOrder(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
|
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
|
||||||
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
|
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
|
||||||
{kitchens
|
{kitchens
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import logger from '../../../utils/logger';
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Backdrop,
|
Backdrop,
|
||||||
@@ -26,15 +27,17 @@ import dayjs from 'dayjs';
|
|||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import { HiOutlineArrowLeft } from 'react-icons/hi';
|
import { HiOutlineArrowLeft } from 'react-icons/hi';
|
||||||
import { IoReload } from 'react-icons/io5';
|
import { IoReload } from 'react-icons/io5';
|
||||||
import { MdTwoWheeler, MdSwapHoriz } from 'react-icons/md';
|
import { MdTwoWheeler, MdSwapHoriz, MdWarning } from 'react-icons/md';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildMilerLookup,
|
||||||
createAutomationDeliveries,
|
createAutomationDeliveries,
|
||||||
createOptimisationDeliveries,
|
createOptimisationDeliveries,
|
||||||
fetchRidersList,
|
fetchRidersList,
|
||||||
finalCreatedeliveries,
|
finalCreatedeliveries,
|
||||||
notifyRider,
|
notifyRider,
|
||||||
reconcileSteps
|
reconcileSteps,
|
||||||
|
resolveMilerForOrder
|
||||||
} from '../../api/api';
|
} from '../../api/api';
|
||||||
import { OpenToast } from 'components/third-party/OpenToast';
|
import { OpenToast } from 'components/third-party/OpenToast';
|
||||||
import CSVExport from 'components/third-party/ReactTable';
|
import CSVExport from 'components/third-party/ReactTable';
|
||||||
@@ -152,6 +155,14 @@ const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName })
|
|||||||
movedOrder = r.orders[oi];
|
movedOrder = r.orders[oi];
|
||||||
r.orders.splice(oi, 1);
|
r.orders.splice(oi, 1);
|
||||||
homeZoneIdx = zi;
|
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 {
|
} else {
|
||||||
next.zones = [
|
next.zones = [
|
||||||
{
|
{
|
||||||
@@ -329,19 +349,17 @@ const Preview = () => {
|
|||||||
const autoRiders = stateData.autoRiders || [];
|
const autoRiders = stateData.autoRiders || [];
|
||||||
const absentRidersPayload = stateData.absentRidersPayload || [];
|
const absentRidersPayload = stateData.absentRidersPayload || [];
|
||||||
|
|
||||||
const appId = useMemo(() => {
|
// fetchRidersList() takes no params — GET /admin/milers is tenant-scoped
|
||||||
if (stateData.appId) return stateData.appId;
|
// server-side from the auth token, not by appId. Gating this query on
|
||||||
if (typeof window !== 'undefined') {
|
// `!!appId` was wrong: orders.js (the entry point into this page) always
|
||||||
const v = localStorage.getItem('applocationid');
|
// navigates here with a hardcoded appId of 0 (it has no zone picker at
|
||||||
return v ? Number(v) : 0;
|
// all), which made this query permanently disabled unless a stale
|
||||||
}
|
// `applocationid` happened to be cached in localStorage from a previous
|
||||||
return 0;
|
// Dispatch.js visit — the Change Rider dropdown showed "no riders" for
|
||||||
}, [stateData.appId]);
|
// anyone who reached this page the normal way.
|
||||||
|
|
||||||
const { data: ridersList } = useQuery({
|
const { data: ridersList } = useQuery({
|
||||||
queryKey: ['ridersList', appId],
|
queryKey: ['ridersList'],
|
||||||
queryFn: fetchRidersList,
|
queryFn: fetchRidersList,
|
||||||
enabled: !!appId,
|
|
||||||
staleTime: 5 * 60 * 1000
|
staleTime: 5 * 60 * 1000
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -361,6 +379,24 @@ const Preview = () => {
|
|||||||
return [];
|
return [];
|
||||||
}, [reconcileRiders, dispatchPreviewData]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const filtered = finaldeliveryList.map((item) => ({
|
const filtered = finaldeliveryList.map((item) => ({
|
||||||
zone_name: item.zone_name,
|
zone_name: item.zone_name,
|
||||||
@@ -387,7 +423,17 @@ const Preview = () => {
|
|||||||
const notifyRiderMutation = useMutation({
|
const notifyRiderMutation = useMutation({
|
||||||
mutationFn: notifyRider,
|
mutationFn: notifyRider,
|
||||||
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000),
|
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({
|
const createDeliveryMutation = useMutation({
|
||||||
@@ -415,26 +461,23 @@ const Preview = () => {
|
|||||||
// database, which neither the Orders "pending" list nor the Deliveries
|
// database, which neither the Orders "pending" list nor the Deliveries
|
||||||
// "dispatched" filter ever read (both come from GET /admin/bookings).
|
// "dispatched" filter ever read (both come from GET /admin/bookings).
|
||||||
mutationFn: finalCreatedeliveries,
|
mutationFn: finalCreatedeliveries,
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
OpenToast('Delivery Created Successfully', 'success', 2000);
|
OpenToast('Delivery Created Successfully', 'success', 2000);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// stateData.rider (a single rider forwarded via navigate() from the
|
// stateData.rider (a single rider forwarded via navigate() from the
|
||||||
// Orders page) is never actually populated in the real flow — that
|
// Orders page) is never actually populated in the real flow — that
|
||||||
// page's navigate() call doesn't include a `rider` key at all — so
|
// 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
|
// this was a permanent no-op and no rider ever got notified after
|
||||||
// assignment. Notify every rider actually present in the committed
|
// assignment. Notify every rider finalCreatedeliveries actually
|
||||||
// list instead. notifyRider expects a milerprofileid (not an FCM
|
// resolved and assigned (data.resolvedMilerProfileIds — real
|
||||||
// token — the server looks the device up itself); rider_id/userid
|
// milerprofileids from GET /admin/milers, deduped there). Previously
|
||||||
// here is the id this page already treats as canonical throughout
|
// this notified using order.rider_id/userid directly, which is the
|
||||||
// (see flattenRiders/moveOrderInPreviewData above) since it's the
|
// solver's own internal rider numbering — confirmed live to NOT be a
|
||||||
// only rider identifier the solver echoes back — unconfirmed whether
|
// real Doormile userid or milerprofileid (see api.js's
|
||||||
// that's actually a milerprofileid by the time it reaches here.
|
// finalCreatedeliveries) — so every notification went out with a
|
||||||
const notifiedRiderIds = new Set();
|
// bogus id and likely silently failed server-side.
|
||||||
finaldeliveryList.forEach((order) => {
|
(data?.resolvedMilerProfileIds || []).forEach((milerprofileid) => {
|
||||||
const riderId = order.rider_id ?? order.userid;
|
notifyRiderMutation.mutate(milerprofileid);
|
||||||
if (riderId == null || notifiedRiderIds.has(String(riderId))) return;
|
|
||||||
notifiedRiderIds.add(String(riderId));
|
|
||||||
notifyRiderMutation.mutate(riderId);
|
|
||||||
});
|
});
|
||||||
navigate('/doormile/deliveries');
|
navigate('/doormile/deliveries');
|
||||||
},
|
},
|
||||||
@@ -446,9 +489,13 @@ const Preview = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const reconcileMutation = useMutation({
|
const reconcileMutation = useMutation({
|
||||||
mutationFn: reconcileSteps,
|
mutationFn: (payload) => {
|
||||||
|
logger.debug('reconcile: sending payload', payload);
|
||||||
|
return reconcileSteps(payload);
|
||||||
|
},
|
||||||
onMutate: () => setReconcileLoading(true),
|
onMutate: () => setReconcileLoading(true),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
logger.debug('reconcile: response', data);
|
||||||
if (Array.isArray(data?.riders)) {
|
if (Array.isArray(data?.riders)) {
|
||||||
// Merge: applyReconcileResponse replaces orders for riders present
|
// Merge: applyReconcileResponse replaces orders for riders present
|
||||||
// in the response and leaves the rest of the cache untouched.
|
// in the response and leaves the rest of the cache untouched.
|
||||||
@@ -459,14 +506,17 @@ const Preview = () => {
|
|||||||
setDirtyRiderIds((prev) => {
|
setDirtyRiderIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
data.riders.forEach((r) => next.delete(String(r.rider_id)));
|
data.riders.forEach((r) => next.delete(String(r.rider_id)));
|
||||||
|
logger.debug('reconcile: dirtyRiderIds after clearing reconciled riders', [...next]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
OpenToast('Steps reconciled — preview updated', 'success', 2000);
|
OpenToast('Steps reconciled — preview updated', 'success', 2000);
|
||||||
} else {
|
} 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);
|
OpenToast('Reconcile returned no rider data', 'warning', 3000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
logger.error('reconcile: request failed', error?.response?.status, error?.response?.data || error?.message);
|
||||||
OpenToast(error.message || 'Reconcile failed', 'error', 4000);
|
OpenToast(error.message || 'Reconcile failed', 'error', 4000);
|
||||||
},
|
},
|
||||||
onSettled: () => setReconcileLoading(false)
|
onSettled: () => setReconcileLoading(false)
|
||||||
@@ -513,11 +563,18 @@ const Preview = () => {
|
|||||||
OpenToast(`Reconcile ${dirtyRiderIds.size} edited rider(s) before assigning`, 'warning', 4000);
|
OpenToast(`Reconcile ${dirtyRiderIds.size} edited rider(s) before assigning`, 'warning', 4000);
|
||||||
return;
|
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);
|
setIsLoading(true);
|
||||||
createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList });
|
createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReconcile = () => {
|
const handleReconcile = () => {
|
||||||
|
logger.debug('handleReconcile: dirtyRiderIds', [...dirtyRiderIds], 'reconcileRiders ids', reconcileRiders.map((r) => r.rider_id));
|
||||||
if (!reconcileRiders.length) {
|
if (!reconcileRiders.length) {
|
||||||
OpenToast('No riders to reconcile', 'warning', 3000);
|
OpenToast('No riders to reconcile', 'warning', 3000);
|
||||||
return;
|
return;
|
||||||
@@ -529,6 +586,10 @@ const Preview = () => {
|
|||||||
dirtyRiderIds.has(String(r.rider_id))
|
dirtyRiderIds.has(String(r.rider_id))
|
||||||
);
|
);
|
||||||
if (!dirty.length) {
|
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);
|
OpenToast('No edits to reconcile', 'info', 2500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -559,21 +620,42 @@ const Preview = () => {
|
|||||||
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
|
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
|
||||||
`Rider ${newRiderId}`;
|
`Rider ${newRiderId}`;
|
||||||
|
|
||||||
setDispatchPreviewData((prev) =>
|
const moved = moveOrderInPreviewData(dispatchPreviewData, {
|
||||||
moveOrderInPreviewData(prev, {
|
orderId: selectedOrder.orderid,
|
||||||
orderId: selectedOrder.orderid,
|
oldRiderId: selectedOldRiderId,
|
||||||
oldRiderId: selectedOldRiderId,
|
newRiderId,
|
||||||
newRiderId,
|
newRiderName
|
||||||
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
|
// 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
|
// 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) => {
|
setDirtyRiderIds((prev) => {
|
||||||
const next = new Set(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));
|
if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
|
||||||
|
logger.debug('confirmChangeRider: dirtyRiderIds after change', [...next]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setHasReconciled(false);
|
setHasReconciled(false);
|
||||||
@@ -742,6 +824,7 @@ const Preview = () => {
|
|||||||
{r.orders.map((o, idx) => {
|
{r.orders.map((o, idx) => {
|
||||||
const stepNum = o.step ?? idx + 1;
|
const stepNum = o.step ?? idx + 1;
|
||||||
const color = stepColor(Number(stepNum) - 1);
|
const color = stepColor(Number(stepNum) - 1);
|
||||||
|
const isUnverified = unverifiedOrderIds.has(String(o.orderid));
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={`${o.orderid}-${idx}`}
|
key={`${o.orderid}-${idx}`}
|
||||||
@@ -749,17 +832,20 @@ const Preview = () => {
|
|||||||
<Box>
|
<Box>
|
||||||
<div>Order #{o.orderid}</div>
|
<div>Order #{o.orderid}</div>
|
||||||
<div>{o.deliveryaddress || o.deliverysuburb || ''}</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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
onClick={() => openChangeRider(r, o)}
|
onClick={() => openChangeRider(r, o)}
|
||||||
sx={{
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: color,
|
bgcolor: isUnverified ? '#ef4444' : color,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -767,13 +853,27 @@ const Preview = () => {
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
boxShadow:
|
boxShadow: isUnverified
|
||||||
'0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
|
? '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',
|
transition: 'transform 0.15s',
|
||||||
'&:hover': { transform: 'scale(1.08)' }
|
'&:hover': { transform: 'scale(1.08)' }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{stepNum}
|
{stepNum}
|
||||||
|
{isUnverified && (
|
||||||
|
<MdWarning
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -5,
|
||||||
|
right: -5,
|
||||||
|
color: '#ef4444',
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -823,12 +923,20 @@ const Preview = () => {
|
|||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</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}>
|
<span style={isMobile ? { width: '100%' } : undefined}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
fullWidth={isMobile}
|
fullWidth={isMobile}
|
||||||
disabled={dirtyRiderIds.size > 0}
|
disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
|
||||||
onClick={handleFinalCreateDelivery}
|
onClick={handleFinalCreateDelivery}
|
||||||
>
|
>
|
||||||
Assign Orders
|
Assign Orders
|
||||||
|
|||||||
@@ -85,3 +85,274 @@ export const getActiveOrder = (orders) => {
|
|||||||
});
|
});
|
||||||
return sorted.find(isActiveDelivery) || null;
|
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">
|
<MobileField label="KMS">
|
||||||
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
|
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
|
||||||
<Tooltip title="KMS" placement="top">
|
<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>
|
||||||
<Tooltip title="Cumulative Kms" placement="right">
|
<Tooltip title="Cumulative Kms" placement="right">
|
||||||
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
|
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
|
||||||
@@ -559,7 +559,7 @@ const OrdersPreview = () => {
|
|||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
|
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
|
||||||
<Tooltip title="KMS" placement="top">
|
<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>
|
||||||
<Tooltip title="Cumulative Kms" placement="right">
|
<Tooltip title="Cumulative Kms" placement="right">
|
||||||
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
|
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
|
||||||
@@ -668,7 +668,7 @@ const OrdersPreview = () => {
|
|||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
|
<Stack display={'flex'} flexDirection={'column'} gap={1} sx={{ cursor: 'pointer' }}>
|
||||||
<Tooltip title="KMS" placement="top">
|
<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>
|
||||||
<Tooltip title="Cumulative Kms" placement="right">
|
<Tooltip title="Cumulative Kms" placement="right">
|
||||||
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
|
<Chip size="small" label={`${val.cumulativekms} km`} color="success" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { useState, useEffect, useMemo, useCallback, Fragment, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, Fragment, useRef } from 'react';
|
||||||
|
import logger from '../../../utils/logger';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
var utc = require('dayjs/plugin/utc');
|
var utc = require('dayjs/plugin/utc');
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
@@ -53,7 +54,8 @@ import {
|
|||||||
MdNotes,
|
MdNotes,
|
||||||
MdMyLocation,
|
MdMyLocation,
|
||||||
MdOutlineFlag,
|
MdOutlineFlag,
|
||||||
MdPerson
|
MdPerson,
|
||||||
|
MdFlashOn
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
import { DeleteOutlined } from '@ant-design/icons';
|
import { DeleteOutlined } from '@ant-design/icons';
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
@@ -62,8 +64,9 @@ import CircularLoader from 'components/CircularLoader';
|
|||||||
import AiImage from '../../../assets/images/aiImage.png';
|
import AiImage from '../../../assets/images/aiImage.png';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { fetchPercentageData, createAutomationDeliveries, getallriders } from '../../api/api';
|
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
|
||||||
import { getBookings, cancelBooking, getAdminCustomers, getBookingTrack } from 'pages/api/doormileApi';
|
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.
|
// Design tokens — shared with the rest of the redesigned operator pages.
|
||||||
@@ -97,13 +100,17 @@ const BRAND = '#C01227';
|
|||||||
const BRAND_LIGHT = '#D25463';
|
const BRAND_LIGHT = '#D25463';
|
||||||
|
|
||||||
// Semantic per-row status palette, keyed by GET /admin/bookings' real `status`
|
// Semantic per-row status palette, keyed by GET /admin/bookings' real `status`
|
||||||
// enum (lowercased — StatusBadge lowercases before lookup). Only
|
// enum (lowercased — StatusBadge lowercases before lookup). pending_pickup,
|
||||||
// pending_pickup and converted_to_consignment have been observed against the
|
// miler_assigned, pickup_scheduled, converted_to_consignment and cancelled are
|
||||||
// live API so far; the rest are reasonable guesses at likely sibling states
|
// now confirmed live (see the ORDERS_STATUS_TABS comment above — a real
|
||||||
// with a safe unknown-status fallback in StatusBadge below.
|
// 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 = {
|
const ROW_STATUS_META = {
|
||||||
pending_pickup: { label: 'Pending Pickup', color: '#f59e0b', icon: MdHourglassEmpty },
|
pending_pickup: { label: 'Pending Pickup', color: '#f59e0b', icon: MdHourglassEmpty },
|
||||||
pending_assignment: { label: 'Pending Assignment', 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 },
|
converted_to_consignment: { label: 'Converted', color: '#6366f1', icon: MdCheckCircle },
|
||||||
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
|
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
|
||||||
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
|
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
|
// below. `status` values here are the raw, lowercased GET /admin/bookings
|
||||||
// enum (matches ROW_STATUS_META's keys), not api.js's separate generic
|
// enum (matches ROW_STATUS_META's keys), not api.js's separate generic
|
||||||
// pending/accepted/delivered mapping used by the Deliveries page.
|
// 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 = [
|
const ORDERS_STATUS_TABS = [
|
||||||
{ idx: 0, status: 'pending_pickup', label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
|
{ idx: 0, status: 'pending_pickup', statuses: ['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: 1,
|
||||||
{ idx: 3, status: 'cancelled', label: 'Cancelled', color: '#ef4444', icon: MdCancel }
|
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`
|
// Haversine straight-line distance in km — GET /admin/bookings has no `kms`
|
||||||
@@ -360,10 +398,30 @@ const Orders = () => {
|
|||||||
return counts;
|
return counts;
|
||||||
}, [allBookings]);
|
}, [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 filteredRows = useMemo(() => {
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
return allBookings.filter((b) => {
|
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;
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
b.bookingno?.toLowerCase().includes(q) ||
|
b.bookingno?.toLowerCase().includes(q) ||
|
||||||
@@ -372,7 +430,7 @@ const Orders = () => {
|
|||||||
b.notes?.toLowerCase().includes(q)
|
b.notes?.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [allBookings, currentStatus, debouncedSearch]);
|
}, [allBookings, activeTabStatuses, debouncedSearch]);
|
||||||
|
|
||||||
// "Load more" now reveals more of the already-fetched, already-filtered
|
// "Load more" now reveals more of the already-fetched, already-filtered
|
||||||
// set instead of requesting another server page (the bulk fetch above
|
// set instead of requesting another server page (the bulk fetch above
|
||||||
@@ -473,9 +531,12 @@ const Orders = () => {
|
|||||||
return {
|
return {
|
||||||
...val,
|
...val,
|
||||||
deliveryid: 0,
|
deliveryid: 0,
|
||||||
deliverydate: dayjs(val.serviceoptions?.[0]?.estimateddeliveryat || val.createdat)
|
// Doormile timestamps are naive IST wall-clock (express-console-api.md) —
|
||||||
.utc()
|
// .utc() was reinterpreting that as local time and shifting it back
|
||||||
.format('YYYY-MM-DD HH:mm:ss'),
|
// 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'),
|
assigntime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||||
orderstatus: 'pending',
|
orderstatus: 'pending',
|
||||||
orderamount: charge,
|
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.
|
// KPI tile definitions.
|
||||||
const kpiCards = [
|
const kpiCards = [
|
||||||
{
|
{
|
||||||
@@ -561,6 +686,39 @@ const Orders = () => {
|
|||||||
</Button>
|
</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) || ============================================= */}
|
{/* ============================================= || Header (compact) || ============================================= */}
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@@ -658,7 +816,7 @@ const Orders = () => {
|
|||||||
{ORDERS_STATUS_TABS.map((t) => {
|
{ORDERS_STATUS_TABS.map((t) => {
|
||||||
const Icon = t.icon;
|
const Icon = t.icon;
|
||||||
const active = tabvalue === t.idx;
|
const active = tabvalue === t.idx;
|
||||||
const count = statusCounts[t.status] ?? 0;
|
const count = t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={t.status}
|
key={t.status}
|
||||||
@@ -903,7 +1061,7 @@ const Orders = () => {
|
|||||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.125 }}>
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.125 }}>
|
||||||
<MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
|
<MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
|
||||||
{(() => {
|
{(() => {
|
||||||
const dateObj = dayjs(row.createdat);
|
const dateObj = parseDoormileTimestamp(row.createdat);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
|
<Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
|
||||||
@@ -1012,28 +1170,6 @@ const Orders = () => {
|
|||||||
|
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
<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 && (
|
{currentStatus === 'pending_pickup' && isCancellable && (
|
||||||
<Tooltip title="Cancel Order">
|
<Tooltip title="Cancel Order">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ export default function OrdersDetails() {
|
|||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={locationlist || []}
|
options={locationlist || []}
|
||||||
getOptionLabel={(option) => (option ? `${option.locationname} (${option.suburb})` : '')}
|
getOptionLabel={(option) => (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')}
|
||||||
value={locationValue}
|
value={locationValue}
|
||||||
PaperComponent={SoftPaper}
|
PaperComponent={SoftPaper}
|
||||||
onOpen={(event) => {
|
onOpen={(event) => {
|
||||||
@@ -725,7 +725,10 @@ export default function OrdersDetails() {
|
|||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={ridersList || []}
|
options={ridersList || []}
|
||||||
value={riderValue}
|
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}
|
PaperComponent={SoftPaper}
|
||||||
onOpen={() => {
|
onOpen={() => {
|
||||||
if (!appId) {
|
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