initial commit
This commit is contained in:
@@ -16,6 +16,37 @@ import axios from 'axios';
|
||||
export const DOORMILE_TOKEN_KEY = 'doormileToken';
|
||||
export const DOORMILE_USER_KEY = 'doormileUser';
|
||||
|
||||
/**
|
||||
* The rest of the signed-in identity, written by Login.jsx because parts of the
|
||||
* data layer read it straight from storage rather than through context.
|
||||
*
|
||||
* Ending a session has to clear these too. They are not the credential, but
|
||||
* `tenantid` is what decides whether a login is Doormile staff or scoped to one
|
||||
* client — so a stale one left behind by the previous operator is both their
|
||||
* identity sitting readable in the browser and a value the next session could
|
||||
* read before signing in.
|
||||
*/
|
||||
export const DOORMILE_SESSION_KEYS = ['authname', 'firstname', 'userid', 'roleid', 'tenantid'];
|
||||
|
||||
/**
|
||||
* Doormile AI's saved thread and its archive (owned by AIPanel.jsx's
|
||||
* HISTORY_KEY / CONVERSATIONS_KEY).
|
||||
*
|
||||
* These are session data, not a preference: the bot answers about live orders,
|
||||
* so a thread routinely contains order numbers, customer names and phone
|
||||
* numbers. Production users are warehouse and dispatch staff, often on a shared
|
||||
* terminal — leaving the transcript behind hands the next operator the last
|
||||
* one's work. The panel width is a preference and stays.
|
||||
*/
|
||||
export const DOORMILE_ASSISTANT_KEYS = ['doormileBotHistory', 'doormileBotConversations'];
|
||||
|
||||
/** Clears every key that makes up a Doormile session. */
|
||||
export const clearStoredSession = () => {
|
||||
localStorage.removeItem(DOORMILE_TOKEN_KEY);
|
||||
localStorage.removeItem(DOORMILE_USER_KEY);
|
||||
[...DOORMILE_SESSION_KEYS, ...DOORMILE_ASSISTANT_KEYS].forEach((key) => localStorage.removeItem(key));
|
||||
};
|
||||
|
||||
/** The backend, for every `/admin/*` call the console makes. */
|
||||
export const DOORMILE_API_URL = 'https://api.doormile.com/api/v1';
|
||||
|
||||
@@ -51,8 +82,7 @@ doormileAxios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && !window.location.href.includes('/login')) {
|
||||
localStorage.removeItem(DOORMILE_TOKEN_KEY);
|
||||
localStorage.removeItem(DOORMILE_USER_KEY);
|
||||
clearStoredSession();
|
||||
window.location.replace('/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import doormileAxios, { DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY } from './client';
|
||||
import doormileAxios, { DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY, clearStoredSession } from './client';
|
||||
|
||||
// The Doormile Express admin API — one named async export per
|
||||
// `api.doormile.com/api/v1/admin/*` endpoint.
|
||||
@@ -34,8 +34,8 @@ export const loginAdmin = async (email, password) => {
|
||||
};
|
||||
|
||||
export const logoutAdmin = () => {
|
||||
localStorage.removeItem(DOORMILE_TOKEN_KEY);
|
||||
localStorage.removeItem(DOORMILE_USER_KEY);
|
||||
// Clears the auxiliary identity keys too — see clearStoredSession in client.js.
|
||||
clearStoredSession();
|
||||
};
|
||||
|
||||
// Drains a paginated list endpoint instead of taking whatever the server's
|
||||
@@ -366,8 +366,37 @@ export const getMilerActivity = async (id, from, to) => {
|
||||
// ==============================|| Bookings ||============================== //
|
||||
|
||||
export const getBookings = async (pageno, pagesize) => {
|
||||
const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`);
|
||||
return response.data.data;
|
||||
// If caller specifically requested page > 1 with small page size:
|
||||
if (pageno > 1 && pagesize && pagesize < 100) {
|
||||
const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`);
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
// The backend hard-caps pagesize at 100 per request.
|
||||
// Drain all pages so no bookings are silently lost.
|
||||
const firstRes = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno: 1, pagesize: 100 })}`);
|
||||
const body = firstRes.data || {};
|
||||
const firstRows = body.data || [];
|
||||
const total = Number.isFinite(Number(body.total)) ? Number(body.total) : firstRows.length;
|
||||
|
||||
if (firstRows.length >= total || firstRows.length < 100) {
|
||||
return firstRows;
|
||||
}
|
||||
|
||||
const allRows = [...firstRows];
|
||||
const totalPages = Math.ceil(total / 100);
|
||||
for (let p = 2; p <= totalPages; p++) {
|
||||
try {
|
||||
const res = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno: p, pagesize: 100 })}`);
|
||||
const rows = res.data?.data || [];
|
||||
if (!rows.length) break;
|
||||
allRows.push(...rows);
|
||||
if (allRows.length >= total) break;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return allRows;
|
||||
};
|
||||
|
||||
// Same request as getBookings, but keeps the envelope's `total`/`page` instead
|
||||
|
||||
@@ -80,20 +80,48 @@ import {
|
||||
// left Picked permanently empty — nothing else in the enum maps to it.
|
||||
const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||
pending_pickup: 'pending',
|
||||
pending_assignment: 'pending',
|
||||
pending: 'pending',
|
||||
miler_assigned: 'pending',
|
||||
pickup_scheduled: 'accepted',
|
||||
arrived: 'arrived',
|
||||
converted_to_consignment: 'picked',
|
||||
collected_by_miler: 'picked',
|
||||
picked: 'picked',
|
||||
out_for_delivery: 'active',
|
||||
in_transit: 'active',
|
||||
intransit: 'active',
|
||||
active: 'active',
|
||||
delivered: 'delivered',
|
||||
cancelled: 'cancelled'
|
||||
completed: 'delivered',
|
||||
cancelled: 'cancelled',
|
||||
canceled: 'cancelled',
|
||||
skipped: 'skipped'
|
||||
};
|
||||
|
||||
// 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;
|
||||
// Supports both (status, reachedAt) and a booking/consignment row object.
|
||||
export const mapBookingStatusToDeliveryStatus = (statusOrRow, maybeReachedAt) => {
|
||||
if (statusOrRow && typeof statusOrRow === 'object') {
|
||||
const b = statusOrRow;
|
||||
const rawConsignmentStatus = b.consignmentstatus ?? b.consignment_status;
|
||||
if (rawConsignmentStatus) {
|
||||
return mapBookingStatusToDeliveryStatus(rawConsignmentStatus);
|
||||
}
|
||||
const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
|
||||
const rawStatus = b.status ?? b.orderstatus ?? b.bookingstatus;
|
||||
return mapBookingStatusToDeliveryStatus(rawStatus, reached);
|
||||
}
|
||||
|
||||
const raw = String(statusOrRow || '').trim().toLowerCase();
|
||||
if (raw === 'pickup_scheduled') {
|
||||
const isReached = maybeReachedAt != null && maybeReachedAt !== false && maybeReachedAt !== '';
|
||||
return isReached ? 'arrived' : 'accepted';
|
||||
}
|
||||
return BOOKING_STATUS_TO_DELIVERY_STATUS[raw] || raw;
|
||||
};
|
||||
|
||||
// A consignment's own status, when this booking has one and it looks like a
|
||||
@@ -105,7 +133,7 @@ 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;
|
||||
const raw = record.status ?? record.consignmentstatus ?? record.currentstatus ?? record.consignment_status;
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
||||
};
|
||||
|
||||
@@ -627,105 +655,90 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
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),
|
||||
// Which record the status above came from, and what it said. A booking
|
||||
// freezes at Converted_To_Consignment the moment it is picked up, so a
|
||||
// row can legitimately read "Active" while GET /admin/bookings still says
|
||||
// Converted_To_Consignment — which looks exactly like a bug unless the UI
|
||||
// can say where the value came from.
|
||||
consignmentstatus: consignmentStatusFor(b, consignmentMap),
|
||||
statusfromconsignment: consignmentStatusFor(b, consignmentMap) != null,
|
||||
droplat: b.deliverylatitude,
|
||||
droplon: b.deliverylongitude
|
||||
};
|
||||
});
|
||||
const cStatus = consignmentStatusFor(b, consignmentMap);
|
||||
const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
|
||||
const effectiveDeliveryStatus = cStatus
|
||||
? mapBookingStatusToDeliveryStatus(cStatus)
|
||||
: mapBookingStatusToDeliveryStatus(b.status, reached);
|
||||
|
||||
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,
|
||||
reachedat: reached,
|
||||
assigntime: b.updatedat,
|
||||
// The consignment's status WINS when there is one (e.g. Collected_By_Miler -> Picked,
|
||||
// Out_for_Delivery -> Active, Delivered -> Delivered).
|
||||
// Otherwise derives from booking status + reachedat:
|
||||
// Pickup_Scheduled + reachedat present -> Arrived
|
||||
// Pickup_Scheduled + reachedat absent -> Accepted
|
||||
orderstatus: effectiveDeliveryStatus,
|
||||
consignmentstatus: cStatus,
|
||||
statusfromconsignment: cStatus != null,
|
||||
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
|
||||
@@ -739,16 +752,6 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
//
|
||||
// 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);
|
||||
@@ -761,15 +764,6 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -780,11 +774,23 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
// 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 [bookings, consignments] = await Promise.all([
|
||||
getBookings(1, 1000).catch(() => []),
|
||||
getConsignments().catch(() => [])
|
||||
]);
|
||||
const consignmentMap = new Map();
|
||||
(consignments || []).forEach((c) => {
|
||||
const id = c?.consignmentid ?? c?.id;
|
||||
if (id != null) consignmentMap.set(String(id), c);
|
||||
});
|
||||
const dispatched = (bookings || []).filter((b) => b.assignedmileruserid || b.consignmentid);
|
||||
const counts = { total: dispatched.length };
|
||||
dispatched.forEach((b) => {
|
||||
const status = mapBookingStatusToDeliveryStatus(b.status);
|
||||
const cStatus = consignmentStatusFor(b, consignmentMap);
|
||||
const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
|
||||
const status = cStatus
|
||||
? mapBookingStatusToDeliveryStatus(cStatus)
|
||||
: mapBookingStatusToDeliveryStatus(b.status, reached);
|
||||
counts[status] = (counts[status] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
|
||||
Reference in New Issue
Block a user