initial commit

This commit is contained in:
2026-08-26 15:01:15 +05:30
parent aa42d9ee81
commit cf4f4ecd9c
87 changed files with 12814 additions and 2328 deletions

View File

@@ -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');
}

View File

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

View File

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