import axios from 'axios'; import { OpenToast } from 'components/third-party/OpenToast'; import logger from 'utils/logger'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; import { getMilers, getMiler, getMilerSummary, getMilerLogs, notifyMiler, getAdminTenants, getAdminCustomers, getTenantLocations, getHubs, getBookings, getBooking, getConsignments, assignMilerToBooking, cancelBooking, updateConsignmentStatus, getAdminPricing, getReports, getLocationsSummary, getProfile } from 'pages/api/doormileApi'; // This file talks exclusively to api.doormile.com (see utils/doormileAxios.js / // pages/api/doormileApi.js) — the old jupiter.nearle.app backend (REACT_APP_URL / // REACT_APP_URL2) has been fully retired from this project. See // express-console-api.md at the repo root for the endpoint reference. // // Several resources the old backend exposed (payment-mode types, tenant tab // counts, rider check-in history, per-location/per-rider report breakdowns, // rider login/checkout audit logs, live rider GPS/battery logs) have no // equivalent in the new API. Those functions below return safe empty defaults // (rather than throwing) so pages render an empty state instead of crashing — // each is commented with why. // The Deliveries page's STATUS_META/STATUS_TABS still use the old jupiter // lifecycle keys (pending/accepted/arrived/picked/active/skipped/delivered/ // cancelled) for its tabs, chip counts, and row badges. The new booking // status enum uses different strings entirely (Pending_Pickup, // Miler_Assigned, Pickup_Scheduled, Converted_To_Consignment, Out_for_Delivery, // ...) — Pending_Pickup, Converted_To_Consignment, Out_for_Delivery (named in // express-console-api.md's CityGate note), and now Miler_Assigned / // Pickup_Scheduled (confirmed live via a real booking-status breakdown logged // right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS // comment) are confirmed; the rest of this mapping is still a best-effort // guess. Unmapped statuses pass through lowercased, which the page's own // fallback renders as an "unknown" badge rather than crashing, AND the // Deliveries page now surfaces those rows under an "Other" tab that names the // unmapped enum — previously such a row was counted nowhere and shown nowhere. // `picked` now has a confirmed source (Converted_To_Consignment, below). // `arrived` and `skipped` still have none: the rider actions that produce them // (POST /miler/bookings/:id/reached, POST /miler/consignments/:id/skip) have no // booking-status equivalent in the confirmed enum, so those two tabs stay at 0 // until the backend is confirmed to expose them. // // 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. // Converted_To_Consignment is PICKED, not accepted. doormile-flow.md §5 is // explicit: `POST /miler/bookings/:bookingid/pickup-complete` "converts the // booking into a consignment" — so the status is written at the moment the // rider completes pickup, and the parcel is in their hands. deliveries.js says // the same thing from the other side ("this order hasn't been picked up yet — // status can only be updated once it becomes a consignment"). // // It sat on 'accepted', which held a picked-up parcel in the Accepted tab and // left Picked permanently empty — nothing else in the enum maps to it. const BOOKING_STATUS_TO_DELIVERY_STATUS = { pending_pickup: 'pending', miler_assigned: 'pending', pickup_scheduled: 'accepted', converted_to_consignment: 'picked', out_for_delivery: 'active', delivered: 'delivered', cancelled: 'cancelled' }; // Exported (not just module-local) so anything else that needs to classify a // raw booking status — the operator bot's status-breakdown intent included — // reuses this instead of growing a second copy of BOOKING_STATUS_TO_DELIVERY_STATUS // that can drift from it. export const mapBookingStatusToDeliveryStatus = (status) => { const key = String(status || '').toLowerCase(); return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key; }; // A consignment's own status, when this booking has one and it looks like a // status. `GET /admin/consignments` has no documented response schema, so this // reads defensively: only a non-empty string on one of the plausible field // names counts, and anything else returns undefined so the caller keeps using // the booking's status. const consignmentStatusFor = (booking, consignmentMap) => { if (!booking?.consignmentid || !consignmentMap?.size) return undefined; const record = consignmentMap.get(String(booking.consignmentid)); if (!record) return undefined; const raw = record.status ?? record.consignmentstatus ?? record.currentstatus; return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; }; // Haversine straight-line distance in km — used wherever a resource carries // lat/lng but no road-distance field (bookings, and by extension deliveries). const haversineKm = (lat1, lon1, lat2, lon2) => { if (![lat1, lon1, lat2, lon2].every(Number.isFinite)) return undefined; const R = 6371; const toRad = (d) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }; // ==============================|| getRiderPeriodicLogs ||============================== // // Confirmed by jupiter2doormile.md as the direct replacement for jupiter's // riderlogs table (Status: Done): GET /admin/milers/:id/logs. Field names on // a log entry aren't documented anywhere (no example JSON in either // reference doc) — this returns the most recent entry as-is and lets the // page's own optional-chaining render whatever fields actually come back. // Falls back to the miler record itself (which may carry a last-known // position) if the logs call fails or returns nothing. // Despite the parameter name (kept for caller compatibility), this must be // a milerprofileid — /admin/milers/:id/* routes 404 on a plain userid // (confirmed live). export const getRiderPeriodicLogs = async (userid) => { if (!userid) return null; try { const logs = await getMilerLogs(userid, undefined, undefined, 1); const latest = Array.isArray(logs) ? logs[0] : logs; if (latest) return latest; } catch { // Falls through to the getMiler() fallback below — this is polled every // 15s by the live map, so a toast here would spam the operator on every // failed tick. Only surface an error if the fallback also fails. } try { return (await getMiler(userid)) || null; } catch (err) { return null; } }; // ==============================|| fetchAppLocations (zone/location picker) ||============================== // // The new API has no "zones" resource — applocationid only exists as a field // on Hubs. Derive a zone picker list from the distinct cities in GET /admin/hubs. // // Scoped per tenant: Hub has no tenantid field at all (confirmed against // express-console-api.md), so GET /admin/hubs always returns every hub // nationwide with no server-side tenant filtering possible. A client-tenant // login should only see their own city's hub(s) — inferred from the // tenant's own GET /admin/tenants/:id/locations (city is free text there, // and on the hub side too, so matched via normalized exact-then-substring // comparison, not a clean foreign key). Staff logins (tenantid falsy) keep // seeing every hub, unchanged. Fails open (full list) whenever the tenant's // city can't be determined or nothing matches, rather than ever locking an // operator out with an empty picker. const normCity = (s) => String(s || '') .trim() .toLowerCase(); export const fetchAppLocations = async () => { try { const hubs = await getHubs(); const seen = new Map(); (hubs || []).forEach((hub) => { if (hub.applocationid != null && !seen.has(hub.applocationid)) { seen.set(hub.applocationid, { applocationid: hub.applocationid, locationname: hub.city || hub.hubname }); } }); const allLocations = [...seen.values()]; const tenantid = localStorage.getItem('tenantid'); const isStaff = !tenantid || tenantid === '0'; if (isStaff) { return [...allLocations, { locationname: 'All', applocationid: 0 }]; } const tenantLocations = await gettenantlocations(tenantid); const tenantCities = new Set((tenantLocations || []).map((loc) => normCity(loc.city)).filter(Boolean)); if (tenantCities.size === 0) { OpenToast("Could not determine your tenant's city — showing all zones.", 'warning', 3000); return [...allLocations, { locationname: 'All', applocationid: 0 }]; } let matched = allLocations.filter((loc) => tenantCities.has(normCity(loc.locationname))); if (matched.length === 0) { matched = allLocations.filter((loc) => { const hubNorm = normCity(loc.locationname); return [...tenantCities].some((cityNorm) => hubNorm.includes(cityNorm) || cityNorm.includes(hubNorm)); }); } if (matched.length === 0) { OpenToast("No zones matched your tenant's city — showing all zones.", 'warning', 3000); return [...allLocations, { locationname: 'All', applocationid: 0 }]; } return matched; } catch (err) { OpenToast(err.message, 'error', 2000); return [{ locationname: 'All', applocationid: 0 }]; } }; // ==============================|| fetchPercentageData (orders) ||============================== // // Best-effort against /admin/reports — field names are our assumption since // the doc only promises "counts + today's numbers", not an exact schema. export const fetchPercentageData = async ({ queryKey }) => { const [, , startdate, enddate] = queryKey; try { const details = (await getReports(startdate, enddate)) || {}; const total = details.total || 0; return { created: String(details.created || 0), uncoveredOrders: String(details.pending || 0), coveredOrders: String(details.delivered || 0), cancelled: String(details.cancelled || 0), percentage1: String(Math.round(((details.created || 0) / total) * 100) || 0), percentage2: String(Math.round(((details.pending || 0) / total) * 100) || 0), percentage3: String(Math.round(((details.delivered || 0) / total) * 100) || 0), percentage4: String(Math.round(((details.cancelled || 0) / total) * 100) || 0) }; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load order percentages', 'error', 2000); return { created: '0', uncoveredOrders: '0', coveredOrders: '0', cancelled: '0', percentage1: '0', percentage2: '0', percentage3: '0', percentage4: '0' }; } }; // ===================================================== || getTenants || ===================================================== export const getTenants = async () => { try { const tenants = await getAdminTenants(); return (tenants || []).map((val) => ({ ...val, label: val.tenantname })); } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load tenants', 'error', 2000); return []; } }; // ============================================= || gettenantlocations (branches) || ============================================= export const gettenantlocations = async (tenantId) => { try { return (await getTenantLocations(tenantId)) || []; } catch (err) { // Log only the message — the raw error object carries the request // config, including the Authorization: Bearer header // doormileAxios attaches, which shouldn't hit the console. OpenToast(err.response?.data?.message || err.message || 'Failed to load tenant locations', 'error', 2000); return []; } }; // ==============================|| fetchPaymentType (orders) ||============================== // // No payment-mode-types endpoint in the new API. export const fetchPaymentType = async () => []; // ==============================|| fetchRidersList (orders) ||============================== // export const fetchRidersList = async () => { try { const milers = await getMilers(); return (milers || []).map((val) => { const name = val.displayname || val.authname || ''; return { ...val, // Only append " | phone" when a phone actually exists — an // unconditional template literal left a dangling " | " on every // rider missing contactno, rendering literally in every dropdown // that falls back to this default label (e.g. OrdersPreview.js, // Preview.js's Change Rider dialog). label: val.contactno ? `${name} | ${val.contactno}` : name }; }); } catch (err) { // Was `throw`ing after already toasting here — deliveries.js also wires // its own onError toast on this same query, so a real failure showed // the operator the same error twice. Matches every sibling function's // convention now: toast once, return a safe default, never throw (the // other 4 call sites had no onError of their own and got zero feedback // on failure before this fix). OpenToast(err.response?.data?.message || err.message || 'Failed to load riders', 'error', 2000); return []; } }; // ==============================|| createOptimisationDeliveries (orders) Arrange the order ||============================== // // AI dispatch optimiser — a separate solver service, left untouched by this // migration (no equivalent exists in the new admin API). export const createOptimisationDeliveries = async (deliveryData) => { const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/createdeliveries`, deliveryData.deliveries); return response.data; }; // ==============================|| reconcileSteps (Preview - validate rider/order step assignments) ||============================== // export const reconcileSteps = async ({ riders }) => { logger.debug(`reconcileSteps: posting ${riders?.length ?? 0} rider(s)`, riders); const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/reconcile-steps`, { riders }); // Diagnostic: this is an external, unverified solver contract (see this // area's CLAUDE.md) — Preview.js's reconcileMutation only clears the // dirty-rider set (which is what re-enables Assign Orders) when // response.data.riders is an array. If the solver's real response shape // is different (wrapped in an envelope, a different key name, etc.), // that check silently fails every time and Assign Orders can never // re-enable — logging the exact raw shape here settles it either way. logger.debug('reconcileSteps: raw response.data', response.data); return response.data; }; // ==============================|| fetchBatchEfficiency (Dispatch - Analysis view) ||============================== // export const fetchBatchEfficiency = async ({ batch, tenantId }) => { const response = await axios.post( `https://routes.workolik.com/api/v1/batch/efficiency`, { batch, tenant_id: tenantId }, { headers: { 'Content-Type': 'application/json' }, validateStatus: () => true } ); return response.data; }; // ==============================|| finalCreatedeliveries (orders) ||============================== // // Final delivery commit. Used to POST the whole batch to jupiter's // /deliveries/createdeliveries in one shot — retired for two confirmed // reasons: (1) it 500'd because jupiter's Deliveries Go struct types // pickuplat/pickuplon/droplat/droplon as string, while GET /admin/bookings // (the new Doormile API) feeds orders.js's solver payload with those as // numbers; (2) even once that's patched, jupiter writes into jupiter's own // now-orphaned database — neither the Orders "pending" list nor the // Deliveries "dispatched" filter read it, since both come from Doormile's // GET /admin/bookings. jupiter2doormile.md §4 (the "assign a rider" row, // mapped from jupiter's old PUT /deliveries/updatedelivery) and // express-console-api.md's exercised-routes list both confirm // POST /admin/bookings/:id/assign-miler as the correct, live-proven // replacement for this exact action — not POST /admin/expressbooking // (that one creates a brand-new booking from scratch; wrong here, since // every order already exists as a Doormile booking pulled from // GET /admin/bookings). // Shared by finalCreatedeliveries (here) and Preview.js's pre-commit // verification UI, so both use the IDENTICAL matching rule rather than two // copies that could silently drift apart. userid/milerprofileid matching is // tried first (the solver's rider pool is fed full miler objects for // Auto/multi-trip mode — see Preview.js's handleCreateDelivery -> `riders: // autoRiders`, raw GET /admin/milers data carrying both fields per rider) // but confirmed live that for Bike hypertuning mode NEITHER matches: that // solver never receives a rider pool at all and assigns from its own // internal roster, seeded against jupiter rider ids when the integration // was first built — disconnected from Doormile's id space entirely (see // jupiter2doormile.md comparison). Falls back to matching the rider's NAME // (also echoed by the solver, see flattenRiders' rider_name) against each // miler's displayname/authname — the only other correlatable field. const normMilerName = (s) => String(s || '') .trim() .toLowerCase(); export const buildMilerLookup = (milers) => { const byUserId = new Map((milers || []).map((m) => [String(m.userid), m])); const byProfileId = new Map((milers || []).map((m) => [String(m.milerprofileid), m])); const byName = new Map(); (milers || []).forEach((m) => { [m.displayname, m.authname].forEach((n) => { const key = normMilerName(n); if (key && !byName.has(key)) byName.set(key, m); }); }); return { byUserId, byProfileId, byName }; }; export const resolveMilerForOrder = (order, lookup) => { const riderUserId = order.rider_id ?? order.userid; const riderName = order.rider_name ?? order.rider; const matchedVia = lookup.byUserId.has(String(riderUserId)) ? 'userid' : lookup.byProfileId.has(String(riderUserId)) ? 'milerprofileid' : lookup.byName.has(normMilerName(riderName)) ? 'name' : null; const rider = lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName)); return rider?.milerprofileid ? { rider, matchedVia } : null; }; export const finalCreatedeliveries = async (deliveryData) => { const deliveries = deliveryData.deliveries || []; logger.debug(`finalCreatedeliveries: ${deliveries.length} order(s) to assign`); if (!deliveries.length) return { success: true, assigned: 0, failed: 0 }; // Diagnostic only (no PII beyond a rider's display name, which every // operator already sees in the UI). Shows exactly which candidate field // names are actually present, so a wrong guess in the fallback chain // below is visible instead of silently mismatching. deliveries.forEach((d, i) => { logger.debug(` order[${i}] id fields:`, { bookingid: d.bookingid, orderheaderid: d.orderheaderid, deliveryid: d.deliveryid, orderid: d.orderid, rider_id: d.rider_id, userid: d.userid, rider_name: d.rider_name ?? d.rider }); }); let milers = []; try { milers = (await getMilers()) || []; } catch (err) { logger.error('finalCreatedeliveries: GET /admin/milers failed', err.response?.status, err.response?.data || err.message); } logger.debug( `finalCreatedeliveries: ${milers.length} miler(s) available for rider resolution`, milers.map((m) => ({ userid: m.userid, milerprofileid: m.milerprofileid, name: m.displayname || m.authname })) ); const lookup = buildMilerLookup(milers); // Booking id resolution: confirmed live that guessing a single field name // (bookingid first, on the assumption the solver passes unknown fields // through untouched) sends assign-miler a wrong id — a small, sequential- // looking number that 404s. The AI solver is a separate, unverified // service (see this area's CLAUDE.md); there's no reliable way to know // which candidate field it actually preserves. Instead of guessing, // fetch the tenant's real current booking list once and VALIDATE each // candidate against it, using whichever one actually matches a real // booking — this is correct regardless of which field the solver happens // to preserve, and regardless of which one changes in a future solver // update. let realBookingIds = new Set(); try { const realBookings = (await getBookings(1, 1000)) || []; realBookingIds = new Set(realBookings.map((b) => String(b.bookingid))); logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`); } catch (err) { logger.error( 'finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', err.response?.status, err.response?.data || err.message ); } const results = await Promise.allSettled( deliveries.map(async (d) => { const candidates = [d.bookingid, d.orderheaderid, d.deliveryid, d.orderid]; const bookingId = candidates.find((c) => c != null && realBookingIds.has(String(c))); const riderUserId = d.rider_id ?? d.userid; const riderName = d.rider_name ?? d.rider; const resolved = resolveMilerForOrder(d, lookup); if (bookingId == null || !resolved) { const reason = bookingId == null ? `no candidate id matched a real booking (tried bookingid=${d.bookingid}, orderheaderid=${d.orderheaderid}, deliveryid=${d.deliveryid}, orderid=${d.orderid})` : `no miler found for rider id ${riderUserId} / name "${riderName}" (checked userid, milerprofileid, name)`; logger.error(`finalCreatedeliveries: skipping order — ${reason}`); throw new Error(`order ${d.orderid ?? bookingId ?? '?'}: ${reason}`); } const { rider, matchedVia } = resolved; logger.debug(`finalCreatedeliveries: order booking ${bookingId} -> rider ${riderUserId} ("${riderName}") matched via ${matchedVia}`); try { // doormile-flow.md (confirmed current, authoritative): assign-miler's // body key is `mileruserid`, and it's the miler's userid — a // DIFFERENT identity space than milerprofileid, which is what admin // miler endpoints (notify, block, etc.) key on instead. Sending // { milerid: rider.milerprofileid } was wrong on both the key name // and the value's identity space — the actual root cause of the // persistent 404s on this call. await assignMilerToBooking(bookingId, { mileruserid: rider.userid }); // Return the REAL resolved milerprofileid, not the solver's own // rider_id/userid — the caller (Preview.js) needs this for // notifyRider, which takes a milerprofileid specifically. Before // this, Preview.js notified using the raw solver id directly, which // is neither a real userid nor a milerprofileid (see the matching // comment above) — so rider push notifications were going out with // a bogus id and very likely silently failing server-side. return { milerprofileid: rider.milerprofileid }; } catch (err) { logger.error( `finalCreatedeliveries: assign-miler failed for booking ${bookingId}`, err.response?.status, err.response?.data || err.message ); throw err; } }) ); const failed = results.filter((r) => r.status === 'rejected'); if (failed.length === deliveries.length) { // Total failure — throw so the caller's mutation onError fires instead // of a false "success" toast + navigate-away with nothing assigned. throw new Error(`Couldn't assign any of the ${deliveries.length} order(s)`); } if (failed.length) { OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000); } const resolvedMilerProfileIds = [...new Set(results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid))]; return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds }; }; // ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== // // Also part of the optimiser pipeline (routes.workolik.com / routemate.workolik.com) — untouched. export const createAutomationDeliveries = async (variables) => { const absentRiders = Array.isArray(variables.absent_riders) ? variables.absent_riders : []; const url = variables.selectedMode.value == 1 ? `https://routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=${variables.hypertuning_params}` : `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip`; const body = variables.selectedMode.value == 1 ? { deliveries: variables.deliveries, absent_riders: absentRiders } : { ...(variables.data || {}), absent_riders: absentRiders }; const response = await axios.post(url, body); return response.data; }; // ==============================|| notifyRider (orders / deliveries) ||============================== // // New API notifies by milerprofileid + title/message (server looks up the // device token itself) instead of the caller passing an FCM token directly. export const notifyRider = async ( milerProfileId, title = 'DoormileXpress', message = 'Orders have been placed for delivery. Kindly accept and process deliveries.' ) => { if (!milerProfileId) { throw new Error('Invalid miler profile id'); } return notifyMiler(milerProfileId, title, message); }; // ==============================|| fetchDeliveries (deliveries) ||============================== // // GET /admin/consignments has zero documented response schema (see // express-console-api.md — it's listed with no example body at all, unlike // bookings). Rather than bind the deliveries table to an unknown shape, this // builds the list from /admin/bookings (fully confirmed schema — see // getBookings/fetchOrders in this same file) filtered to bookings that have // actually been dispatched (assignedmileruserid or consignmentid set) — the // Deliveries page is conceptually "orders that have moved past just being // created", same underlying resource as the Orders page. // // Real consignment-only data (route step order, transit minutes, cumulative // km, live delivery-leg status distinct from the booking's own status) has no // confirmed field names and is NOT fabricated here — those cells render a // "—" placeholder in the UI instead of invented numbers. If GET // /admin/consignments' real shape is confirmed later, this is the function to // extend with a join by booking.consignmentid. // // Each row is normalised onto the *old* jupiter field names the Deliveries // page already reads (orderheaderid, deliverycustomer, ridername, etc.) so // deliveries.js itself doesn't need a full rewrite — same compatibility-shim // approach as fetchAllRiders' `{details}` wrapper. export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // Every caller builds the key as // [name, appId, userid, status, startdate, enddate, rowsPerPage, …] // (Dispatch.js, deliveries.js, reports/ordersDetails.js, // reports/profitability.js — all four verified). Only rowsPerPage used to be // read here, which meant startdate/enddate were accepted and then ignored: // changing the date on the Dispatch page rebuilt the query key and refetched, // but issued an identical request, so the row set — and every batch count // derived from it — was byte-identical for today, yesterday and last week. // // GET /admin/bookings takes only pageno/pagesize (doormileApi.js; no date // parameter is documented in express-console-api.md and guessing one risks a // silent 400 or, worse, a silently-ignored filter), so the range is applied // client-side below, after the rows are normalised. const [, , , , startdate, enddate, rowsPerPage] = queryKey; // Unlike the 3 joins below (customers/milers/tenants — each individually // guarded so a failed join just degrades a display field, not the whole // page), a failed bookings call is the one thing this function can't // recover from — the previous version let it reject the whole Promise.all // uncaught, with no toast anywhere across the 4 pages that call this. let bookings; try { bookings = await getBookings(pageParam, rowsPerPage); } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load deliveries', 'error', 2000); return { rows: [], nextPage: undefined }; } // Consignments are joined for ONE reason: once a booking becomes a // consignment, its lifecycle continues on the CONSIGNMENT record and the // booking's own `status` stops moving. `PUT /admin/consignments/:id/status` // — the Update Status dialog — writes there, so the write succeeded, the // toast said so, and this page went on showing the booking's stale // `Converted_To_Consignment` because that is the only field it read. // // Guarded like the other three joins: if the call fails, or the response // carries no recognisable status, the row falls back to the booking's status // and behaviour is exactly what it was before this join existed. const [customers, milers, tenants, consignments] = await Promise.all([ getAdminCustomers().catch(() => []), getMilers().catch(() => []), getAdminTenants().catch(() => []), getConsignments().catch(() => []) ]); // The id field on a consignment record has never been captured, so both // plausible names are indexed rather than guessing one. const consignmentMap = new Map(); (consignments || []).forEach((c) => { const id = c?.consignmentid ?? c?.id; if (id != null) consignmentMap.set(String(id), c); }); const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c])); const milerMap = new Map((milers || []).map((m) => [m.userid ?? m.milerid, m])); const tenantMap = new Map((tenants || []).map((t) => [t.tenantid, t])); const dispatched = (bookings || []).filter((b) => b.assignedmileruserid || b.consignmentid); const rows = dispatched.map((b) => { const customer = customerMap.get(b.appcustomerid); const miler = milerMap.get(b.assignedmileruserid); const tenant = tenantMap.get(b.tenantid); const charge = b.serviceoptions?.[0]?.estimatedprice; return { orderheaderid: b.bookingid, deliveryid: b.bookingid, orderid: b.bookingno || `#${b.bookingid}`, consignmentid: b.consignmentid, tenantid: b.tenantid, tenantname: tenant?.tenantname || '', tenantsuburb: '', applocation: '', tenantadress: tenant?.primaryemail || '', locationname: tenant?.tenantname || '', locationsuburb: '', // Was hardcoded '' — Dispatch.js's kitchen markers read this as the // pickup business name (`o.pickupcustomer || o.kitchen_key || 'Unknown'`, // Dispatch.js:1660), and no booking on this API carries a `kitchen_key` // field at all, so every kitchen pin fell through to the literal string // 'Unknown' — rendered as a "U" marker whose hover/popup then showed // "Unknown". The tenant IS the kitchen for a B2B booking (same value // already used for tenantname/locationname above), so reuse it here. pickupcustomer: tenant?.tenantname || '', pickupcontactno: '', Pickupaddress: b.pickupaddress || '', pickupaddress: b.pickupaddress || '', pickuplocation: b.pickupaddress || '', pickupsuburb: '', deliverycustomer: customer?.firstname || customer?.name || (b.appcustomerid ? `Customer #${b.appcustomerid}` : ''), deliverycontactno: customer?.phone || customer?.contactno || '', deliveryaddress: b.deliveryaddress || '', deliverylocation: b.deliveryaddress || '', deliverysuburb: '', ridername: miler?.displayname || (b.assignedmileruserid ? `Rider #${b.assignedmileruserid}` : ''), userid: b.assignedmileruserid, // GET /admin/milers/:id/notify (and block/assign-vehicle) key off // milerprofileid, not the userid stored on the booking — confirmed // live (a booking's assignedmileruserid matches a miler's `userid` // field, which 404s against /admin/milers/:id; milerprofileid is the // real primary key of that resource). milerprofileid: miler?.milerprofileid, ridercontact: miler?.phone || '', expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat, // No route-plan data source (step order/transit time/cumulative km were // computed by the old jupiter backend from the dispatch optimiser's // output, not stored on a booking/consignment) — left undefined so the // UI's own "—" fallbacks render instead of a fabricated number. transitminutes: undefined, cumulativekms: undefined, step: undefined, // No road-distance field on a booking — approximated as a straight // line between pickup and delivery coordinates. kms: haversineKm(b.pickuplatitude, b.pickuplongitude, b.deliverylatitude, b.deliverylongitude), pickuplatitude: b.pickuplatitude, pickuplongitude: b.pickuplongitude, deliverylatitude: b.deliverylatitude, deliverylongitude: b.deliverylongitude, deliverycharges: charge, deliveryamt: charge, deliveryamount: charge, Quantity: b.parcels?.length || 0, quantity: b.parcels?.length || 0, collectionamt: undefined, notes: b.notes || '', deliverytype: customer ? 'B' : 'C', orderdate: b.createdat, deliverydate: b.serviceoptions?.[0]?.estimateddeliveryat || b.updatedat, // ⚠ NOT a real assignment time. The Doormile bookings feed has no // assignment timestamp (the true one lives on `bookingassignments`, // reachable only per-booking via GET /admin/bookings/:id/track), so this // is the booking's last-modified column. It moves every time ANYTHING // touches the row — status change, parcel scan, payment, pickup-complete. // // **Never bucket or group by this field.** Dispatch.js and deliveries.js // used to bucket their Morning/Afternoon/Evening batches on it, which // meant an order re-stamped during the evening silently jumped out of the // batch it was actually assigned to and into whichever window contained // the current clock time — the same orders appearing under Afternoon and // then Evening on the same day. Both now bucket on `orderdate` (this // row's `createdat`, immutable) instead — see dispatch/CLAUDE.md §1's // table for why `expecteddeliverytime` was also tried and rejected (it's // the promised delivery slot, not the wave the order was placed in). It // remains fine to DISPLAY assigntime as a "last updated" stamp, which is // all the reports use it for. assigntime: b.updatedat, // The consignment's status WINS when there is one. That is the record // the rider app and the Update Status dialog both advance; the booking's // status is frozen at Converted_To_Consignment from pickup onwards. // Falls back to the booking whenever the consignment is absent or carries // nothing status-shaped — never invents a state. orderstatus: mapBookingStatusToDeliveryStatus(consignmentStatusFor(b, consignmentMap) ?? b.status), droplat: b.deliverylatitude, droplon: b.deliverylongitude }; }); // Apply the requested date range to the booking's CREATION day. This has to // agree with what the batch bucketing reads (Dispatch.js's // BATCH_TIME_FIELD / deliveries.js's BATCH_TIME_KEYS, both `orderdate`) — // filtering on one field while bucketing on another is how you get a row // that is counted for the day but belongs to no batch in it. // // parseDoormileTimestamp, not bare dayjs(): some Doormile timestamps carry a // false trailing Z, and near midnight an unstripped one shifts the row +5:30 // into the next calendar day, dropping it from the selected date. // // A missing/blank bound means "unbounded on that side", which preserves the // old behaviour for any caller that doesn't pass real dates. // // ⛔ An "activity" basis — also admitting a row whose `assigntime` // (== `updatedat`) falls in the window — was tried and REVERTED. It let an // order created yesterday evening and merely touched today onto today's // board, but batch bucketing reads `orderdate`, so that row landed in // Evening Batch. The live result was "Evening 6" at 10:41 in the morning on a // day with no orders created at all. Admitting a row on one timestamp while // bucketing it on another cannot produce an honest batch count; if // carried-over work needs to be visible it needs its own bucket, not a // time-of-day wave it does not belong to. const inRange = (row) => { if (!startdate && !enddate) return true; const t = parseDoormileTimestamp(row.orderdate); if (!t.isValid()) return false; const day = t.format('YYYY-MM-DD'); if (startdate && day < String(startdate)) return false; if (enddate && day > String(enddate)) return false; return true; }; return { rows: rows.filter(inRange), // 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 }; }; // Backs fetchCountAPI so the chip counts are derived from the exact same // source (and status mapping) as fetchDeliveries' rows — counting against // the separately-guessed /admin/dashboard shape produced numbers that didn't // match what the table actually showed. const getDeliveryStatusCounts = async () => { try { const bookings = (await getBookings(1, 1000)) || []; const dispatched = bookings.filter((b) => b.assignedmileruserid || b.consignmentid); const counts = { total: dispatched.length }; dispatched.forEach((b) => { const status = mapBookingStatusToDeliveryStatus(b.status); counts[status] = (counts[status] || 0) + 1; }); return counts; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load delivery counts', 'error', 2000); return {}; } }; // ==============================|| fetchCountAPI (deliveries) ||============================== // export const fetchCountAPI = async () => { const data = await getDeliveryStatusCounts(); return { total: data.total || 0, uncoveredLength: data.pending || 0, assignedLength: data.accepted || 0, arrivedLength: data.arrived || 0, pickedLength: data.picked || 0, activeLength: data.active || 0, coveredLength: data.delivered || 0, cancelLength: data.cancelled || 0, skippedLength: data.skipped || 0 }; }; // ==============================|| cancelDeliveryAPI (deliveries) ||============================== // // jupiter2doormile.md §4 confirms cancel is its own booking-level action — // POST /admin/bookings/:id/cancel — not a consignment status PUT. cancelFeed // isn't part of the documented cancel body; sent as a best-effort extra field // rather than silently dropped, since it's unconfirmed whether the server // persists it. export const cancelDeliveryAPI = async (selectedRow, cancelFeed) => cancelBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { reason: cancelFeed }); // ==============================|| getorderdetails (deliveries) ||============================== // export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid); // ==============================|| changeRiderAPI (deliveries) ||============================== // // doormile-flow.md (confirmed current, authoritative) settles this: the body // is { "mileruserid": } — the previous guess here (`milerid` // key, `milerprofileid` value) was wrong on both counts. Admin miler // endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the // one exception that wants userid instead — "different identity spaces on // adjacent endpoints," per that doc's own wording. selectedRider comes // straight from getMilers(), which carries both fields on the same object. export const changeRiderAPI = async (selectedRider, selectedRow) => assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { mileruserid: selectedRider.userid }); // ==============================|| updateDeliveryAPI (deliveries) ||============================== // // No amount/notes field exists on PUT /admin/consignments/:id/status — closest // available write is a status update. Free-text amount/notes edits have no home // in the new API yet. // 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. // // ---- The body --------------------------------------------------------------- // // This used to forward the dialog's WHOLE state object as the request body — // the old jupiter shape (`orderstatus`, `deliveryid`, `orderheaderid`, // `deliveryamt`, `cumulativekms`, `userid`). The endpoint wants one field // called `status`, so every submit came back: // // PUT /admin/consignments/40/status → 400 {"status is required"} // // The status was in the payload the whole time, under the wrong name. // // The VALUE has to be translated too. The dialog's options are this page's own // lifecycle keys (`delivered`, `cancelled`, …); the API speaks the booking enum // (`Delivered`, `Cancelled`, …). Sending `delivered` where `Delivered` is // expected is the same class of bug one layer down. // // Reverse of BOOKING_STATUS_TO_DELIVERY_STATUS, and deliberately NOT derived // from it by inversion: that map is many-to-one (`pending_pickup` and // `miler_assigned` both mean `pending`), so an automatic inversion would pick // whichever happened to be last and silently write the wrong one. const DELIVERY_STATUS_TO_BOOKING_STATUS = { pending: 'Pending_Pickup', accepted: 'Pickup_Scheduled', picked: 'Converted_To_Consignment', // The dialog offers "started", which this API has no separate state for — a // consignment that has started IS out for delivery. started: 'Out_for_Delivery', active: 'Out_for_Delivery', delivered: 'Delivered', cancelled: 'Cancelled', canceled: 'Cancelled' }; export const updateDeliveryAPI = async (orderData) => { const id = orderData.consignmentid ?? orderData.deliveryid; const chosen = String(orderData.orderstatus || '').toLowerCase(); const status = DELIVERY_STATUS_TO_BOOKING_STATUS[chosen]; // `arrived` and `skipped` have no booking-status equivalent at all (the rider // actions behind them — /miler/bookings/:id/reached and // /miler/consignments/:id/skip — write no booking status). Refusing here with // the reason is honest; guessing a near-enough status would set the wrong one // on a real delivery. if (!status) { return { success: false, message: chosen ? `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.` : 'Choose a status first.' }; } // Only `status` is sent. The dialog's kms / amount / notes have no field on // this endpoint (see the note above), and this request 400s on validation — // so posting the rest is at best ignored and at worst another rejection. return updateConsignmentStatus(id, { status }); }; // ==============================|| getalltenants (tenants) ||============================== // export const getalltenants = async () => { try { return (await getAdminTenants()) || []; } catch (err) { const message = err.response?.data?.message || err.message || 'Something went wrong'; OpenToast(message); return null; } }; // ==============================|| getallpricing (clientPricing) ||============================== // // This is the client-facing "Pricing" page (tenant pricing rules, GET // /admin/pricing) — not GET /admin/doormile-pricing, which is Doormile's own // internal banding with no tenant concept. Previously pointed at // getDoormilePricing(), so the page's "Tenant"/"Tenants Priced" columns never // had real data behind them. export const getallpricing = async () => { try { return (await getAdminPricing()) || []; } catch (err) { const message = err.response?.data?.message || err.message || 'Something went wrong'; OpenToast(message); return []; } }; // ==============================|| fetchAllRiders (riders) ||============================== // export const fetchAllRiders = async () => { try { const milers = await getMilers(); return { details: milers || [], nextPage: undefined }; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load riders', 'error', 2000); return { details: [], nextPage: undefined }; } }; // ==============================|| getallridersummary (riders) ||============================== // export const getallridersummary = async () => { try { const milers = (await getMilers()) || []; // A miler has no `status` field — availabilitystatus is the real one // (confirmed live: Available/Assigned/On_Pickup/Offline/Blocked). // "Active" here means not Offline and not Blocked, matching riders.js's // own tab filter. `available`/`onDelivery` are a KPI-card-friendly // breakdown of that same "active" bucket — Available is free/idle, // On Delivery covers both Assigned (accepted, not yet picked up) and // On_Pickup (en route) — computed separately so an unrecognised status // value still falls into "active" (unchanged behaviour) without also // silently inflating one of the two new buckets. const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length; const available = milers.filter((m) => String(m.availabilitystatus || '').toLowerCase() === 'available').length; const onDelivery = milers.filter((m) => ['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())).length; return { total: milers.length, active, inactive: milers.length - active, available, onDelivery }; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000); return { total: 0, active: 0, inactive: 0, available: 0, onDelivery: 0 }; } }; // ==============================|| getreportsummary (orders summary)||============================== // // appId (queryKey[0]) was previously discarded here — the LocationAutocomplete // zone picker on the Orders Summary page changed the header text but never // the actual request. GET /admin/reports does accept a hubid param (see // getReports in doormileApi.js) — hubs are this API's zone-equivalent (see // LocationAutocomplete's own comment) — so pass appId through as that. export const getreportsummary = async ({ queryKey }) => { const [appId, tenantid, locationid, startdate, enddate] = queryKey; try { return (await getReports(startdate, enddate, tenantid, locationid, appId || undefined)) || {}; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load the report summary', 'error', 2000); return {}; } }; // ==============================|| getreportlocationsummary (orders summary)||============================== // // Confirmed against jupiter2doormile.md (Status: Done) as the replacement for // jupiter's getlocationsummary — GET /admin/locations/summary. export const getreportlocationsummary = async ({ queryKey }) => { const [, tenantid, locationid, startdate, enddate] = queryKey; try { return (await getLocationsSummary(tenantid, locationid, startdate, enddate)) || []; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load the location summary', 'error', 2000); return []; } }; // ==============================|| fetchRidersSummary (riders summary)||============================== // // No per-rider performance summary endpoint in the new API. // Confirmed against jupiter2doormile.md (Status: Done) as the replacement // for jupiter's getridersummary — GET /admin/milers/summary. Per-rider row // field names aren't documented anywhere; this passes the response through // as-is and lets the page's own rendering degrade gracefully on unknown keys. export const fetchRidersSummary = async ({ queryKey }) => { const [, appId, startdate, enddate] = queryKey; try { const data = await getMilerSummary(appId || undefined, startdate, enddate); return Array.isArray(data) ? data : data ? [data] : []; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load the riders summary', 'error', 2000); return []; } }; // ==============================|| fetchRidersLogs (RiderLogs)||============================== // // No rider login/checkout audit-log endpoint in the new API. // Was a permanent [] stub ("no rider login/checkout audit-log endpoint") — // that was true for the jupiter audit-log concept this was originally named // after, but jupiter2doormile.md confirms (Status: Done) that // GET /admin/milers/summary carries live position (currentlatitude/ // currentlongitude/lastpingat per that doc) for every rider at a zone. This // feeds Dispatch.js's live map pins and "All Active Routes" view, which were // both silently dead with the old stub. // // GET /admin/milers/summary confirmed live: { milerprofileid, userid, // displayname, phone, availabilitystatus, defaultvehicletype, hubid, // hubname, rating, onduty, dutystartedat, currentlatitude, currentlongitude, // lastlocationupdatedat, lastpingat, assigned, accepted, rejected, // completed, cancelled, delivered, riderkms, ridercharges } — no `status`, // `contactno`, or current-order-id field exists at all. `id` is exposed as // an alias of milerprofileid, which is what every /admin/milers/:id/* route // (notify, block, assign-vehicle, logs) actually keys off — not userid. export const fetchRidersLogs = async ({ queryKey } = {}) => { const [appId] = queryKey || []; try { const data = await getMilerSummary(appId || undefined); const riders = Array.isArray(data) ? data : data?.riders || data?.milers || []; return riders.map((r) => ({ id: r.milerprofileid, milerprofileid: r.milerprofileid, userid: r.userid, username: r.displayname, status: r.availabilitystatus, contactno: r.phone, orderid: undefined, logdate: r.lastpingat ?? r.lastlocationupdatedat, latitude: r.currentlatitude, longitude: r.currentlongitude })); } catch { // Polled every 1s by Dispatch.js's live map (RIDER_LOG_POLL_MS) — a toast // here would fire once a second on any outage. Fail silently; the map // just stops updating rider pins until the next successful poll. return []; } }; // ==============================|| getusers (viewProfile)||============================== // export const getusers = async () => { try { return (await getProfile()) || {}; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load profile', 'error', 2000); return {}; } }; // ==============================|| getallriders (order)||============================== // export const getallriders = async () => { try { return (await getMilers()) || []; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load riders', 'error', 2000); return []; } };