diff --git a/package-lock.json b/package-lock.json
index 9186748..466d972 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,6 +43,7 @@
"recharts": "^3.10.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
+ "use-debounce": "^10.1.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
@@ -7472,6 +7473,18 @@
}
}
},
+ "node_modules/use-debounce": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz",
+ "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
diff --git a/package.json b/package.json
index f8f5635..aef1808 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"recharts": "^3.10.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
+ "use-debounce": "^10.1.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
diff --git a/src/App.jsx b/src/App.jsx
index 3f7b6f0..f073e37 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -88,6 +88,7 @@ const AuthenticatedApp = () => {
order whose id is "create". */}
} />
} />
+ } />
} />
} />
diff --git a/src/api/doormile/client.js b/src/api/doormile/client.js
index 1e1fbdf..87e04a0 100644
--- a/src/api/doormile/client.js
+++ b/src/api/doormile/client.js
@@ -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');
}
diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js
index f949288..ecc8e91 100644
--- a/src/api/doormile/endpoints.js
+++ b/src/api/doormile/endpoints.js
@@ -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
diff --git a/src/api/doormile/queries.js b/src/api/doormile/queries.js
index 5740b10..19ade80 100644
--- a/src/api/doormile/queries.js
+++ b/src/api/doormile/queries.js
@@ -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;
diff --git a/src/components/assistant/CLAUDE.md b/src/components/assistant/CLAUDE.md
new file mode 100644
index 0000000..390062d
--- /dev/null
+++ b/src/components/assistant/CLAUDE.md
@@ -0,0 +1,285 @@
+# CLAUDE.md — `src/pages/nearle/assistant/`
+
+Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `DoormileAI/`). Read this before touching either.
+
+---
+
+## 1. What this is
+
+An in-console Q&A assistant that answers operator questions about live data — "how many orders today", "morning batch orders", "how many riders are active" — by calling the same API functions every other page in this console already uses. Product name is **Doormile AI**, subtitle **Operations Copilot**. Not a standalone page: it lives as a **right-side slide-over** opened from a header icon.
+
+- **Mounted in**: `src/layout/MainLayout/AppTopNav.js` — a single ``. There is **no route and no sidebar entry** for this feature — don't add one back. If you're tempted to give it a full page, re-read §2 first; that was tried and deliberately reverted.
+- **`intents.js`** — all data logic: the intent catalog, keyword/phrase matching, and the API calls that produce answers. The UI never fetches.
+- **`DoormileAI/`** — all UI:
+ - `index.js` — the trigger button; owns open/closed state and returns focus to itself on close.
+ - `AIPanel.js` — the portal, scrim, slide-over, focus/Escape handling, message state, history persistence, and the ask() flow.
+ - `AIWelcome.js` — greeting + suggestion cards (empty-thread state only).
+ - `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
+ - `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline.
+ - `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
+ - `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
+ - `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
+ - `pageContext.js` — route → context label + suggested questions.
+- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
+
+### UI rules that are load-bearing, not cosmetic
+
+- **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot.
+- **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message.
+- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
+- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
+- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
+- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
+- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
+- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
+
+---
+
+## 2. Why this is deterministic, not LLM-based
+
+This was a deliberate, explicit product decision (not a technical limitation worked around silently): **this app has zero backend of its own** — confirmed exhaustively (no `server/`, no Firebase Cloud Functions, no `firebase-admin`/`firebase-functions` dependency, `Dockerfile` just serves a static CRA build via nginx). An LLM call needs an API key held server-side; there is nowhere in this repo's infrastructure to put one without shipping it to the browser.
+
+Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no.
+
+### RAG — rejected for the data, later built for the ROUTING
+
+RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.**
+
+What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced:
+
+```
+question → embed → Chroma → intentId + confidence → the SAME run() → live API call
+```
+
+Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase.
+
+Three rules that must hold:
+
+- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today.
+- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values.
+- **Write intents need high confidence.** A semantic near-miss must never open a create form.
+
+---
+
+## 3. The intent pattern (`intents.js`)
+
+Each entry in `INTENTS` is:
+
+```js
+{
+ id: 'someIntent',
+ label: 'Human-readable description — e.g. "example phrasing"',
+ match: (text) => params | null, // does this intent apply? extract params or refuse
+ run: async (params) => ({ headline, detail, sourceCalls }) | null // real answer, or "couldn't resolve"
+}
+```
+
+- `answerQuestion(text)` walks `INTENTS` **in order** and returns the first intent whose `match` recognises the text **and** whose `run` resolves to a non-null result. `run` returning `null` means "the pattern matched but couldn't be resolved" (e.g. no tenant name in the question actually matched a real tenant) — the loop falls through to the next intent rather than answering with a guess.
+- `sourceCalls` feeds `` behind the per-message "Sources" disclosure in `AIMessage.js` — every answer can still show which API was queried and what came back, so an operator can verify it wasn't invented. It is collapsed by default for visual quiet; **do not remove it**, that disclosure is the verifiability contract.
+- `metric` and `stats` (optional) drive the panel's headline number and breakdown grid. `stats` comes from `statusStats()`, which tallies the *same* `mapBookingStatusToDeliveryStatus` classification as the prose `headline`, so the sentence and the cards can never disagree.
+- Every `run` calls a real function from `pages/api/api.js` / `pages/api/doormileApi.js`. **Never fabricate a number** — if no existing function covers a question, either add a new intent that calls a real endpoint, or leave the question unanswered (falls through to the "I can't answer that one yet" state in `AIPanel.js`). A wrong number from this bot is worse than no answer.
+
+### Coverage grows with the console, not ahead of it
+
+The catalog covers orders/bookings, riders, tenants, and the fleet/ops resources (hubs, vehicles, tripsheets, exceptions, app users, customers, pricing, consignments, partners, competitor branches, carrier pricing). When a new admin resource gets its own page in this console, add a matching intent here too — and **always call the exact same `getX()` function that page's own table already calls** (e.g. `hubStatus` calls `getHubs()`, the same function `hubs.js` uses). Never write a bespoke fetch for the bot. This is what keeps the bot's numbers live and in agreement with what the corresponding page shows — the whole point of not hand-rolling a separate data path.
+
+### Composite questions — `orderQuery`
+
+`orderQuery` (ordered 3rd, ahead of `riderCounts`) is the one intent that composes filters: status x batch x tenant x rider, plus rankings ("top 5 tenants by orders"). Everything else in the catalog answers exactly one dimension and discards the rest of the sentence.
+
+**It claims a question only when two or more of status/batch/tenant/rider are present, or a ranking is asked for.** A date is deliberately NOT counted as a dimension — every intent already handles dates, and counting it re-routed four working questions ("how many cancelled orders today") away from the intents that answer them better. If you widen this matcher, re-run the routing probe first; over-claiming here silently changes answers across the whole catalog.
+
+`run` returns `null` when a named tenant or rider doesn't resolve, so an unrecognised name falls through rather than having its filter silently dropped — which is the exact bug this intent exists to fix.
+
+Entity names resolve through `bestNameMatch`, which is bidirectional (the question may name a shorter or longer form than the record) and prefers the longest match, so "Acme" can't beat "Acme Foods" when both exist.
+
+### Beyond single-question matching
+
+A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, all still deterministic (no LLM):
+
+- **Typo tolerance** — `correctTypos()` runs once before matching, correcting misspelled domain keywords (length ≥5, Levenshtein distance ≤1/≤2) against a fixed `KEYWORD_VOCAB`. It never touches order IDs, tenant names, or short words — only known keywords get "corrected," so it can't invent a wrong one.
+- **Richer dates** — `explicitDateFromWords` (DD/MM/YYYY, ISO), `weekdayFromWords` (most recent past occurrence of a named day), and `rangeFromWords` (this/last week, this/last month, explicit "from X to Y") feed `dayFromWords`/`rangeFromWords`. Still a fixed vocabulary, not a date-parsing library — an unrecognised phrase falls back to today, never a guessed date.
+- **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue".
+- **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched.
+- **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up.
+- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale.
+- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all.
+- **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent.
+
+### Ordering and cross-domain guards — read before adding an intent
+
+A real bug shipped here once: `statusBreakdown` matched the word "active" (a valid order status), so "how many riders are active today" was swallowed by the order-status intent and called `getBookings` instead of `getallridersummary` — because `statusBreakdown` sat earlier in `INTENTS` than `riderCounts` and its `run` never returns `null` (it always finds *some* count, even 0), so it never yielded.
+
+The fix, and the rule going forward:
+
+1. **Domain-specific intents (rider, tenant) are ordered near the top**, ahead of the generic order/status/date intents, so an unambiguous keyword like "rider" always wins first-match.
+2. **Generic intents explicitly refuse to match on another domain's keyword**, via helper guards like `mentionsRiders(text)` at the top of their `match`. This is deliberately redundant with (1) — if someone reorders `INTENTS` later without noticing the significance, the guards still hold.
+
+If you add a new intent whose trigger words could plausibly appear in an unrelated intent's question (status words, "for", generic nouns), do both: place it appropriately in the order, and add a guard to anything downstream it could shadow — don't rely on ordering alone.
+
+### Date/batch/status vocabulary — reuse, don't reinvent
+
+- **Batch bucketing** (`morning`/`afternoon`/`evening`) comes from `src/utils/batchBucket.js`, extracted from `Dispatch.js`/`deliveries.js`'s canonical model (see `dispatch/CLAUDE.md` §1). Bucketing on anything other than `orderdate` (a booking's `createdat`) will disagree with what those two pages show — don't reintroduce `expecteddeliverytime`/`assigntime` bucketing here, they were both tried and rejected for the same reasons documented there.
+- **Order status classification comes from `utils/orderStatusGroups.js`**, which is the SAME match set the Orders page's tabs count with (`orders.js` imports `statusesInGroup` for its `ORDERS_STATUS_TABS`). Use `groupForBookingStatus` / `isInGroup` / `statusesInGroup`; don't grow a third definition.
+ - It is deliberately **not** `mapBookingStatusToDeliveryStatus` (api.js), which is the *Deliveries* page's rider-centric taxonomy and keeps `miler_assigned` on `pending`. The two exist on purpose — Orders tracks the operator's action, Deliveries tracks the rider's. Don't merge them; that was tried and reverted per explicit product direction.
+ - The assistant answers order-status questions with the ORDERS taxonomy because that is the screen an operator compares its answers against. A live bug came from the mismatch: the Orders page showed 19 Assigned while the bot said 0.
+- **Date words** are a fixed, small vocabulary — not a real date-parsing library. Don't guess at "the 5th" style phrasing; an unrecognised date phrase falls back to today rather than to a wrong date.
+- **State questions vs flow questions — do not default a state question to today.** `mentionsAnyDate(text)` distinguishes "the question named a date" from "we defaulted to one":
+ - *State* ("how many orders are assigned / cancelled") describes the queue **right now** and must be unscoped, because the Orders page's tabs apply no date filter either. Scoping it to orders *created today* is what made the bot answer 0 against a page showing 19.
+ - *Flow* ("how many orders today", revenue, batches) genuinely needs a period and keeps the today default.
+ When a state question is answered unscoped, say so in the detail — the answer must never leave the operator guessing which window it covered.
+- `GET /admin/bookings` has no server-side date/status/tenant filter, so every intent fetches and filters client-side. It does **not** fetch a single page: `pagesize` is capped at 1000 server-side, so a lone `getBookings(1, 1000)` silently under-reports the moment an account passes 1000 lifetime bookings. Use `fetchBookingsInRange(start, end)` / `fetchBookingsForDay(day)` / `scanBookings()`, which drain pages via `getBookingsPage` up to `MAX_PAGES` and return `{ rows, truncated, scanned, pagesFetched, total }`.
+- **`truncated` is not optional to handle.** If you write a new intent, run its count through `countPhrase(scan, n)` ("At least 42"), append `truncationNote(scan)` to the detail, and build its audit entry with `scanCall(scan, ...)` — which reports `status: 'error'` when capped so the tool-call strip can't show a green "complete" beside a partial number. An intent that reads `scan.rows` and ignores `scan.truncated` reintroduces exactly the bug this replaced.
+- **Revenue excludes cancelled orders and is labelled "estimated"** — `revenueOf(rows)` sums every `serviceoptions[].estimatedprice` on non-cancelled rows. It is a quote, not a settled amount; don't relabel it "revenue" flat.
+- **"Assigned" does not go through the coarse bucket.** `mapBookingStatusToDeliveryStatus` collapses `miler_assigned` into `pending` alongside `pending_pickup` (orders with no rider at all), so `rawStatusFromWords` matches the backend enum directly. Any other question naming a raw enum should do the same rather than being forced into a delivery-status bucket.
+
+---
+
+### Customer creation writes to `/admin/tenantcustomers`
+
+Settled by evidence, not by reading the docs:
+
+```
+POST /admin/customers → 405 Method Not Allowed (confirmed live)
+```
+
+405 is unambiguous — the route exists and POST is not among its methods. `express-console-api.md` lists `/admin/customers` as GET + PATCH only and the server agrees. It was pointed there briefly on explicit instruction; the live 405 settled it. **Don't try it again.**
+
+**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.)
+
+**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately.
+
+Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change.
+
+The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks.
+
+Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery.
+
+The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree.
+
+---
+
+## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js`
+
+Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable.
+
+**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`.
+
+### The panel drives the conversation, not the router
+
+`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router.
+
+Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer.
+
+### One engine, three flows
+
+The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker.
+
+Step entries carry:
+
+| key | meaning |
+|---|---|
+| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. |
+| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. |
+| `type: 'text'` | answered through the composer. |
+| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). |
+| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. |
+| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. |
+| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. |
+| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. |
+
+### Two silent-NaN traps that were live
+
+- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value.
+- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was.
+
+`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate.
+
+### Bulk — a conversation, then one long pass
+
+Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient.
+
+**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked.
+
+**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept.
+
+**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column.
+
+### One row pipeline, two inputs
+
+A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else.
+
+**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column.
+
+`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row.
+
+**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional:
+- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely.
+- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197.
+- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup.
+
+**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose.
+
+**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded.
+
+Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated.
+
+### Repeat Runs — `repeatRuns.js` / `repeatFlow.js`
+
+"Same orders as yesterday." One question (which day), then a pass, then the usual gate.
+
+**It is the cheapest write here, and the reason is structural:** a booking already carries 15 of the 17 fields `buildOrderPayload` needs — including BOTH SETS OF COORDINATES. Only `customer_name` and `customer_phone` are missing, and they come from the `appcustomerid` → `/admin/customers` join. **So a repeat needs no geocoding at all** — the ~1 lookup/second Nominatim throttle that dominates the bulk-file flow simply doesn't apply.
+
+**A booking is a snapshot, not a template.** The drift check is phase one, not polish. All three of its main rules came from one live page of 36 bookings, not from imagination:
+
+| Trap | Seen on |
+|---|---|
+| `pickupaddress` absent entirely | booking 57 — has the pincode and coordinates, no address key |
+| `tenantid` is null | every `Customer_App` booking (24–27). `Number(null)` → tenant `0` |
+| pickup pincode no longer served | CityGate refuses at the middleware, before the handler |
+
+Plus: the customer record can be deleted, and a booking can lack delivery coordinates. `driftReason` returns a **reason, never a boolean** — an operator dropping a row deserves to know which field went stale.
+
+**Duplicate safety is INVERTED here.** Everywhere else near-identical orders are an error (`wasAlreadySubmitted`); a repeat deliberately creates them, so that guard would misfire every time. The question that matters is *has this run already been repeated today?* — answered by fingerprinting today's own bookings on `(phone + delivery address + pickup pincode)` and setting aside anything already present. Without it, a double-click books every customer twice, because the bulk endpoint has no idempotency key.
+
+**Prices are re-quoted at today's tariff, never copied.** `finalprice` is deliberately left blank so `priceBulkRows` fills it exactly as an unpriced bulk row. Yesterday's number is kept as `previousPrice` purely so a tariff change is *visible* rather than discovered on an invoice. Pricing runs **per row** because a day's run can span tenants, and a tenant's own pricing row decides the number.
+
+**`__pickup` travels on the ROW, not the shared draft** — which is why `executeCreateBulk` now prefers `r.__pickup ?? shared.__pickup`. A bulk file shares one kitchen; a repeated day does not, and collapsing them would silently re-address half the orders.
+
+Cancelled orders are never repeated. Lookback is 7 days — beyond that it stops being "the usual round".
+
+### Assigning a rider — `assignActions.js` / `assignFlow.js`
+
+The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run.
+
+**Two endpoints, and they are not interchangeable.** One order → `POST /admin/bookings/:id/assign-miler`. Many orders → `POST /hub/bookings/batch-assign`, which is **the only call that sequences stops** (doormile-flow.md §4): it sends each affected rider's whole active set to the route optimiser and writes step order, per-leg distance and ETA. Assigning ten orders with ten single calls leaves every route unsequenced.
+
+**⚠ Two different rider IDs on adjacent endpoints.** `assign-miler` takes a **`mileruserid`**; `/admin/milers/:id/notify` keys off a **`milerprofileid`**. Getting it wrong fails silently in both directions — the assign 404s, or the rider is never told. `buildMilerLookup` is the bridge and orders.js already uses it for exactly this; don't grow a second lookup. The assertions cover this specifically because it is invisible in review: both are small integers on the same record.
+
+**The backend already assigns riders.** Creation publishes `booking.assignment_requested`; a worker picks a rider within 10km on proximity and retries 5× over 10 minutes (§3). Everything here is an **override**, which is why the flow re-reads the booking's current assignee and asks before replacing them. Silently overwriting throws away a better-informed choice and strands a rider who has already been told the job is theirs.
+
+**The holder lookup happens inside the booking step's `resolve`, not after it.** `advanceFlow` evaluates the keep/replace step's `when` the instant the booking is applied — a lookup landing one tick later means the step is skipped and an already-assigned order is silently reassigned. That was a live bug caught by the assertions.
+
+Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed.
+
+Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 30 for `repeatRuns`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
+
+---
+
+## 4. What's deliberately out of scope right now
+
+- **Deleting or cancelling anything.** Creates and rider assignment are built (§3.5); destructive writes are not. Cancelling an order has downstream effects a confirm button doesn't cover. Note that *replacing* an already-assigned rider IS reachable — but only behind an explicit keep-or-replace question naming the current holder, never as a silent overwrite.
+- **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives.
+- **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built.
+- **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started.
+- **Automated tests for the intent matcher.** The matcher is pure functions (`match`/`run` per intent) and would be straightforward to unit-test, but the project's stated convention is "no tests of consequence, lint is the only gate" (root `CLAUDE.md`). Adding a test framework here is a scope decision for the user, not something to introduce silently.
+
+---
+
+## 5. Don'ts
+
+- Don't re-add a route/sidebar entry for this feature — it's a header slide-over, not a page.
+- Don't let a new intent's `match` fire without considering what other intents' trigger words it might contain (see §3's ordering rule).
+- Don't bucket batches or classify statuses with page-local logic — reuse `utils/batchBucket.js` and `mapBookingStatusToDeliveryStatus`.
+- Don't answer with a number that didn't come from `sourceCalls`-tracked real data — including anything rendered into a `metric` or `stats` card.
+- Don't surface a raw API error string to the operator. Errors log to `console.error` and render as the polished error state; the toast that used to leak `err.response.data.message` is gone.
diff --git a/src/components/assistant/DoormileAI.css b/src/components/assistant/DoormileAI.css
index 884c938..745eed3 100644
--- a/src/components/assistant/DoormileAI.css
+++ b/src/components/assistant/DoormileAI.css
@@ -45,24 +45,32 @@
}
.dai-root {
- --dai-accent: #C8102E;
+ /* Brand accent — resolves to the app's accent token, which is black
+ (root CLAUDE.md §6.2: "the brand is black", one accent only). Send
+ button, active states and the trigger all read from this single
+ variable, so switching the assistant to Doormile red is a one-line
+ change here rather than a hunt through the components. */
+ --dai-accent: var(--color-accent, #0f172a);
--dai-accent-contrast: #ffffff;
- --dai-accent-ring: rgba(200, 16, 46, 0.16);
+ /* Focus ring, tinted with the accent rather than a flat grey wash. */
+ --dai-accent-ring: color-mix(in srgb, var(--dai-accent) 14%, transparent);
- --dai-ai-from: #C8102E;
- --dai-ai-to: #E11D48;
- --dai-ai-glow: rgba(200, 16, 46, 0.15);
+ /* AI identity accent — used ONLY on the spark/orb marks so the assistant
+ reads as an AI surface without turning the panel into a purple product. */
+ --dai-ai-from: #6366f1;
+ --dai-ai-to: #8b5cf6;
+ --dai-ai-glow: rgba(99, 102, 241, 0.18);
--dai-text: #0f172a;
- --dai-text-secondary: #475569;
+ --dai-text-secondary: #64748b;
--dai-text-muted: #94a3b8;
--dai-surface: #ffffff;
--dai-surface-alt: #f8fafc;
--dai-surface-hover: #f1f5f9;
- --dai-border: #e2e8f0;
- --dai-border-strong: #cbd5e1;
+ --dai-border: rgba(15, 23, 42, 0.08);
+ --dai-border-strong: rgba(15, 23, 42, 0.14);
--dai-scrim: rgba(15, 23, 42, 0.08);
- --dai-shadow: -6px 0 24px rgba(15, 23, 42, 0.06);
+ --dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
--dai-live: #10b981;
--dai-panel-width: 428px;
@@ -109,22 +117,16 @@
No radius and no drop shadow: both are what make a surface read as floating
ABOVE the page. A soft shadow is kept only as a left-edge falloff so the
seam has depth without the panel detaching. */
-/* Two classes, not one: the element is a shadcn SheetContent, which carries
- `inset-y-0 h-full` from Tailwind. Those utilities are emitted after this
- stylesheet, so a single `.dai-panel` ties on specificity and loses — the
- dock then starts at y=0 and covers the top nav it is supposed to sit under.
- `.dai-root.dai-panel` out-specifies them; `height: auto` hands the box back
- to top/bottom so it cannot run past the bottom of the window. */
-.dai-root.dai-panel {
+.dai-panel {
position: fixed;
- /* Measured from the app header by AIPanel — see the [data-app-header] hook. */
+ /* Set from JS by AIPanel — Astryx does not publish a header-height token,
+ despite --appshell-header-height looking like one. */
top: var(--dai-dock-top, 57px);
right: 0;
bottom: 0;
- height: auto;
z-index: 1200;
width: var(--dai-dock-width);
- display: none;
+ display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
@@ -138,20 +140,13 @@
transform var(--dai-duration) var(--dai-ease),
visibility 0s linear var(--dai-duration);
}
-
-.dai-panel[data-state='open'],
.dai-panel[data-open='true'] {
- display: flex !important;
transform: translateX(0);
visibility: visible;
transition:
transform var(--dai-duration) var(--dai-ease),
visibility 0s;
}
-.dai-panel[data-state='closed'],
-.dai-panel[data-open='false'] {
- display: none !important;
-}
/* ---- The page makes room -------------------------------------------------
`.dai-docked` is set on while the panel is open. Padding rather than
@@ -162,10 +157,10 @@
The transition sits on the container unconditionally so the page slides back
when the panel closes too — a rule that only exists while `.dai-docked` is
applied cannot animate its own removal. */
-.dai-page {
+.astryx-layout-content {
transition: padding-right var(--dai-duration) var(--dai-ease);
}
-body.dai-docked .dai-page {
+body.dai-docked .astryx-layout-content {
padding-right: var(--dai-dock-width);
}
@@ -174,7 +169,7 @@ body.dai-docked .dai-page {
used to be everywhere — a full-width overlay with a scrim — and the page
stops reserving a strip it cannot afford. */
@media (max-width: 900px) {
- .dai-root.dai-panel {
+ .dai-panel {
top: 0;
width: 100%;
z-index: 1301;
@@ -187,7 +182,7 @@ body.dai-docked .dai-page {
.dai-scrim[data-open='true'] {
opacity: 1;
}
- body.dai-docked .dai-page {
+ body.dai-docked .astryx-layout-content {
padding-right: 0;
}
}
@@ -199,59 +194,67 @@ body.dai-docked .dai-page {
-------------------------------------------------------------------------- */
.dai-root .dai-header {
flex: 0 0 auto;
- padding: 14px 16px 12px;
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(12px);
+ padding: 14px 12px 12px 14px;
border-bottom: 1px solid var(--dai-border);
}
.dai-root .dai-title {
font-size: 15px;
- font-weight: 700;
+ font-weight: 650;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--dai-text);
}
.dai-root .dai-subtitle {
font-size: 12px;
- font-weight: 500;
line-height: 1.3;
color: var(--dai-text-muted);
}
-/* The AI mark — sleek rounded tinted container */
+/* The AI mark. A soft gradient orb — not a robot face. */
.dai-root .dai-spark {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
- border-radius: 9px;
- background: #fff1f2;
- border: 1px solid #fecdd3;
+ border-radius: 999px;
+ background: var(--dai-surface);
overflow: hidden;
}
+/* The mark's own canvas is only 66.8% content — a third of every edge is
+ transparent padding, measured off the PNG's alpha bounding box (x and y both
+ 170..853 of 1024, i.e. perfectly centred). Drawing it at 100% therefore
+ rendered a D two-thirds the size the box implied, which is exactly why it
+ read as too small. 150% cancels that padding so the D fills its box edge to
+ edge; the overflow:hidden above clips only transparent pixels. */
+.dai-root .dai-spark img {
+ width: 150%;
+ height: 150%;
+ max-width: none;
+ object-fit: contain;
+ display: block;
+}
.dai-root .dai-spark[data-size='sm'] {
- width: 28px;
- height: 28px;
+ width: 30px;
+ height: 30px;
}
.dai-root .dai-spark[data-size='md'] {
- width: 36px;
- height: 36px;
+ width: 40px;
+ height: 40px;
}
.dai-root .dai-spark[data-size='lg'] {
- width: 48px;
- height: 48px;
+ width: 56px;
+ height: 56px;
}
-/* Live indicator — crisp pill */
+/* Live indicator — subtle, not a large pill. */
.dai-root .dai-live {
display: inline-flex;
align-items: center;
gap: 5px;
- padding: 3px 9px;
+ padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
- font-weight: 600;
+ font-weight: 550;
color: #047857;
- background: #ecfdf5;
- border: 1px solid #a7f3d0;
+ background: rgba(16, 185, 129, 0.08);
white-space: nowrap;
}
.dai-root .dai-live-dot {
@@ -275,10 +278,9 @@ body.dai-docked .dai-page {
/* Page-context strip — "Orders · Today · All locations" */
.dai-root .dai-context {
flex: 0 0 auto;
- padding: 8px 16px;
+ padding: 7px 14px;
font-size: 11.5px;
- font-weight: 500;
- color: var(--dai-text-secondary);
+ color: var(--dai-text-muted);
background: var(--dai-surface-alt);
border-bottom: 1px solid var(--dai-border);
white-space: nowrap;
@@ -367,31 +369,34 @@ body.dai-docked .dai-page {
align-items: center;
gap: 6px;
max-width: 100%;
- padding: 6px 14px;
+ padding: 5px 10px;
text-align: left;
font: inherit;
font-size: 12px;
line-height: 1.35;
- font-weight: 550;
+ font-weight: 500;
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
- border-radius: 999px;
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ /* 14px, pinned — NOT 999px.
+
+ On a one-line chip a fully round radius already resolves to about 14px, so
+ these look identical. The difference shows on a chip whose question wraps
+ to two lines: 999px would resolve to half of ~46px and the chip stops
+ reading as a chip and starts reading as a card, so one long suggestion
+ would look like a different component from the nineteen beside it. */
+ border-radius: 14px;
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
- transform 140ms ease,
- box-shadow 140ms ease;
+ transform 140ms ease;
}
.dai-suggestion:hover {
- background: #fff1f2;
- border-color: #fecdd3;
- color: #C8102E;
+ background: var(--dai-surface-alt);
+ border-color: var(--dai-border-strong);
transform: translateY(-1px);
- box-shadow: 0 2px 6px rgba(200, 16, 46, 0.1);
}
.dai-suggestion:active {
transform: translateY(0);
@@ -406,7 +411,7 @@ body.dai-docked .dai-page {
transition: color 140ms ease;
}
.dai-suggestion:hover .dai-suggestion-icon {
- color: #C8102E;
+ color: var(--dai-text-secondary);
}
.dai-root .dai-suggestion-text {
min-width: 0;
@@ -497,32 +502,22 @@ body.dai-docked .dai-page {
}
.dai-root .dai-msg-user {
- max-width: 84%;
+ max-width: 82%;
margin-left: auto;
- padding: 10px 15px;
- border-radius: 18px 18px 4px 18px;
+ padding: 8px 12px;
+ border-radius: 5px;
+ border-bottom-right-radius: 5px;
font-size: 13.5px;
- font-weight: 500;
line-height: 1.45;
- color: #ffffff;
- background: linear-gradient(135deg, #C8102E 0%, #A00C24 100%);
- box-shadow: 0 2px 8px rgba(200, 16, 46, 0.16);
+ color: var(--dai-accent-contrast);
+ background: var(--dai-accent);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
-/* Assistant — sleek rounded response card */
-.dai-root .dai-msg-row {
- background: #f8fafc;
- border: 1px solid var(--dai-border);
- border-radius: 18px 18px 18px 4px;
- padding: 12px 14px;
- box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03);
- max-width: 95%;
- width: fit-content;
-}
+/* Assistant — no bubble. Text sits on the panel surface. */
.dai-root .dai-msg-ai {
font-size: 13.5px;
- line-height: 1.55;
+ line-height: 1.5;
color: var(--dai-text);
overflow-wrap: anywhere;
}
@@ -534,8 +529,8 @@ body.dai-docked .dai-page {
}
.dai-root .dai-msg-name {
font-size: 11.5px;
- font-weight: 700;
- color: #334155;
+ font-weight: 600;
+ color: var(--dai-text-secondary);
}
.dai-root .dai-msg-footer {
font-size: 11px;
@@ -700,23 +695,24 @@ body.dai-docked .dai-page {
-------------------------------------------------------------------------- */
.dai-root .dai-composer-wrap {
flex: 0 0 auto;
- padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px));
+ padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--dai-border);
background: var(--dai-surface);
}
.dai-root .dai-composer {
- border: 1.5px solid var(--dai-border);
- border-radius: 16px;
- background: var(--dai-surface-alt);
+ border: 1px solid var(--dai-border-strong);
+ /* 14px, the same corner the suggestion chips use. At 5px the input was the
+ one sharp-cornered thing in a panel of rounded surfaces, and it sat
+ directly beneath the chips where the mismatch was most visible. */
+ border-radius: 14px;
+ background: var(--dai-surface);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
- padding: 10px 12px 8px 14px;
+ padding: 10px 10px 8px 14px;
transition:
border-color 140ms ease,
- background-color 140ms ease,
box-shadow 140ms ease;
}
.dai-root .dai-composer[data-focused='true'] {
- background: var(--dai-surface);
border-color: var(--dai-accent);
box-shadow: 0 0 0 3px var(--dai-accent-ring);
}
@@ -815,27 +811,24 @@ body.dai-docked .dai-page {
align-items: center;
justify-content: center;
flex: 0 0 auto;
- width: 32px;
- height: 32px;
+ width: 30px;
+ height: 30px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dai-accent);
color: var(--dai-accent-contrast);
cursor: pointer;
- box-shadow: 0 2px 6px rgba(200, 16, 46, 0.25);
transition:
background-color 140ms ease,
opacity 140ms ease,
- transform 140ms ease,
- box-shadow 140ms ease;
+ transform 140ms ease;
}
.dai-root .dai-send:hover:not(:disabled) {
- opacity: 0.92;
- transform: scale(1.05);
+ opacity: 0.86;
}
.dai-root .dai-send:active:not(:disabled) {
- transform: scale(0.95);
+ transform: scale(0.94);
}
.dai-root .dai-send:focus-visible {
outline: 2px solid var(--dai-accent);
@@ -846,7 +839,6 @@ body.dai-docked .dai-page {
.dai-root .dai-send:disabled {
background: var(--dai-surface-hover);
color: var(--dai-text-muted);
- box-shadow: none;
cursor: default;
}
.dai-root .dai-composer textarea {
diff --git a/src/components/assistant/DoormileAI/AIComposer.jsx b/src/components/assistant/DoormileAI/AIComposer.jsx
index b95ae38..31f91bf 100644
--- a/src/components/assistant/DoormileAI/AIComposer.jsx
+++ b/src/components/assistant/DoormileAI/AIComposer.jsx
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
-import { ArrowUp } from 'lucide-react';
+import { LuArrowUp } from 'react-icons/lu';
-import { HStack } from './shim';
-import { VStack } from './shim';
-import { Text } from './shim';
-import { ChatDictationButton, useChatDictation } from './shim';
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
+import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat';
// ==============================|| Doormile AI — composer ||============================== //
//
@@ -136,7 +136,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
disabled={!canSend}
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
>
-
+
diff --git a/src/components/assistant/DoormileAI/AIFlowStep.jsx b/src/components/assistant/DoormileAI/AIFlowStep.jsx
index 7e3144b..d3f0ad4 100644
--- a/src/components/assistant/DoormileAI/AIFlowStep.jsx
+++ b/src/components/assistant/DoormileAI/AIFlowStep.jsx
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
-import { VStack } from './shim';
-import { HStack } from './shim';
-import { Text } from './shim';
-import { Button } from './shim';
-import { Selector } from './shim';
+import { VStack } from '@astryxdesign/core/VStack';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { Button } from '@astryxdesign/core/Button';
+import { Selector } from '@astryxdesign/core/Selector';
// ==============================|| Doormile AI — one dropdown step ||============================== //
//
diff --git a/src/components/assistant/DoormileAI/AIMessage.jsx b/src/components/assistant/DoormileAI/AIMessage.jsx
index f521981..3d31fff 100644
--- a/src/components/assistant/DoormileAI/AIMessage.jsx
+++ b/src/components/assistant/DoormileAI/AIMessage.jsx
@@ -1,14 +1,14 @@
import { memo, useState } from 'react';
import PropTypes from 'prop-types';
-import { Copy } from 'lucide-react';
-import { ChevronDown } from 'lucide-react';
+import { AiOutlineCopy as CopyOutlined } from 'react-icons/ai';
+import { LuChevronDown } from 'react-icons/lu';
-import { HStack } from './shim';
-import { VStack } from './shim';
-import { Text } from './shim';
-import { IconButton } from './shim';
-import { Button } from './shim';
-import { ChatToolCalls } from './shim';
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
+import { IconButton } from '@astryxdesign/core/IconButton';
+import { Button } from '@astryxdesign/core/Button';
+import { ChatToolCalls } from '@astryxdesign/core/Chat';
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
import AIFlowStep from './AIFlowStep';
@@ -51,7 +51,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
variant="ghost"
label="Copy answer"
tooltip="Copy answer"
- icon={}
+ icon={}
onClick={() => onCopy(message)}
/>
@@ -96,7 +96,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
-
+
{showSources && }
diff --git a/src/components/assistant/DoormileAI/AIPanel.jsx b/src/components/assistant/DoormileAI/AIPanel.jsx
index eb9c6b9..aed7d1b 100644
--- a/src/components/assistant/DoormileAI/AIPanel.jsx
+++ b/src/components/assistant/DoormileAI/AIPanel.jsx
@@ -1,22 +1,20 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-import '../DoormileAI.css';
import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
-import { X as CloseOutlined, MoreHorizontal as MoreOutlined } from 'lucide-react';
-import { ChevronDown as LuChevronDown, PanelRightOpen as LuPanelRightOpen, PanelRightClose as LuPanelRightClose, History as LuHistory, Trash2 as LuTrash2, MessageSquare as LuMessageSquare } from 'lucide-react';
+import { AiOutlineClose as CloseOutlined, AiOutlineMore as MoreOutlined } from 'react-icons/ai';
+import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose, LuHistory, LuTrash2, LuMessageSquare } from 'react-icons/lu';
-import { HStack } from './shim';
-import { VStack } from './shim';
-import { Text } from './shim';
-import { IconButton } from './shim';
-import { DropdownMenu } from './shim';
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
+import { IconButton } from '@astryxdesign/core/IconButton';
+import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
-import { toast } from '@/components/ui/use-toast';
-const OpenToast = ({message, tone}) => toast({description: message, variant: tone === 'error' ? 'destructive' : 'default'});
-const STATUS = { error: 'error', success: 'success', warning: 'warning', info: 'info', muted: 'muted' };
+import { OpenToast } from 'components/third-party/OpenToast';
+import { STATUS } from 'themes/dt/tokens';
import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '@/lib/assistant/intents';
import { executeCreateCustomer, buildCustomerPayload } from '@/lib/assistant/actions';
import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions';
@@ -228,17 +226,8 @@ const AIPanel = ({ isOpen, onClose }) => {
useEffect(() => {
if (isOpen) {
setIsMounted(true);
- // One frame between mounting and showing, so the slide-in has a
- // from-state to animate out of. rAF is the right clock for that, but a
- // hidden or heavily throttled document never runs the callback at all —
- // and a panel that refuses to open is worse than one that opens without
- // its animation, so a timer backs it up.
const raf = requestAnimationFrame(() => setIsShown(true));
- const fallback = setTimeout(() => setIsShown(true), 50);
- return () => {
- cancelAnimationFrame(raf);
- clearTimeout(fallback);
- };
+ return () => cancelAnimationFrame(raf);
}
setIsShown(false);
const timer = setTimeout(() => setIsMounted(false), ANIMATION_MS);
@@ -275,14 +264,15 @@ const AIPanel = ({ isOpen, onClose }) => {
}, [isWide]);
// ---- anchor the dock to the real header height ----
- // The dock starts where the top nav ends. Measured rather than assumed, and
- // re-measured on resize, so the seam stays closed if the header ever changes
- // height — a hard-coded number leaves a sliver of page visible above the
- // panel the moment the bar grows.
+ // The dock starts where the top nav ends. `--appshell-header-height` looks
+ // like the right token for that and Dispatch.css already reads it, but
+ // Astryx does not actually publish it — verified in the browser, where it
+ // resolves to nothing and the fallback wins. The header is 57px, not the 64
+ // a fallback would guess, so trusting it left a 7px sliver of page visible
+ // above the panel.
//
- // AdminLayout marks its bar `data-app-header`; that attribute exists purely
- // as this hook. The selector used to be `.astryx-layout-header`, a class this
- // app does not render at all, so the measurement silently never ran.
+ // Measured instead, and re-measured on resize, so the seam stays closed if
+ // the header ever changes height.
useEffect(() => {
if (!isMounted) return undefined;
const header = document.querySelector('[data-app-header]');
@@ -1394,25 +1384,22 @@ const AIPanel = ({ isOpen, onClose }) => {
// shrink and scroll. See the [data-compact] rule in DoormileAI.css.
const isCompactStrip = hasThread;
- if (!isMounted || !isShown) return null;
-
return createPortal(
<>
-
+ (
-
+
);
@@ -87,8 +99,8 @@ StatGrid.propTypes = {
// silently dropped), and the rehydrated "element" would then crash the render
// on the next panel open.
const STATE_ICONS = {
- warning: ,
- info:
+ warning: ,
+ info:
};
// A full list, rendered readably. Replaces the "…and 4 more" truncation that
diff --git a/src/components/assistant/DoormileAI/AIRowsStep.jsx b/src/components/assistant/DoormileAI/AIRowsStep.jsx
index 6feaf04..2f8fa14 100644
--- a/src/components/assistant/DoormileAI/AIRowsStep.jsx
+++ b/src/components/assistant/DoormileAI/AIRowsStep.jsx
@@ -1,12 +1,12 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
-import { VStack } from './shim';
-import { HStack } from './shim';
-import { Text } from './shim';
-import { Button } from './shim';
-import { TextArea } from './shim';
-import { FileInput } from './shim';
+import { VStack } from '@astryxdesign/core/VStack';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { Button } from '@astryxdesign/core/Button';
+import { TextArea } from '@astryxdesign/core/TextArea';
+import { FileInput } from '@astryxdesign/core/FileInput';
import { parseBulkRows, BULK_MAX } from '@/lib/assistant/bulkOrderActions';
import { parseBulkFile, templateCsv, downloadCsv } from '@/lib/assistant/bulkFile';
diff --git a/src/components/assistant/DoormileAI/AIWelcome.jsx b/src/components/assistant/DoormileAI/AIWelcome.jsx
index f878501..4649b26 100644
--- a/src/components/assistant/DoormileAI/AIWelcome.jsx
+++ b/src/components/assistant/DoormileAI/AIWelcome.jsx
@@ -1,9 +1,9 @@
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
-import { HStack } from './shim';
-import { VStack } from './shim';
-import { Text } from './shim';
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
import { CHIP_LABELS } from './pageContext';
diff --git a/src/components/assistant/DoormileAI/index.jsx b/src/components/assistant/DoormileAI/index.jsx
index 00a4a08..34f488c 100644
--- a/src/components/assistant/DoormileAI/index.jsx
+++ b/src/components/assistant/DoormileAI/index.jsx
@@ -1,5 +1,8 @@
import { useCallback, useRef, useState } from 'react';
-import { Bot } from 'lucide-react';
+
+import { Tooltip } from '@astryxdesign/core/Tooltip';
+
+import doormileMark from 'assets/images/doormile-mark.png';
import AIPanel from './AIPanel';
import '../DoormileAI.css';
@@ -20,17 +23,19 @@ const DoormileAITrigger = () => {
return (
<>
-
+
+
+
>
);
diff --git a/src/components/assistant/DoormileAI/pageContext.jsx b/src/components/assistant/DoormileAI/pageContext.jsx
index 66e3b52..557e42c 100644
--- a/src/components/assistant/DoormileAI/pageContext.jsx
+++ b/src/components/assistant/DoormileAI/pageContext.jsx
@@ -1,19 +1,19 @@
import {
- Clock3 as LuClock3,
- Package as LuPackage,
- Bike as LuBike,
- Truck as LuTruck,
- Building2 as LuBuilding2,
- CircleDot as LuCircleDot,
- Banknote as LuBanknote,
- Users as LuUsers,
- Layers as LuLayers,
- TimerOff as LuTimerOff,
- UserPlus as LuUserPlus,
- PackagePlus as LuPackagePlus,
- ListPlus as LuListPlus,
- Repeat as LuRepeat
-} from 'lucide-react';
+ LuClock3,
+ LuPackage,
+ LuBike,
+ LuTruck,
+ LuBuilding2,
+ LuCircleDot,
+ LuBanknote,
+ LuUsers,
+ LuLayers,
+ LuTimerOff,
+ LuUserPlus,
+ LuPackagePlus,
+ LuListPlus,
+ LuRepeat
+} from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== //
//
diff --git a/src/components/assistant/DoormileAI/shim.jsx b/src/components/assistant/DoormileAI/shim.jsx
deleted file mode 100644
index 01d1159..0000000
--- a/src/components/assistant/DoormileAI/shim.jsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React from 'react';
-import { DropdownMenu as KrowDropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
-
-export const HStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, vAlign, justify, wrap, style, ...props }) => {
- return (
- 0 ? `${padding * 0.5}rem` : undefined, ...style }}
- {...props}
- >
- {children}
-
- );
-};
-
-export const VStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, align, justify, style, ...props }) => {
- return (
- 0 ? `${padding * 0.5}rem` : undefined, ...style }}
- {...props}
- >
- {children}
-
- );
-};
-
-export const Text = ({ as: Component = 'span', children, className = '', style, ...props }) => {
- return (
-
- {children}
-
- );
-};
-
-export const IconButton = ({ children, className = '', icon, onClick, tooltip, label, size, variant, isIconOnly, ...props }) => {
- return (
-
- );
-};
-
-export const DropdownMenu = ({ button, items }) => (
-
-
-
-
-
- {items?.map((it, i) => (
-
- {it.label}
-
- ))}
-
-
-);
-
-export const Button = ({ children, onClick, className = '' }) => (
-
-);
-
-export const Selector = ({ value, onChange, options = [], placeholder }) => (
-
-);
-
-export const TextArea = ({ value, onChange, placeholder, rows = 3, className = '' }) => (
-
-);
-
-export const FileInput = ({ onChange, accept, className = '' }) => (
-
-);
-
-export const ChatToolCalls = ({ calls }) => (
-
- {calls?.map((c, i) => (
-
- {c.tool || 'Tool'}
-
{JSON.stringify(c.args, null, 2)}
-
- ))}
-
-);
-
-export const useChatDictation = () => {
- return { isDictating: false, startDictation: () => {}, stopDictation: () => {}, toggleDictation: () => {} };
-};
-
-export const ChatDictationButton = ({ isDictating, onToggle }) => (
-
-);
\ No newline at end of file
diff --git a/src/components/assistant/RAG_PLAN.md b/src/components/assistant/RAG_PLAN.md
new file mode 100644
index 0000000..4bc894e
--- /dev/null
+++ b/src/components/assistant/RAG_PLAN.md
@@ -0,0 +1,306 @@
+# Doormile AI — RAG implementation plan
+
+Retrieval-augmented routing and document Q&A, backed by a local ChromaDB.
+
+**Status:** plan only. Nothing here is built.
+**Prerequisite decision:** where the sidecar runs (see §11).
+
+---
+
+## 1. What this changes, and what it deliberately does not
+
+### The problem being solved
+
+The assistant routes questions with **34 hand-written regex matchers**. That has a hard vocabulary ceiling, and we hit it repeatedly:
+
+| Phrase | What went wrong |
+|---|---|
+| "cancellation rate" | `\bcancel(led)?\b` doesn't match "cancellation" — the `\b` fails on the following `l` |
+| "8494948494" (a bare reply) | matched no intent at all; the reply was dropped |
+| "orders per day this week" | `weekOrders` answered with one number and silently dropped "per day" |
+| "delivered orders for Acme" | tenant discarded until `orderQuery` was built |
+
+Every one of those was a regex fix. The next ten will be too. **RAG removes the ceiling** — a new phrasing works because it's *semantically near* an example, not because someone wrote a pattern for it.
+
+### What RAG must NOT do here
+
+`assistant/CLAUDE.md` §2 records RAG being considered and rejected, and that reasoning stands **for the data**:
+
+> "this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions."
+
+A vector store is a snapshot. "How many orders today" changes every minute. Answering it from embeddings means answering from whenever we last indexed.
+
+**So: RAG selects the question. The existing API layer still produces every number.**
+
+```
+question ──► embed ──► Chroma ──► top-k intent examples ──► intentId + slots + confidence
+ │
+ ▼
+ the SAME deterministic run() executes
+ │
+ ▼
+ real API call ──► real number
+```
+
+The "never fabricate a number" guarantee survives untouched. Nothing in the vector store ever becomes a figure the operator reads.
+
+### Why this is not blocked by the §2 decision
+
+§2's blocker was: *an LLM call needs an API key, and a static CRA build has nowhere to put one.*
+
+This plan uses a **local embedding model running in Node** (`@xenova/transformers`). No key, no network, no per-call cost. The blocker doesn't apply. If we later want a hosted embedding model or a generation step, §2 applies again and needs its own decision.
+
+---
+
+## 2. Architecture
+
+```
+docker-compose.yml
+├── chroma chromadb/chroma:latest :8000 persistent volume ./.chroma
+└── ai-sidecar node:20 :8787 services/ai
+
+services/ai/
+├── package.json own deps — NOT added to the CRA package.json
+├── index.js Express app: /health, /route, /ask, /reindex
+├── embed.js MiniLM via @xenova/transformers, cached in-process
+├── collections.js Chroma client, collection get-or-create
+├── seed/
+│ ├── intents.js builds intent_examples from the phrasing catalog
+│ ├── docs.js chunks the markdown docs
+│ └── phrasings.json ~20 example phrasings per intent (hand-written)
+├── eval.js routing accuracy vs the regex baseline
+└── README.md how to run it locally
+```
+
+**The CRA app gains no new dependencies.** The sidecar is a separate package with its own `package.json`, so `react-scripts`, the webpack config and the `resolutions` block in the root `package.json` are untouched (root `CLAUDE.md` §4.3).
+
+### Ports and env
+
+| | |
+|---|---|
+| Chroma | `http://localhost:8000` |
+| Sidecar | `http://localhost:8787` |
+| CRA reads | `REACT_APP_AI_URL` (absent → RAG disabled, regex only) |
+
+`REACT_APP_AI_URL` being unset must be a supported state, not a broken one — that is what keeps the app deployable exactly as it is today.
+
+---
+
+## 3. Data model
+
+### Collection `intent_examples`
+
+One vector per example phrasing. ~34 intents × ~20 phrasings ≈ **700 vectors**. Trivially small; Chroma handles it in memory.
+
+```js
+{
+ id: 'statusBreakdown::07',
+ document: 'how many orders got cancelled this week',
+ metadata: {
+ intentId: 'statusBreakdown',
+ domain: 'orders', // orders | riders | tenants | hubs | vehicles | ops | write
+ isWrite: false, // write intents need a higher bar — see §6
+ slotsHint: 'status,range' // documentation only; slots still parsed deterministically
+ }
+}
+```
+
+### Collection `console_docs`
+
+Chunked markdown for genuine document Q&A — "what is CityGate", "why does dispatch reconcile before commit".
+
+```js
+{
+ id: 'express-console-api.md::conventions::2',
+ document: '',
+ metadata: {
+ source: 'express-console-api.md',
+ heading: 'Conventions across every endpoint',
+ updatedAt: '2026-08-19'
+ }
+}
+```
+
+**Chunking:** split on markdown headings, then hard-wrap at ~800 characters with ~100 characters of overlap. Heading path is prepended to each chunk so a chunk carries its own context.
+
+**Sources to index:** `express-console-api.md`, root `CLAUDE.md`, `src/pages/api/CLAUDE.md`, `src/pages/nearle/assistant/CLAUDE.md`, `src/pages/nearle/dispatch/CLAUDE.md`, `src/pages/nearle/orders/CLAUDE.md`, `ROADMAP.md`.
+
+---
+
+## 4. The routing contract
+
+### `POST /route`
+
+```jsonc
+// request
+{ "text": "how many orders got cancelled this week" }
+
+// response
+{
+ "intentId": "statusBreakdown",
+ "confidence": "high", // high | medium | low
+ "score": 0.91, // cosine similarity of top-1
+ "margin": 0.19, // top-1 minus top-2 — the honest signal
+ "alternatives": [
+ { "intentId": "orderRate", "score": 0.72 }
+ ],
+ "matchedExample": "how many orders got cancelled this week"
+}
+```
+
+### How confidence is derived — and why margin, not score
+
+Cosine similarity is **not** a probability of correctness. 0.87 does not mean "87% likely right". What actually carries information is the **margin** between the best and second-best match:
+
+| Condition | Confidence | Bot behaviour |
+|---|---|---|
+| `score ≥ 0.75` and `margin ≥ 0.10` | **high** | route and answer |
+| `score ≥ 0.60` and `margin ≥ 0.05` | **medium** | route, and name the interpretation in the answer |
+| otherwise | **low** | **don't guess** — offer the top 2–3 as buttons |
+
+Thresholds are starting values, tuned in phase 7 against the eval corpus.
+
+**What the operator sees:** *"Matched: orders by status · high confidence"* — never *"87% sure the answer is 42."* The answer's correctness comes from deterministic execution; the score only describes how sure we are which question was asked. Conflating the two would be exactly the kind of false precision this bot has avoided all along.
+
+### `POST /ask` (docs)
+
+```jsonc
+{ "text": "what is CityGate" }
+→ { "chunks": [ { "text": "...", "source": "express-console-api.md",
+ "heading": "Conventions", "score": 0.88 } ] }
+```
+
+**No generation step.** It returns the source passages with attribution and the panel renders them. Summarising them into new prose would require an LLM — which is §2's blocked decision — and would also let a paraphrase drift from what the doc says.
+
+---
+
+## 5. Embedding model
+
+**`Xenova/all-MiniLM-L6-v2`** via `@xenova/transformers`.
+
+| | |
+|---|---|
+| Size | ~25 MB, downloaded once, cached on disk |
+| Dimensions | 384 |
+| Runs | in-process in Node — no key, no network, no per-call cost |
+| Speed | ~5 ms per short query on CPU |
+
+Good enough for short operator phrases, which is the whole workload. Upgrade path if the eval shows it's not: `text-embedding-3-small`. That needs a key, which re-opens §2 — do not do it silently.
+
+**Important:** queries and documents must be embedded with the *same* model and the same normalisation. A model change means a full re-seed; `seed.js` writes the model name into collection metadata so a mismatch is detectable rather than silently wrong.
+
+---
+
+## 6. Integration with the bot
+
+`answerQuestion(text, context)` gains a routing step **in front of** the existing matcher:
+
+```js
+// 1. try semantic routing, but never let it break the bot
+const routed = await routeViaRag(text).catch(() => null);
+
+// 2. high/medium confidence → run that intent's own run()
+if (routed && routed.confidence !== 'low') {
+ const intent = INTENTS_BY_ID[routed.intentId];
+ const params = intent?.match(text) ?? deriveSlots(text, routed);
+ const result = params && (await intent.run(params));
+ if (result) return { ...result, routing: routed };
+}
+
+// 3. fall back to the 34 regex intents, exactly as today
+return matchAndRun(text);
+```
+
+### Non-negotiables
+
+**The regex matcher stays.** It is the fallback when the sidecar is down, `REACT_APP_AI_URL` is unset, or confidence is low. Today's behaviour is the floor — RAG can only improve it, never remove it.
+
+**Slots stay deterministic.** `rangeFromWords`, `statusFromWords`, `orderStatusGroups`, entity resolution all keep their jobs. Embeddings are good at *"what kind of question is this"* and bad at *"the 14th"*. RAG picks the intent; parsing extracts the values.
+
+**Write intents need a higher bar.** `createCustomer` and `createOrder` must require **high** confidence AND an explicit verb match. A semantic near-miss must never open a write form. The `isWrite` flag in metadata exists for exactly this check.
+
+**Timeout.** 400 ms budget on `/route`; past that, fall through to regex. The bot must never feel slower because a container is cold.
+
+---
+
+## 7. Evaluation — how we prove it is better
+
+The existing **40-phrase routing corpus** (`scratchpad/route_probe.mjs`) becomes the regression baseline. But it is not a fair test: those phrases were written *for* the regex matcher and it scores 40/40 on them.
+
+The real test is a **held-out set of ~60 phrasings neither implementation was tuned against**, written by someone who hasn't read the matchers. Operator language, not developer language: *"anything stuck?"*, *"what's late"*, *"how'd Kumar do"*.
+
+`eval.js` reports:
+
+| Metric | Meaning |
+|---|---|
+| Routing accuracy | correct `intentId` — the headline number |
+| Coverage | % answered at all (regex's weakness: silent no-match) |
+| False routes | wrong intent answered confidently — **the number that matters most** |
+| Write safety | zero write intents triggered by non-write phrasings |
+| p50 / p95 latency | must stay under the 400 ms budget |
+
+**Ship criterion:** RAG beats regex on accuracy *and* coverage on the held-out set, with **zero** false write routes. A false route is worse than a no-match — it's the same class of failure as the "19 assigned vs 0" bug.
+
+---
+
+## 8. Phases
+
+| # | Deliverable | Acceptance | Effort |
+|---|---|---|---|
+| 0 | **Sidecar hosting decision** (§11) | answered | — |
+| 1 | `docker-compose.yml`, Chroma up, Express `/health` | `curl :8787/health` → ok, Chroma reachable | 0.5 d |
+| 2 | `embed.js`, model cached, round-trip verified | same text → identical vector twice | 0.5 d |
+| 3 | `phrasings.json` — 20 per intent × 34 | seeded, count verified | 1.5 d |
+| 4 | `POST /route` with confidence + margin | returns correct intent for 20 hand checks | 0.5 d |
+| 5 | Bot integration + fallback + 400 ms timeout | **kill the sidecar mid-session → bot still answers** | 0.5 d |
+| 6 | `console_docs` + `POST /ask` + panel rendering | "what is CityGate" returns the right passage | 1 d |
+| 7 | `eval.js` + held-out set + threshold tuning | report produced, thresholds fixed from data | 1 d |
+
+**Total ≈ 5.5 days.** Phases 1–5 (≈3.5 d) deliver the whole routing win; 6–7 add docs and proof.
+
+---
+
+## 9. Operations
+
+- **`npm run seed:ai`** rebuilds both collections from scratch. Idempotent.
+- **Docs drift silently.** Re-seed on any change to an indexed markdown file — a CI step, or a pre-commit hook. A stale doc answer is worse than none, because it looks authoritative.
+- **Chroma persistence** is a bind-mounted `./.chroma` volume. Add to `.gitignore`.
+- **Model cache** likewise (`.cache/transformers`).
+- **Nothing in the sidecar touches the Doormile API.** It only routes text. All data access stays in the browser through the existing typed functions, which keeps the tenant scoping and the bearer token exactly where they are today.
+
+---
+
+## 10. Risks
+
+| Risk | Mitigation |
+|---|---|
+| **First backend in the project** | Dev-only to start. Deploying it is a separate decision with real ops cost. |
+| Sidecar down → bot dead | Regex fallback + timeout. Tested explicitly in phase 5. |
+| Semantic near-miss opens a write form | `isWrite` requires high confidence **and** verb match. Zero-tolerance metric in eval. |
+| Local model too weak on short phrases | Measured in phase 7. Upgrade path exists but re-opens §2. |
+| Docs go stale | Re-seed in CI. |
+| Phrasing catalog becomes a second matcher to maintain | It is data, not code — and unlike regexes, near-misses still work. |
+| Scope creep into generation | Explicitly out (§12). |
+
+---
+
+## 11. Open decisions — needed before phase 1
+
+1. **Where does the sidecar run?**
+ - **(a) Dev-only** — runs on operator machines / a dev box. Zero ops. RAG is an enhancement that's simply absent in production.
+ - **(b) Deployed alongside nginx** — the project gains a backend: hosting, monitoring, a deploy pipeline, an internal network hop. Bigger commitment than the vector DB itself.
+
+ *Recommendation: (a) first.* Prove routing accuracy with real operator language before taking on ops.
+
+2. **Does `/ask` (docs) ship to operators, or is it internal?** The indexed docs contain engineering notes, including known backend bugs.
+
+3. **Who writes the held-out eval set?** It has to be someone who hasn't read the matchers, or the test is worthless.
+
+---
+
+## 12. Explicitly out of scope
+
+- **Any generation step.** `/ask` returns source passages with attribution, never paraphrase. Generation needs a hosted model and a key — §2's blocked decision.
+- **Embedding operational data.** No bookings, riders or customers in the vector store. Numbers come from the API, always.
+- **Replacing the regex matcher.** It becomes the fallback, permanently.
+- **Semantic slot extraction.** Dates, statuses and entities stay deterministic.
diff --git a/src/components/assistant/ROADMAP.md b/src/components/assistant/ROADMAP.md
new file mode 100644
index 0000000..1df1b84
--- /dev/null
+++ b/src/components/assistant/ROADMAP.md
@@ -0,0 +1,390 @@
+# Doormile Bot — v3 development plan
+
+Analysis of the shipped v2 (`BotPanel.js` + `intents.js`, 9 intents) and the staged plan to take it to an advanced operator assistant.
+
+Authority: `src/pages/nearle/assistant/CLAUDE.md` constrains this work — no route/sidebar (§5), no LLM without a key-location decision (§2), no write action without a propose→confirm gate (§4). This plan does not override any of those; where it touches one, it says so explicitly.
+
+---
+
+## Part 1 — Analysis of v2
+
+### Architecture today
+
+```
+answerQuestion(text)
+ └─ for each of 9 INTENTS, in array order
+ ├─ intent.match(text) → params | null
+ └─ intent.run(params) → { headline, detail, sourceCalls } | null
+ first match that resolves wins; otherwise "I can't answer that yet"
+```
+
+One question maps to exactly one intent. Every data intent bulk-fetches `getBookings(1, 1000)` and filters client-side.
+
+### Defects, ranked by damage
+
+#### Tier A — the bot states wrong numbers confidently
+
+**A1. The 1000-row ceiling is real and undetectable.**
+`BULK_PAGESIZE = 1000` is not a chosen page size, it is the API's hard cap (`express-console-api.md` → Conventions: "Default 500, cap 1000"). Worse, `getBookings` returns `response.data.data` and discards the envelope's `total`, so no caller can even detect truncation. Past 1000 lifetime bookings every count intent under-reports with no warning, while `sourceCalls` displays `status: 'complete'` beside it. This directly violates the stated promise in CLAUDE.md §3 ("a wrong number from this bot is worse than no answer").
+
+**A2. Range words are silently downgraded to "today".**
+Only `revenueTotal` and `weekOrders` call `rangeFromWords`. The other seven use `dayFromWords`, which ignores "this week" and returns today. "How many delivered orders this week" is answered by `statusBreakdown` as *today's* count. The headline says "today", so it is disclosed — but the operator asked something else and got an answer to a different question.
+
+**A3. `revenueTotal` is mislabelled and includes cancelled orders.**
+It sums `serviceoptions[0].estimatedprice` over every row in range with no status filter. Cancelled bookings inflate it, only the first service option is counted, and the figure is an *estimate* presented as "Total revenue".
+
+**A4. "assigned" resolves to the wrong bucket.**
+`statusFromWords` maps `assigned → 'accepted'`, but `mapBookingStatusToDeliveryStatus` maps the backend's `miler_assigned → 'pending'`. "How many assigned orders today" therefore counts `pickup_scheduled` + `converted_to_consignment` and excludes the orders the operator means.
+
+#### Tier B — the bot answers a different question
+
+**B1. `riderCounts` swallows every sentence containing "rider".**
+`match: (text) => (mentionsRiders(text) ? {} : null)` and its `run` never returns null. "Which rider has order #4821", "how many orders did rider Suresh deliver" and "rider performance this week" all return the same fleet availability summary. This is the mirror image of the bug CLAUDE.md §3 documents as fixed — the guard stopped `statusBreakdown` stealing rider questions, but nothing stops `riderCounts` stealing everything else.
+
+**B2. `orderLookup`'s fall-through answers a different question entirely.**
+It searches only page 1. An order not in the most recent 1000 returns `null`, the loop continues, and `totalOrders` answers "142 orders created today" to the question "status of order #9931". Fall-through is right when an intent *mismatched*; it is wrong when the intent matched and the lookup failed.
+
+**B3. No composite filters.** "Delivered orders for Acme this week" is answered by `statusBreakdown` alone — tenant and range discarded.
+
+**B4. `resolveTenant` is one-directional substring matching.** The question must contain the tenant's full name. "Orders for acme foods" against tenant "Acme Foods Pvt Ltd" fails, falls through, and `totalOrders` answers with the all-tenant count.
+
+**B5. `orderIdFromWords` treats any bare 4+ digit run as an order id** — years, pincodes, quantities.
+
+#### Tier C — architecture
+
+**C1. No TanStack Query.** CLAUDE.md §7 makes it the rule for all reads. `answerQuestion` calls the API functions raw. Clicking the five suggestion chips issues five separate 1000-row fetches; `tenantCount` issues two sequentially.
+
+**C2. `sourceCalls` are hand-written after the fact** with `status: 'complete'` hardcoded. They cannot represent a failed or partial call and will drift from the code the moment a `run` is edited. `ChatToolCalls` already supports `'pending' | 'running' | 'complete' | 'error'` plus `duration`, `errorMessage` and `resultDetail` — none used.
+
+**C3. The two aggregation endpoints are unused.** `GET /admin/reports` (`from`/`to`/`tenantid`/`locationid`/`hubid`, with `by_location`/`by_hub`/`by_tenant`/`by_rider` blocks per `getReports`'s comment) and `GET /admin/dashboard` ("counts + today's numbers") are server-computed, uncapped, and already wrapped in `doormileApi.js`. They are the correct source for every counting question and the answer to A1.
+
+**C4. No abort or timeout.** A 1000-row fetch cannot be cancelled; the composer is simply disabled.
+
+#### Tier D — UX
+
+- **D1.** Suggestion chips are gated on `messages.length === 0`, so they vanish permanently after the first question. No reset control.
+- **D2.** History dies when the popover closes (accepted in CLAUDE.md §1, but a liability once answers get expensive).
+- **D3.** Answers are two strings. `detail` truncates at 10 ids with "…and N more" and there is no way to see the rest, and no way to jump to the matching rows on the Orders page.
+- **D4.** Unanswered questions are dropped — no signal on what to build next.
+- **D5.** Fixed `PANEL_WIDTH = 400` / `MESSAGES_HEIGHT = 420` raw px.
+
+---
+
+## Part 2 — Target architecture
+
+Replace one-question-one-intent with a three-stage pipeline:
+
+```
+parse(text) → Query pure, no I/O, fully unit-testable
+resolve(Query) → Dataset cached, paginated, audited, abortable
+render(Query, Dataset) → Answer headline + blocks + real sourceCalls
+```
+
+`Query` is a slot bag, not an intent id:
+
+```js
+{
+ subject : 'orders' | 'riders' | 'tenants' | 'revenue' | 'order',
+ metric : 'count' | 'sum' | 'breakdown' | 'lookup' | 'top',
+ filters : { range: {start, end, label}, batch, status, tenantId, hubId, riderId, orderId },
+ groupBy : 'status' | 'batch' | 'tenant' | 'rider' | 'hour' | null,
+ limit : number
+}
+```
+
+Filters compose. "Delivered orders for Acme this week" fills three slots and runs one query instead of picking one of three intents.
+
+Proposed file layout inside `src/pages/nearle/assistant/`:
+
+```
+parse/
+ vocab.js date/range, batch, status, metric, groupBy vocabularies
+ entities.js tenant/rider/hub resolution + fuzzy scoring
+ parseQuery.js text → Query (+ confidence, + unresolved slots)
+resolve/
+ source.js cached, paginated booking source; reports/dashboard source
+ aggregate.js count / sum / breakdown / top over a normalised row set
+render/
+ answer.js Query + Dataset → { headline, blocks[], sourceCalls[] }
+intents.js thin compatibility shim → parseQuery + resolve + render
+BotPanel.js richer rendering, chips, reset, stop, persistence
+```
+
+`intents.js` keeps its exported surface (`answerQuestion`, `EXAMPLE_QUESTIONS`) so `BotPanel.js` and `AppTopNav.js` are unaffected during the swap.
+
+---
+
+## Part 3 — Phased plan
+
+### Phase 0 — Truth foundation (blocking; ship before any new capability)
+
+Nothing else matters while the numbers can be wrong.
+
+| # | Task | Files | Acceptance |
+|---|---|---|---|
+| 0.1 | Expose the envelope `total`/`page` from `getBookings` — return `{ rows, total, page }` or add `getBookingsPage`. Keep the existing signature working for `fetchDeliveries`' four callers. | `pages/api/doormileApi.js` | A caller can detect that more rows exist than were returned. |
+| 0.2 | Build a paginated booking source that drains pages until the oldest row predates the requested range, with a hard page budget. Emits a `truncated` flag when the budget is hit. | `resolve/source.js` | "How many orders today" is correct on an account with >1000 lifetime bookings. |
+| 0.3 | Route counting/aggregate questions through `getReports(from, to, tenantid, locationid, hubid)` first; fall back to the paginated booking scan only when reports can't answer the shape. | `resolve/source.js` | Counts for a date range come from one server call, not a 1000-row scan. |
+| 0.4 | Never present a truncated result as complete — if `truncated`, headline reads "at least N" and the tool call carries `status: 'error'` or an `errorMessage`. | `render/answer.js` | Truncation is visible in the answer, not just the console. |
+| 0.5 | Fix A3: exclude `cancelled` from revenue, sum all `serviceoptions`, relabel as "estimated". | `render/answer.js` | "Total revenue today" excludes cancelled and says "estimated". |
+| 0.6 | Fix A4: align status synonyms with `BOOKING_STATUS_TO_DELIVERY_STATUS`. "assigned" → the bucket `miler_assigned` actually lands in. | `parse/vocab.js` | "Assigned orders today" matches what the Orders page shows for the same filter. |
+| 0.7 | Fix B2: when an intent matched but its lookup failed, answer "I couldn't find order X" — do not fall through to a broader intent. | `resolve/` + `render/` | Asking for a nonexistent order never returns a global count. |
+
+**Risk:** 0.3 depends on the `/admin/reports` response shape, which is documented only in `jupiter2doormile.md` and is not in `express-console-api.md`. Confirm live before building on it; if the shape doesn't carry what's needed, 0.2 alone still fixes A1 at higher cost.
+
+### Phase 1 — Slot parser (the capability multiplier)
+
+| # | Task | Acceptance |
+|---|---|---|
+| 1.1 | `parseQuery(text) → Query` with independent slot extraction; unrecognised slots stay empty rather than defaulting. | Unit tests over a fixed corpus (Part 5). |
+| 1.2 | Real relative-date vocabulary: today, yesterday, this/last week, last N days, this month, explicit `DD MMM` and `YYYY-MM-DD`. Anything unparsed → *ask*, don't assume today. | "Delivered orders this week" returns the week, not today. |
+| 1.3 | Entity resolution with bidirectional + fuzzy matching and an ambiguity path: 0 matches → say so; 1 → use it; 2+ → ask which. | "Orders for acme foods" resolves to "Acme Foods Pvt Ltd". |
+| 1.4 | Confidence scoring replaces first-match-wins. Below threshold → clarifying question listing what was understood. | "Rider Suresh's orders today" no longer returns a fleet summary (fixes B1). |
+| 1.5 | Conversation context: carry the last `Query` forward; a follow-up mutates only the slots it names. Reset on explicit "start over" and on an entity switch. | "and yesterday?" / "just for Acme" work as follow-ups. |
+| 1.6 | Guard B5: a bare number is an order id only with an order-ish trigger word nearby and no date/quantity reading. | "Orders in 2026" is not treated as an id lookup. |
+
+Coverage after this phase is the product of the slots, not a list of nine — subject × range × status × batch × tenant × rider all compose.
+
+### Phase 2 — Answers that are objects, not sentences
+
+| # | Task | Notes |
+|---|---|---|
+| 2.1 | `Answer.blocks[]` — typed blocks (`stat`, `table`, `breakdown`, `link`) rendered by `BotPanel`. | Replaces the two-string shape. |
+| 2.2 | Result table for row-returning answers: `Table` + `StatusBadge` cells instead of "…and N more". | Reuse `components/nearle_components/StatusBadge`. |
+| 2.3 | Deep link — every answer carries the filter state that produced it, with a button that navigates to the Orders/Deliveries page pre-filtered. | The single biggest usability jump: answer → action. |
+| 2.4 | Breakdown answers via `groupBy` (by status / batch / tenant / rider / hour). | Feeds off `/admin/reports` `by_*` blocks where available. |
+| 2.5 | New subjects using already-exported functions: `getBookingTrack` (where is order X), `getMilerActivity` / `getMilerSummary` (what has rider X done), `getConsignments`, `getTripsheets`, `getHubs`, `getVehicles`. | No new endpoints needed. |
+| 2.6 | Real `sourceCalls`: emitted by the fetch layer, streaming `pending → running → complete/error`, with `duration` and `errorMessage`. | `ChatToolCalls` already supports all four states. |
+
+### Phase 3 — Panel UX
+
+| # | Task |
+|---|---|
+| 3.1 | Keep suggestion chips available after the first message (collapse into a `ChatComposerDrawer` or a header affordance), plus a "New chat" reset. |
+| 3.2 | Persist thread + last `Query` to `sessionStorage` so closing the popover doesn't lose it. |
+| 3.3 | `onStop` / `isStopShown` on `ChatComposer` wired to an `AbortController` through the fetch layer. |
+| 3.4 | `ChatSystemMessage` for context resets, day dividers and truncation notices. |
+| 3.5 | `ChatLayoutScrollButton` + `useChatNewMessages` for long threads. |
+| 3.6 | `useTriggerMenu` + `ChatComposerTokenElement`: `@tenant` / `@rider` / `/` commands so an operator *picks* a real entity instead of relying on fuzzy matching. Directly de-risks 1.3. |
+| 3.7 | Replace raw `PANEL_WIDTH`/`MESSAGES_HEIGHT` px with tokens; keyboard/focus check inside the `Popover` (Escape currently closes the panel mid-typing). |
+| 3.8 | Log unanswered questions locally (capped ring buffer) and surface them — this is the backlog for the next intent round. |
+| 3.9 | i18n the bot's strings into `utils/locales/en.json` like the rest of the app. |
+
+### Phase 4 — Actions, confirm-gated (needs sign-off)
+
+CLAUDE.md §4 rules this out today and specifies the shape it must take if built: propose → operator confirms → execute. Plan accordingly:
+
+1. Parse produces an `Action` (never executed at parse time).
+2. Render shows exactly what will be submitted — target rows, field values, the endpoint — as a `ChatSystemMessage` with explicit confirm/cancel.
+3. Execute only on confirm, through the same api.js functions the pages use, honouring the dispatch reconcile rule (root CLAUDE.md §4) and the notify-rider-after-mutation rule (§9).
+4. Post-action, refetch the related queries and show the new state.
+
+Safe first candidates: `notifyMiler` (broadcast to a rider), `cancelBooking` (single, with confirm). Deliberately last: order creation — CityGate pincode validation and delivery-slot windows live elsewhere and must not be bypassed.
+
+### Phase 5 — LLM as parser only (BLOCKED on a decision)
+
+CLAUDE.md §2 records that this was raised and rejected because there is nowhere to hold a key — no backend, static nginx build. That reasoning still stands, so this phase is blocked, not dismissed. If the key question is ever answered, the correct shape is narrow:
+
+- The model does **slot extraction only** — text in, a validated `Query` JSON out via tool-use / structured output. It never produces a number, a row, or a sentence the operator reads as fact.
+- Deterministic code still executes every fetch and every calculation.
+- Slots that don't resolve against real tenants/hubs/riders are rejected and the regex parser runs as fallback.
+
+This preserves the "no fabricated numbers" guarantee exactly, while removing the vocabulary ceiling. Note it also makes Phase 1 the fallback path rather than dead work.
+
+### Phase 6 — Proactive
+
+Once the query layer is trustworthy: watch for conditions rather than waiting to be asked — "14 morning-batch orders unassigned, 30 minutes to cutoff", "rider X offline mid-route". Surfaces as a badge on the bot icon and a `ChatSystemMessage`. Ties into the existing FCM path.
+
+---
+
+## Part 4 — Decisions needed
+
+1. **`/admin/reports` response shape** — confirm live. Blocks task 0.3, which is the cheap fix for the 1000-row problem.
+2. **Write actions** — in scope for this round, or stays read-only? Blocks Phase 4 entirely.
+3. **LLM key location** — unchanged from CLAUDE.md §2? Blocks Phase 5.
+4. **History persistence** — `sessionStorage` (dies with the tab) or Redux + `localStorage` (survives)? Affects 3.2.
+5. **Tenant-scoped logins** — should the bot say "across your tenant" when the token carries a tenantid, rather than implying global figures?
+
+---
+
+## Part 5 — Regression corpus
+
+Build this as a fixture the parser is tested against; every row is a question the bot must either answer correctly or explicitly decline.
+
+| Question | Must produce |
+|---|---|
+| how many orders today | count, orders, today |
+| how many orders this week | count, orders, 7-day range (currently → today) |
+| how many delivered orders this week | count + status + range (currently drops range) |
+| delivered orders for Acme this week | count + status + tenant + range (currently drops two) |
+| morning batch orders yesterday | count + batch + day |
+| how many riders are active | rider availability |
+| which rider has order #4821 | order lookup → rider (currently fleet summary) |
+| how many orders did rider Suresh deliver today | rider activity (currently fleet summary) |
+| status of order #9931 (nonexistent) | "couldn't find it" (currently a global count) |
+| orders in 2026 | not an id lookup |
+| total revenue today | estimated, cancelled excluded |
+| how many assigned orders today | must agree with the Orders page |
+| and yesterday? (follow-up) | previous query, day shifted |
+| how many tenants | tenant count |
+| where is order DM-BK-123 | tracking |
+| top 5 tenants by orders this week | breakdown + limit |
+| unassigned orders right now | pending count |
+| how many orders (account with >1000 bookings) | correct, or explicitly "at least N" |
+
+---
+
+# Part 6 — Capability levels
+
+A different cut from the phases above: not *how* to build it, but *what the bot
+could do*, ordered by how much has to exist underneath.
+
+Baseline: `doormileApi.js` exports **96 functions, 37 of them reads**. The bot
+calls **13** — all list endpoints. Every level below L6 is built from functions
+that already exist and are already used by some page in this console.
+
+---
+
+## L0 — Foundation (not a feature; blocks everything)
+
+Every count the bot gives is capped at `getBookings(1, 1000)` — page 1, at the
+API's hard cap — and `getBookings` discards the envelope's `total`, so
+truncation is undetectable. Fix pagination, surface `total`, route counts
+through `GET /admin/reports`, and say "at least N" when truncated.
+
+Until this lands, every level below inherits a silent wrong-number risk.
+
+---
+
+## L1 — Counts and lists — **SHIPPED**
+
+25 intents over 13 API functions. Single-dimension questions, plus typo
+tolerance, date vocabulary, comparisons, multi-part, and date follow-ups.
+
+**Ceiling:** one filter at a time. "Delivered orders for Acme this week"
+answers only the status.
+
+---
+
+## L2 — Composable queries
+
+Slot filling replaces first-match-wins: `subject x range x status x batch x
+tenant x rider x hub` all compose into one query.
+
+- "Delivered orders for Acme this week"
+- "Pending morning-batch orders at Coimbatore hub yesterday"
+- "Cancelled orders for Acme vs Beta last month"
+
+**New endpoints needed:** none. Coverage becomes the product of the slots
+rather than a list of 25.
+
+---
+
+## L3 — Entity intelligence
+
+Deep answers about *one* thing, using the detail endpoints the bot has never
+touched.
+
+| Subject | Functions available now | Unlocks |
+|---|---|---|
+| Order | `getBooking`, `getBookingTrack` | "Where is DM-BK-123", full status timeline, assigned rider, ETA |
+| Parcel | `getConsignment`, `getConsignmentLogs`, `trackConsignment` | Scan history, exception trail |
+| Rider | `getMiler`, `getMilerActivity`, `getMilerLogs`, `getMilerSummary` | "What has Suresh done today" — assigned/completed/rejected/cancelled, riderkms, last ping, live position |
+| Hub / vehicle | `getHub`, `getVehicle` | Per-site detail, assigned fleet |
+| Tenant | `getAdminTenant`, `getTenantLocations`, `getTenantCustomers` | Sites, customers, contact |
+| Exception | `getException` | Why a delivery failed |
+| Pricing | `quotePricing`, `simulatePricing` | "What would a 5kg parcel from 641001 to 600001 cost?" — a real calculation, not a lookup |
+
+**Warning:** `riderLookup` today reads `found.status`, `found.phonenumber`,
+`found.vehicletype`. The confirmed-live miler shape (documented in `api.js`)
+has none of those — it carries `availabilitystatus`, `phone`,
+`defaultvehicletype`. Fix against the real shape before extending this level.
+
+---
+
+## L4 — Analytics, ranking, anomaly
+
+Built on `getReports` (`by_tenant` / `by_hub` / `by_rider` blocks),
+`getLocationsSummary`, `getMilerSummary`.
+
+- "Top 5 tenants by orders this week"
+- "Which hub is busiest / which needs attention"
+- "Which riders have the most cancellations"
+- "Cancellation rate this week vs last"
+- "Orders per hour today"
+
+**"Which orders are delayed" is computable today** — `serviceoptions[0].
+estimateddeliveryat` exists on a booking, so "past estimated delivery and not
+yet delivered" is a real filter, not a guess. This is probably the single
+highest-value question on the list and nothing currently answers it.
+
+---
+
+## L5 — Navigation and UI control
+
+The assistant stops being a read-only oracle and starts driving the console.
+
+- Every answer carries the filter state that produced it → "Open in Orders"
+- "Show me cancelled orders" navigates and applies the filter, instead of
+ returning a count
+- "Open order DM-BK-123" routes to the record
+
+**Dependency:** the target pages must accept filter state from the URL. Check
+what `orders.js` supports before committing to this.
+
+---
+
+## L6 — Write actions, confirm-gated
+
+Out of scope per `CLAUDE.md` §4 until signed off, and that doc already fixes
+the required shape: propose -> show the exact payload and affected rows ->
+operator confirms -> execute -> refetch. Never straight from match to mutation.
+
+Tiered by blast radius:
+
+| Tier | Functions | Risk |
+|---|---|---|
+| T1 | `notifyMiler` | Sends a message. Reversible by sending another. |
+| T2 | `assignMilerToBooking`, `assignVehicleToBooking`, `updateBookingStatus`, `cancelBooking`, `updateExceptionStatus`, `blockMiler` | Single record, real consequence |
+| T3 | `batchAssignBookings`, `bulkCancelBookings` | Many records at once |
+| T4 | `createTripsheet`, `addTripsheetItem`, `dispatchTripsheet`, `arriveTripsheet` | Multi-step workflow with ordering rules |
+| T5 | `createExpressBooking`, `createExpressBookingBulk` | Last. CityGate pincode validation and delivery-slot windows live elsewhere and must not be bypassed. |
+
+Must honour the dispatch reconcile rule (root `CLAUDE.md` §4) and the
+notify-rider-after-mutation rule (§9) inside the executor, so the assistant
+can't become a backdoor around either.
+
+---
+
+## L7 — Proactive / watch
+
+Stops waiting to be asked. A watch loop evaluates threshold conditions and
+pushes into the thread plus a badge on the trigger.
+
+- "14 morning-batch orders unassigned, 30 minutes to cutoff"
+- "Rider offline mid-route"
+- "Hub with zero active riders"
+
+**Dependency:** the notification panel in `AppTopNav` is currently static
+scaffolding, not wired to a real alert stream.
+
+---
+
+## L8 — LLM as parser only — BLOCKED
+
+`CLAUDE.md` §2 records the decision: no backend, nowhere to hold a key. If
+that ever changes, the model does **slot extraction only** — text in, a
+validated Query out. It never produces a number, a row, or a sentence read as
+fact. Deterministic code still executes every fetch and every calculation, and
+the L2 parser becomes the fallback.
+
+---
+
+## Suggested order
+
+1. **L0** — one day, removes the wrong-number risk
+2. **L4's delay detection** — highest value per unit of work, no new endpoints
+3. **L2** — multiplies coverage, deletes code
+4. **L3** — the 24 unused read functions
+5. **L5** — makes answers actionable
+6. **L6/L7** — only after sign-off
diff --git a/src/components/assistant/actions.js b/src/components/assistant/actions.js
new file mode 100644
index 0000000..4923b4b
--- /dev/null
+++ b/src/components/assistant/actions.js
@@ -0,0 +1,203 @@
+import { createTenantCustomer } from 'pages/api/doormileApi';
+
+// ==============================|| Doormile AI — write actions ||============================== //
+//
+// The FIRST write capability in the assistant. Read the rules before adding
+// another one.
+//
+// The contract (assistant/CLAUDE.md §4): an intent never mutates. It returns a
+// PROPOSAL — the exact payload it would submit — and nothing reaches the API
+// until the operator presses Create in the panel. Parsing, validation and
+// execution are separated here so the proposal can be built, shown and
+// discarded without any possibility of a request going out.
+//
+// Customer creation was chosen as the first write deliberately: it needs two
+// required fields where an order needs fourteen, it has no CityGate pincode
+// gate, no geocoding and no delivery slot, and a wrong record is an edit
+// rather than a rider dispatched to the wrong address.
+
+// "create a customer", "add new client" — an explicit verb + noun. Deliberately
+// narrow: nothing here should fire on a question that merely mentions customers.
+export const CREATE_CUSTOMER_TRIGGER = /\b(?:create|add|register|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:customer|client)\b/i;
+
+// Words that are part of the instruction rather than the person's name.
+const FILLER =
+ /\b(?:create|add|register|new|a|an|the|customer|client|named|called|with|phone|number|mobile|contact|no|email|id|please)\b/gi;
+
+// Pulls what it can out of free text. Anything it can't find stays undefined
+// and is asked for — never guessed.
+export const parseCustomerDraft = (text) => {
+ const raw = String(text || '');
+
+ const email = (raw.match(/[\w.+-]+@[\w-]+\.[\w.]{2,}/) || [])[0];
+
+ // Exactly ten digits, standalone. A longer run is not a phone number and
+ // must not be silently truncated into one.
+ const phone = (raw.match(/(? w.length > 1);
+
+ return {
+ firstname: nameWords[0],
+ lastname: nameWords.slice(1).join(' ') || undefined,
+ phone,
+ email
+ };
+};
+
+// Mirrors createCustomer.js's own checks — a name, and a phone of exactly ten
+// digits. If the form would refuse it, the assistant refuses it too, rather
+// than letting the server decide.
+//
+// No tenant check: a customer record carries no tenantid, and the documented
+// POST body doesn't take one. The page's own `isStaffLogin && !tid` guard
+// exists because its dropdown sends a speculative tenantid; the assistant
+// doesn't send one at all.
+export const validateCustomerDraft = (draft) => {
+ const missing = [];
+ if (!draft.firstname) missing.push('the customer’s name');
+ if (!draft.phone || !/^\d{10}$/.test(String(draft.phone))) missing.push('a 10-digit mobile number');
+ return { ok: missing.length === 0, missing };
+};
+
+// The exact body that will be POSTed.
+//
+// Documented body for POST /admin/tenantcustomers is
+// { firstname, lastname, phone, email } — and a customer record carries NO
+// tenantid, which is why the tenant field was removed. A real
+// GET /admin/customers response does carry address, doorno, landmark, suburb,
+// city, state, postcode, latitude and longitude, so those are sent on a
+// best-effort basis: empty strings are dropped rather than sent as noise, and
+// if the server ignores the rest nothing breaks.
+const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
+
+export const buildCustomerPayload = (draft) =>
+ clean({
+ firstname: draft.firstname,
+ lastname: draft.lastname || '',
+ phone: draft.phone,
+ email: draft.email || '',
+ address: draft.address,
+ doorno: draft.doorno,
+ landmark: draft.landmark,
+ suburb: draft.suburb,
+ city: draft.city,
+ state: draft.state,
+ postcode: draft.postcode,
+ latitude: draft.latitude,
+ longitude: draft.longitude
+ });
+
+// The ONLY function in the assistant that mutates anything. Called exclusively
+// from the panel's confirm handler — never from an intent's run().
+//
+// ---- Which endpoint, and why -----------------------------------------------
+//
+// Writes to POST /admin/tenantcustomers. This is now settled by evidence, not
+// by reading the docs:
+//
+// POST /admin/customers → 405 Method Not Allowed (confirmed live)
+//
+// 405 is the unambiguous answer: the route exists, and POST is not among its
+// methods. express-console-api.md lists /admin/customers as GET + PATCH only,
+// and the server agrees. It was pointed there briefly on explicit instruction;
+// the live 405 settled it.
+//
+// The consequence, which the assistant states in its success message rather
+// than leaving the operator to discover: a customer created here does NOT
+// appear on the Customers page, because that page reads GET /admin/customers.
+// On that resource a customer comes into existence as a side effect of a
+// booking — POST /admin/expressbooking documents `customer_phone` as "creates a
+// Guest customer if unknown". A B2C customer is, by design, someone who has
+// ordered.
+//
+// To make created customers visible on that page, one of these has to happen:
+// • the Customers page reads /admin/tenantcustomers (tried once, reverted —
+// it changes what that page means, and its edit dialog would then PATCH a
+// different store by id), or
+// • the backend adds POST /admin/customers.
+//
+// The payload keeps the address fields. The documented body is
+// { firstname, lastname, phone, email }; the rest are sent best-effort and
+// ignored if unsupported.
+export const executeCreateCustomer = async (payload) => {
+ const started = Date.now();
+ const call = {
+ name: 'createTenantCustomer',
+ target: 'POST /admin/tenantcustomers',
+ stats: Object.keys(payload).join(', ')
+ };
+
+ try {
+ const res = await createTenantCustomer(payload);
+ const duration = `${Date.now() - started}ms`;
+
+ // doormileApi mutations return the full envelope, so a `success: false`
+ // arrives as a resolved promise, not a rejection.
+ if (res && res.success === false) {
+ return {
+ ok: false,
+ message: res.message || 'The server rejected the customer.',
+ sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message || 'Rejected' }]
+ };
+ }
+
+ const created = res?.data || res;
+ return {
+ ok: true,
+ id: created?.appcustomerid ?? created?.customerid ?? created?.id,
+ created,
+ message: 'Customer created.',
+ sourceCalls: [{ ...call, duration, status: 'complete', stats: `created id ${created?.appcustomerid ?? created?.id ?? '—'}` }]
+ };
+ } catch (err) {
+ // An HTTP failure used to throw straight past this function, and the panel
+ // printed a generic "could not be created" with the status thrown away —
+ // which is the one detail needed to tell "the route does not exist" apart
+ // from "the body was wrong". Report the status, the server's own message,
+ // and name the endpoint.
+ const duration = `${Date.now() - started}ms`;
+ // doormileAxios rejects with the response BODY, not the axios error, so
+ // `err.response` is undefined here — the status arrives as `err.httpStatus`.
+ const status = err.httpStatus ?? err.response?.status;
+ const serverMessage = err.message || err.error;
+
+ let message;
+ if (status === 405) {
+ // 405 is "the route exists but not this method" — a different fact from
+ // 404, and worth stating precisely so nobody re-tries the same call.
+ message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.';
+ } else if (status === 404) {
+ message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.';
+ } else if (status === 400 || status === 422) {
+ message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
+ } else if (status) {
+ message = `POST /admin/tenantcustomers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
+ } else {
+ message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
+ }
+
+ return {
+ ok: false,
+ status,
+ message,
+ sourceCalls: [
+ {
+ ...call,
+ duration,
+ status: 'error',
+ errorMessage: `${status || 'network'}${serverMessage ? ` · ${serverMessage}` : ''}`
+ }
+ ]
+ };
+ }
+};
diff --git a/src/components/assistant/assignActions.js b/src/components/assistant/assignActions.js
new file mode 100644
index 0000000..c93fe8b
--- /dev/null
+++ b/src/components/assistant/assignActions.js
@@ -0,0 +1,252 @@
+import { getMilers, assignMilerToBooking } from 'pages/api/doormileApi';
+import { buildMilerLookup, notifyRider } from 'pages/api/api';
+
+// ==============================|| Doormile AI — assigning a rider ||============================== //
+//
+// Fourth write capability. Two endpoints, and which one is correct depends
+// entirely on how many orders are being assigned:
+//
+// ONE order → POST /admin/bookings/:id/assign-miler
+// MANY orders → POST /hub/bookings/batch-assign
+//
+// These are NOT interchangeable. doormile-flow.md §4: batch-assign is "the only
+// place stops get ordered" — after assigning it sends each affected rider's
+// whole active set to the route optimiser and writes step, per-leg distance and
+// ETA back onto `bookingassignments`. Assigning ten orders with ten single
+// calls leaves every route unsequenced and riders choosing their own order.
+//
+// ---- The two rider IDs -----------------------------------------------------
+//
+// `assign-miler` takes a **mileruserid** in its body. `/admin/milers/:id/notify`
+// keys off a **milerprofileid**. Different identity spaces on adjacent
+// endpoints — doormile-flow.md calls this out by name. Getting it wrong fails
+// quietly in both directions: the assign 404s, or the rider is never told.
+// `buildMilerLookup` is the bridge, and it is the SAME one orders.js already
+// uses for exactly this translation. Don't grow a second lookup here.
+//
+// ---- Assignment is normally automatic --------------------------------------
+//
+// Creating a booking publishes `booking.assignment_requested`; a worker finds a
+// rider within 10km via Redis GEO, scores them, and commits — retrying 5 times,
+// 2 minutes apart (doormile-flow.md §3). So everything here is an OVERRIDE of a
+// decision the backend is already making, which is why the flow re-reads the
+// booking's current assignee before offering to change it.
+
+export const ASSIGN_TRIGGER =
+ /\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it|this|that|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
+
+// Riders, plus the id bridge, in one call.
+export const loadRiders = async () => {
+ const milers = (await getMilers()) || [];
+ return { milers, lookup: buildMilerLookup(milers) };
+};
+
+const riderName = (m) => m.displayname || m.authname || m.name || `Rider #${m.userid}`;
+
+// Available riders first — an operator picking by hand wants the ones who can
+// actually take it at the top. Beyond that, alphabetical: any other ordering
+// (nearest, least loaded) would need position data this list doesn't carry, and
+// a proximity label nobody can stand behind is worse than none.
+const isAvailable = (m) => /avail|active|online|free/i.test(String(m.availabilitystatus || ''));
+
+export const riderOptions = (milers) =>
+ [...(milers || [])]
+ .sort((a, b) => {
+ const byAvail = Number(isAvailable(b)) - Number(isAvailable(a));
+ return byAvail || riderName(a).localeCompare(riderName(b));
+ })
+ .map((m) => ({
+ value: String(m.userid),
+ label: [riderName(m), m.phone, m.defaultvehicletype, m.availabilitystatus || 'availability unknown'].filter(Boolean).join(' · '),
+ record: m
+ }));
+
+// Who currently holds this booking, resolved to a person rather than an id.
+export const currentAssignee = (booking, lookup) =>
+ booking?.assignedmileruserid ? lookup?.byUserId?.get(String(booking.assignedmileruserid)) || null : null;
+
+export const describeRider = (m) => (m ? [riderName(m), m.phone].filter(Boolean).join(' · ') : null);
+
+// ---- one order --------------------------------------------------------------
+export const executeAssign = async (booking, rider) => {
+ const started = Date.now();
+ const bookingLabel = booking?.bookingno || `#${booking?.bookingid}`;
+ const call = {
+ name: 'assignMilerToBooking',
+ target: `POST /admin/bookings/${booking?.bookingid}/assign-miler`,
+ stats: `${bookingLabel} → ${riderName(rider)}`
+ };
+
+ try {
+ // mileruserid, NOT milerprofileid. See the note at the top of this file.
+ const res = await assignMilerToBooking(booking.bookingid, { mileruserid: Number(rider.userid) });
+ const duration = `${Date.now() - started}ms`;
+
+ if (res && res.success === false) {
+ return {
+ ok: false,
+ message: res.message || 'The server refused the assignment.',
+ sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
+ };
+ }
+
+ const sourceCalls = [{ ...call, duration, status: 'complete' }];
+
+ // CLAUDE.md §9: any mutation that affects a rider is followed by a push.
+ // It is deliberately NOT allowed to fail the assignment — the order IS
+ // assigned at this point, and reporting otherwise would be a lie.
+ // Whether the push ACTUALLY went out, not whether it could have been
+ // attempted. This used to be reported as `Boolean(rider.milerprofileid)`
+ // — i.e. "this rider has an id, so assume they were told" — which is a
+ // different claim entirely. Caught live: notify returned 400 and the
+ // assistant still said "The rider has been notified."
+ //
+ // The assignment itself is unaffected; it had already landed. But a
+ // dispatcher who believes a rider was pinged does not follow up, and this
+ // bot's whole contract is that it never states something it has not
+ // confirmed.
+ let notified = false;
+
+ if (rider.milerprofileid) {
+ try {
+ await notifyRider(rider.milerprofileid);
+ notified = true;
+ sourceCalls.push({
+ name: 'notifyRider',
+ target: `POST /admin/milers/${rider.milerprofileid}/notify`,
+ status: 'complete',
+ stats: 'rider notified'
+ });
+ } catch (err) {
+ sourceCalls.push({
+ name: 'notifyRider',
+ target: `POST /admin/milers/${rider.milerprofileid}/notify`,
+ status: 'error',
+ errorMessage: err.message || 'notification failed'
+ });
+ }
+ } else {
+ // No profile id means no push is possible — say so rather than letting
+ // the operator assume the rider's phone buzzed.
+ sourceCalls.push({
+ name: 'notifyRider',
+ target: '/admin/milers/:id/notify',
+ status: 'error',
+ errorMessage: 'this rider has no milerprofileid, so no notification could be sent'
+ });
+ }
+
+ return { ok: true, rider, bookingLabel, notified, sourceCalls };
+ } catch (err) {
+ // doormileAxios rejects with the response BODY; the status rides on
+ // err.httpStatus.
+ const status = err.httpStatus;
+ const message = status
+ ? `POST /admin/bookings/${booking?.bookingid}/assign-miler returned ${status}${
+ err.message ? ` — ${err.message}` : ''
+ }. Nothing changed.`
+ : `${err.message || 'The request failed'} — nothing changed.`;
+ return {
+ ok: false,
+ message,
+ sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }]
+ };
+ }
+};
+
+// ---- repeat a run, keeping each order with the rider who ran it last --------
+//
+// A repeated run is the SAME drops to the SAME doors. The rider who did them
+// yesterday already knows the buzzer, the gate code and which side of the
+// building to park on, so re-deriving an assignment from scratch throws away
+// the one piece of routing knowledge the previous day produced.
+//
+// Deliberately built on /admin/bookings/:id/assign-miler, one call per order,
+// rather than the hub batch endpoint: batch-assign lets the BACKEND choose
+// riders, which is the opposite of the intent here, and it is refused to every
+// non-hub login anyway (403, confirmed live).
+//
+// The trade-off this accepts: assigning individually does not sequence a
+// rider's stops. Yesterday's run was already sequenced for these same drops,
+// so the ordering is not arbitrary — but it is not recomputed either, and the
+// caller states that rather than implying a fresh optimisation.
+export const executeRepeatAssign = async (createdPairs, rows) => {
+ const started = Date.now();
+
+ // Only rows whose source order actually had a rider. A blank one is not a
+ // failure — yesterday's copy was never assigned either.
+ const targets = (createdPairs || [])
+ .map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null }))
+ .filter((t) => t.mileruserid != null);
+
+ if (!targets.length) {
+ return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] };
+ }
+
+ const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
+ const assignedRiders = new Set();
+ const failures = [];
+ let assigned = 0;
+
+ // Sequential on purpose. These are writes against real dispatch records, and
+ // firing a burst of them concurrently makes a partial failure much harder to
+ // report accurately — which order did not land, and to whom.
+ // eslint-disable-next-line no-restricted-syntax
+ for (const t of targets) {
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
+ assigned += 1;
+ assignedRiders.add(String(t.mileruserid));
+ } catch (err) {
+ failures.push({ bookingid: t.bookingid, reason: err.message || `HTTP ${err.httpStatus || '?'}` });
+ }
+ }
+
+ const sourceCalls = [
+ {
+ name: 'assignMilerToBooking',
+ target: 'POST /admin/bookings/:id/assign-miler',
+ duration: `${Date.now() - started}ms`,
+ status: failures.length ? 'error' : 'complete',
+ stats: `${assigned} of ${targets.length} re-assigned to yesterday's rider`,
+ errorMessage: failures.length ? `${failures.length} could not be assigned` : undefined
+ }
+ ];
+
+ // One push per rider, not per order — a rider getting ten of yesterday's
+ // drops back should feel one buzz, not ten.
+ let notified = 0;
+ if (lookup) {
+ // eslint-disable-next-line no-restricted-syntax
+ for (const userid of assignedRiders) {
+ const rider = lookup.byUserId.get(userid);
+ if (rider?.milerprofileid) {
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ await notifyRider(rider.milerprofileid);
+ notified += 1;
+ } catch {
+ // Notification failure never fails the assignment — the order IS
+ // assigned by this point. It is reported, not swallowed.
+ }
+ }
+ }
+ sourceCalls.push({
+ name: 'notifyRider',
+ target: 'POST /admin/milers/:id/notify',
+ status: notified === assignedRiders.size ? 'complete' : 'error',
+ stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified`
+ });
+ }
+
+ return {
+ ok: assigned > 0,
+ assigned,
+ skipped: (createdPairs || []).length - targets.length,
+ failures,
+ notified,
+ riders: assignedRiders.size,
+ sourceCalls
+ };
+};
diff --git a/src/components/assistant/assignFlow.js b/src/components/assistant/assignFlow.js
new file mode 100644
index 0000000..b5e0570
--- /dev/null
+++ b/src/components/assistant/assignFlow.js
@@ -0,0 +1,200 @@
+import { scanBookings } from './intents';
+import { getStatusMeta } from 'themes/dt/status';
+import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions';
+import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
+
+// ==============================|| Doormile AI — conversational assign ||============================== //
+//
+// Reached two ways, and the difference is only what the draft is seeded with:
+//
+// • straight after creating an order — the panel seeds `booking`, so the
+// first question is already about a known order
+// • "assign a rider to DM-BK-…" — the operator names it, and the first step
+// resolves that reference against the real booking list
+//
+// ---- Why there is a "keep or replace" step ---------------------------------
+//
+// The backend assigns riders BY ITSELF within seconds of creation and keeps
+// retrying for ten minutes (doormile-flow.md §3). By the time an operator
+// answers a dropdown, a rider may already hold the order — one the backend
+// chose on proximity, which is information this list does not have.
+//
+// So the flow re-reads the booking's current assignee and, if there is one,
+// asks before replacing them. Silently overwriting would throw away a better
+// decision and strand a rider who has already been told the job is theirs.
+
+export const ASSIGN_STEPS = [
+ {
+ id: 'bookingno',
+ // A SELECT, not a text field.
+ //
+ // This used to ask "Give me its number — for example DM-BK-0D915D43-33705"
+ // and wait for it to be typed. Two problems with that. An operator does not
+ // know the number by heart, so the question sent them to another screen to
+ // go and read one. And it was the fallback reached whenever a create did
+ // not hand back a booking id — so the moment the API response shape was
+ // anything other than expected, "assign the order I just made" turned into
+ // "recite a 20-character reference".
+ //
+ // A list removes the failure mode rather than patching it: unassigned
+ // orders first, because those are the ones anyone is here to assign.
+ //
+ // `resolve` below is kept, so typing a number still works — the engine runs
+ // it for a picked option too, and it already matches on bookingid.
+ type: 'select',
+ ask: 'Which order should I assign?',
+ // The list is built from a live scan, so it can come back empty — an API
+ // failure, or genuinely no bookings. `resolve` below still accepts a typed
+ // number, so say that rather than leaving Cancel as the only way out.
+ emptyHint: 'I couldn’t load the order list. Type the order number instead — for example DM-BK-0D915D43-33705.',
+ // Seeded by the panel when this follows a create, so it is skipped there —
+ // that path goes straight to the rider list.
+ when: (d) => !d.booking,
+ options: async () => {
+ const scan = await scanBookings();
+ const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
+
+ return [...(scan.rows || [])]
+ .map((b) => ({ b, holder: lookup ? currentAssignee(b, lookup) : null }))
+ // Unassigned first; the API already returns newest-first, and that
+ // order is preserved within each group by a stable sort.
+ .sort((x, y) => Number(Boolean(x.holder)) - Number(Boolean(y.holder)))
+ // A dropdown is for picking, not for browsing. Past this many the
+ // operator is better served by naming the order.
+ .slice(0, 30)
+ .map(({ b, holder }) => ({
+ value: String(b.bookingid),
+ label: [
+ b.bookingno || `#${b.bookingid}`,
+ // A raw booking carries `status`; `orderstatus` is the mapped
+ // field the LIST pages add. Reading the wrong one made every
+ // option in this dropdown say "Unknown" — seen live.
+ getStatusMeta(b.status ?? b.orderstatus).label,
+ holder ? `held by ${describeRider(holder)}` : 'unassigned'
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ }));
+ },
+ resolve: async (raw) => {
+ const needle = String(raw || '')
+ .trim()
+ .toLowerCase();
+ if (!needle) return { error: 'I need an order number.' };
+
+ const scan = await scanBookings();
+
+ // EXACT matches win across the whole list before any fuzzy one is
+ // considered. The old version tested all three conditions per row inside
+ // a single find(), so row ORDER decided the winner: a row whose
+ // bookingno merely CONTAINED the needle could match before the row whose
+ // bookingid actually equalled it.
+ //
+ // That is not theoretical. Picking from the dropdown sends a bare
+ // numeric bookingid as the answer, and every bookingno ends in a digit
+ // run — so "32143" substring-matched DM-BK-81DFAF19-32143 while some
+ // other booking genuinely had id 32143. The assignment then went to a
+ // different order than the one on screen, which reads as "it said it
+ // assigned but nothing updated".
+ const exact = scan.rows.find(
+ (b) => String(b.bookingid) === needle || String(b.bookingno || '').toLowerCase() === needle
+ );
+
+ // Substring is a convenience for someone typing part of a reference, so
+ // it needs enough characters to identify one order. Below this it is
+ // guesswork — "1" would match most of the list.
+ const MIN_FUZZY = 4;
+ const found =
+ exact ||
+ (needle.length >= MIN_FUZZY
+ ? scan.rows.find((b) => String(b.bookingno || '').toLowerCase().includes(needle))
+ : null);
+ if (!found) {
+ return {
+ error: scan.truncated
+ ? `I couldn't find ${raw} in the most recent ${scan.scanned.toLocaleString(
+ 'en-IN'
+ )} bookings. It may be further back than I can scan.`
+ : `I couldn't find an order matching ${raw}. Check the number and try again.`
+ };
+ }
+ // Resolve the current holder HERE, not afterwards. advanceFlow evaluates
+ // keepOrReplace's `when` the instant this step is applied — a lookup that
+ // lands even one tick later means the step is skipped and an
+ // already-assigned order is silently reassigned.
+ const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
+ return { value: { booking: found, currentRider: lookup ? currentAssignee(found, lookup) : null } };
+ },
+ apply: (d, v) => ({ ...d, booking: v.booking, __currentRider: v.currentRider })
+ },
+ {
+ id: 'keepOrReplace',
+ type: 'select',
+ // The question text is rewritten by the panel to name the current holder;
+ // this is the fallback if that lookup came back empty.
+ ask: 'This order already has a rider. Keep them, or assign someone else?',
+ when: (d) => Boolean(d.__currentRider),
+ options: async (d) => [
+ { value: 'keep', label: `Keep ${describeRider(d.__currentRider) || 'the current rider'}` },
+ { value: 'replace', label: 'Assign someone else' }
+ ],
+ apply: (d, v) => ({ ...d, keepOrReplace: v, __keep: v === 'keep' })
+ },
+ {
+ id: 'mileruserid',
+ type: 'select',
+ ask: 'Which rider should take it?',
+ when: (d) => !d.__keep,
+ options: async () => {
+ const { milers } = await loadRiders();
+ return riderOptions(milers);
+ },
+ // Resolved rather than taken straight from the clicked option.
+ //
+ // `apply` used to read `option?.record`, which is only populated when a
+ // button was CLICKED. Answer this step by typing — a rider's name, or an
+ // id — and option is undefined, so __rider was undefined, and executeAssign
+ // then threw on `rider.userid` inside its try/catch. The operator saw a
+ // failed assignment with "Cannot read properties of undefined" instead of
+ // an answer.
+ //
+ // Resolving here means both paths produce a real miler record, and a name
+ // that matches nobody gets a sentence rather than a crash.
+ resolve: async (raw) => {
+ const needle = String(raw || '').trim();
+ if (!needle) return { error: 'I need a rider.' };
+
+ const { milers, lookup } = await loadRiders();
+ const rider =
+ lookup?.byUserId?.get(needle) ||
+ lookup?.byName?.get(needle.toLowerCase()) ||
+ (milers || []).find((m) => String(m.userid) === needle) ||
+ (milers || []).find((m) => (m.displayname || m.authname || '').toLowerCase() === needle.toLowerCase());
+
+ if (!rider) return { error: `I couldn't find a rider matching ${raw}. Pick one from the list.` };
+ // Both ids travel: assign-miler needs `userid`, the push needs
+ // `milerprofileid`, and they are different fields on the same record.
+ return { value: { mileruserid: String(rider.userid), rider } };
+ },
+ apply: (d, v) => ({ ...d, mileruserid: v.mileruserid, __rider: v.rider })
+ }
+];
+
+// `booking` is optional — present when this follows a create.
+export const startAssignFlow = async (booking) => {
+ const draft = booking ? { booking } : {};
+ if (booking) {
+ // Resolve who holds it right now, so the keep/replace step knows whether to
+ // ask at all. A failure here degrades to "nobody assigned yet", which is
+ // the safe direction: the operator is asked to choose rather than being
+ // told something untrue about the current rider.
+ const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
+ const held = lookup ? currentAssignee(booking, lookup) : null;
+ if (held) draft.__currentRider = held;
+ }
+ return startFlow(ASSIGN_STEPS, 'assignRider', draft);
+};
+
+export const advanceAssign = (flow) => advanceFlow(ASSIGN_STEPS, flow);
+
+export const answerAssignStep = (flow, raw, option) => answerFlowStep(ASSIGN_STEPS, flow, raw, option);
diff --git a/src/components/assistant/bulkFile.js b/src/components/assistant/bulkFile.js
new file mode 100644
index 0000000..c29ff09
--- /dev/null
+++ b/src/components/assistant/bulkFile.js
@@ -0,0 +1,223 @@
+import Papa from 'papaparse';
+import * as XLSX from 'xlsx';
+
+import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from 'utils/bulkOrderColumns';
+
+// ==============================|| Doormile AI — bulk order file upload ||============================== //
+//
+// Turns a CSV / XLS / XLSX into the SAME row array `parseBulkRows` produces from
+// a paste, so everything downstream — geocoding, validation, review, the chunked
+// submit, the per-row outcome report — is untouched by where the rows came from.
+//
+// Both parsers are already dependencies (`papaparse`, `xlsx`) and both are the
+// ones multipleOrders.js uses, as is the column map. A sheet that uploads on
+// that page uploads here.
+//
+// Three reporting rules, all of them about not lying by omission:
+//
+// • An unparseable row becomes a REPORTED error with its line number, never a
+// silently skipped line. A bulk import that quietly drops row 14 is worse
+// than one that refuses outright.
+// • Columns that were not recognised are NAMED. An operator whose price
+// column is titled something unexpected has to be told it was ignored, or
+// they'll submit 200 orders priced from a column nothing ever read.
+// • Rows duplicated inside the file are flagged BEFORE submit. The bulk
+// endpoint has no idempotency key, so a duplicate that gets through is a
+// second real rider dispatched to the same door.
+
+const CSV_EXT = /\.csv$/i;
+const EXCEL_EXT = /\.xlsx?$/i;
+
+const digits = (v) => String(v ?? '').replace(/\D/g, '');
+const text = (v) => String(v ?? '').trim();
+
+// A sheet cell can be a number, a date, or padded text — normalise to the same
+// shapes parseBulkRows yields so validateBulkRow behaves identically.
+const shapeRow = (raw, line) => {
+ const { row, ignored } = mapSheetRow(raw);
+
+ const lat = Number(row.deliverylatitude);
+ const lng = Number(row.deliverylongitude);
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0;
+
+ return {
+ ignored,
+ row: {
+ line,
+ customer_name: text(row.customer_name),
+ // A 10-digit Indian mobile arrives as 9812345678, 09812345678, +91
+ // 98123 45678, or — from Excel — 9812345678 as a float. Strip to digits
+ // and drop a leading country/trunk prefix the same way the single-order
+ // flow does.
+ customer_phone: digits(row.customer_phone).replace(/^(?:0|91)(?=\d{10}$)/, ''),
+ deliveryaddress: text(row.deliveryaddress),
+ deliverypincode: digits(row.deliverypincode),
+ deliverycity: text(row.deliverycity),
+ // Blank is meaningful: it means "quote this row from the tenant's pricing
+ // row and the routed distance", the same as the single-order flow. It is
+ // NOT zero.
+ finalprice: text(row.finalprice),
+ itemdescription: text(row.itemdescription) || 'Order',
+ itemcategory: text(row.itemcategory) || 'General',
+ quantity: Math.max(1, Number(row.quantity) || 1),
+ weight: text(row.weight),
+ // Coordinates from the sheet let the geocode pass skip this row entirely,
+ // which on a 200-row file is the difference between minutes and seconds.
+ ...(hasCoords ? { deliverylatitude: lat, deliverylongitude: lng, resolvedAddress: text(row.deliveryaddress) } : {})
+ }
+ };
+};
+
+// Structural check only — enough to know the row is worth geocoding. The full
+// gate is validateBulkRow, applied after coordinates exist.
+const structuralError = (row) => {
+ if (!row.customer_name) return 'No receiver name';
+ if (!row.deliveryaddress) return 'No delivery address';
+ if (!row.customer_phone) return 'No phone number';
+ if (row.finalprice !== '' && Number.isNaN(Number(row.finalprice))) return `Price "${row.finalprice}" is not a number`;
+ return null;
+};
+
+export const mapSheetRecords = (records, headers, sheetName) => {
+ const rows = [];
+ const errors = [];
+ const ignoredColumns = new Set();
+
+ records.forEach((raw, i) => {
+ // +2: the header row is line 1, so the first data row is line 2 — the line
+ // number an operator sees in their own spreadsheet.
+ const line = i + 2;
+ const { row, ignored } = shapeRow(raw, line);
+ ignored.forEach((c) => ignoredColumns.add(c));
+
+ // A trailing blank row is an artefact of the file, not an operator error.
+ if (!row.customer_name && !row.deliveryaddress && !row.customer_phone) return;
+
+ const error = structuralError(row);
+ if (error) errors.push({ line, text: row.customer_name || row.deliveryaddress || `Row ${line}`, reason: error });
+ else rows.push(row);
+ });
+
+ const normalised = headers.map(normalizeHeader);
+ const missingRequired = requiredSheetColumns().filter((c) => !normalised.includes(normalizeHeader(c)));
+
+ return {
+ rows,
+ errors,
+ sheetName,
+ ignoredColumns: [...ignoredColumns],
+ // Reported, not enforced: the page only warns about these too, and a
+ // hand-built sheet using plain headers ("name", "phone") legitimately has
+ // none of the tenant's official titles while still being complete.
+ missingRequired,
+ recognisedColumns: headers.filter((h) => rowFieldForHeader(h)).map((h) => String(h).trim()),
+ duplicates: findDuplicateRows(rows)
+ };
+};
+
+// Same recipient at the same address twice in one file. Reported, never removed
+// automatically — two parcels to one door is a legitimate order, and deciding
+// which is which is the operator's call, not the parser's.
+export const findDuplicateRows = (rows) => {
+ const seen = new Map();
+ const dupes = [];
+ rows.forEach((r) => {
+ const key = `${r.customer_phone}|${normalizeHeader(r.deliveryaddress)}`;
+ if (seen.has(key)) dupes.push({ line: r.line, firstLine: seen.get(key), customer_name: r.customer_name });
+ else seen.set(key, r.line);
+ });
+ return dupes;
+};
+
+export const parseBulkFile = (file) =>
+ new Promise((resolve, reject) => {
+ if (!file) {
+ reject(new Error('No file selected.'));
+ return;
+ }
+ const isCsv = CSV_EXT.test(file.name);
+ const isExcel = EXCEL_EXT.test(file.name);
+ if (!isCsv && !isExcel) {
+ reject(new Error(`“${file.name}” isn’t a spreadsheet. Upload a .csv, .xls or .xlsx file.`));
+ return;
+ }
+
+ if (isCsv) {
+ Papa.parse(file, {
+ header: true,
+ dynamicTyping: false,
+ skipEmptyLines: true,
+ complete: (results) => {
+ if (!results.data?.length) {
+ reject(new Error('That CSV has a header row but no data rows.'));
+ return;
+ }
+ resolve(mapSheetRecords(results.data, results.meta.fields || [], file.name));
+ },
+ error: (err) => reject(new Error(`Couldn’t read that CSV — ${err.message}`))
+ });
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onerror = () => reject(new Error('Couldn’t read that file.'));
+ reader.onload = (e) => {
+ try {
+ const workbook = XLSX.read(e.target.result, { type: 'binary' });
+ const sheetName = workbook.SheetNames[0];
+ // Only the first sheet is read, and the name is reported back so an
+ // operator whose data sits on "Sheet2" can see which one was used.
+ const records = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '', raw: false });
+ if (!records?.length) {
+ reject(new Error(`Sheet “${sheetName}” is empty.`));
+ return;
+ }
+ resolve(mapSheetRecords(records, Object.keys(records[0]), `${file.name} · ${sheetName}`));
+ } catch (err) {
+ reject(new Error(`Couldn’t read that spreadsheet — ${err.message}`));
+ }
+ };
+ reader.readAsBinaryString(file);
+ });
+
+// ---- downloads --------------------------------------------------------------
+
+// Hands the operator a file built from data they already supplied — a Blob
+// assembled in the page, not a fetch and not an upload.
+export const downloadCsv = (filename, csv) => {
+ const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ link.click();
+ URL.revokeObjectURL(url);
+};
+
+const toCsv = (headers, rows) =>
+ [headers, ...rows]
+ .map((r) => r.map((c) => (/[",\n]/.test(String(c ?? '')) ? `"${String(c).replace(/"/g, '""')}"` : String(c ?? ''))).join(','))
+ .join('\r\n');
+
+// A blank sheet with the exact headers this parser reads, so operators stop
+// guessing at column titles.
+export const templateCsv = () =>
+ toCsv(TEMPLATE_HEADERS, [['Ravi Kumar', '9812345678', '12 Trichy Rd, Coimbatore', '641018', 'Coimbatore', 'Documents', '1', '']]);
+
+// The rows that did NOT go through, in the same column shape, so they can be
+// fixed and re-uploaded. This is what makes a partial success recoverable
+// without re-submitting the rows that already landed.
+export const failedRowsCsv = (failed) =>
+ toCsv(
+ [...TEMPLATE_HEADERS, 'Reason'],
+ failed.map((r) => [
+ r.customer_name || '',
+ r.customer_phone || '',
+ r.deliveryaddress || '',
+ r.deliverypincode || '',
+ r.deliverycity || '',
+ r.itemdescription || '',
+ r.quantity ?? 1,
+ r.finalprice ?? '',
+ r.error || r.reason || 'Rejected'
+ ])
+ );
diff --git a/src/components/assistant/bulkFlow.js b/src/components/assistant/bulkFlow.js
new file mode 100644
index 0000000..aebeb1b
--- /dev/null
+++ b/src/components/assistant/bulkFlow.js
@@ -0,0 +1,193 @@
+import { getTenantLocations } from 'pages/api/doormileApi';
+import { getalltenants } from 'pages/api/api';
+import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
+import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
+import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
+import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
+
+// ==============================|| Doormile AI — conversational bulk create ||============================== //
+//
+// Same conversation shape as orderFlow.js — one question per turn, a dropdown
+// wherever the page uses one — but the rows come from a sheet instead of being
+// dictated one field at a time. The form version was replaced on explicit
+// direction: "don't show it as the form way, it should be like chatting".
+//
+// The steps deliberately mirror the single-order flow's opening, because they
+// ARE the same questions: which tenant, which pickup location, which service.
+// Only the last step differs — a whole file instead of one recipient.
+//
+// What is NOT a step: locating and pricing the rows. Those are a long-running
+// pass over the whole file (~1 lookup/second), so the panel runs them after the
+// last answer and reports progress into the conversation. Making them a "step"
+// would mean a question nobody is being asked.
+
+const isStaffLogin = () => {
+ const t = localStorage.getItem('tenantid');
+ return !t || t === '0';
+};
+
+export const BULK_STEPS = [
+ {
+ id: 'tenantid',
+ type: 'select',
+ ask: 'Which tenant are these orders for?',
+ when: () => isStaffLogin(),
+ options: async () => {
+ const tenants = (await getalltenants()) || [];
+ return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
+ },
+ apply: (d, v) => ({ ...d, tenantid: v })
+ },
+ {
+ id: 'pickuplocationid',
+ type: 'select',
+ // One pickup location for the whole file — the same shape the bulk page
+ // uses, and what makes a single batch dispatchable.
+ ask: 'Which business location are they all picked up from?',
+ options: async (d) => {
+ const tid = d.tenantid || localStorage.getItem('tenantid');
+ const locations = (await getTenantLocations(tid)) || [];
+ return locations.map((l) => ({
+ value: String(l.locationid),
+ label: `${l.locationname || l.address || 'Location'}${
+ l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
+ }`,
+ record: l
+ }));
+ },
+ // Refused here rather than after submitting: CityGate runs server-side
+ // before the handler, and would reject every row in the file with an
+ // opaque middleware error.
+ validate: (v, option) =>
+ cityGateFor(option?.record?.pincode)
+ ? null
+ : `That location’s pincode (${
+ option?.record?.pincode || 'unknown'
+ }) is outside the cities Doormile serves, so every row would be refused. Pick another location.`,
+ apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
+ },
+ {
+ id: 'service_option',
+ type: 'select',
+ ask: 'Which service level for all of them?',
+ options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
+ apply: (d, v) => ({ ...d, service_option: v })
+ },
+ {
+ id: 'rows',
+ type: 'rows',
+ ask: 'Now the orders themselves — upload a sheet, or paste the rows.',
+ // The whole parse result is stored, not just the rows: the ignored columns
+ // and in-file duplicates have to be reportable, and a count of rows alone
+ // can't say what was quietly not read.
+ validate: (parsed) =>
+ parsed?.rows?.length
+ ? null
+ : 'I couldn’t read any complete rows out of that. Every row needs at least a name, a phone and an address.',
+ apply: (d, parsed) => ({ ...d, rows: parsed.rows, __parse: parsed })
+ }
+];
+
+export const startBulkFlow = () => {
+ // Same seeding rule as the single-order flow: a client login skips the tenant
+ // question, so the id has to be in the draft or the payload sends NaN.
+ const tid = localStorage.getItem('tenantid');
+ return startFlow(BULK_STEPS, 'createBulk', tid && tid !== '0' ? { tenantid: tid } : {});
+};
+
+export const advanceBulk = (flow) => advanceFlow(BULK_STEPS, flow);
+export const answerBulkStep = (flow, raw, option) => answerFlowStep(BULK_STEPS, flow, raw, option);
+
+// ---- the long pass: locate, then price --------------------------------------
+//
+// Extracted from the old form so the conversation can run it and narrate it.
+// Two economies keep a large file practical, and both are load-bearing:
+//
+// • a sheet carrying latitude/longitude columns skips the lookup entirely
+// • results are cached by address, so a re-run after fixing a few rows does
+// not re-look-up the ones that were already fine
+//
+// `shouldStop` is read through a function, never a captured boolean — as state
+// it was evaluated once at call time and Stop did nothing for 200 rows.
+export const GEOCODE_INTERVAL_MS = 1100;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+export const cacheKey = (row) => `${String(row.deliveryaddress || '').toLowerCase()}|${row.deliverypincode || ''}`;
+export const hasCoords = (row) => Number.isFinite(Number(row.deliverylatitude)) && Number.isFinite(Number(row.deliverylongitude));
+
+export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgress, shouldStop } = {}) => {
+ const located = [];
+
+ for (let i = 0; i < rows.length; i += 1) {
+ if (shouldStop?.()) break;
+ const row = rows[i];
+
+ if (hasCoords(row)) {
+ located.push(row);
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ const key = cacheKey(row);
+ if (cache?.has(key)) {
+ located.push({ ...row, ...cache.get(key) });
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+
+ onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
+ // eslint-disable-next-line no-await-in-loop
+ const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
+ const found = {
+ deliverylatitude: place?.geometry?.location?.lat?.(),
+ deliverylongitude: place?.geometry?.location?.lng?.(),
+ resolvedAddress: place?.formatted_address
+ };
+ cache?.set(key, found);
+ located.push({ ...row, ...found });
+
+ // Only wait after a real request. A cache hit or a sheet coordinate costs
+ // nothing, which is what makes a re-run fast.
+ // eslint-disable-next-line no-await-in-loop
+ if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
+ }
+
+ // Rows never reached because Stop was pressed keep no coordinates, so they
+ // report as unsendable instead of vanishing from the count.
+ if (located.length < rows.length) located.push(...rows.slice(located.length));
+
+ // Only a row that is located AND unpriced needs a routing call. Pricing an
+ // unlocatable row spends an OSRM request just to fail, and re-pricing a row
+ // that carried its own price would overwrite the operator's number.
+ const needPricing = located.filter((r) => hasCoords(r) && String(r.finalprice ?? '') === '');
+
+ // Stop deliberately does NOT gate this phase. It exists to stop the ~1/second
+ // ADDRESS lookups; pricing is unthrottled and bounded by what was already
+ // located. Gating it here meant a Stop mid-lookup left every located row
+ // unpriced and therefore unsendable — throwing away exactly the work the
+ // operator is told is kept.
+ const priced = needPricing.length
+ ? await priceBulkRows(needPricing, pickup, tenantid, { onProgress: (p) => onProgress?.({ ...p, phase: 'price' }) })
+ : [];
+ const pricedByLine = new Map(priced.map((r) => [r.line, r]));
+
+ const checked = located.map((r) => {
+ const merged = pricedByLine.get(r.line) || r;
+ return {
+ ...merged,
+ // The pricing reason is more specific than "Price must be a number", so it
+ // wins when both apply.
+ error: merged.priceError ? `Couldn’t price it — ${merged.priceError}` : validateBulkRow(merged)
+ };
+ });
+
+ return {
+ rows: checked,
+ valid: checked.filter((r) => !r.error),
+ invalid: checked.filter((r) => r.error)
+ };
+};
+
+// How many rows still need a network lookup — the only honest basis for an ETA.
+export const lookupsNeeded = (rows, cache) => rows.filter((r) => !hasCoords(r) && !cache?.has(cacheKey(r))).length;
+
+export const batchCount = (n) => Math.ceil(n / BULK_MAX);
diff --git a/src/components/assistant/bulkOrderActions.js b/src/components/assistant/bulkOrderActions.js
new file mode 100644
index 0000000..5e36bf7
--- /dev/null
+++ b/src/components/assistant/bulkOrderActions.js
@@ -0,0 +1,299 @@
+import { createExpressBookingBulk, getAdminPricing } from 'pages/api/doormileApi';
+import { calculateDrivingDistance, calculateTotalCharge } from 'utils/distance';
+import { buildOrderPayload } from './orderActions';
+
+// ==============================|| Doormile AI — bulk order creation ||============================== //
+//
+// Third write capability, and the highest-blast-radius one: a single press can
+// dispatch dozens of riders. Everything here is built around making that
+// visible BEFORE it happens and legible AFTER.
+//
+// Shared with the single-order path on purpose:
+// • buildOrderPayload — so a bulk row and a single order are byte-identical
+// on the wire, including the pickuplocationid workaround (that field 500s
+// server-side; raw pickup fields are sent instead).
+// • validateOrderDraft — the same gate, applied per row.
+
+// Server cap, documented in express-console-api.md. Exceeding it is a hard
+// error rather than a silent truncation, so rows are chunked instead.
+export const BULK_MAX = 200;
+
+export const CREATE_BULK_TRIGGER =
+ /\b(?:create|add|place|book|new|bulk|multiple)\s+(?:multiple|many|several|bulk|\d+)\s*(?:orders|bookings|deliveries)\b|\bbulk\s+(?:order|booking|upload)\b|\bmultiple\s+orders\b/i;
+
+// ---- Parsing a pasted list --------------------------------------------------
+//
+// Operators paste from a spreadsheet, so accept the shapes that actually
+// arrive: comma, tab or pipe separated, one row per line, with an optional
+// header row.
+//
+// name, phone, address, pincode, city, price, description
+//
+// Anything unparseable becomes a REPORTED row error rather than a silently
+// dropped line — a bulk import that quietly skips row 14 is worse than one
+// that refuses.
+const SPLIT = /\t|\||,(?![^(]*\))/;
+
+const HEADER_HINT = /name|phone|mobile|address|pincode|city|price|amount|item|description/i;
+
+export const parseBulkRows = (text) => {
+ const lines = String(text || '')
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .filter(Boolean);
+
+ if (!lines.length) return { rows: [], errors: [] };
+
+ // Drop a header row only when it looks like one AND carries no phone number.
+ const first = lines[0];
+ const looksLikeHeader = HEADER_HINT.test(first) && !/\d{10}/.test(first);
+ const body = looksLikeHeader ? lines.slice(1) : lines;
+
+ const rows = [];
+ const errors = [];
+
+ body.forEach((line, i) => {
+ const parts = line.split(SPLIT).map((p) => p.trim());
+ const lineNo = (looksLikeHeader ? 2 : 1) + i;
+ if (parts.length < 4) {
+ errors.push({ line: lineNo, text: line, reason: 'Needs at least name, phone, address and pincode' });
+ return;
+ }
+ const [customer_name, customer_phone, deliveryaddress, deliverypincode, deliverycity, finalprice, itemdescription] = parts;
+ rows.push({
+ line: lineNo,
+ customer_name,
+ customer_phone: String(customer_phone || '').replace(/\D/g, ''),
+ deliveryaddress,
+ deliverypincode: String(deliverypincode || '').replace(/\D/g, ''),
+ deliverycity: deliverycity || '',
+ finalprice: finalprice || '',
+ itemdescription: itemdescription || 'Order',
+ itemcategory: 'General',
+ quantity: 1
+ });
+ });
+
+ return { rows, errors };
+};
+
+// Per-row validation. Deliberately NOT validateOrderDraft: a pasted row has no
+// coordinates (there is no address search on a paste), and the single-order
+// gate requires them. Bulk rows are geocoded by the caller before submit, and
+// rows that fail to geocode are reported, not sent.
+const PHONE_RE = /^\d{10}$/;
+
+export const validateBulkRow = (row) => {
+ if (!row.customer_name) return 'Missing customer name';
+ if (!PHONE_RE.test(row.customer_phone)) return 'Phone must be exactly 10 digits';
+ if (!row.deliveryaddress) return 'Missing delivery address';
+ if (!row.deliverypincode) return 'Missing delivery pincode';
+ // Coordinates are checked BEFORE the price, because an unlocatable address is
+ // the root cause and a blank price is its symptom — the row was never priced
+ // precisely because there was nothing to route. Reporting "price must be a
+ // number" here sent the operator to fix the wrong column.
+ if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
+ return 'Address could not be located — the order could not be routed';
+ }
+ if (row.finalprice === '' || Number.isNaN(Number(row.finalprice))) return 'Price must be a number';
+ return null;
+};
+
+// ---- per-row pricing --------------------------------------------------------
+//
+// A blank price column means "quote it", exactly as the single-order flow does —
+// not zero. The tenant's pricing row is fetched ONCE for the whole file (fetching
+// it per row would be 200 identical requests), then each unpriced row costs one
+// OSRM call for its routed distance.
+//
+// A row that can't be priced keeps its blank price and carries the reason. It
+// then fails validateBulkRow and is reported, rather than being submitted at a
+// number nobody chose.
+export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, shouldStop } = {}) => {
+ const pricing = (await getAdminPricing()) || [];
+ // tenantid is numeric on the pricing row and a string from localStorage — a
+ // strict comparison here silently priced every order at zero once before.
+ const match = pricing.find((p) => String(p.tenantid) === String(tenantid));
+
+ const out = [];
+ for (let i = 0; i < rows.length; i += 1) {
+ const row = rows[i];
+ // A stop leaves the remaining rows exactly as they were — unpriced and
+ // therefore invalid — instead of half-pricing the file.
+ if (shouldStop?.()) {
+ out.push(...rows.slice(i));
+ break;
+ }
+ onProgress?.({ done: i, total: rows.length, current: row.customer_name || row.deliveryaddress });
+
+ if (String(row.finalprice ?? '') !== '') {
+ out.push(row);
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ if (!match) {
+ out.push({ ...row, priceError: 'no pricing configured for this tenant' });
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ // eslint-disable-next-line no-await-in-loop
+ const km = await calculateDrivingDistance(
+ { latitude: pickup?.latitude, longitude: pickup?.longitude },
+ { latitude: row.deliverylatitude, longitude: row.deliverylongitude }
+ ).catch(() => null);
+
+ if (km == null) {
+ out.push({ ...row, priceError: 'could not measure the distance' });
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
+ out.push({ ...row, finalprice: Number(Number(total).toFixed(2)), km, quoted: true });
+ }
+ return out;
+};
+
+// ---- double-submit protection ----------------------------------------------
+//
+// POST /admin/expressbooking/bulk takes no idempotency key, so if a submit times
+// out the operator cannot tell what landed — and re-sending the file double-books
+// every row that succeeded. Fingerprints of what has already been submitted this
+// session are kept so an identical re-submit can at least be questioned.
+//
+// Session-scoped on purpose: it guards the realistic accident (pressing Create
+// twice, or re-uploading the same file minutes later), not a next-day re-run,
+// which may be a legitimately repeated delivery round.
+const submitted = new Set();
+
+export const rowSetFingerprint = (rows) =>
+ (rows || [])
+ .map((r) => `${r.customer_phone}|${r.deliverypincode}|${r.finalprice}`)
+ .sort()
+ .join(';');
+
+export const wasAlreadySubmitted = (rows) => rows?.length > 0 && submitted.has(rowSetFingerprint(rows));
+
+const chunk = (arr, size) => {
+ const out = [];
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
+ return out;
+};
+
+// The only bulk-writing function in the assistant.
+//
+// Returns a per-row outcome, never a bare success/failure. A partial success
+// is the normal case for a bulk import, and the operator has to be able to see
+// exactly which rows landed — otherwise the only safe response to any error is
+// to assume nothing worked and re-submit everything, which double-books.
+export const executeCreateBulk = async (rows, shared) => {
+ const started = Date.now();
+ // Recorded BEFORE the request, not after: a timed-out submit is the case that
+ // most needs the warning, and it never reaches a success handler.
+ submitted.add(rowSetFingerprint(rows));
+ // A row may carry its OWN pickup. A bulk FILE shares one kitchen, but a
+ // repeated day's run can span several tenants and locations — collapsing
+ // those onto one shared pickup would silently re-address half the orders.
+ const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, r.__pickup ?? shared.__pickup));
+ const batches = chunk(payloads, BULK_MAX);
+
+ const sourceCalls = [];
+ let created = 0;
+ const failures = [];
+ // The ids of what actually landed, so the run can be handed straight to
+ // batch-assign without a re-scan of /admin/bookings to find them again.
+ const createdIds = [];
+ // {index, bookingid} — the same ids, but each still tied to its source row.
+ const createdPairs = [];
+
+ for (let b = 0; b < batches.length; b += 1) {
+ const batch = batches[b];
+ const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ const res = await createExpressBookingBulk(batch);
+ // Three shapes, most-nested first. The live endpoint returns
+ // { data: { results: [ { index, success, bookingid, bookingno } ] } }
+ // — confirmed against api.doormile.com — and only the two flatter shapes
+ // were checked here. So `res.data` was an object rather than an array,
+ // `res.results` was undefined, perRow fell through to null, and the
+ // whole run was treated as all-or-nothing: the count came out right by
+ // accident (created += batch.length) while EVERY booking id was thrown
+ // away. That is why "Assign the 13 you just created" never appeared —
+ // there were no ids to offer.
+ const perRow = Array.isArray(res?.data?.results)
+ ? res.data.results
+ : Array.isArray(res?.data)
+ ? res.data
+ : Array.isArray(res?.results)
+ ? res.results
+ : null;
+
+ if (res?.success === false) {
+ failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason: res.message || 'Rejected' })));
+ sourceCalls.push({
+ name: 'createExpressBookingBulk',
+ target: `POST /admin/expressbooking/bulk${label}`,
+ status: 'error',
+ errorMessage: res.message || 'Rejected'
+ });
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+
+ // The endpoint is documented as returning per-row results. If it does,
+ // trust it row by row; if it doesn't, treat the batch as all-or-nothing
+ // rather than inventing a success count.
+ if (perRow) {
+ perRow.forEach((r, i) => {
+ const index = b * BULK_MAX + i;
+ if (r?.success === false || r?.error) failures.push({ index, reason: r.message || r.error || 'Rejected' });
+ else {
+ created += 1;
+ // Paired with the row it came from, not just collected. A bare list
+ // of ids cannot say WHICH row produced which booking, and the
+ // repeat flow needs exactly that to hand each new order back to the
+ // rider who ran it last time.
+ if (r?.bookingid) {
+ createdIds.push(r.bookingid);
+ createdPairs.push({ index, bookingid: r.bookingid });
+ }
+ }
+ });
+ } else {
+ created += batch.length;
+ }
+
+ sourceCalls.push({
+ name: 'createExpressBookingBulk',
+ target: `POST /admin/expressbooking/bulk${label}`,
+ status: 'complete',
+ stats: `${batch.length} submitted`
+ });
+ } catch (err) {
+ // doormileAxios rejects with the response BODY; the status is attached
+ // as `err.httpStatus`.
+ const status = err.httpStatus;
+ const reason = err.message || 'Request failed';
+ failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason })));
+ sourceCalls.push({
+ name: 'createExpressBookingBulk',
+ target: `POST /admin/expressbooking/bulk${label}`,
+ status: 'error',
+ errorMessage: `${status || 'network'} · ${reason}`
+ });
+ }
+ }
+
+ return {
+ ok: created > 0,
+ created,
+ // Empty when the endpoint returned no per-row array — the caller must treat
+ // "no ids" as "cannot offer assignment", not as "nothing was created".
+ createdIds,
+ createdPairs,
+ failed: failures.length,
+ failures,
+ batches: batches.length,
+ sourceCalls: sourceCalls.map((c) => ({ ...c, duration: `${Date.now() - started}ms` }))
+ };
+};
diff --git a/src/components/assistant/customerFlow.js b/src/components/assistant/customerFlow.js
new file mode 100644
index 0000000..aac1c7c
--- /dev/null
+++ b/src/components/assistant/customerFlow.js
@@ -0,0 +1,142 @@
+import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions';
+
+// ==============================|| Doormile AI — conversational create-customer ||============================== //
+//
+// Asks for one field at a time, then shows what it will send and waits for
+// Submit.
+//
+// ---- Why this lives here and not in the router ------------------------------
+//
+// A first attempt at this shipped and broke immediately: the operator typed
+// "create customer", was asked for a phone number, replied "8494948494", and
+// got "I can't answer that one yet".
+//
+// The cause was architectural, not a typo. `answerQuestion` picks an intent by
+// MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was
+// routed to the fallback and discarded. Threading a partial draft through the
+// router's `context` didn't help, because the router had already failed to
+// choose an intent before the draft was ever consulted.
+//
+// So the conversation is owned by the PANEL, which checks for an active flow
+// BEFORE calling the router at all. A reply mid-flow is never routed. That is
+// the only arrangement where "8494948494" can't be misread as a question.
+//
+// Field set and validation mirror pages/nearle/clients/createCustomer.js:
+// name and a 10-digit phone are required, everything else is optional and
+// skippable.
+
+const PHONE_RE = /^\d{10}$/;
+const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
+
+// "skip", "none", "no", "-" all mean "leave it blank". Without this the
+// operator has no way past an optional field except inventing a value.
+const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i;
+
+export const isSkip = (text) => SKIP_RE.test(String(text || '').trim());
+
+// Ordered. `ask` is the question; `apply` folds the answer into the draft;
+// `validate` returns an error string to re-ask with, or null to accept.
+export const CUSTOMER_STEPS = [
+ {
+ field: 'name',
+ ask: 'What’s the customer’s name?',
+ required: true,
+ apply: (draft, text) => {
+ const [firstname, ...rest] = String(text).trim().split(/\s+/);
+ return { ...draft, firstname, lastname: rest.join(' ') || undefined };
+ },
+ validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.')
+ },
+ {
+ field: 'phone',
+ ask: 'And their 10-digit mobile number?',
+ required: true,
+ apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }),
+ // Validated against the digits only, so "98765 43210" and "+91 9876543210"
+ // are both accepted rather than rejected on formatting.
+ validate: (text) => {
+ const digits = String(text)
+ .replace(/\D/g, '')
+ .replace(/^91(?=\d{10}$)/, '');
+ return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.';
+ }
+ },
+ {
+ field: 'email',
+ ask: 'Email address? (say “skip” if there isn’t one)',
+ apply: (draft, text) => ({ ...draft, email: String(text).trim() }),
+ validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.')
+ },
+ {
+ field: 'address',
+ ask: 'Address? (or “skip”)',
+ apply: (draft, text) => ({ ...draft, address: String(text).trim() })
+ },
+ {
+ field: 'city',
+ ask: 'City? (or “skip”)',
+ apply: (draft, text) => ({ ...draft, city: String(text).trim() })
+ },
+ {
+ field: 'postcode',
+ ask: 'Postcode? (or “skip”)',
+ apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') })
+ }
+];
+
+// Starts the flow, pre-filling anything already said in the opening message —
+// "create a customer Ramesh 9876543210" should not then ask for the name and
+// the phone it was just given.
+export const startCustomerFlow = (text) => {
+ const draft = parseCustomerDraft(text);
+ return advance({ kind: 'createCustomer', step: 0, draft });
+};
+
+// Moves to the next step that still needs an answer. Returns either a question
+// to ask, or the finished proposal.
+export const advance = (flow) => {
+ let { step } = flow;
+ const { draft } = flow;
+
+ while (step < CUSTOMER_STEPS.length) {
+ const s = CUSTOMER_STEPS[step];
+ const already = s.field === 'name' ? draft.firstname : draft[s.field];
+ if (already) {
+ step += 1;
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ return { flow: { ...flow, step }, ask: s.ask, done: false };
+ }
+
+ // Every step visited. The required check is repeated here rather than
+ // trusted from the walk above, so a skipped-but-required field can never
+ // reach a proposal.
+ const { ok, missing } = validateCustomerDraft(draft);
+ if (!ok) {
+ return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false };
+ }
+ return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) };
+};
+
+// Applies one answer. Returns the next question, or the completed proposal, or
+// a re-ask when the answer didn't validate.
+export const answerStep = (flow, text) => {
+ const s = CUSTOMER_STEPS[flow.step];
+ if (!s) return advance(flow);
+
+ if (isSkip(text)) {
+ if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false };
+ // Skipped optional field: step past it without writing anything, so the
+ // payload builder drops it rather than sending an empty string.
+ return advance({ ...flow, step: flow.step + 1 });
+ }
+
+ const error = s.validate?.(text);
+ if (error) return { flow, ask: error, done: false, retry: true };
+
+ return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) });
+};
+
+// Human-readable summary of what will be sent, for the confirm step.
+export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));
diff --git a/src/components/assistant/flowEngine.js b/src/components/assistant/flowEngine.js
new file mode 100644
index 0000000..5944584
--- /dev/null
+++ b/src/components/assistant/flowEngine.js
@@ -0,0 +1,83 @@
+// ==============================|| Doormile AI — conversational flow engine ||============================== //
+//
+// One step-walker, shared by every conversational create (orderFlow.js,
+// bulkFlow.js). It was written inside orderFlow and extracted when the bulk
+// create became a conversation too — a second copy would have been a third
+// definition of the same branching rules to keep in sync.
+//
+// A step is a plain object:
+//
+// id required. Also the draft key the answer lands on.
+// type 'select' → the panel renders a dropdown (AIFlowStep)
+// 'rows' → the panel renders the file/paste input
+// 'text' → answered through the composer
+// ask the question
+// when (draft) => boolean. Omitted means always asked. THIS is branching.
+// options async (draft) => [{ value, label, record? }] — for 'select'
+// validate (raw, option) => error | null. Re-asks; stores nothing.
+// resolve async (raw) => { value } | { error }. May fail and re-ask —
+// geocoding. A value the rest of the flow depends on is never
+// stored half-resolved.
+// auto async (draft) => { value?, ask?, patch? }. The step answers itself
+// from real data and is only ASKED when that fails, with the reason.
+// apply (draft, value, option) => draft
+//
+// A step is skipped when `when` is false OR when `draft[step.id]` is already
+// set — which is what lets a caller seed the draft (a client login's tenant) or
+// one step fill several fields (picking an existing customer).
+
+const applicable = (step, draft) => (typeof step.when === 'function' ? step.when(draft) : true);
+
+// Finds the next step that applies and hasn't been answered. Async because a
+// step may answer itself from the network before we know whether to ask it.
+export const advanceFlow = async (steps, flow) => {
+ let { step, draft } = flow;
+
+ while (step < steps.length) {
+ const s = steps[step];
+ if (!applicable(s, draft) || draft[s.id] !== undefined) {
+ step += 1;
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+
+ if (s.auto) {
+ // eslint-disable-next-line no-await-in-loop
+ const auto = await s.auto(draft);
+ if (auto?.patch) draft = { ...draft, ...auto.patch };
+ if (auto?.value !== undefined) {
+ draft = s.apply(draft, auto.value);
+ step += 1;
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
+ }
+
+ return { flow: { ...flow, step, draft }, step: s, done: false };
+ }
+
+ return { flow: { ...flow, step, draft, complete: true }, done: true, draft };
+};
+
+export const startFlow = (steps, kind, draft = {}) => advanceFlow(steps, { kind, step: 0, draft });
+
+// Applies one answer — typed text, a chosen dropdown option, or a parsed file.
+export const answerFlowStep = async (steps, flow, raw, option) => {
+ const s = steps[flow.step];
+ if (!s) return advanceFlow(steps, flow);
+
+ if (s.validate) {
+ const error = s.validate(raw, option);
+ if (error) return { flow, step: s, ask: error, done: false, retry: true };
+ }
+
+ let value = raw;
+ if (s.resolve) {
+ const resolved = await s.resolve(raw);
+ if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
+ value = resolved.value;
+ }
+
+ return advanceFlow(steps, { ...flow, step: flow.step + 1, draft: s.apply(flow.draft, value, option) });
+};
diff --git a/src/components/assistant/intents.js b/src/components/assistant/intents.js
new file mode 100644
index 0000000..c1fd5de
--- /dev/null
+++ b/src/components/assistant/intents.js
@@ -0,0 +1,2310 @@
+import dayjs from 'dayjs';
+
+import {
+ getBookingsPage,
+ getHubs,
+ getVehicles,
+ getTripsheets,
+ getExceptions,
+ getAppUsers,
+ getAdminCustomers,
+ getAdminPricing,
+ getMilers,
+ getConsignments,
+ getPartners,
+ getCompetitorBranches,
+ getCarrierPricing,
+ getMilerSummary,
+ getMilerActivity,
+ trackConsignment,
+ getConsignmentLogs,
+ getAdminTenant,
+ getTenantLocations
+} from 'pages/api/doormileApi';
+import { getalltenants, getallridersummary } from 'pages/api/api';
+import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
+import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
+import { STATUS } from 'themes/dt/tokens';
+// Only the read-only half of actions.js belongs here. Tenant resolution,
+// payload building and execution live in the panel's submit handler — an
+// intent must have no route to a write.
+import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
+import { CREATE_ORDER_TRIGGER } from './orderActions';
+import { ASSIGN_TRIGGER } from './assignActions';
+import { REPEAT_TRIGGER } from './repeatRuns';
+import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
+import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
+import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
+
+// ==============================|| Doormile Bot — intent catalog ||============================== //
+//
+// Deterministic, not LLM-based (see the plan this was built from — a real
+// LLM step needs a secret key held server-side, and this app has no backend
+// of its own; the tradeoff accepted here is broader keyword/synonym coverage
+// instead). Every answer comes from a real API call against the same
+// functions the rest of the console uses, never a generated guess. `match`
+// extracts params from raw text or returns null (this intent doesn't
+// apply); `run` calls real data and returns { headline, detail,
+// sourceCalls } — `sourceCalls` feeds so an operator can see
+// exactly what was queried.
+//
+// `run` may also return null (matched the pattern but couldn't resolve
+// something, e.g. an unrecognised tenant name) — the caller then tries the
+// next intent in the list rather than answering with a guess.
+//
+// GET /admin/bookings has no server-side date/status/tenant filter — every
+// intent bulk-fetches and filters client-side, the same pattern
+// fetchDeliveries (api.js) already uses.
+const BULK_PAGESIZE = 1000;
+
+// ---- Typo tolerance --------------------------------------------------------
+// A small Levenshtein-distance corrector run once, before any intent match,
+// so a misspelled domain keyword ("riedrs", "vehcile") still routes to the
+// right intent. Deliberately narrow: only whole alphabetic words of length
+// >=5 are ever considered, and only against a fixed vocabulary of the same
+// keywords the intents below key off — order IDs, tenant names, and short
+// words are never touched, so this can't quietly rewrite something that was
+// supposed to stay exact.
+const levenshtein = (a, b) => {
+ const m = a.length;
+ const n = b.length;
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+ for (let i = 0; i <= m; i += 1) dp[i][0] = i;
+ for (let j = 0; j <= n; j += 1) dp[0][j] = j;
+ for (let i = 1; i <= m; i += 1) {
+ for (let j = 1; j <= n; j += 1) {
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ return dp[m][n];
+};
+
+const KEYWORD_VOCAB = [
+ 'order',
+ 'orders',
+ 'booking',
+ 'bookings',
+ 'rider',
+ 'riders',
+ 'tenant',
+ 'tenants',
+ 'hub',
+ 'hubs',
+ 'vehicle',
+ 'vehicles',
+ 'tripsheet',
+ 'tripsheets',
+ 'exception',
+ 'exceptions',
+ 'customer',
+ 'customers',
+ 'pricing',
+ 'revenue',
+ 'consignment',
+ 'consignments',
+ 'partner',
+ 'partners',
+ 'competitor',
+ 'competitors',
+ 'carrier',
+ 'status',
+ 'morning',
+ 'afternoon',
+ 'evening',
+ 'pending',
+ 'cancelled',
+ 'delivered',
+ 'active',
+ 'today',
+ 'yesterday',
+ 'week',
+ 'month',
+ 'available'
+];
+
+// Words that form a NAMED ENTITY must never be "corrected". A tenant called
+// "Partnerz" is one edit from "partners" and a rider called "Delivara" is two
+// from "delivered"; rewriting either makes the name unresolvable, and
+// orderQuery extracts tenant/rider names from the corrected text, so the
+// failure is silent — the filter just never matches.
+//
+// The original comment here claimed order IDs and tenant names were never
+// touched. Nothing enforced that; this does.
+const ENTITY_NAME_SPAN = /\b(?:rider|tenant|hub|vehicle|customer|partner|named|called|for)\s+((?:[A-Za-z][A-Za-z0-9.'-]*\s*){1,4})/gi;
+
+const protectedNameWords = (text) => {
+ const keep = new Set();
+ const re = new RegExp(ENTITY_NAME_SPAN.source, 'gi');
+ let m = re.exec(text);
+ while (m !== null) {
+ m[1]
+ .split(/\s+/)
+ .filter(Boolean)
+ .forEach((w) => keep.add(w.toLowerCase()));
+ m = re.exec(text);
+ }
+ return keep;
+};
+
+const correctTypos = (text) => {
+ const keep = protectedNameWords(text);
+ return text
+ .split(/\b/)
+ .map((token) => {
+ const word = token.toLowerCase();
+ if (keep.has(word)) return token;
+ if (!/^[a-z]+$/.test(word) || word.length < 5 || KEYWORD_VOCAB.includes(word)) return token;
+ // >=6 chars allows distance 2, which is what a single adjacent-letter
+ // transposition ("riedrs" for "riders") costs in plain Levenshtein
+ // distance (two substitutions, not one) — a very common typo shape.
+ const maxDist = word.length >= 6 ? 2 : 1;
+ let best = null;
+ let bestDist = maxDist + 1;
+ KEYWORD_VOCAB.forEach((v) => {
+ if (Math.abs(v.length - word.length) > maxDist) return;
+ const dist = levenshtein(word, v);
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = v;
+ }
+ });
+ return best && bestDist <= maxDist ? best : token;
+ })
+ .join('');
+};
+
+const TODAY = () => dayjs().format('YYYY-MM-DD');
+
+const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
+
+// Explicit calendar date — "12/08/2026" or "12-08-2026" (DD/MM/YYYY, matching
+// this console's Indian-locale date convention elsewhere), or ISO
+// "2026-08-12". Returns null when nothing explicit is found — callers fall
+// back to relative-word parsing rather than guessing a date shape.
+const explicitDateFromWords = (text) => {
+ const iso = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
+ if (iso) {
+ const d = dayjs(`${iso[1]}-${iso[2]}-${iso[3]}`);
+ return d.isValid() ? d.format('YYYY-MM-DD') : null;
+ }
+ const dmy = text.match(/\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b/);
+ if (dmy) {
+ const d = dayjs(`${dmy[3]}-${dmy[2].padStart(2, '0')}-${dmy[1].padStart(2, '0')}`);
+ return d.isValid() ? d.format('YYYY-MM-DD') : null;
+ }
+ return null;
+};
+
+// "last Monday" / "on Monday" / bare "Monday" — the most recent day
+// (including today) that falls on that weekday. Never resolves to a future
+// date.
+const weekdayFromWords = (text) => {
+ const lower = text.toLowerCase();
+ const found = WEEKDAYS.find((w) => lower.includes(w));
+ if (!found) return null;
+ const targetDow = WEEKDAYS.indexOf(found);
+ let d = dayjs();
+ for (let i = 0; i < 7; i += 1) {
+ if (d.day() === targetDow) return d.format('YYYY-MM-DD');
+ d = d.subtract(1, 'day');
+ }
+ return null;
+};
+
+// Broader than v1's today/yesterday-only: explicit dates, named weekdays,
+// and a few more relative-date phrasings. Anything not recognised falls
+// back to today — this stays a fixed, defensive vocabulary, not a general
+// date-parsing library; guessing wrong on a date is worse than defaulting
+// to today.
+const dayFromWords = (text) => {
+ const explicit = explicitDateFromWords(text);
+ if (explicit) return explicit;
+ const weekday = weekdayFromWords(text);
+ if (weekday) return weekday;
+ if (/\byesterday\b/i.test(text)) return dayjs().subtract(1, 'day').format('YYYY-MM-DD');
+ return TODAY();
+};
+
+// Ranges: rolling "this week", calendar "last week", "this/last month", and
+// an explicit "from to ". Returns null when the text doesn't
+// ask for a range, so callers can tell "today" and "this week" apart.
+const rangeFromWords = (text) => {
+ if (/\bthis week\b|\bpast week\b|\blast 7 days\b|\blast seven days\b/i.test(text)) {
+ return { start: dayjs().subtract(6, 'day').format('YYYY-MM-DD'), end: TODAY(), label: 'this week' };
+ }
+ if (/\blast week\b/i.test(text)) {
+ const start = dayjs().subtract(1, 'week').startOf('week');
+ const end = dayjs().subtract(1, 'week').endOf('week');
+ return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD'), label: 'last week' };
+ }
+ if (/\bthis month\b/i.test(text)) {
+ return { start: dayjs().startOf('month').format('YYYY-MM-DD'), end: TODAY(), label: 'this month' };
+ }
+ if (/\blast month\b/i.test(text)) {
+ const start = dayjs().subtract(1, 'month').startOf('month');
+ const end = dayjs().subtract(1, 'month').endOf('month');
+ return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD'), label: 'last month' };
+ }
+ // A named calendar year. Requires a preposition so a bare 4-digit run (an
+ // order number) is never read as a year, and only accepts 19xx/20xx.
+ const year = text.match(/\b(?:in|during|for|of)\s+((?:19|20)\d{2})\b/i);
+ if (year) {
+ return { start: `${year[1]}-01-01`, end: `${year[1]}-12-31`, label: year[1] };
+ }
+ // "from X to Y" — only resolves when BOTH sides parse as a real date/
+ // weekday; otherwise this is probably "orders from Chennai to Mumbai" and
+ // must fall through untouched.
+ const fromTo = text.match(/\bfrom\b(.+?)\bto\b(.+)/i);
+ if (fromTo) {
+ const start = explicitDateFromWords(fromTo[1]) || weekdayFromWords(fromTo[1]);
+ const end = explicitDateFromWords(fromTo[2]) || weekdayFromWords(fromTo[2]);
+ if (start && end) {
+ const [rangeStart, rangeEnd] = end >= start ? [start, end] : [end, start];
+ return { start: rangeStart, end: rangeEnd, label: `${dayjs(rangeStart).format('DD MMM')} to ${dayjs(rangeEnd).format('DD MMM')}` };
+ }
+ }
+ return null;
+};
+
+const describeDay = (day) => (day === TODAY() ? 'today' : dayjs(day).format('DD MMM YYYY'));
+
+const batchFromWords = (text) => {
+ if (/\bmorning\b/i.test(text)) return 'morning';
+ if (/\bafternoon\b/i.test(text)) return 'afternoon';
+ if (/\bevening\b/i.test(text)) return 'evening';
+ return null;
+};
+
+// Wider synonym coverage than v1 — "in progress"/"on the way" for active,
+// "done"/"completed" alongside "delivered", "declined"/"rejected" alongside
+// "cancelled". Order matters: more specific phrases are checked before the
+// broader "delivered" pattern so "undelivered" doesn't false-match it.
+// Returns an ORDER STATUS GROUP key (utils/orderStatusGroups.js) — the same
+// taxonomy the Orders page's tabs count with. It previously returned api.js's
+// delivery-status buckets, which is the Deliveries page's rider-centric view,
+// so "how many assigned orders" never agreed with the Assigned tab.
+//
+// Branch order matters: "not assigned"/"unassigned" must be caught by the
+// pending branch before the assigned branch sees the word "assigned".
+const statusFromWords = (text) => {
+ if (/\bpending\b|\bnot\s*assigned\b|\bunassigned\b|\bawaiting\b/i.test(text)) return 'pending';
+ // `cancel(led)?` did not match \"cancellation\" — the \b after \"cancel\" fails
+ // against the following 'l', so \"cancellation rate\" fell through to a plain
+ // week count.
+ if (/\bcancel(?:s|led|lation|lations)?\b|\bdeclined?\b|\brejected\b/i.test(text)) return 'cancelled';
+ if (/\bassigned\b|\baccept(ed)?\b/i.test(text)) return 'assigned';
+ if (/\bactive\b|\bin[- ]?transit\b|\bin\s*progress\b|\bon\s*the\s*way\b|\bout\s*for\s*delivery\b/i.test(text)) return 'active';
+ if (/\bundeliver/i.test(text)) return null;
+ if (/\bdeliver(ed)?\b|\bdone\b|\bcomplete[d]?\b/i.test(text)) return 'delivered';
+ return null;
+};
+
+// True only when the question actually NAMES a date. Distinguishing "named a
+// date" from "defaulted to today" is what lets a state question ("how many are
+// cancelled") mean right-now while a flow question ("how many orders today")
+// still means today.
+const mentionsAnyDate = (text) =>
+ explicitDateFromWords(text) !== null ||
+ weekdayFromWords(text) !== null ||
+ rangeFromWords(text) !== null ||
+ /\btoday\b|\byesterday\b/i.test(text);
+
+// ---- Paginated booking source ---------------------------------------------
+//
+// Previously this was a single `getBookings(1, 1000)`. That is page ONE at the
+// API's hard cap (express-console-api.md: pagesize default 500, cap 1000), and
+// getBookings discards the envelope's `total`, so once an account passed 1000
+// lifetime bookings EVERY count and sum in this file silently under-reported
+// while the sourceCalls line next to it still read `status: 'complete'`. That
+// is precisely the failure mode CLAUDE.md §3 forbids ("a wrong number from
+// this bot is worse than no answer").
+//
+// Now: drain pages up to a budget, and when the budget is hit say so instead
+// of presenting a partial scan as a total.
+const MAX_PAGES = 12; // 12k rows — generous, but bounded so one question can't hammer the API
+
+// Row order is not documented. When page 1 comes back newest-first we can stop
+// as soon as a page ends older than the range; otherwise we scan to the budget.
+// Detected per-call rather than assumed, so a backend change degrades to "scan
+// everything" (slower, still correct) rather than to a wrong answer.
+const isDescendingByCreatedAt = (rows) => {
+ if (rows.length < 2) return false;
+ const first = parseDoormileTimestamp(rows[0].createdat);
+ const last = parseDoormileTimestamp(rows[rows.length - 1].createdat);
+ return first.isValid() && last.isValid() && first.valueOf() > last.valueOf();
+};
+
+const inRange = (b, start, end) => {
+ const d = parseDoormileTimestamp(b.createdat);
+ if (!d.isValid()) return false;
+ const day = d.format('YYYY-MM-DD');
+ return day >= start && day <= end;
+};
+
+// Interim request cache. answerMultiPart runs several intents for one
+// question, and comparisonIntent runs two ranges — each would otherwise
+// re-drain the same pages. Keyed by page number with a short TTL.
+//
+// Deliberately small and local: this is NOT a caching layer to settle on. The
+// real fix is routing these reads through TanStack Query like the rest of the
+// console (CLAUDE.md §7), at which point this goes away.
+const PAGE_CACHE_TTL_MS = 20000;
+const pageCache = new Map();
+
+const getBookingsPageCached = (page) => {
+ const hit = pageCache.get(page);
+ if (hit && Date.now() - hit.at < PAGE_CACHE_TTL_MS) return hit.promise;
+ const promise = getBookingsPage(page, BULK_PAGESIZE).catch((err) => {
+ // Never cache a failure — the next question should retry, not inherit it.
+ pageCache.delete(page);
+ throw err;
+ });
+ pageCache.set(page, { at: Date.now(), promise });
+ return promise;
+};
+
+// Returns { rows, truncated, scanned, total } — NOT a bare array. Callers must
+// surface `truncated`; see countPhrase/truncationNote/scanCall below.
+const fetchBookingsInRange = async (start, end) => {
+ const firstPage = await getBookingsPageCached(1);
+ const total = firstPage.total;
+ const pageCount = Math.max(1, Math.ceil(total / BULK_PAGESIZE));
+ const budget = Math.min(pageCount, MAX_PAGES);
+
+ const collected = [...firstPage.rows];
+ const descending = isDescendingByCreatedAt(firstPage.rows);
+
+ // Newest-first and page 1 already ends before the window opens → every later
+ // page is older still. Nothing left to find.
+ const pageEndsBeforeRange = (rows) => {
+ if (!descending || !rows.length) return false;
+ const oldest = parseDoormileTimestamp(rows[rows.length - 1].createdat);
+ return oldest.isValid() && oldest.format('YYYY-MM-DD') < start;
+ };
+
+ let stoppedEarly = pageEndsBeforeRange(firstPage.rows);
+ let lastPageFetched = 1;
+
+ for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
+ // eslint-disable-next-line no-await-in-loop
+ const next = await getBookingsPageCached(page);
+ lastPageFetched = page;
+ if (!next.rows.length) {
+ stoppedEarly = true;
+ break;
+ }
+ collected.push(...next.rows);
+ stoppedEarly = pageEndsBeforeRange(next.rows);
+ }
+
+ return {
+ rows: collected.filter((b) => inRange(b, start, end)),
+ // Only truncated if we ran out of budget with pages still unread AND we
+ // didn't stop early because we'd already scanned past the window.
+ truncated: !stoppedEarly && pageCount > budget,
+ scanned: collected.length,
+ pagesFetched: lastPageFetched,
+ total
+ };
+};
+
+const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day);
+
+// Full scan, no date window — for the per-order lookup, which has to look
+// everywhere rather than inside a range. The sentinel bounds keep one code
+// path: they can never trigger the early-stop, so this always drains to the
+// page budget and reports `truncated` honestly if the id could be further back.
+// Exported for assignFlow.js, which resolves a typed order number against the
+// real booking list. It reuses this rather than growing a second scanner with
+// its own idea of pagination and truncation.
+export const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31');
+
+// A count built on a truncated scan is a floor, not a total — say "at least".
+const countPhrase = (scan, n) => `${scan.truncated ? 'At least ' : ''}${n}`;
+
+const truncationNote = (scan) =>
+ scan.truncated
+ ? `\nScanned the most recent ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
+ 'en-IN'
+ )} bookings — this is a floor, not a complete count.`
+ : '';
+
+// The audit entry for a scan. Reports the real page count, and flags itself as
+// `error` when truncated so the tool-call strip can't show a green "complete"
+// beside a partial number.
+const scanCall = (scan, note) => ({
+ name: 'getBookingsPage',
+ target: `/admin/bookings (${scan.pagesFetched} page${scan.pagesFetched === 1 ? '' : 's'} x ${BULK_PAGESIZE})`,
+ status: scan.truncated ? 'error' : 'complete',
+ errorMessage: scan.truncated ? `Scan capped at ${MAX_PAGES} pages; ${scan.total} bookings exist` : undefined,
+ stats: note
+});
+
+const summarizeStatuses = (rows) => {
+ const counts = {};
+ rows.forEach((b) => {
+ const s = ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] || groupForBookingStatus(b.status);
+ counts[s] = (counts[s] || 0) + 1;
+ });
+ return Object.entries(counts)
+ .map(([k, v]) => `${v} ${k}`)
+ .join(', ');
+};
+
+// Structured counterpart to summarizeStatuses — the SAME tallies, shaped for
+// the AI panel's metric grid instead of a prose string. Both read the same
+// groupForBookingStatus classification, so the sentence and the
+// cards can never disagree, and a card can never show a number that didn't
+// come from the rows the sourceCalls entry accounts for.
+// One colour per order status group. `assigned` borrows the 'accepted' hex —
+// STATUS is the raw palette and has no 'assigned' key; the group taxonomy and
+// the colour palette are separate concerns and shouldn't be forced to match
+// names.
+const GROUP_COLOR = {
+ pending: STATUS.pending,
+ assigned: STATUS.accepted,
+ active: STATUS.active,
+ delivered: STATUS.delivered,
+ cancelled: STATUS.cancelled
+};
+
+const statusStats = (rows) => {
+ const counts = {};
+ rows.forEach((b) => {
+ const g = groupForBookingStatus(b.status);
+ counts[g] = (counts[g] || 0) + 1;
+ });
+ const ordered = ORDER_STATUS_ORDER.filter((g) => counts[g]).map((g) => ({
+ label: ORDER_STATUS_LABELS[g],
+ value: counts[g],
+ color: GROUP_COLOR[g]
+ }));
+ // A backend enum the group map has never seen still shows (neutral) rather
+ // than being dropped — an unmapped state should be visible, not silently
+ // absent from the totals.
+ const unmapped = Object.keys(counts)
+ .filter((g) => !ORDER_STATUS_ORDER.includes(g))
+ .map((g) => ({ label: g, value: counts[g], color: STATUS.muted }));
+ return [...ordered, ...unmapped];
+};
+
+// Generic "count per distinct value of `field`" — used by the fleet/ops
+// intents below (hub type, vehicle type, exception severity, ...) so they
+// don't each hand-roll the same tally loop.
+const summarizeByField = (rows, field) => {
+ const counts = {};
+ rows.forEach((r) => {
+ const key = r?.[field] || 'unknown';
+ counts[key] = (counts[key] || 0) + 1;
+ });
+ return Object.entries(counts)
+ .map(([k, v]) => `${v} ${k}`)
+ .join(', ');
+};
+
+const resolveTenant = async (text) => {
+ const tenants = (await getalltenants()) || [];
+ const lower = text.toLowerCase();
+ return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase()));
+};
+
+const bookingLabel = (b) => b.bookingno || `#${b.bookingid}`;
+
+// ---- Lateness --------------------------------------------------------------
+//
+// The promised delivery time. deliveries.js and Dispatch.js BOTH document this
+// field as "stable, but it is the PROMISED DELIVERY slot" and both rejected it
+// — for BATCH BUCKETING, because a promised ETA is not the wave an order
+// belongs to. That reasoning doesn't carry over here: an SLA promise that
+// never gets re-stamped is exactly the right baseline to measure lateness
+// against. (`assigntime` would be useless here for the same reason it was
+// useless there — api.js maps it to the last-modified column.)
+//
+// ⚠ `serviceoptions` is NOT documented in express-console-api.md; its shape is
+// inferred from fetchDeliveries. Hence the coverage guard in the intent below:
+// if no row carries an ETA we say we can't tell, rather than reporting a
+// reassuring "0 delayed".
+const etaOf = (b) => b?.serviceoptions?.[0]?.estimateddeliveryat || null;
+
+const AT_RISK_MINUTES = 60;
+
+// 'closed' — delivered or cancelled; lateness is not a live concern
+// 'no-eta' — open, but nothing to measure against
+// 'late' — open and past its promised time
+// 'at-risk' — open and due within the hour
+// 'on-time' — open and comfortably ahead
+const delayState = (b, now) => {
+ const group = groupForBookingStatus(b.status);
+ if (group === 'delivered' || group === 'cancelled') return 'closed';
+ const raw = etaOf(b);
+ if (!raw) return 'no-eta';
+ const eta = parseDoormileTimestamp(raw);
+ if (!eta.isValid()) return 'no-eta';
+ const minutes = eta.diff(now, 'minute');
+ if (minutes < 0) return 'late';
+ if (minutes <= AT_RISK_MINUTES) return 'at-risk';
+ return 'on-time';
+};
+
+const formatLateness = (b, now) => {
+ const mins = now.diff(parseDoormileTimestamp(etaOf(b)), 'minute');
+ if (mins < 60) return `${mins}m late`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ${mins % 60}m late`;
+ return `${Math.floor(hours / 24)}d late`;
+};
+
+// Late orders carry no hub of their own — a booking has no hubid/hubname field
+// at all (verified against fetchDeliveries' mapping). The only route to a hub
+// is the assigned rider: booking.assignedmileruserid matches a miler's
+// `userid`, and GET /admin/milers/summary is confirmed live to carry
+// hubname per rider. Unassigned orders genuinely have no hub and are reported
+// as their own bucket rather than being dropped or guessed at.
+const hubBreakdownForLate = async (late) => {
+ if (!late.length) return null;
+ const milers = await getMilerSummary().catch(() => null);
+ if (!milers) return null;
+ const list = Array.isArray(milers) ? milers : [milers];
+ const hubByUser = new Map(list.map((m) => [m.userid, m.hubname]).filter(([userid]) => userid != null));
+ const counts = {};
+ late.forEach((b) => {
+ const hub = b.assignedmileruserid ? hubByUser.get(b.assignedmileruserid) || 'Unknown hub' : 'Unassigned';
+ counts[hub] = (counts[hub] || 0) + 1;
+ });
+ return Object.entries(counts).sort((a, b) => b[1] - a[1]);
+};
+
+// Guard so the delay question can't be swallowed by hubStatus ("which HUBS are
+// experiencing delays" contains "hubs"), statusBreakdown or totalOrders. Same
+// belt-and-braces rule as mentionsRiders: delayedOrders is ordered ahead of all
+// three AND they each refuse explicitly, so a later reorder can't regress it.
+// Trailing verbs/adverbs that get swept up by a greedy name capture —
+// "rider Suresh deliver today" must resolve to "Suresh", not "Suresh deliver".
+const NAME_TAIL_WORDS =
+ /\b(deliver(ed|y|ies)?|complete[d]?|assign(ed)?|do|did|does|has|have|is|are|was|were|today|yesterday|now|status|this|last|week|month|year)\b/gi;
+
+const cleanEntityName = (raw) => {
+ const cleaned = String(raw || '')
+ .replace(NAME_TAIL_WORDS, ' ')
+ .replace(/[?.,]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return cleaned.length >= 2 ? cleaned : null;
+};
+
+const riderNameFromWords = (text) => {
+ const m = text.match(/\brider\s+(?:named\s+|called\s+)?([A-Za-z][A-Za-z0-9 .'-]{1,40})/i);
+ return m ? cleanEntityName(m[1]) : null;
+};
+
+const tenantNameCandidate = (text) => {
+ const m = text.match(/\b(?:for|tenant)\s+(?:the\s+)?([A-Za-z][A-Za-z0-9 &.'-]{1,40})/i);
+ return m ? cleanEntityName(m[1]) : null;
+};
+
+// "top 5 tenants by orders", "orders by hub" — a ranking request rather than a
+// single count.
+const rankingFromWords = (text) => {
+ const top = text.match(/\btop\s+(\d{1,2})\b/i);
+ const by = text.match(/\bby\s+(tenant|hub|rider|status|batch)s?\b/i);
+ if (!top && !by) return null;
+ let dimension = by ? by[1].toLowerCase() : null;
+ if (!dimension) {
+ if (/\btenants?\b/i.test(text)) dimension = 'tenant';
+ else if (/\briders?\b/i.test(text)) dimension = 'rider';
+ else if (/\bhubs?\b/i.test(text)) dimension = 'hub';
+ }
+ if (!dimension) return null;
+ return { groupBy: dimension, limit: top ? Math.min(Number(top[1]), 25) : 5 };
+};
+
+// Bidirectional, case-insensitive containment — the question may name a
+// shorter form of the record ("acme foods" vs "Acme Foods Pvt Ltd") or a
+// longer one. Longest match wins so "Acme" doesn't beat "Acme Foods" when
+// both exist.
+const bestNameMatch = (needle, records, nameOf) => {
+ const q = String(needle).toLowerCase();
+ const hits = records.filter((r) => {
+ const name = String(nameOf(r) || '').toLowerCase();
+ return name.length > 1 && (name.includes(q) || q.includes(name));
+ });
+ if (!hits.length) return null;
+ return hits.sort((a, b) => String(nameOf(b) || '').length - String(nameOf(a) || '').length)[0];
+};
+
+// ---- L4 analytics helpers --------------------------------------------------
+
+// Percentage with one decimal, but only when the denominator is big enough to
+// mean anything. "100% cancelled" off two orders is noise, not a rate.
+const rateOf = (part, whole) => (whole > 0 ? `${((part / whole) * 100).toFixed(whole < 20 ? 0 : 1)}%` : '—');
+
+// Buckets rows by day (for a range) or by hour (for a single day), so a trend
+// is answered from the same scan every other count uses.
+const trendBuckets = (rows, byHour) => {
+ const counts = new Map();
+ rows.forEach((b) => {
+ const d = parseDoormileTimestamp(b.createdat);
+ if (!d.isValid()) return;
+ const key = byHour ? d.format('HH:00') : d.format('DD MMM');
+ counts.set(key, (counts.get(key) || 0) + 1);
+ });
+ return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
+};
+
+// A text bar keeps a trend readable in a 428px panel without a chart library.
+const sparkBar = (n, max) => '█'.repeat(Math.max(1, Math.round((n / Math.max(max, 1)) * 12)));
+
+const TREND_TRIGGER =
+ /\btrend\b|\bper\s+day\b|\bper\s+hour\b|\bby\s+day\b|\bby\s+hour\b|\bdaily\b|\bhourly\b|\bover\s+time\b|\bbreakdown\s+by\s+(?:day|hour)\b/i;
+const RATE_TRIGGER = /\brate\b|\bpercentage\b|\bpercent\b|\b%\b|\bratio\b/i;
+
+const mentionsDelay = (text) =>
+ /\bdelay(ed|s|ing)?\b|\blate\b|\boverdue\b|\bbehind\s+schedule\b|\brunning\s+late\b|\bsla\b|\bbreach(ed|es)?\b|\bat\s*risk\b/i.test(text);
+
+// Sum across ALL service options. Reading only serviceoptions[0] under-counted
+// any booking carrying more than one priced option.
+const bookingCharge = (b) =>
+ (Array.isArray(b.serviceoptions) ? b.serviceoptions : []).reduce((sum, o) => sum + (Number(o?.estimatedprice) || 0), 0);
+
+// Revenue excludes cancelled bookings — a cancelled order is not money earned,
+// and counting it inflated every "total revenue" answer.
+const isCancelled = (b) => groupForBookingStatus(b.status) === 'cancelled';
+const revenueOf = (rows) => rows.filter((b) => !isCancelled(b)).reduce((sum, b) => sum + bookingCharge(b), 0);
+
+const formatRupees = (n) => new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', minimumFractionDigits: 2 }).format(n || 0);
+
+// A specific order id/number mentioned in the question — "#1234", "DM-xxx",
+// or a bare number 4+ digits long (short numbers are too likely to be part of
+// an unrelated word/date to treat as an order id).
+// Returns { id, strong } or null. `strong` marks an unmistakable order
+// reference (#1234, DM-...); a bare run of digits is WEAK because it could
+// equally be a year, a pincode or a quantity. The distinction decides what
+// happens on a miss: a strong id that isn't found is answered "I couldn't find
+// it", a weak one falls through to the broader intents rather than hard-failing
+// a question that was never about one specific order.
+const orderIdFromWords = (text) => {
+ const hash = text.match(/#\s*([A-Za-z0-9-]{3,})/);
+ if (hash) return { id: hash[1], strong: true };
+ const dmCode = text.match(/\bDM-[A-Za-z0-9-]+\b/i);
+ if (dmCode) return { id: dmCode[0], strong: true };
+ // Strip explicit dates first so "order status on 12/08/2026" doesn't read
+ // 2026 as an order number.
+ const withoutDates = text.replace(/\b\d{1,2}[/-]\d{1,2}[/-]\d{4}\b/g, ' ').replace(/\b\d{4}-\d{2}-\d{2}\b/g, ' ');
+ const bareDigits = withoutDates.match(/\b\d{4,}\b/);
+ if (bareDigits) return { id: bareDigits[0], strong: false };
+ return null;
+};
+
+// Named-entity lookup helper for riderLookup/hubLookup/vehicleLookup below —
+// pulls the word(s) right after "rider"/"hub"/"vehicle" (optionally preceded
+// by "named"/"called"/"number"/"no.") as the thing to search for. Alnum only
+// (vehicle numbers mix letters and digits), stops at a trailing "?" or a
+// trailing status word.
+const nameAfterKeyword = (text, keyword) => {
+ const re = new RegExp(
+ `\\b${keyword}\\b\\s+(?:named\\s+|called\\s+|number\\s+|no\\.?\\s+)?([a-z0-9][a-z0-9 .-]{1,40}?)(?:\\s*\\?|\\s+(?:today|now|status)\\b|$)`,
+ 'i'
+ );
+ const m = text.match(re);
+ return m ? m[1].trim() : null;
+};
+
+// Requires one of these explicit phrasings before treating a rider/hub/
+// vehicle question as a specific-entity lookup rather than an aggregate
+// count — "how many riders are active" must NOT be swallowed by
+// riderLookup just because it contains the word "rider".
+const LOOKUP_TRIGGER = /\b(?:find|where\s+is|status\s+of|search(?:\s+for)?|lookup)\b/i;
+
+// Questions ABOUT Doormile or about the assistant itself, rather than about
+// the data. Kept tight: "how many doormile orders today" mentions the name but
+// is an orders question, and must not be caught here.
+// `['’]s|s` covers "what's", "what’s" and the apostrophe-less "whats" people
+// actually type.
+const ABOUT_TRIGGER =
+ /\b(?:what|who)(?:['’]s|s|\s+is|\s+are)\s+doormile\b|\b(?:tell\s+me\s+)?about\s+doormile\b|\bdoormile\s*\.\s*com\b|\bwho\s+are\s+you\b|\bwhat\s+(?:can|do)\s+you\s+(?:do|answer|help)\b|\bwhat\s+are\s+you\b/i;
+
+// Guards against the class of bug found live: "how many riders are active
+// today" contains the word "active", which is ALSO a valid order status
+// (in-transit) — statusBreakdown's match used to fire on that word alone and
+// never returns null on a match (it always finds *some* count, even 0), so
+// it never yielded to riderCounts and every rider question silently called
+// getBookings instead of getallridersummary. Fixed two ways, deliberately
+// redundant: riderCounts/tenantList are ordered ahead of the generic
+// intents below (INTENTS is checked in order, first match wins), AND the
+// generic intents explicitly refuse to match when "rider" is mentioned, so
+// the bug can't come back just because someone reorders the array later.
+const mentionsRiders = (text) => /\brider(s)?\b/i.test(text);
+
+// "orders today vs yesterday" / "revenue this week compared to last week" —
+// a narrow, explicit trigger phrase so this can't misfire on plain aggregate
+// questions.
+const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
+
+// "orders and revenue today" — segments a multi-part question so each half
+// can be matched independently through the normal INTENTS catalog.
+const MULTI_SPLIT = /\band\b|,|\+|&/i;
+
+const INTENTS = [
+ {
+ // Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
+ // contains "orders", so createBulkOrders and createOrder would otherwise
+ // claim it and open a blank create instead of recalling the run.
+ id: 'repeatRun',
+ label: 'Repeat a past day’s orders — e.g. "repeat yesterday’s orders"',
+ match: (text) => (REPEAT_TRIGGER.test(text) ? {} : null),
+ run: async () => ({
+ headline: 'Let’s repeat a previous run.',
+ form: { kind: 'repeatRun', status: 'open' },
+ sourceCalls: []
+ })
+ },
+ {
+ // Ordered FIRST, ahead of createOrder: "create multiple orders" also
+ // matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger
+ // has to get first refusal or every bulk request opens the single form.
+ id: 'createBulkOrders',
+ label: 'Create several orders from a sheet or a paste — e.g. "bulk upload orders"',
+ match: (text) => (CREATE_BULK_TRIGGER.test(text) ? {} : null),
+ run: async () => ({
+ headline: 'Upload a sheet or paste your rows and I’ll create them.',
+ form: { kind: 'createBulkOrders', status: 'open' },
+ sourceCalls: []
+ })
+ },
+ {
+ // Ordered FIRST, above createCustomer: "create an order" contains
+ // "order", which orderLookup / orderQuery / totalOrders all match on.
+ // Like createCustomer this NEVER mutates — the panel intercepts this and
+ // starts a CONVERSATION (orderFlow.js); the mutation fires only when the
+ // operator presses Create on the priced confirmation at the end.
+ id: 'createOrder',
+ label: 'Create an order — e.g. "create an order"',
+ match: (text) => (CREATE_ORDER_TRIGGER.test(text) ? {} : null),
+ run: async () => ({
+ headline: 'Let’s build the order.',
+ form: { kind: 'createOrder', status: 'open' },
+ sourceCalls: []
+ })
+ },
+ {
+ // ---- The only write-capable intent -------------------------------------
+ //
+ // Ordered FIRST: "create a customer …" contains the word "customer"
+ // (customerCount matches on it) and usually a 10-digit phone number
+ // (which orderIdFromWords would read as a weak order id). Neither may get
+ // the question.
+ //
+ // This intent NEVER mutates. It returns a proposal — the exact payload it
+ // would submit — and the panel does not send anything until the operator
+ // presses Create. See actions.js.
+ id: 'createCustomer',
+ label: 'Create a customer — e.g. "create a customer"',
+ match: (text) => (CREATE_CUSTOMER_TRIGGER.test(text) ? { text } : null),
+ run: async ({ text }) => {
+ // Opens a FORM rather than collecting fields conversationally.
+ //
+ // The conversational version shipped and immediately failed in use: a
+ // reply of just "8494948494" matches no intent, so answerQuestion routed
+ // it to the "I can't answer that yet" fallback and the number was lost.
+ // Threading a partial draft through `context` did not help, because the
+ // router selects an intent by matching TEXT and a bare phone number
+ // matches nothing. Real inputs remove the parsing step altogether.
+ //
+ // Still no mutation here: this returns the form's initial values, and
+ // the panel submits only when the operator presses Create.
+ const parsed = parseCustomerDraft(text);
+ return {
+ headline: 'Fill this in and I’ll create the customer.',
+ form: {
+ kind: 'createCustomer',
+ status: 'open',
+ initial: parsed
+ },
+ sourceCalls: []
+ };
+ }
+ },
+ {
+ // Ordered ahead of orderLookup: "assign a rider to DM-BK-…" names an order
+ // number, and orderLookup would otherwise claim it and answer with the
+ // order's details instead of assigning anything.
+ id: 'assignRider',
+ label: 'Assign a rider to an order — e.g. "assign a rider to DM-BK-0D915D43-33705"',
+ match: (text) => (ASSIGN_TRIGGER.test(text) ? { ref: orderIdFromWords(text) } : null),
+ run: async ({ ref }) => {
+ // No order named — the flow will ask for one.
+ if (!ref?.strong) {
+ return { headline: 'Which order should I assign?', form: { kind: 'assignRider', status: 'open' }, sourceCalls: [] };
+ }
+ const scan = await scanBookings();
+ const needle = String(ref.id).toLowerCase();
+ const found = scan.rows.find((b) => String(b.bookingno || '').toLowerCase() === needle || String(b.bookingid) === ref.id);
+ if (!found) {
+ return {
+ headline: `I couldn't find order ${ref.id}.`,
+ detail: truncationNote(scan).trim() || 'Check the order number and try again.',
+ sourceCalls: [scanCall(scan, `no match for "${ref.id}"`)]
+ };
+ }
+ return {
+ headline: `Assigning ${bookingLabel(found)}.`,
+ form: { kind: 'assignRider', status: 'open', booking: found },
+ sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`)]
+ };
+ }
+ },
+ {
+ id: 'orderLookup',
+ label: 'Everything about one order — e.g. "DM-BK-0D915D43-33705" or "status of order #1234"',
+ match: (text) => {
+ const ref = orderIdFromWords(text);
+ if (!ref) return null;
+ // A STRONG reference (DM-…, #1234) is the whole question — an operator
+ // pasting a booking number shouldn't have to wrap a sentence around it.
+ // A WEAK one (bare digits) still needs an order/booking/status/where
+ // word, or a stray "42" would be read as an order id.
+ if (!ref.strong && !/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
+ return { orderId: ref.id, strong: ref.strong };
+ },
+ run: async ({ orderId, strong }) => {
+ const scan = await scanBookings();
+ const needle = orderId.toLowerCase();
+ const found = scan.rows.find(
+ (b) =>
+ String(b.bookingno || '').toLowerCase() === needle ||
+ String(b.bookingid) === orderId ||
+ String(b.bookingno || '')
+ .toLowerCase()
+ .includes(needle)
+ );
+ if (!found) {
+ // A weak reference (bare digits) was probably never an order id —
+ // let the broader intents have the question. A strong one (#1234,
+ // DM-...) unmistakably WAS, so say it wasn't found rather than
+ // falling through and answering something else entirely.
+ if (!strong) return null;
+ return {
+ headline: `I couldn't find order ${orderId}.`,
+ detail: scan.truncated
+ ? `Searched the most recent ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
+ 'en-IN'
+ )} bookings — it may exist further back than I can scan.`
+ : `Searched all ${scan.scanned.toLocaleString('en-IN')} bookings. Check the order number and try again.`,
+ sourceCalls: [scanCall(scan, `no match for "${orderId}"`)]
+ };
+ }
+ // Same taxonomy the Orders page shows, so a per-order answer and the
+ // tab that order sits under can't disagree.
+ const status = ORDER_STATUS_LABELS[groupForBookingStatus(found.status)] || found.status;
+
+ // Resolve the rider to a NAME (the booking only carries an id), and pull
+ // the tracking trail. Both are enrichment: either failing degrades that
+ // one line rather than the whole answer.
+ // A booking carries `appcustomerid`, never the recipient's name — so the
+ // customer store is read to turn it into one. All three are enrichment:
+ // any of them failing costs that one line, not the whole answer.
+ // GET /admin/bookings/:id/track is deliberately NOT called. Its response
+ // shape was never confirmed (express-console-api.md lists it as
+ // written-but-unproven), so it contributed a "Tracking" line nobody could
+ // rely on and an audit entry that reported an error on every order that
+ // simply has no trail yet. Removed on explicit direction — don't add it
+ // back without a confirmed response shape.
+ const [milers, customers] = await Promise.all([
+ found.assignedmileruserid ? getMilers().catch(() => null) : Promise.resolve(null),
+ found.appcustomerid ? getAdminCustomers().catch(() => null) : Promise.resolve(null)
+ ]);
+ const rider = milers ? milers.find((m) => m.userid === found.assignedmileruserid) : null;
+ const customer = customers ? customers.find((c) => (c.appcustomerid ?? c.id) === found.appcustomerid) : null;
+
+ const extraCalls = [];
+ if (milers) {
+ extraCalls.push({
+ name: 'getMilers',
+ target: '/admin/milers',
+ status: 'complete',
+ stats: rider ? `resolved ${rider.displayname || rider.name}` : 'no match for rider id'
+ });
+ }
+ if (customers) {
+ extraCalls.push({
+ name: 'getAdminCustomers',
+ target: '/admin/customers',
+ status: 'complete',
+ stats: customer ? `resolved ${customer.name || customer.phone || `#${found.appcustomerid}`}` : 'no match for customer id'
+ });
+ }
+
+ const riderLine = found.assignedmileruserid
+ ? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${
+ rider?.phone ? ` · ${rider.phone}` : ''
+ }`
+ : 'Not yet assigned to a rider.';
+
+ const service = found.serviceoptions?.[0];
+ const parcels = found.parcels || [];
+ const addr = (a, pin, city) => [a, city, pin].filter(Boolean).join(', ');
+
+ // Every field that is actually on the record, and nothing that isn't — a
+ // row is omitted rather than rendered as "—", so a blank never reads as
+ // "we checked and it's empty" when it means "this booking has no such
+ // field at all".
+ const items = [
+ { label: 'Status', meta: `${status}${found.status && found.status !== status ? ` (${found.status})` : ''}` },
+ { label: 'Rider', meta: riderLine.replace(/^Rider: /, '') },
+ customer || found.appcustomerid
+ ? {
+ label: 'Customer',
+ meta: customer
+ ? [customer.name || [customer.firstname, customer.lastname].filter(Boolean).join(' '), customer.phone]
+ .filter(Boolean)
+ .join(' · ')
+ : `#${found.appcustomerid} (name unavailable)`
+ }
+ : null,
+ found.pickupaddress ? { label: 'Pickup', meta: addr(found.pickupaddress, found.pickuppincode) } : null,
+ found.deliveryaddress ? { label: 'Drop', meta: addr(found.deliveryaddress, found.deliverypincode, found.deliverycity) } : null,
+ service
+ ? {
+ label: 'Service',
+ meta: [service.servicetype, service.estimatedprice != null ? `₹${Number(service.estimatedprice).toFixed(2)}` : null]
+ .filter(Boolean)
+ .join(' · ')
+ }
+ : null,
+ parcels.length
+ ? {
+ label: `Parcel${parcels.length === 1 ? '' : `s (${parcels.length})`}`,
+ meta: parcels
+ .slice(0, 3)
+ .map((p) => [p.itemcategory, p.itemdescription].filter(Boolean).join(' · '))
+ .join(' | ')
+ }
+ : null,
+ found.createdat ? { label: 'Created', meta: parseDoormileTimestamp(found.createdat).format('DD MMM YYYY, hh:mm A') } : null,
+ found.updatedat ? { label: 'Last updated', meta: parseDoormileTimestamp(found.updatedat).format('DD MMM YYYY, hh:mm A') } : null,
+ etaOf(found) ? { label: 'Promised by', meta: parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A') } : null,
+ service?.sladueat ? { label: 'SLA due', meta: parseDoormileTimestamp(service.sladueat).format('DD MMM, hh:mm A') } : null,
+ found.consignmentid ? { label: 'Consignment', meta: `#${found.consignmentid}` } : null,
+ found.bookingsource ? { label: 'Source', meta: found.bookingsource } : null,
+ found.notes ? { label: 'Notes', meta: found.notes } : null
+ ].filter(Boolean);
+
+ return {
+ headline: `Order ${bookingLabel(found)} is ${status}.`,
+ list: { title: 'The full record', numbered: false, items },
+ // Lateness is the one thing worth saying before the rows are read.
+ detail:
+ delayState(found, dayjs()) === 'late'
+ ? `Past its promised time — it was due ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}.`
+ : undefined,
+ sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls]
+ };
+ }
+ },
+ {
+ // Ahead of riderCounts so "where is rider Kumar" resolves to a specific
+ // rider instead of the aggregate count.
+ id: 'riderLookup',
+ label: 'Look up a specific rider — e.g. "where is rider Kumar"',
+ match: (text) => {
+ if (!mentionsRiders(text) || !LOOKUP_TRIGGER.test(text)) return null;
+ const name = nameAfterKeyword(text, 'rider');
+ return name ? { name } : null;
+ },
+ run: async ({ name }) => {
+ const riders = (await getMilers()) || [];
+ const needle = name.toLowerCase();
+ const found = riders.find((r) =>
+ String(r.displayname || r.name || '')
+ .toLowerCase()
+ .includes(needle)
+ );
+ if (!found) return null;
+ return {
+ // Field names matter here: a miler has NO `status`, `phonenumber` or
+ // `vehicletype` field. The real ones are `availabilitystatus`,
+ // `phone` and `defaultvehicletype` (api.js documents the confirmed
+ // live shape). Reading the wrong names meant this answer always said
+ // "status unknown" and never showed a phone or vehicle.
+ headline: `${found.displayname || found.name} — ${found.availabilitystatus || 'availability unknown'}.`,
+ detail:
+ [
+ found.phone ? `Phone: ${found.phone}` : null,
+ found.defaultvehicletype ? `Vehicle: ${found.defaultvehicletype}` : null,
+ found.hubname ? `Hub: ${found.hubname}` : null
+ ]
+ .filter(Boolean)
+ .join('\n') || undefined,
+ sourceCalls: [{ name: 'getMilers', target: '/admin/milers', status: 'complete', stats: `matched "${name}"` }]
+ };
+ }
+ },
+ {
+ // ---- L3: what has this rider actually done ----------------------------
+ //
+ // orderQuery can already count a named rider's ORDERS, but the rider's own
+ // record carries what the bookings feed cannot: accepted vs rejected,
+ // distance covered, and current duty state. GET /admin/milers/summary is
+ // the confirmed-live shape for that (api.js documents the fields).
+ id: 'riderActivity',
+ label: 'A rider’s own numbers — e.g. "how is rider Kumar doing today"',
+ match: (text) => {
+ if (!mentionsRiders(text)) return null;
+ if (
+ !/\bperformance\b|\bactivity\b|\bdoing\b|\bstats\b|\bhow\s+is\b|\bhow\s+has\b|\bcompleted\b|\brejected\b|\baccepted\b|\bkms?\b|\bdistance\b/i.test(
+ text
+ )
+ )
+ return null;
+ const name = riderNameFromWords(text);
+ if (!name) return null;
+ const range = rangeFromWords(text);
+ if (range) return { name, start: range.start, end: range.end, label: range.label };
+ const day = dayFromWords(text);
+ return { name, start: day, end: day, label: describeDay(day) };
+ },
+ run: async ({ name, start, end, label }) => {
+ const summary = (await getMilerSummary(undefined, start, end)) || [];
+ const list = Array.isArray(summary) ? summary : [summary];
+ const rider = bestNameMatch(name, list, (m) => m.displayname || m.name);
+ if (!rider) return null;
+
+ // The activity feed is optional colour — a failure degrades that one
+ // line rather than the whole answer.
+ const activity = rider.milerprofileid ? await getMilerActivity(rider.milerprofileid, start, end).catch(() => null) : null;
+
+ const assigned = Number(rider.assigned) || 0;
+ const stats = [
+ { label: 'Assigned', value: assigned, color: STATUS.accepted },
+ { label: 'Completed', value: Number(rider.completed) || Number(rider.delivered) || 0, color: STATUS.delivered },
+ { label: 'Rejected', value: Number(rider.rejected) || 0, color: STATUS.cancelled },
+ { label: 'Cancelled', value: Number(rider.cancelled) || 0, color: STATUS.muted }
+ ];
+
+ return {
+ headline: `${rider.displayname || rider.name} — ${rider.availabilitystatus || 'status unknown'} ${label}.`,
+ metric: { value: Number(rider.completed) || Number(rider.delivered) || 0, label: `Completed ${label}` },
+ stats,
+ detail: [
+ assigned ? `Acceptance ${rateOf(assigned - (Number(rider.rejected) || 0), assigned)} of ${assigned} assigned.` : null,
+ rider.riderkms ? `${rider.riderkms} km covered.` : null,
+ rider.hubname ? `Hub: ${rider.hubname}` : null,
+ rider.onduty != null ? `On duty: ${rider.onduty ? 'yes' : 'no'}` : null,
+ Array.isArray(activity) && activity.length ? `${activity.length} activity events in range.` : null
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ sourceCalls: [
+ { name: 'getMilerSummary', target: '/admin/milers/summary', status: 'complete', stats: `matched ${rider.displayname || name}` },
+ activity
+ ? {
+ name: 'getMilerActivity',
+ target: `/admin/milers/${rider.milerprofileid}/activity`,
+ status: 'complete',
+ stats: `${activity.length} events`
+ }
+ : null
+ ].filter(Boolean)
+ };
+ }
+ },
+ {
+ // ---- L3: follow a parcel ----------------------------------------------
+ id: 'parcelTrack',
+ label: 'Track a parcel — e.g. "track consignment DM-CN-123"',
+ match: (text) => {
+ if (!/\btrack\b|\bconsignment\b|\bparcel\b|\bshipment\b/i.test(text)) return null;
+ const code = text.match(/\b([A-Z]{2,}-[A-Za-z0-9-]+)\b/) || text.match(/#\s*([A-Za-z0-9-]{3,})/);
+ if (!code) return null;
+ return { trackingno: code[1] };
+ },
+ run: async ({ trackingno }) => {
+ const found = await trackConsignment(trackingno).catch(() => null);
+ if (!found) {
+ return {
+ headline: `I couldn’t find a consignment for ${trackingno}.`,
+ detail: 'Check the tracking number — it looks like a code but nothing matched.',
+ sourceCalls: [
+ { name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'error', errorMessage: 'No match' }
+ ]
+ };
+ }
+ const id = found.consignmentid ?? found.id;
+ const logs = id ? await getConsignmentLogs(id).catch(() => null) : null;
+ const events = Array.isArray(logs) ? logs : [];
+ return {
+ headline: `${trackingno} — ${found.status || 'status unknown'}.`,
+ detail: [found.origin ? `From: ${found.origin}` : null, found.destination ? `To: ${found.destination}` : null]
+ .filter(Boolean)
+ .join('\n'),
+ list: events.length
+ ? {
+ title: 'Scan history',
+ items: events.map((e) => ({
+ label: e.status || e.event || 'event',
+ meta: e.createdat ? parseDoormileTimestamp(e.createdat).format('DD MMM, hh:mm A') : undefined
+ }))
+ }
+ : undefined,
+ sourceCalls: [
+ { name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'complete' },
+ logs
+ ? { name: 'getConsignmentLogs', target: `/admin/consignments/${id}/logs`, status: 'complete', stats: `${events.length} events` }
+ : null
+ ].filter(Boolean)
+ };
+ }
+ },
+ {
+ // ---- L3: one tenant in depth -------------------------------------------
+ id: 'tenantDetail',
+ label: 'A tenant’s detail — e.g. "tell me about Acme Foods"',
+ match: (text) => {
+ if (!/\btenant\b|\babout\b|\bdetails?\s+(?:of|for)\b/i.test(text)) return null;
+ if (/\bhow many\b/i.test(text)) return null; // that's tenantList
+ const name = tenantNameCandidate(text) || riderNameFromWords(text);
+ return name ? { name } : null;
+ },
+ run: async ({ name }) => {
+ const tenants = (await getalltenants()) || [];
+ const match = bestNameMatch(name, tenants, (t) => t.tenantname);
+ if (!match) return null;
+ const [detail, locations] = await Promise.all([
+ getAdminTenant(match.tenantid).catch(() => null),
+ getTenantLocations(match.tenantid).catch(() => null)
+ ]);
+ const t = detail || match;
+ const sites = Array.isArray(locations) ? locations : [];
+ return {
+ headline: `${t.tenantname}${t.status ? ` — ${t.status}` : ''}.`,
+ metric: { value: sites.length, label: 'Pickup locations' },
+ detail: [t.primaryemail ? `Email: ${t.primaryemail}` : null, t.contactno ? `Phone: ${t.contactno}` : null]
+ .filter(Boolean)
+ .join('\n'),
+ list: sites.length
+ ? { title: 'Locations', items: sites.map((l) => ({ label: l.locationname || `Location ${l.locationid}`, meta: l.pincode })) }
+ : undefined,
+ sourceCalls: [
+ { name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` },
+ { name: 'getAdminTenant', target: `/admin/tenants/${match.tenantid}`, status: detail ? 'complete' : 'error' },
+ {
+ name: 'getTenantLocations',
+ target: `/admin/tenants/${match.tenantid}/locations`,
+ status: locations ? 'complete' : 'error',
+ stats: `${sites.length} locations`
+ }
+ ]
+ };
+ }
+ },
+ {
+ // ---- L4: trend over time ------------------------------------------------
+ //
+ // Ordered above the plain counting intents: "orders per day this week"
+ // contains "orders" and a range, so weekOrders would otherwise answer it
+ // with a single number and drop the "per day" entirely.
+ id: 'orderTrend',
+ label: 'Order trend — e.g. "orders per day this week"',
+ match: (text) => {
+ if (mentionsRiders(text) || mentionsDelay(text)) return null;
+ if (!TREND_TRIGGER.test(text)) return null;
+ if (!/\border(s)?\b|\bbooking(s)?\b|\bdeliver/i.test(text)) return null;
+ const byHour = /\bper\s+hour\b|\bhourly\b|\bby\s+hour\b/i.test(text);
+ const range = rangeFromWords(text);
+ if (range) return { start: range.start, end: range.end, label: range.label, byHour };
+ const day = dayFromWords(text);
+ return { start: day, end: day, label: describeDay(day), byHour: true };
+ },
+ run: async ({ start, end, label, byHour }) => {
+ const scan = await fetchBookingsInRange(start, end);
+ const buckets = trendBuckets(scan.rows, byHour);
+ if (!buckets.length) {
+ return { headline: `No orders to chart ${label}.`, sourceCalls: [scanCall(scan, '0 rows')] };
+ }
+ const max = Math.max(...buckets.map(([, n]) => n));
+ const peak = buckets.find(([, n]) => n === max);
+ return {
+ headline: `${countPhrase(scan, scan.rows.length)} order${scan.rows.length === 1 ? '' : 's'} ${label}, peaking at ${max} (${
+ peak[0]
+ }).`,
+ metric: { value: max, label: `Peak ${byHour ? 'hour' : 'day'} — ${peak[0]}` },
+ list: {
+ title: byHour ? 'Orders per hour' : 'Orders per day',
+ numbered: false,
+ items: buckets.map(([k, n]) => ({ label: k, meta: `${sparkBar(n, max)} ${n}` }))
+ },
+ detail: truncationNote(scan).trim() || undefined,
+ sourceCalls: [scanCall(scan, `${scan.rows.length} rows → ${buckets.length} buckets`)]
+ };
+ }
+ },
+ {
+ // ---- L4: rates ---------------------------------------------------------
+ id: 'orderRate',
+ label: 'A rate — e.g. "cancellation rate this week"',
+ match: (text) => {
+ if (mentionsRiders(text) || mentionsDelay(text)) return null;
+ if (!RATE_TRIGGER.test(text)) return null;
+ const group = statusFromWords(text);
+ if (!group) return null;
+ const range = rangeFromWords(text);
+ if (range) return { group, start: range.start, end: range.end, label: range.label };
+ if (mentionsAnyDate(text)) {
+ const day = dayFromWords(text);
+ return { group, start: day, end: day, label: describeDay(day) };
+ }
+ return { group, start: null, end: null, label: null };
+ },
+ run: async ({ group, start, end, label }) => {
+ const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
+ const rows = scan.rows;
+ const matched = rows.filter((b) => isInGroup(b.status, group));
+ const noun = ORDER_STATUS_LABELS[group].toLowerCase();
+ const scopeText = label ? ` ${label}` : '';
+ return {
+ headline: `${rateOf(matched.length, rows.length)} of orders are ${noun}${scopeText}.`,
+ metric: { value: rateOf(matched.length, rows.length), label: `${ORDER_STATUS_LABELS[group]} rate${scopeText}` },
+ stats: statusStats(rows),
+ detail:
+ `${matched.length} of ${rows.length} orders.` +
+ (rows.length < 20 ? ' Small sample — treat the percentage loosely.' : '') +
+ (label ? '' : '\nCovers every order, not just today.') +
+ truncationNote(scan),
+ sourceCalls: [scanCall(scan, `${matched.length}/${rows.length} ${noun}`)]
+ };
+ }
+ },
+ {
+ // ---- Composable order questions ----------------------------------------
+ //
+ // Every other intent in this file answers ONE dimension and silently
+ // discards the rest of the sentence: "delivered orders for Acme this week"
+ // was answered by statusBreakdown, which had nowhere to put the tenant.
+ // This intent composes status x batch x date x tenant x rider, and also
+ // handles rankings ("top 5 tenants by orders").
+ //
+ // It deliberately claims a question ONLY when it carries two or more
+ // filter dimensions, or asks for a ranking — precisely the shapes the
+ // single-intent path cannot express. Anything simpler still routes to the
+ // proven intents below, so this adds capability without re-routing what
+ // already works.
+ //
+ // `run` returns null when a named tenant/rider doesn't resolve, so an
+ // unrecognised name falls through rather than being silently ignored.
+ id: 'orderQuery',
+ label: 'Composite order question — e.g. "delivered orders for Acme this week"',
+ match: (text) => {
+ if (mentionsDelay(text)) return null;
+ if (!/\border(s)?\b|\bbooking(s)?\b|\bdeliver/i.test(text)) return null;
+
+ const ranking = rankingFromWords(text);
+ const status = statusFromWords(text);
+ const batch = batchFromWords(text);
+ const range = rangeFromWords(text);
+ const dated = mentionsAnyDate(text);
+ const riderName = riderNameFromWords(text);
+ const tenantName = tenantNameCandidate(text);
+
+ // A date is NOT counted as a dimension. Every intent below already
+ // handles dates, so counting it would make "how many cancelled orders
+ // today" look composite and re-route four working questions away from
+ // the intents that answer them best. What the single-intent path
+ // genuinely cannot express is two or more of
+ // status / batch / tenant / rider at once.
+ const dimensions = [status, batch, riderName, tenantName].filter(Boolean).length;
+ if (!ranking && dimensions < 2) return null;
+
+ let scope = { start: null, end: null, label: null };
+ if (range) {
+ scope = { start: range.start, end: range.end, label: range.label };
+ } else if (dated) {
+ const day = dayFromWords(text);
+ scope = { start: day, end: day, label: describeDay(day) };
+ }
+ return { status, batch, riderName, tenantName, ranking, ...scope };
+ },
+ run: async ({ status, batch, riderName, tenantName, ranking, start, end, label }) => {
+ const sourceCalls = [];
+
+ // ---- resolve named entities before touching bookings ----
+ let tenant = null;
+ let tenants = null;
+ if (tenantName || ranking?.groupBy === 'tenant') {
+ tenants = (await getalltenants()) || [];
+ sourceCalls.push({ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` });
+ if (tenantName) {
+ tenant = bestNameMatch(tenantName, tenants, (t) => t.tenantname);
+ // Unrecognised name — don't quietly drop the filter and answer a
+ // broader question, which is exactly the bug this intent exists to
+ // fix. Fall through instead.
+ if (!tenant) return null;
+ }
+ }
+
+ let rider = null;
+ let milers = null;
+ if (riderName || ranking?.groupBy === 'rider') {
+ milers = (await getMilers()) || [];
+ sourceCalls.push({ name: 'getMilers', target: '/admin/milers', status: 'complete', stats: `${milers.length} riders` });
+ if (riderName) {
+ rider = bestNameMatch(riderName, milers, (m) => m.displayname || m.name);
+ if (!rider) return null;
+ }
+ }
+
+ const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
+ sourceCalls.push(scanCall(scan, `${scan.rows.length} in scope`));
+
+ // ---- apply every named filter ----
+ let rows = scan.rows;
+ if (status) rows = rows.filter((b) => isInGroup(b.status, status));
+ if (batch) rows = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch);
+ if (tenant) rows = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid));
+ if (rider) rows = rows.filter((b) => b.assignedmileruserid === (rider.userid ?? rider.milerid));
+
+ const scopeText = label ? ` ${label}` : '';
+ const filterText = [
+ status ? ORDER_STATUS_LABELS[status].toLowerCase() : null,
+ batch ? getBatchLabel(batch) : null,
+ tenant ? `for ${tenant.tenantname}` : null,
+ rider ? `by ${rider.displayname || rider.name}` : null
+ ]
+ .filter(Boolean)
+ .join(', ');
+
+ // ---- ranking ----
+ if (ranking) {
+ const nameFor = (b) => {
+ if (ranking.groupBy === 'tenant') {
+ return (tenants || []).find((t) => Number(t.tenantid) === Number(b.tenantid))?.tenantname || `Tenant #${b.tenantid}`;
+ }
+ if (ranking.groupBy === 'rider') {
+ if (!b.assignedmileruserid) return 'Unassigned';
+ const m = (milers || []).find((x) => x.userid === b.assignedmileruserid);
+ return m?.displayname || m?.name || `Rider #${b.assignedmileruserid}`;
+ }
+ if (ranking.groupBy === 'status') return ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] || b.status;
+ if (ranking.groupBy === 'batch') return getBatchLabel(getRowBatchId({ orderdate: b.createdat })) || 'No batch';
+ return 'Unknown';
+ };
+ // No hub field exists on a booking (see hubBreakdownForLate) — refuse
+ // rather than invent a grouping.
+ if (ranking.groupBy === 'hub') return null;
+
+ const counts = {};
+ rows.forEach((b) => {
+ const k = nameFor(b);
+ counts[k] = (counts[k] || 0) + 1;
+ });
+ const ordered = Object.entries(counts).sort((a, b) => b[1] - a[1]);
+ const top = ordered.slice(0, ranking.limit);
+ if (!top.length) {
+ return {
+ headline: `No orders to rank${scopeText}.`,
+ sourceCalls
+ };
+ }
+ return {
+ headline: `Top ${top.length} ${ranking.groupBy}${top.length === 1 ? '' : 's'} by orders${scopeText}: ${top[0][0]} (${
+ top[0][1]
+ }).`,
+ metric: { value: top[0][1], label: `${top[0][0]} — most orders${scopeText}` },
+ stats: top.map(([name, n]) => ({ label: name, value: n, color: STATUS.info })),
+ detail:
+ `${ordered.length} ${ranking.groupBy}${ordered.length === 1 ? '' : 's'} with orders${scopeText}.` +
+ (label ? '' : '\nCovers every order, not just today.') +
+ truncationNote(scan),
+ sourceCalls
+ };
+ }
+
+ // ---- plain composite count ----
+ return {
+ headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'}${
+ filterText ? ` ${filterText}` : ''
+ }${scopeText}.`,
+ metric: { value: rows.length, label: [filterText, label].filter(Boolean).join(' · ') || 'Orders' },
+ stats: statusStats(rows),
+ list: rows.length
+ ? {
+ title: 'Matching orders',
+ items: rows.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] }))
+ }
+ : undefined,
+ detail:
+ (rows.length ? '' : 'Nothing matched every part of that question.') +
+ (label ? '' : '\nCovers every order, not just today.') +
+ truncationNote(scan),
+ sourceCalls
+ };
+ }
+ },
+ {
+ // Ordered early, right after the order-id lookup — "rider(s)" is a
+ // strong, unambiguous domain signal and must win before any of the
+ // generic order-status/date intents below get a chance to misfire on a
+ // shared word like "active". See the mentionsRiders comment above.
+ id: 'riderCounts',
+ label: 'Rider availability — e.g. "how many riders are active"',
+ match: (text) => (mentionsRiders(text) ? {} : null),
+ run: async () => {
+ const summary = await getallridersummary();
+ return {
+ headline: `${summary?.active ?? 0} active riders (${summary?.available ?? 0} available, ${
+ summary?.onDelivery ?? 0
+ } on a delivery) out of ${summary?.total ?? 0} total.`,
+ detail: `${summary?.inactive ?? 0} inactive/offline.`,
+ sourceCalls: [{ name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }]
+ };
+ }
+ },
+ {
+ id: 'tenantList',
+ label: 'Tenant count — e.g. "how many tenants do we have"',
+ match: (text) => (/\btenants?\b/i.test(text) && /\bhow many\b|\blist\b|\ball\b/i.test(text) ? {} : null),
+ run: async () => {
+ const tenants = (await getalltenants()) || [];
+ return {
+ headline: `${tenants.length} tenant${tenants.length === 1 ? '' : 's'} total.`,
+ metric: { value: tenants.length, label: 'Tenants' },
+ // Full list, not the first ten. "…and 4 more" left the operator with
+ // no way to see the rest at all.
+ list: {
+ title: 'Tenants',
+ items: tenants
+ .filter((t) => t.tenantname)
+ .map((t) => ({ label: t.tenantname, meta: t.tenantid != null ? `#${t.tenantid}` : undefined }))
+ },
+ sourceCalls: [{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }]
+ };
+ }
+ },
+ {
+ // Ordered high: "which hubs are experiencing delays" contains "hubs" and
+ // would otherwise be answered by hubStatus with a hub inventory, and
+ // "which orders are delayed" contains "orders" and would fall through to
+ // totalOrders. Both now also guard with mentionsDelay.
+ id: 'delayedOrders',
+ label: 'Late and at-risk orders — e.g. "which orders are delayed"',
+ match: (text) => {
+ if (mentionsRiders(text)) return null;
+ if (!mentionsDelay(text)) return null;
+ const range = rangeFromWords(text);
+ if (range) return { start: range.start, end: range.end, label: range.label };
+ if (mentionsAnyDate(text)) {
+ const day = dayFromWords(text);
+ return { start: day, end: day, label: describeDay(day) };
+ }
+ // Lateness is a state question — "what is late right now" — so with no
+ // date named it covers every open order, not just today's.
+ return { start: null, end: null, label: null };
+ },
+ run: async ({ start, end, label }) => {
+ const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
+ const rows = scan.rows;
+ const now = dayjs();
+
+ const buckets = { late: [], 'at-risk': [], 'on-time': [], 'no-eta': [], closed: [] };
+ rows.forEach((b) => buckets[delayState(b, now)].push(b));
+
+ const open = rows.length - buckets.closed.length;
+ const measurable = buckets.late.length + buckets['at-risk'].length + buckets['on-time'].length;
+ const scopeNote = label ? ` ${label}` : '';
+
+ // Coverage guard. Reporting "0 delayed" when nothing carries an ETA
+ // would be a falsely reassuring answer about data we simply don't have.
+ if (open > 0 && measurable === 0) {
+ return {
+ headline: "I can't tell which orders are late.",
+ detail: `None of the ${open} open order${
+ open === 1 ? '' : 's'
+ }${scopeNote} carry a delivery ETA, so there is nothing to measure lateness against.`,
+ sourceCalls: [scanCall(scan, `${rows.length} scanned, 0 with an ETA`)]
+ };
+ }
+
+ const late = buckets.late;
+ const hubs = await hubBreakdownForLate(late);
+
+ return {
+ headline: `${countPhrase(scan, late.length)} order${late.length === 1 ? '' : 's'} running late${scopeNote}.`,
+ metric: { value: late.length, label: `Late${label ? `, ${label}` : ' right now'}` },
+ list: late.length
+ ? { title: 'Late orders', items: late.map((b) => ({ label: bookingLabel(b), meta: formatLateness(b, now) })) }
+ : undefined,
+ stats: [
+ { label: 'Late', value: late.length, color: STATUS.cancelled },
+ { label: 'Due within 1h', value: buckets['at-risk'].length, color: STATUS.pending },
+ { label: 'On time', value: buckets['on-time'].length, color: STATUS.delivered },
+ { label: 'No ETA', value: buckets['no-eta'].length, color: STATUS.muted }
+ ],
+ detail:
+ [
+ late.length ? null : 'Nothing is past its promised delivery time.',
+ hubs ? `By hub: ${hubs.map(([hub, n]) => `${hub} ${n}`).join(', ')}` : null,
+ buckets['no-eta'].length
+ ? `${buckets['no-eta'].length} open order${
+ buckets['no-eta'].length === 1 ? '' : 's'
+ } have no ETA and are not counted either way.`
+ : null,
+ label ? null : 'Covers every open order, not just today.'
+ ]
+ .filter(Boolean)
+ .join('\n') + truncationNote(scan),
+ sourceCalls: [
+ scanCall(scan, `${open} open → ${late.length} late, ${buckets['at-risk'].length} at risk`),
+ hubs
+ ? {
+ name: 'getMilerSummary',
+ target: '/admin/milers/summary',
+ status: 'complete',
+ stats: `${hubs.length} hubs with late orders`
+ }
+ : null
+ ].filter(Boolean)
+ };
+ }
+ },
+ {
+ // A rare, explicit trigger phrase ("vs"/"versus"/"compare[d] to") —
+ // ordered ahead of every generic order/revenue intent below so a
+ // comparison question can't be swallowed by totalOrders/revenueTotal
+ // (neither of which ever returns null, so whichever gets checked first
+ // wins the whole question).
+ id: 'comparisonIntent',
+ label: 'Compare two periods — e.g. "orders today vs yesterday"',
+ match: (text) => {
+ if (mentionsRiders(text)) return null;
+ if (!COMPARE_TRIGGER.test(text)) return null;
+ const parts = text.split(COMPARE_TRIGGER);
+ if (parts.length < 2) return null;
+ const left = parts[0];
+ const right = parts.slice(1).join(' ');
+ const leftRange = rangeFromWords(left);
+ const rightRange = rangeFromWords(right);
+ const isRevenue = /\brevenue\b|\bcharges?\b|\bearnings?\b/i.test(text);
+ return {
+ isRevenue,
+ left: leftRange || { start: dayFromWords(left), end: dayFromWords(left), label: describeDay(dayFromWords(left)) },
+ right: rightRange || { start: dayFromWords(right), end: dayFromWords(right), label: describeDay(dayFromWords(right)) }
+ };
+ },
+ run: async ({ isRevenue, left, right }) => {
+ const [leftScan, rightScan] = await Promise.all([
+ fetchBookingsInRange(left.start, left.end),
+ fetchBookingsInRange(right.start, right.end)
+ ]);
+ const leftRows = leftScan.rows;
+ const rightRows = rightScan.rows;
+ // A comparison across a truncated scan isn't just imprecise, it's
+ // directionally unsafe — one side can be capped and the other not.
+ const truncated = leftScan.truncated || rightScan.truncated;
+ const sourceCalls = [
+ scanCall(leftScan, `${leftRows.length} (${left.label})`),
+ scanCall(rightScan, `${rightRows.length} (${right.label})`)
+ ];
+ const caveat = truncated ? '\nBoth sides come from a capped scan — treat the difference as indicative, not exact.' : undefined;
+ if (isRevenue) {
+ const leftTotal = revenueOf(leftRows);
+ const rightTotal = revenueOf(rightRows);
+ const diff = leftTotal - rightTotal;
+ return {
+ headline: `${formatRupees(leftTotal)} (${left.label}) vs ${formatRupees(rightTotal)} (${right.label}) — ${
+ diff >= 0 ? 'up' : 'down'
+ } ${formatRupees(Math.abs(diff))}.`,
+ detail: caveat,
+ sourceCalls
+ };
+ }
+ const diff = leftRows.length - rightRows.length;
+ return {
+ headline: `${countPhrase(leftScan, leftRows.length)} order${leftRows.length === 1 ? '' : 's'} (${left.label}) vs ${countPhrase(
+ rightScan,
+ rightRows.length
+ )} order${rightRows.length === 1 ? '' : 's'} (${right.label}) — ${diff >= 0 ? 'up' : 'down'} ${Math.abs(diff)}.`,
+ detail: caveat,
+ sourceCalls
+ };
+ }
+ },
+ // Fleet/ops intents below — each is a live count against one more of the
+ // console's own resources (hubs, vehicles, tripsheets, exceptions, app
+ // users, customers, pricing, consignments, partners, competitor branches,
+ // carrier pricing), so the bot's coverage grows the same way the rest of
+ // this console does: as new admin resources get their own page here, add
+ // a matching intent here too, always reading the SAME getX() call that
+ // page's own table uses — never a bespoke fetch. Each trigger word is
+ // domain-unique enough that none of them need a mentionsRiders-style
+ // guard against the order/rider/tenant intents above.
+ {
+ // Ahead of hubStatus so "status of hub Chennai" resolves to one hub
+ // instead of the full list.
+ id: 'hubLookup',
+ label: 'Look up a specific hub — e.g. "status of hub Chennai"',
+ match: (text) => {
+ if (!/\bhub(s)?\b/i.test(text) || !LOOKUP_TRIGGER.test(text)) return null;
+ const name = nameAfterKeyword(text, 'hub');
+ return name ? { name } : null;
+ },
+ run: async ({ name }) => {
+ const hubs = (await getHubs()) || [];
+ const needle = name.toLowerCase();
+ const found = hubs.find((h) =>
+ String(h.hubname || '')
+ .toLowerCase()
+ .includes(needle)
+ );
+ if (!found) return null;
+ return {
+ headline: `${found.hubname} — ${found.status || 'status unknown'}.`,
+ detail: found.address ? `Address: ${found.address}` : undefined,
+ sourceCalls: [{ name: 'getHubs', target: '/admin/hubs', status: 'complete', stats: `matched "${name}"` }]
+ };
+ }
+ },
+ {
+ id: 'hubStatus',
+ label: 'Hub status — e.g. "current hub status"',
+ match: (text) => (/\bhub(s)?\b/i.test(text) && !mentionsDelay(text) ? {} : null),
+ run: async () => {
+ const hubs = (await getHubs()) || [];
+ const active = hubs.filter((h) => String(h.status || '').toLowerCase() === 'active').length;
+ return {
+ headline: `${hubs.length} hub${hubs.length === 1 ? '' : 's'} total, ${active} active.`,
+ metric: { value: active, label: `Active of ${hubs.length} hubs` },
+ list: { title: 'Hubs', items: hubs.map((h) => ({ label: h.hubname || `Hub #${h.hubid}`, meta: h.status || 'unknown' })) },
+ sourceCalls: [{ name: 'getHubs', target: '/admin/hubs', status: 'complete', stats: `${hubs.length} hubs` }]
+ };
+ }
+ },
+ {
+ // Ahead of vehicleStatus so "find vehicle TN01AB1234" resolves to one
+ // vehicle instead of the fleet aggregate.
+ id: 'vehicleLookup',
+ label: 'Look up a specific vehicle — e.g. "find vehicle TN01AB1234"',
+ match: (text) => {
+ if (!/\bvehicles?\b/i.test(text) || !LOOKUP_TRIGGER.test(text)) return null;
+ const name = nameAfterKeyword(text, 'vehicle');
+ return name ? { name } : null;
+ },
+ run: async ({ name }) => {
+ const vehicles = (await getVehicles()) || [];
+ const needle = name.toLowerCase().replace(/\s+/g, '');
+ const found = vehicles.find((v) =>
+ String(v.vehicleno || v.vehiclenumber || '')
+ .toLowerCase()
+ .replace(/\s+/g, '')
+ .includes(needle)
+ );
+ if (!found) return null;
+ return {
+ headline: `${found.vehicleno || found.vehiclenumber} — ${found.status || 'status unknown'}.`,
+ detail: found.vehicletype ? `Type: ${found.vehicletype}` : undefined,
+ sourceCalls: [{ name: 'getVehicles', target: '/admin/vehicles', status: 'complete', stats: `matched "${name}"` }]
+ };
+ }
+ },
+ {
+ id: 'vehicleStatus',
+ label: 'Vehicle status — e.g. "how many vehicles are available"',
+ match: (text) => (/\bvehicles?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const vehicles = (await getVehicles()) || [];
+ const available = vehicles.filter((v) => String(v.status || '').toLowerCase() === 'available').length;
+ return {
+ headline: `${vehicles.length} vehicle${vehicles.length === 1 ? '' : 's'} total, ${available} available.`,
+ detail: vehicles.length ? `By type: ${summarizeByField(vehicles, 'vehicletype')}` : undefined,
+ sourceCalls: [{ name: 'getVehicles', target: '/admin/vehicles', status: 'complete', stats: `${vehicles.length} vehicles` }]
+ };
+ }
+ },
+ {
+ id: 'tripsheetStatus',
+ label: 'Tripsheet status — e.g. "how many tripsheets are dispatched"',
+ match: (text) => (/\btrip\s*sheets?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const trips = (await getTripsheets()) || [];
+ return {
+ headline: `${trips.length} tripsheet${trips.length === 1 ? '' : 's'} total.`,
+ detail: trips.length ? `Statuses: ${summarizeByField(trips, 'status')}` : undefined,
+ sourceCalls: [{ name: 'getTripsheets', target: '/admin/tripsheets', status: 'complete', stats: `${trips.length} tripsheets` }]
+ };
+ }
+ },
+ {
+ id: 'exceptionStatus',
+ label: 'Exceptions — e.g. "how many open exceptions"',
+ match: (text) => (/\bexceptions?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const exceptions = (await getExceptions()) || [];
+ return {
+ headline: `${exceptions.length} exception${exceptions.length === 1 ? '' : 's'} total.`,
+ detail: exceptions.length ? `By severity: ${summarizeByField(exceptions, 'severity')}` : undefined,
+ sourceCalls: [{ name: 'getExceptions', target: '/admin/exceptions', status: 'complete', stats: `${exceptions.length} exceptions` }]
+ };
+ }
+ },
+ {
+ id: 'appUserCount',
+ label: 'App users — e.g. "how many app users do we have"',
+ match: (text) => (/\bapp\s*users?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const users = (await getAppUsers()) || [];
+ return {
+ headline: `${users.length} app user${users.length === 1 ? '' : 's'} total.`,
+ detail: users.length ? `Statuses: ${summarizeByField(users, 'status')}` : undefined,
+ sourceCalls: [{ name: 'getAppUsers', target: '/admin/users', status: 'complete', stats: `${users.length} users` }]
+ };
+ }
+ },
+ {
+ id: 'customerCount',
+ label: 'Customers — e.g. "how many customers do we have"',
+ match: (text) => (/\bcustomers?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const customers = (await getAdminCustomers()) || [];
+ return {
+ headline: `${customers.length} customer${customers.length === 1 ? '' : 's'} total.`,
+ sourceCalls: [{ name: 'getAdminCustomers', target: '/admin/customers', status: 'complete', stats: `${customers.length} customers` }]
+ };
+ }
+ },
+ {
+ id: 'pricingCount',
+ label: 'Pricing rules — e.g. "how many pricing rules are configured"',
+ match: (text) => (/\bpricing\b|\bprice\s*list\b|\brate\s*card\b/i.test(text) ? {} : null),
+ run: async () => {
+ const pricing = (await getAdminPricing()) || [];
+ return {
+ headline: `${pricing.length} pricing rule${pricing.length === 1 ? '' : 's'} configured.`,
+ detail: pricing.length ? `By vehicle type: ${summarizeByField(pricing, 'vehicletype')}` : undefined,
+ sourceCalls: [{ name: 'getAdminPricing', target: '/admin/pricing', status: 'complete', stats: `${pricing.length} rules` }]
+ };
+ }
+ },
+ {
+ id: 'consignmentStatus',
+ label: 'Consignments — e.g. "how many consignments do we have"',
+ match: (text) => (/\bconsignments?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const consignments = (await getConsignments()) || [];
+ return {
+ headline: `${consignments.length} consignment${consignments.length === 1 ? '' : 's'} total.`,
+ detail: consignments.length ? `Statuses: ${summarizeByField(consignments, 'status')}` : undefined,
+ sourceCalls: [
+ { name: 'getConsignments', target: '/admin/consignments', status: 'complete', stats: `${consignments.length} consignments` }
+ ]
+ };
+ }
+ },
+ {
+ id: 'partnerCount',
+ label: 'Partners — e.g. "how many partners do we have"',
+ match: (text) => (/\bpartners?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const partners = (await getPartners()) || [];
+ return {
+ headline: `${partners.length} partner${partners.length === 1 ? '' : 's'} total.`,
+ sourceCalls: [{ name: 'getPartners', target: '/admin/partners', status: 'complete', stats: `${partners.length} partners` }]
+ };
+ }
+ },
+ {
+ id: 'competitorBranchCount',
+ label: 'Competitor branches — e.g. "how many competitor branches are tracked"',
+ match: (text) => (/\bcompetitors?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const resp = await getCompetitorBranches(1, BULK_PAGESIZE);
+ const branches = resp?.data || [];
+ const total = resp?.total ?? branches.length;
+ return {
+ headline: `${total} competitor branch${total === 1 ? '' : 'es'} tracked.`,
+ detail: branches.length ? `By city: ${summarizeByField(branches, 'city')}` : undefined,
+ sourceCalls: [
+ { name: 'getCompetitorBranches', target: '/admin/competitor-branches', status: 'complete', stats: `${total} branches` }
+ ]
+ };
+ }
+ },
+ {
+ id: 'carrierPricingCount',
+ label: 'Carrier pricing — e.g. "how many carrier pricing rules"',
+ match: (text) => (/\bcarriers?\b/i.test(text) ? {} : null),
+ run: async () => {
+ const resp = await getCarrierPricing(1, BULK_PAGESIZE);
+ const rows = resp?.data || [];
+ const total = resp?.total ?? rows.length;
+ return {
+ headline: `${total} carrier pricing rule${total === 1 ? '' : 's'} configured.`,
+ sourceCalls: [{ name: 'getCarrierPricing', target: '/admin/carrier-pricing', status: 'complete', stats: `${total} rules` }]
+ };
+ }
+ },
+ {
+ // Ordered ahead of the generic order/status intents: "summary"/"overview"
+ // is an explicit, rare trigger, but a phrasing like "order summary today"
+ // would otherwise be swallowed by totalOrders (whose `run` never returns
+ // null, so whichever is checked first wins the whole question).
+ id: 'opsSummary',
+ label: 'Operations summary — e.g. "give me today\'s operations summary"',
+ match: (text) => {
+ if (mentionsRiders(text)) return null;
+ if (!/\bsummary\b|\boverview\b|\bsnapshot\b|\bhow\s+are\s+we\s+doing\b|\bops\b|\boperations?\b/i.test(text)) return null;
+ const range = rangeFromWords(text);
+ if (range) return { start: range.start, end: range.end, rangeLabel: range.label };
+ const day = dayFromWords(text);
+ return { start: day, end: day, rangeLabel: describeDay(day) };
+ },
+ run: async ({ start, end, rangeLabel }) => {
+ // Two independent real calls — bookings for the tallies, milers for
+ // the fleet line. A rider-summary failure degrades that one line
+ // rather than failing the whole answer.
+ const scan = await fetchBookingsInRange(start, end);
+ const rows = scan.rows;
+ const riders = await getallridersummary().catch(() => null);
+ return {
+ headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} ${rangeLabel}.`,
+ metric: { value: rows.length, label: `Orders ${rangeLabel}${scan.truncated ? ' (at least)' : ''}` },
+ stats: statusStats(rows),
+ detail:
+ [
+ riders ? `${riders.active} of ${riders.total} riders active (${riders.available} available).` : null,
+ `${formatRupees(revenueOf(rows))} estimated revenue, excluding cancelled.`
+ ]
+ .filter(Boolean)
+ .join('\n') + truncationNote(scan),
+ sourceCalls: [
+ scanCall(scan, `${rows.length} orders ${rangeLabel}`),
+ riders
+ ? { name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${riders.total} riders` }
+ : { name: 'getallridersummary', target: '/admin/milers', status: 'error', errorMessage: 'Rider summary unavailable' }
+ ]
+ };
+ }
+ },
+ {
+ id: 'batchCount',
+ label: 'Orders in a batch — e.g. "morning batch orders today"',
+ match: (text) => {
+ const batch = batchFromWords(text);
+ if (!batch) return null;
+ return { batch, day: dayFromWords(text) };
+ },
+ run: async ({ batch, day }) => {
+ const scan = await fetchBookingsForDay(day);
+ const rows = scan.rows;
+ const matched = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch);
+ return {
+ headline: `${countPhrase(scan, matched.length)} order${matched.length === 1 ? '' : 's'} in the ${getBatchLabel(
+ batch
+ )} ${describeDay(day)}.`,
+ metric: { value: matched.length, label: `${getBatchLabel(batch)} ${describeDay(day)}` },
+ list: matched.length
+ ? {
+ title: `${getBatchLabel(batch)} orders`,
+ items: matched.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] }))
+ }
+ : undefined,
+ detail: (matched.length ? '' : 'No orders fall in this batch for that day.') + truncationNote(scan),
+ sourceCalls: [scanCall(scan, `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}`)]
+ };
+ }
+ },
+ {
+ id: 'statusBreakdown',
+ label: 'Orders by status — e.g. "how many delivered orders today"',
+ match: (text) => {
+ // "active" is a real order status AND common rider-availability
+ // language ("riders active today") — riderCounts already runs first,
+ // but refuse explicitly too so this can't regress if reordered.
+ if (mentionsRiders(text) || mentionsDelay(text)) return null;
+ const group = statusFromWords(text);
+ if (!group) return null;
+ const range = rangeFromWords(text);
+ if (range) return { group, start: range.start, end: range.end, label: range.label };
+ if (mentionsAnyDate(text)) {
+ const day = dayFromWords(text);
+ return { group, start: day, end: day, label: describeDay(day) };
+ }
+ // No date named at all. "How many orders are assigned" is a question
+ // about the CURRENT STATE of the queue, which is what the Orders page's
+ // tabs show — they apply no date filter either. Silently scoping it to
+ // orders *created today* answered a different question and returned 0
+ // while the page showed 19.
+ return { group, start: null, end: null, label: null };
+ },
+ run: async ({ group, start, end, label }) => {
+ const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
+ const rows = scan.rows;
+ const matched = rows.filter((b) => isInGroup(b.status, group));
+ const noun = ORDER_STATUS_LABELS[group].toLowerCase();
+ const whenHeadline = label ? ` ${label}` : '';
+ return {
+ headline: `${countPhrase(scan, matched.length)} ${noun} order${matched.length === 1 ? '' : 's'}${whenHeadline}.`,
+ metric: { value: matched.length, label: `${ORDER_STATUS_LABELS[group]}${label ? `, ${label}` : ''}` },
+ stats: statusStats(rows),
+ detail:
+ (label
+ ? `Out of ${rows.length} orders created ${label}.`
+ : `Out of ${rows.length} orders in total. Matches the Orders page's ${ORDER_STATUS_LABELS[group]} tab, which is also unfiltered by date — ask "${noun} orders today" to scope it.`) +
+ `\nCounts ${statusesInGroup(group).join(', ')}.` +
+ truncationNote(scan),
+ sourceCalls: [scanCall(scan, `${rows.length} total → ${matched.length} ${noun}`)]
+ };
+ }
+ },
+ {
+ id: 'revenueTotal',
+ label: 'Revenue/charges total — e.g. "total revenue today"',
+ match: (text) => {
+ if (!/\brevenue\b|\bcharges?\b|\bamount\b|\bearnings?\b|\bcollections?\b/i.test(text)) return null;
+ const range = rangeFromWords(text);
+ if (range) return { start: range.start, end: range.end, rangeLabel: range.label };
+ const day = dayFromWords(text);
+ return { start: day, end: day, rangeLabel: describeDay(day) };
+ },
+ run: async ({ start, end, rangeLabel }) => {
+ const scan = await fetchBookingsInRange(start, end);
+ const rows = scan.rows;
+ const billable = rows.filter((b) => !isCancelled(b));
+ const total = revenueOf(rows);
+ const cancelledCount = rows.length - billable.length;
+ return {
+ // "Estimated" is not hedging — the figure is the sum of
+ // serviceoptions[].estimatedprice, which is a quote, not a settled
+ // amount. Calling it "revenue" flat was the misleading part.
+ headline: `${formatRupees(total)} estimated across ${billable.length} order${billable.length === 1 ? '' : 's'} ${rangeLabel}.`,
+ metric: { value: formatRupees(total), label: `Estimated, ${rangeLabel}` },
+ detail:
+ [
+ billable.length ? `Average ${formatRupees(total / billable.length)} per order.` : null,
+ cancelledCount ? `${cancelledCount} cancelled order${cancelledCount === 1 ? '' : 's'} excluded.` : null
+ ]
+ .filter(Boolean)
+ .join('\n') + truncationNote(scan),
+ sourceCalls: [scanCall(scan, `${billable.length} billable orders, ${formatRupees(total)}`)]
+ };
+ }
+ },
+ {
+ id: 'tenantCount',
+ label: 'Orders for a tenant — e.g. "orders for today"',
+ match: (text) => {
+ if (mentionsRiders(text)) return null;
+ if (!/\bfor\b/i.test(text) && !/\btenant\b/i.test(text)) return null;
+ return { text, day: dayFromWords(text) };
+ },
+ run: async ({ text, day }) => {
+ const tenant = await resolveTenant(text);
+ // No tenant name recognised in the question — don't guess which one
+ // was meant, fall through to the next intent instead.
+ if (!tenant) return null;
+ const scan = await fetchBookingsForDay(day);
+ const rows = scan.rows;
+ const matched = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid));
+ return {
+ headline: `${countPhrase(scan, matched.length)} order${matched.length === 1 ? '' : 's'} for ${tenant.tenantname} ${describeDay(
+ day
+ )}.`,
+ metric: { value: matched.length, label: `${tenant.tenantname}, ${describeDay(day)}` },
+ stats: statusStats(matched),
+ detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.` + truncationNote(scan),
+ sourceCalls: [
+ { name: 'getalltenants', target: '/admin/tenants', status: 'complete' },
+ scanCall(scan, `${rows.length} total → ${matched.length} for ${tenant.tenantname}`)
+ ]
+ };
+ }
+ },
+ {
+ id: 'weekOrders',
+ label: 'Orders this week — e.g. "how many orders this week"',
+ match: (text) => {
+ const range = rangeFromWords(text);
+ if (!range) return null;
+ return range;
+ },
+ run: async ({ start, end, label }) => {
+ const scan = await fetchBookingsInRange(start, end);
+ const rows = scan.rows;
+ return {
+ headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} ${label}.`,
+ metric: { value: rows.length, label: `Orders ${label}${scan.truncated ? ' (at least)' : ''}` },
+ stats: statusStats(rows),
+ detail: (rows.length ? `Statuses: ${summarizeStatuses(rows)}` : '') + truncationNote(scan),
+ sourceCalls: [scanCall(scan, `${rows.length} matched (${start} to ${end})`)]
+ };
+ }
+ },
+ {
+ // Deliberately last — the broadest match ("order"/"orders" alone), so
+ // every more specific intent above gets first refusal.
+ id: 'totalOrders',
+ label: 'Total order count — e.g. "how many orders today"',
+ match: (text) =>
+ !mentionsRiders(text) && !mentionsDelay(text) && /\border(s)?\b|\bbooking(s)?\b/i.test(text) ? { day: dayFromWords(text) } : null,
+ run: async ({ day }) => {
+ const scan = await fetchBookingsForDay(day);
+ const rows = scan.rows;
+ return {
+ headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} created ${describeDay(day)}.`,
+ metric: { value: rows.length, label: `Orders ${describeDay(day)}${scan.truncated ? ' (at least)' : ''}` },
+ stats: statusStats(rows),
+ detail: truncationNote(scan).trim() || undefined,
+ sourceCalls: [scanCall(scan, `${rows.length} matched`)]
+ };
+ }
+ },
+ {
+ // LAST in the catalog on purpose: every operational intent gets first
+ // refusal, so this can only ever claim a question none of them recognised.
+ // Its trigger is deliberately narrow — a question that merely CONTAINS the
+ // word "doormile" ("how many doormile orders today") must still route to
+ // the intent that can actually count it.
+ id: 'aboutDoormile',
+ label: 'What Doormile is, and what I can answer — e.g. "what is doormile"',
+ match: (text) => (ABOUT_TRIGGER.test(text) ? {} : null),
+ run: async () => ({
+ // Only what this console demonstrably does. Nothing here is a claim about
+ // the company, its coverage, its pricing or its history — none of that is
+ // in this app, and inventing it would be exactly the failure mode the
+ // whole catalog is built to avoid. doormile.com is where that lives.
+ headline: 'Doormile is the delivery operation this console runs.',
+ detail:
+ 'From here you manage orders and deliveries, dispatch riders in batches, run hubs and vehicles, and handle tenants, pricing and reports. I answer questions about that live data — I don’t hold Doormile’s own company information, so for anything beyond day-to-day operations see doormile.com.',
+ list: {
+ title: 'What I can answer',
+ numbered: false,
+ items: [
+ { label: 'Orders', meta: 'counts by status, batch, tenant or rider · revenue · delays · one order by its number' },
+ { label: 'Riders', meta: 'how many are active · what a named rider has done' },
+ { label: 'Fleet', meta: 'hubs, vehicles, tripsheets and exceptions' },
+ { label: 'Business', meta: 'tenants, customers, pricing, consignments, partners' },
+ { label: 'Creating', meta: 'a customer, one order, or many from a spreadsheet' }
+ ]
+ },
+ sourceCalls: []
+ })
+ }
+];
+
+const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i]));
+
+export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label);
+
+// Clean, directly-askable example phrasings — for "recommended question"
+// chips in the UI. Kept separate from SUPPORTED_QUESTIONS (which reads as
+// documentation, "Orders in a batch — e.g. ...") since a chip needs to be
+// the literal text to send, not a description of the intent.
+export const EXAMPLE_QUESTIONS = [
+ 'How many orders today?',
+ `${BATCHES[0].label} orders today`,
+ 'How many riders are active?',
+ 'Current hub status',
+ 'How many vehicles are available?',
+ 'Total revenue today',
+ 'Orders today vs yesterday',
+ 'How many consignments do we have?'
+];
+
+// Chips suggested right after a given intent answers — a light nudge toward
+// a plausible next question, not a real "understands context" feature.
+// Keyed by intent id so the AI panel can look these up off the last answer.
+export const FOLLOW_UP_SUGGESTIONS = {
+ totalOrders: ['What about yesterday?', 'Revenue today'],
+ batchCount: ['What about yesterday?'],
+ statusBreakdown: ['Revenue today'],
+ revenueTotal: ['What about yesterday?', 'This week'],
+ weekOrders: ['Last week'],
+ riderCounts: ['Current hub status'],
+ hubStatus: ['How many vehicles are available?'],
+ vehicleStatus: ['Current hub status'],
+ tenantCount: ['What about yesterday?']
+};
+
+const matchAndRun = async (text) => {
+ for (const intent of INTENTS) {
+ const params = intent.match(text);
+ if (!params) continue;
+ // eslint-disable-next-line no-await-in-loop
+ const result = await intent.run(params);
+ if (result) return { ...result, intentId: intent.id, params };
+ }
+ return null;
+};
+
+// True when, after stripping filler words, the text is JUST a date/range
+// phrase with no other recognisable domain keyword — "what about
+// yesterday?" qualifies, "how many riders yesterday" does not (that's its
+// own new question, not a follow-up on the same one).
+const isBareDatePhrase = (text) => {
+ const stripped = text.replace(/\bwhat\s+about\b|\bhow\s+about\b|\band\b|\?/gi, '').trim();
+ if (!stripped) return false;
+ const looksLikeADate =
+ explicitDateFromWords(stripped) !== null ||
+ weekdayFromWords(stripped) !== null ||
+ rangeFromWords(stripped) !== null ||
+ /\byesterday\b|\btoday\b/i.test(stripped);
+ if (!looksLikeADate) return false;
+ return !/\brider|\btenant|\bhub|\bvehicle|\btripsheet|\bexception|\bcustomer|\bpricing|\bconsignment|\bpartner|\bcompetitor|\bcarrier/i.test(
+ stripped
+ );
+};
+
+// Follow-up context: "what about yesterday?" after an order-count question
+// re-runs the SAME intent with just the date/range swapped, instead of
+// requiring the whole question to be repeated. Only kicks in when the new
+// text doesn't resolve to anything on its own (checked by the caller) AND
+// reads as a bare date phrase AND there's a previous intent to re-run.
+const rerunWithNewDate = async (lastIntentId, lastParams, text) => {
+ const intent = INTENTS_BY_ID[lastIntentId];
+ if (!intent || !lastParams) return null;
+ const range = rangeFromWords(text);
+ const newParams = { ...lastParams };
+ if (range) {
+ if ('start' in newParams) newParams.start = range.start;
+ if ('end' in newParams) newParams.end = range.end;
+ if ('rangeLabel' in newParams) newParams.rangeLabel = range.label;
+ if ('label' in newParams) newParams.label = range.label;
+ } else {
+ const day = dayFromWords(text);
+ if ('day' in newParams) newParams.day = day;
+ if ('start' in newParams) {
+ newParams.start = day;
+ newParams.end = day;
+ }
+ if ('rangeLabel' in newParams) newParams.rangeLabel = describeDay(day);
+ }
+ const result = await intent.run(newParams);
+ return result ? { ...result, intentId: intent.id, params: newParams } : null;
+};
+
+// "orders and revenue today" — answered as one combined response instead of
+// only the first-matching intent, when the question plainly asks two things
+// at once (segments joined by and/,/&). Each segment is matched
+// independently through the exact same INTENTS catalog; a segment that
+// doesn't resolve to anything is silently dropped rather than surfacing a
+// partial/wrong result — same "no answer beats a guessed one" rule as
+// everywhere else in this file.
+const answerMultiPart = async (text) => {
+ const segments = text
+ .split(MULTI_SPLIT)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 2);
+ if (segments.length < 2) return null;
+ const results = [];
+ for (const segment of segments) {
+ // eslint-disable-next-line no-await-in-loop
+ const r = await matchAndRun(segment);
+ if (r) results.push(r);
+ }
+ if (results.length < 2) return null; // not genuinely multi-part — let the normal single-intent path handle it
+ return {
+ headline: results.map((r) => r.headline).join(' '),
+ detail:
+ results
+ .map((r) => r.detail)
+ .filter(Boolean)
+ .join('\n\n') || undefined,
+ sourceCalls: results.flatMap((r) => r.sourceCalls || []),
+ intentId: 'multiPart',
+ params: { segments }
+ };
+};
+
+// Tries each intent in order; the first one whose `match` recognises the
+// text AND whose `run` resolves to a real answer wins. Falls back to
+// multi-part splitting, then to follow-up context (re-running the previous
+// turn's intent with a new date) if `context` was passed in. Returns null
+// if nothing matched (or every match failed to resolve) — the caller shows
+// the "I can't answer that yet" fallback rather than a guess.
+//
+// `context` is optional: { lastIntentId, lastParams } from the previous
+// turn's result, used only for the "what about yesterday?" follow-up path.
+export async function answerQuestion(text, context = {}) {
+ const normalized = correctTypos(text);
+
+ // ---- Semantic routing (optional) ---------------------------------------
+ //
+ // Tried FIRST, because the regex catalog's weakness is vocabulary, not
+ // logic: "cancellation" not matching `cancel(led)?`, a bare reply matching
+ // nothing, "per day" being dropped. Retrieval fixes the matching problem
+ // without touching how an answer is produced — the intent's own run() still
+ // executes and every number still comes from a live API call.
+ //
+ // Returns null whenever the sidecar is absent, slow, or unsure, in which
+ // case the deterministic matcher below runs exactly as it does today. This
+ // path can only add coverage.
+ const routed = await routeQuestion(normalized);
+ if (isRouteTrustworthy(routed)) {
+ const intent = INTENTS_BY_ID[routed.intentId];
+ // The intent's own match() still extracts the slots — dates, statuses,
+ // tenants, riders. Retrieval decides WHICH question; parsing decides WITH
+ // WHAT. Embeddings are good at the former and unreliable at the latter.
+ const params = intent?.match(normalized);
+ if (intent && params) {
+ // eslint-disable-next-line no-await-in-loop
+ const result = await intent.run(params);
+ if (result) return { ...result, intentId: intent.id, params, routing: routed };
+ }
+ }
+
+ // Tried BEFORE the single-intent pass: several intents match on a bare
+ // substring ("revenue" anywhere in the text) and their `run` never
+ // returns null, so on a combined question like "orders and revenue
+ // today" the broad intent would swallow the whole sentence and
+ // answerMultiPart would never get a turn. Only spend the extra fetches
+ // on this path when the text actually contains a connector; a real
+ // multi-part answer still requires >=2 segments to independently
+ // resolve, so a single-question false trigger ("service and delivery
+ // timing?") safely falls through to the normal single-intent match below.
+ if (MULTI_SPLIT.test(normalized)) {
+ const multi = await answerMultiPart(normalized);
+ if (multi) return multi;
+ }
+
+ const direct = await matchAndRun(normalized);
+ if (direct) return direct;
+
+ if (context.lastIntentId && isBareDatePhrase(normalized)) {
+ const followUp = await rerunWithNewDate(context.lastIntentId, context.lastParams, normalized);
+ if (followUp) return followUp;
+ }
+
+ // ---- Last resort: is this a question about how the console WORKS? -------
+ //
+ // Reached only when no intent produced an answer. "What is CityGate", "why
+ // does dispatch reconcile before commit", "what's the pagination cap" are
+ // real operator questions that no amount of API access can answer — they're
+ // answered by the documentation.
+ //
+ // Passages are returned VERBATIM with their source. There is no generation
+ // step: summarising would need a hosted model (CLAUDE.md §2) and would let a
+ // paraphrase drift from what the doc actually says. The operator reads the
+ // real words and can see which file they came from.
+ const docs = await askDocs(normalized);
+ if (docs?.chunks?.length) {
+ const best = docs.chunks[0];
+ return {
+ headline: best.heading || 'From the documentation',
+ detail: best.text,
+ list:
+ docs.chunks.length > 1
+ ? {
+ title: 'Other passages',
+ numbered: false,
+ items: docs.chunks.slice(1).map((c) => ({ label: c.heading || c.source, meta: c.source }))
+ }
+ : undefined,
+ intentId: 'docsAnswer',
+ sourceCalls: docs.chunks.map((c) => ({
+ name: 'console_docs',
+ target: c.source,
+ status: 'complete',
+ stats: `similarity ${c.score}`
+ }))
+ };
+ }
+
+ return null;
+}
diff --git a/src/components/assistant/orderActions.js b/src/components/assistant/orderActions.js
new file mode 100644
index 0000000..7a6c691
--- /dev/null
+++ b/src/components/assistant/orderActions.js
@@ -0,0 +1,152 @@
+import { createExpressBooking, getTenantLocations } from 'pages/api/doormileApi';
+import { getalltenants } from 'pages/api/api';
+
+// ==============================|| Doormile AI — create order ||============================== //
+//
+// Second write capability. Same contract as actions.js: nothing here mutates
+// except executeCreateOrder, which the panel calls only when the operator
+// presses Create.
+//
+// Order creation is materially riskier than customer creation — a wrong record
+// dispatches a real rider.
+//
+// ⚠ `pickuplocationid` DOES NOT WORK. express-console-api.md documents it as
+// the way to reference a stored pickup site, and this file was originally
+// built on it, but createorder1.js records (in two places) that the field
+// live-500s on POST /admin/expressbooking — a reported backend bug, with no
+// frontend workaround other than not using it. The page therefore always
+// sends the RAW pickup fields, copying them out of the chosen saved location.
+//
+// This does the same. The operator still picks a saved location — that part
+// is good UX and keeps CityGate satisfied, since a stored site has already
+// passed it — but what goes on the wire is pickupaddress / pickuppincode /
+// pickupcity / pickuplatitude / pickuplongitude, exactly as the page sends.
+
+export const CREATE_ORDER_TRIGGER = /\b(?:create|add|place|book|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:order|booking|delivery)\b/i;
+
+// Doormile's own service tiers (express-console-api.md).
+export const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
+
+// CityGate is enforced server-side, before the handler runs. Checking it here
+// too means the operator is told which cities are open BEFORE submitting,
+// instead of getting an opaque middleware rejection back.
+export const OPEN_CITY_PREFIXES = {
+ 641: 'Coimbatore',
+ 600: 'Chennai',
+ 560: 'Bengaluru',
+ 500: 'Hyderabad',
+ 629: 'Nagercoil'
+};
+
+export const cityGateFor = (pincode) => {
+ const prefix = String(pincode || '').slice(0, 3);
+ return OPEN_CITY_PREFIXES[prefix] || null;
+};
+
+export const loadOrderTenants = async () => (await getalltenants()) || [];
+
+// Saved pickup sites for a tenant. Each carries address / city / pincode /
+// coordinates — which is what the payload copies onto the booking, since
+// `pickuplocationid` itself 500s (see the note at the top of this file).
+export const loadPickupLocations = async (tenantid) => {
+ if (!tenantid) return [];
+ return (await getTenantLocations(tenantid)) || [];
+};
+
+const PHONE_RE = /^\d{10}$/;
+
+// Mirrors createorder1.js's own gate, plus the two rules the endpoint enforces
+// that the page leaves to the server (non-empty parcels, CityGate).
+export const validateOrderDraft = (d) => {
+ const errors = {};
+ if (!d.tenantid) errors.tenantid = 'Choose a tenant';
+ // Still required: the operator picks a saved site so its address/pincode/
+ // coordinates can be copied onto the booking. The id itself is never sent.
+ if (!d.pickuplocationid) errors.pickuplocationid = 'Choose a pickup location';
+ if (!d.customer_name?.trim()) errors.customer_name = 'Required';
+ if (!PHONE_RE.test(String(d.customer_phone || '').trim())) errors.customer_phone = 'Enter exactly 10 digits';
+ if (!d.deliveryaddress?.trim()) errors.deliveryaddress = 'Required';
+ if (!String(d.deliverypincode || '').trim()) errors.deliverypincode = 'Required';
+ if (!d.itemdescription?.trim()) errors.itemdescription = 'Describe what is being sent';
+ if (d.finalprice === '' || d.finalprice == null || Number.isNaN(Number(d.finalprice))) errors.finalprice = 'Enter an amount';
+
+ // Delivery coordinates come from the address search. Without them the
+ // optimiser has nothing to route against, so refuse rather than send a
+ // booking that can never be dispatched.
+ if (!Number.isFinite(Number(d.deliverylatitude)) || !Number.isFinite(Number(d.deliverylongitude))) {
+ errors.deliveryaddress = 'Pick the address from the suggestions so coordinates are captured';
+ }
+
+ return { ok: Object.keys(errors).length === 0, errors };
+};
+
+// The exact body that will be POSTed. Mirrors createorder1.js's own payload —
+// raw pickup fields, never `pickuplocationid` (see the note at the top).
+//
+// `pickup` is the saved location record the operator chose; its address,
+// pincode, city and coordinates are copied onto the booking.
+export const buildOrderPayload = (d, pickup) => ({
+ tenantid: Number(d.tenantid),
+ pickupaddress: pickup?.address || '',
+ pickuppincode: String(pickup?.pincode || ''),
+ pickupcity: pickup?.city || '',
+ pickuplatitude: Number(pickup?.latitude) || 0,
+ pickuplongitude: Number(pickup?.longitude) || 0,
+ customer_name: d.customer_name.trim(),
+ customer_phone: String(d.customer_phone).trim(),
+ deliveryaddress: d.deliveryaddress.trim(),
+ deliverypincode: String(d.deliverypincode).trim(),
+ deliverycity: d.deliverycity || '',
+ deliverylatitude: Number(d.deliverylatitude),
+ deliverylongitude: Number(d.deliverylongitude),
+ service_option: SERVICE_OPTIONS.includes(d.service_option) ? d.service_option : 'Normal',
+ finalprice: Number(d.finalprice),
+ notes: d.notes || '',
+ // A parcel entry has no quantity field — N items means N entries, which is
+ // how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
+ parcels: Array.from({ length: Math.max(1, Number(d.quantity) || 1) }, () => ({
+ itemcategory: d.itemcategory || 'General',
+ itemdescription: d.itemdescription.trim(),
+ declaredvalue: Number(d.declaredvalue) || 0
+ }))
+});
+
+// The only order-writing function in the assistant.
+export const executeCreateOrder = async (payload) => {
+ const started = Date.now();
+ const call = {
+ name: 'createExpressBooking',
+ target: 'POST /admin/expressbooking',
+ stats: `tenant ${payload.tenantid}, ${payload.parcels.length} parcel${payload.parcels.length === 1 ? '' : 's'}`
+ };
+ try {
+ const res = await createExpressBooking(payload);
+ const duration = `${Date.now() - started}ms`;
+ if (res && res.success === false) {
+ return {
+ ok: false,
+ message: res.message || 'The server rejected the order.',
+ sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
+ };
+ }
+ const created = res?.data || res;
+ return {
+ ok: true,
+ id: created?.bookingid ?? created?.id,
+ bookingno: created?.bookingno,
+ created,
+ sourceCalls: [{ ...call, duration, status: 'complete', stats: `created ${created?.bookingno || created?.bookingid || '—'}` }]
+ };
+ } catch (err) {
+ const duration = `${Date.now() - started}ms`;
+ const status = err.httpStatus ?? err.response?.status;
+ const serverMessage = err.message || err.error;
+ const message =
+ status === 404 || status === 405
+ ? `POST /admin/expressbooking returned ${status} — that route does not exist on the server.`
+ : status
+ ? `POST /admin/expressbooking returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`
+ : `${serverMessage || 'The request failed'} — nothing was saved.`;
+ return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
+ }
+};
diff --git a/src/components/assistant/orderFlow.js b/src/components/assistant/orderFlow.js
new file mode 100644
index 0000000..8c19785
--- /dev/null
+++ b/src/components/assistant/orderFlow.js
@@ -0,0 +1,267 @@
+import { getAdminPricing, getAdminCustomers, getTenantLocations } from 'pages/api/doormileApi';
+import { getalltenants } from 'pages/api/api';
+import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from 'utils/distance';
+import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
+import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
+import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
+
+// ==============================|| Doormile AI — conversational create-order ||============================== //
+//
+// One question at a time, mirroring createorder1.js's own field set and using a
+// DROPDOWN wherever that page uses one — business location, customer, category,
+// weight, service tier. Free text is only for things that genuinely are free
+// text (a name, an address, a description).
+//
+// Two capabilities the customer flow didn't need:
+//
+// • BRANCHING. "Existing customer or new?" splits the path: existing skips
+// straight to picking from the real customer list, new asks for the fields.
+// Steps carry a `when` predicate and are skipped when it's false.
+// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
+// dropdown never shows a stale or invented list.
+//
+// The conversation is driven by the PANEL, which intercepts replies before the
+// router ever sees them — see customerFlow.js for why that matters (a bare
+// "8494948494" matches no intent and used to be discarded).
+
+const PHONE_RE = /^\d{10}$/;
+const digits = (t) => String(t || '').replace(/\D/g, '');
+
+const isStaffLogin = () => {
+ const t = localStorage.getItem('tenantid');
+ return !t || t === '0';
+};
+
+// ---- steps ------------------------------------------------------------------
+//
+// type: 'select' → the panel renders a dropdown from `options(draft)`
+// 'text' → answered through the composer
+// when: omitted means always asked
+export const ORDER_STEPS = [
+ {
+ id: 'tenantid',
+ type: 'select',
+ ask: 'Which tenant is this order for?',
+ // A client login already has its tenant; only Doormile staff choose.
+ when: () => isStaffLogin(),
+ options: async () => {
+ const tenants = (await getalltenants()) || [];
+ return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
+ },
+ apply: (d, v) => ({ ...d, tenantid: v })
+ },
+ {
+ id: 'pickuplocationid',
+ type: 'select',
+ ask: 'Which business location is this picked up from?',
+ options: async (d) => {
+ const tid = d.tenantid || localStorage.getItem('tenantid');
+ const locations = (await getTenantLocations(tid)) || [];
+ return locations.map((l) => ({
+ value: String(l.locationid),
+ // The pincode is shown because it decides CityGate — a location outside
+ // the open cities will be refused server-side, and the operator should
+ // see that before choosing rather than after submitting.
+ label: `${l.locationname || l.address || 'Location'}${
+ l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
+ }`,
+ record: l
+ }));
+ },
+ validate: (v, option) =>
+ cityGateFor(option?.record?.pincode)
+ ? null
+ : `That location’s pincode (${
+ option?.record?.pincode || 'unknown'
+ }) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
+ apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
+ },
+ {
+ id: 'customerMode',
+ type: 'select',
+ ask: 'Is this an existing customer, or a new one?',
+ options: async () => [
+ { value: 'existing', label: 'Existing customer' },
+ { value: 'new', label: 'New customer' }
+ ],
+ apply: (d, v) => ({ ...d, customerMode: v })
+ },
+ {
+ id: 'existingCustomer',
+ type: 'select',
+ ask: 'Which customer?',
+ when: (d) => d.customerMode === 'existing',
+ options: async () => {
+ const customers = (await getAdminCustomers()) || [];
+ return customers
+ .filter((c) => c.phone)
+ .map((c) => ({
+ value: String(c.appcustomerid ?? c.id),
+ label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
+ record: c
+ }));
+ },
+ // Picking an existing customer fills the name and phone, so the two
+ // free-text steps below are skipped by their own `when`.
+ apply: (d, v, option) => ({
+ ...d,
+ customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
+ customer_phone: digits(option?.record?.phone)
+ })
+ },
+ {
+ id: 'customer_name',
+ type: 'text',
+ ask: 'What’s the customer’s name?',
+ when: (d) => d.customerMode === 'new' && !d.customer_name,
+ validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
+ apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
+ },
+ {
+ id: 'customer_phone',
+ type: 'text',
+ ask: 'And their 10-digit mobile number?',
+ when: (d) => !d.customer_phone,
+ validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
+ apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
+ },
+ {
+ id: 'deliveryaddress',
+ type: 'text',
+ ask: 'Where is it being delivered? Give the full address.',
+ // Geocoded on the way in: the dispatch optimiser routes on coordinates, so
+ // an address that can't be located is refused here rather than becoming a
+ // booking nothing can dispatch.
+ resolve: async (t) => {
+ const place = await geocodeAddress(String(t).trim()).catch(() => null);
+ if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
+ const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
+ (place.address_components || []).forEach((c) => {
+ if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
+ if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
+ });
+ return {
+ value: {
+ ...parts,
+ deliverylatitude: place.geometry?.location?.lat?.(),
+ deliverylongitude: place.geometry?.location?.lng?.()
+ }
+ };
+ },
+ apply: (d, v) => ({ ...d, ...v })
+ },
+ {
+ id: 'deliverypincode',
+ type: 'text',
+ ask: 'What’s the delivery pincode?',
+ when: (d) => !d.deliverypincode,
+ validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
+ apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
+ },
+ {
+ id: 'service_option',
+ type: 'select',
+ ask: 'Which service level?',
+ options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
+ apply: (d, v) => ({ ...d, service_option: v })
+ },
+ {
+ id: 'itemcategory',
+ type: 'select',
+ ask: 'What kind of parcel is it?',
+ options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
+ apply: (d, v) => ({ ...d, itemcategory: v })
+ },
+ {
+ id: 'weight',
+ type: 'select',
+ ask: 'Roughly how heavy?',
+ options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
+ apply: (d, v) => ({ ...d, weight: v })
+ },
+ {
+ id: 'itemdescription',
+ type: 'text',
+ ask: 'Briefly, what’s inside?',
+ validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
+ apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
+ },
+ {
+ id: 'quantity',
+ type: 'select',
+ ask: 'How many parcels?',
+ options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
+ apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
+ },
+ {
+ id: 'finalprice',
+ type: 'text',
+ ask: 'What should the price be? Enter the amount in ₹.',
+ // `auto` answers a step from real data and only falls back to asking. The
+ // quote is stashed either way so the confirmation can show the distance it
+ // measured, and say why it couldn’t price when it couldn’t.
+ auto: async (d) => {
+ const quote = await priceOrder(d);
+ if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
+ return {
+ patch: { __quote: quote },
+ ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
+ };
+ },
+ validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
+ apply: (d, t) => ({ ...d, finalprice: Number(t) })
+ }
+];
+
+// Mirrors createorder1.js's own lists so the bot offers the same choices.
+const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
+const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
+
+// ---- pricing ----------------------------------------------------------------
+//
+// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
+// this tenant's own pricing row. Quoted, never invented — if no pricing row
+// matches, the operator is asked for the amount rather than shown a zero.
+export const priceOrder = async (draft) => {
+ const tid = draft.tenantid || localStorage.getItem('tenantid');
+ const pricing = (await getAdminPricing()) || [];
+ // tenantid is numeric on the pricing row and a string from localStorage — a
+ // strict comparison here silently priced every order at zero once before.
+ const match = pricing.find((p) => String(p.tenantid) === String(tid));
+
+ const pickup = draft.__pickup;
+ if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
+
+ const km = await calculateDrivingDistance(
+ { latitude: pickup.latitude, longitude: pickup.longitude },
+ { latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
+ ).catch(() => null);
+
+ if (km == null) return { error: 'could not measure the distance' };
+ if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
+
+ const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
+ return {
+ km,
+ durationMin: getLastRouteDurationMin(),
+ basePrice: match.baseprice,
+ total: Number(Number(total).toFixed(2))
+ };
+};
+
+// ---- engine -----------------------------------------------------------------
+//
+// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
+// These wrappers keep the order-specific names the panel and the tests use.
+
+export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
+
+export const startOrderFlow = () => {
+ // A client login already belongs to a tenant, so its step is skipped — but
+ // the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
+ // draft is what makes the skip safe.
+ const tid = localStorage.getItem('tenantid');
+ return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
+};
+
+export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);
diff --git a/src/components/assistant/ragRouter.js b/src/components/assistant/ragRouter.js
new file mode 100644
index 0000000..4092ae5
--- /dev/null
+++ b/src/components/assistant/ragRouter.js
@@ -0,0 +1,66 @@
+// ==============================|| Doormile AI — semantic routing client ||============================== //
+//
+// Talks to the retrieval sidecar (services/ai) to decide WHICH QUESTION was
+// asked. It never returns data — every figure still comes from the intent's own
+// deterministic run(), through the same typed API functions the pages use.
+//
+// The whole module is optional by design:
+//
+// • REACT_APP_AI_URL unset → disabled, regex matcher only (production today)
+// • sidecar unreachable → disabled for this call, regex matcher
+// • slow → aborted at ROUTE_TIMEOUT_MS, regex matcher
+// • low confidence → not used, regex matcher
+//
+// Today's behaviour is the floor. This can raise it, never lower it.
+
+const BASE = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AI_URL) || (typeof process !== 'undefined' && process.env?.REACT_APP_AI_URL) || '';
+const ROUTE_TIMEOUT_MS = 400;
+
+export const isRagEnabled = () => Boolean(BASE);
+
+// Once the sidecar has failed we stop hammering it on every keystroke-fast
+// question. Re-armed after a cool-off so a restarted container is picked up
+// without a page reload.
+let disabledUntil = 0;
+const COOL_OFF_MS = 30000;
+
+const post = async (path, body) => {
+ if (!BASE || Date.now() < disabledUntil) return null;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ROUTE_TIMEOUT_MS);
+ try {
+ const res = await fetch(`${BASE}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: controller.signal
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ return await res.json();
+ } catch {
+ // Any failure — offline, timeout, 5xx — is silent by design. The operator
+ // gets the deterministic answer; they should never see plumbing.
+ disabledUntil = Date.now() + COOL_OFF_MS;
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+};
+
+// Returns { intentId, confidence, score, margin, alternatives } or null.
+export const routeQuestion = (text) => post('/route', { text });
+
+// Documentation passages, verbatim with attribution. No generation step —
+// summarising would need a hosted model (assistant/CLAUDE.md §2) and would let
+// a paraphrase drift from what the doc actually says.
+export const askDocs = (text) => post('/ask', { text });
+
+// A semantic near-miss must never open a create form. Write intents require
+// high confidence AND corroboration from the deterministic trigger, so the
+// worst case is that the operator types the phrase the regex already knows.
+export const isRouteTrustworthy = (routed) => {
+ if (!routed?.intentId) return false;
+ if (routed.isWrite) return routed.confidence === 'high';
+ return routed.confidence === 'high' || routed.confidence === 'medium';
+};
diff --git a/src/components/assistant/repeatFlow.js b/src/components/assistant/repeatFlow.js
new file mode 100644
index 0000000..5a9931c
--- /dev/null
+++ b/src/components/assistant/repeatFlow.js
@@ -0,0 +1,30 @@
+import { findRecentRuns, describeDay } from './repeatRuns';
+import { startFlow, advanceFlow, answerFlowStep } from './flowEngine';
+
+// ==============================|| Doormile AI — repeat a run ||============================== //
+//
+// One question: which day. Everything after it — resolving customers, the drift
+// check, re-quoting at today's tariff, the already-repeated guard — is a pass
+// the panel runs and narrates, exactly like the bulk file's locate/price phase.
+// None of it is a question, so none of it is a step.
+
+export const REPEAT_STEPS = [
+ {
+ id: 'day',
+ type: 'select',
+ ask: 'Which day’s orders should I repeat?',
+ options: async () => {
+ const { runs } = await findRecentRuns();
+ return runs.map((r) => ({
+ value: r.day,
+ label: `${describeDay(r.day)} — ${r.count} order${r.count === 1 ? '' : 's'}`,
+ record: r
+ }));
+ },
+ apply: (d, v, option) => ({ ...d, day: v, sourceCount: option?.record?.count })
+ }
+];
+
+export const startRepeatFlow = () => startFlow(REPEAT_STEPS, 'repeatRun', {});
+export const advanceRepeat = (flow) => advanceFlow(REPEAT_STEPS, flow);
+export const answerRepeatStep = (flow, raw, option) => answerFlowStep(REPEAT_STEPS, flow, raw, option);
diff --git a/src/components/assistant/repeatRuns.js b/src/components/assistant/repeatRuns.js
new file mode 100644
index 0000000..df41b48
--- /dev/null
+++ b/src/components/assistant/repeatRuns.js
@@ -0,0 +1,244 @@
+import dayjs from 'dayjs';
+
+import { getAdminCustomers } from 'pages/api/doormileApi';
+import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
+import { groupForBookingStatus } from 'utils/orderStatusGroups';
+import { scanBookings } from './intents';
+import { cityGateFor } from './orderActions';
+import { priceBulkRows } from './bulkOrderActions';
+
+// ==============================|| Doormile AI — repeat a past day's run ||============================== //
+//
+// "I created ten orders yesterday; make the same ten today."
+//
+// This is the cheapest write in the assistant, and the reason is worth stating:
+// a booking already carries 15 of the 17 fields `buildOrderPayload` needs —
+// both addresses, both pincodes, BOTH SETS OF COORDINATES, the service tier,
+// the parcels. Only the recipient's name and phone are missing, and those come
+// from the `appcustomerid` → /admin/customers join the assistant already does.
+//
+// So a repeat needs NO geocoding. The ~1 lookup/second Nominatim throttle that
+// dominates the bulk-file flow does not apply here at all.
+//
+// ---- A booking is a snapshot, not a template --------------------------------
+//
+// Which is why every row goes through a drift check before it can be repeated.
+// These are not hypothetical; all three were found in one live page of 36
+// bookings:
+//
+// • `pickupaddress` can be ABSENT entirely (booking 57 carries the pincode
+// and coordinates but no address key at all) — repeating it blind sends an
+// empty pickup address.
+// • `tenantid` can be null (every `Customer_App` booking) — `Number(null)`
+// is 0, so the payload would claim tenant zero.
+// • a pickup pincode that was open when the order was placed may not be now,
+// and CityGate refuses at the middleware, before the handler runs.
+//
+// ---- Duplicate safety is INVERTED here --------------------------------------
+//
+// Everywhere else in this assistant, near-identical orders are an error to
+// prevent (`wasAlreadySubmitted`). A repeat run deliberately creates them, so
+// that guard would misfire on every single run. The question that actually
+// matters is different: *has this run already been repeated today?* The
+// endpoint has no idempotency key, so it is answered by looking: today's own
+// bookings are fingerprinted and any row already present is set aside rather
+// than booked twice.
+
+export const REPEAT_TRIGGER = /\b(?:repeat|redo|re-?run)\b|\bsame\s+orders?\s+as\b|\bsame\s+as\s+(?:yesterday|last)\b/i;
+
+// How far back a run can be recalled from. Beyond a week it stops being "the
+// usual round" and starts being archaeology.
+const LOOKBACK_DAYS = 7;
+
+const dayOf = (value) => {
+ const t = parseDoormileTimestamp(value);
+ return t.isValid() ? t.format('YYYY-MM-DD') : null;
+};
+
+// A row is only worth repeating if it was a real order. Cancelled ones are
+// excluded — repeating a cancellation is never what "same as yesterday" means.
+const isRepeatable = (b) => groupForBookingStatus(b.status) !== 'cancelled';
+
+// ---- which days have a run to repeat ---------------------------------------
+export const findRecentRuns = async () => {
+ const scan = await scanBookings();
+ const today = dayjs().format('YYYY-MM-DD');
+ const counts = new Map();
+
+ (scan.rows || []).forEach((b) => {
+ if (!isRepeatable(b)) return;
+ const day = dayOf(b.createdat);
+ if (!day || day === today) return;
+ if (dayjs(today).diff(dayjs(day), 'day') > LOOKBACK_DAYS) return;
+ counts.set(day, (counts.get(day) || 0) + 1);
+ });
+
+ return {
+ scan,
+ runs: [...counts.entries()].map(([day, count]) => ({ day, count })).sort((a, b) => (a.day < b.day ? 1 : -1))
+ };
+};
+
+export const describeDay = (day) => {
+ const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
+ if (day === yesterday) return 'Yesterday';
+ return dayjs(day).format('ddd D MMM');
+};
+
+// ---- one booking → one repeatable row ---------------------------------------
+//
+// `__pickup` travels on the ROW, not on the shared draft: a day's run can span
+// several kitchens and tenants, and collapsing them onto one shared pickup
+// would silently re-address half the orders.
+const toRow = (booking, customer, index) => ({
+ line: index + 1,
+ source: booking.bookingno || `#${booking.bookingid}`,
+ tenantid: booking.tenantid,
+ customer_name: customer?.name || [customer?.firstname, customer?.lastname].filter(Boolean).join(' ') || '',
+ customer_phone: String(customer?.phone || customer?.contactno || '').replace(/\D/g, ''),
+ deliveryaddress: booking.deliveryaddress || '',
+ deliverypincode: String(booking.deliverypincode || ''),
+ deliverycity: booking.deliverycity || '',
+ deliverylatitude: booking.deliverylatitude,
+ deliverylongitude: booking.deliverylongitude,
+ service_option: booking.serviceoptions?.[0]?.servicetype || 'Normal',
+ // Deliberately blank: the chosen behaviour is to re-quote at today's tariff,
+ // so this is left for priceBulkRows to fill exactly as an unpriced bulk row
+ // would be. Copying yesterday's number would silently bill an old tariff.
+ finalprice: '',
+ previousPrice: booking.serviceoptions?.[0]?.estimatedprice,
+ itemcategory: booking.parcels?.[0]?.itemcategory || 'General',
+ itemdescription: booking.parcels?.[0]?.itemdescription || 'Order',
+ quantity: Math.max(1, booking.parcels?.length || 1),
+ notes: booking.notes || '',
+ // Who ran this drop last time. Carried so the repeat can hand the new order
+ // back to the same rider — they already know the door, the buzzer and the
+ // customer. Null when yesterday's order was never assigned, which is a
+ // normal case and simply means the copy stays pending.
+ __previousMilerUserId: booking.assignedmileruserid ?? null,
+ __pickup: {
+ address: booking.pickupaddress,
+ pincode: booking.pickuppincode,
+ city: booking.pickupcity,
+ latitude: booking.pickuplatitude,
+ longitude: booking.pickuplongitude
+ }
+});
+
+// ---- the drift check --------------------------------------------------------
+//
+// Returns a REASON, never a boolean — an operator dropping a row deserves to
+// know which field went stale.
+const driftReason = (row) => {
+ if (!row.tenantid) return 'the original had no tenant, so this would be booked against tenant 0';
+ if (!row.__pickup?.address) return 'the original booking carries no pickup address';
+ if (!row.customer_phone) return 'the customer record is gone, so there is no phone number';
+ if (!row.customer_name) return 'the customer record is gone, so there is no name';
+ if (!row.deliveryaddress) return 'no delivery address on the original';
+ if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
+ return 'the original has no delivery coordinates, so it could never be routed';
+ }
+ if (!cityGateFor(row.__pickup.pincode)) {
+ return `its pickup pincode (${row.__pickup.pincode || 'unknown'}) is no longer a city Doormile serves`;
+ }
+ return null;
+};
+
+// What makes two orders "the same order". It has to include the PARCEL, not
+// just the destination: a run to one address for one customer is a completely
+// normal thing to do twice in a day with different contents, and judging on
+// phone + address + pickup alone made three unrelated bookings to the same bus
+// stand look identical — the repeat then reported all of yesterday as already
+// created when only one unrelated order existed today.
+//
+// Every field here is one a repeat reproduces EXACTLY, which is what makes it a
+// usable identity. Price is deliberately excluded: it is re-quoted at today's
+// tariff, so it differs by design on every legitimate repeat.
+const fingerprint = (row) =>
+ [
+ row.customer_phone,
+ String(row.deliveryaddress || '').toLowerCase(),
+ row.__pickup?.pincode || '',
+ row.service_option,
+ row.itemcategory,
+ row.itemdescription,
+ row.quantity
+ ].join('|');
+
+// ---- assemble the run -------------------------------------------------------
+export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
+ const [{ scan }, customers] = await Promise.all([findRecentRuns(), getAdminCustomers().catch(() => [])]);
+ const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.id, c]));
+
+ const source = (scan.rows || []).filter((b) => isRepeatable(b) && dayOf(b.createdat) === day);
+ const rows = source.map((b, i) => toRow(b, customerMap.get(b.appcustomerid), i));
+
+ // Already repeated today? Today's own bookings are put through the SAME
+ // `toRow` shaping, so both sides of the comparison get identical defaults —
+ // hand-rolling the today side is how the two drifted apart in the first place.
+ //
+ // Counts, not a Set. A Set answers "does anything today look like this", so a
+ // single matching order suppressed EVERY row that shared its fingerprint —
+ // one order created today wiped out all of yesterday's. A multiset answers
+ // the question that was actually meant: how many of these already exist. Two
+ // identical orders yesterday and one today means one still needs creating.
+ const today = dayjs().format('YYYY-MM-DD');
+ const todayCounts = new Map();
+ (scan.rows || [])
+ .filter((b) => isRepeatable(b) && dayOf(b.createdat) === today)
+ .forEach((b) => {
+ const key = fingerprint(toRow(b, customerMap.get(b.appcustomerid), 0));
+ todayCounts.set(key, (todayCounts.get(key) || 0) + 1);
+ });
+
+ const drifted = [];
+ const already = [];
+ const candidates = [];
+
+ rows.forEach((row) => {
+ const reason = driftReason(row);
+ if (reason) {
+ drifted.push({ ...row, error: reason });
+ return;
+ }
+ // Consume one match per already-existing order, so a second identical row
+ // is still offered once the first has been accounted for.
+ const key = fingerprint(row);
+ const remaining = todayCounts.get(key) || 0;
+ if (remaining > 0) {
+ todayCounts.set(key, remaining - 1);
+ already.push(row);
+ return;
+ }
+ candidates.push(row);
+ });
+
+ // Re-quote at today's tariff. Priced per row because a run can span tenants,
+ // and a tenant's own pricing row is what decides the number.
+ const priced = [];
+ for (let i = 0; i < candidates.length; i += 1) {
+ if (shouldStop?.()) break;
+ const row = candidates[i];
+ onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
+ // eslint-disable-next-line no-await-in-loop
+ const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
+ priced.push(out);
+ }
+ if (priced.length < candidates.length) priced.push(...candidates.slice(priced.length));
+
+ const valid = priced.filter((r) => !r.priceError && Number(r.finalprice) > 0);
+ const unpriced = priced.filter((r) => r.priceError || !(Number(r.finalprice) > 0));
+
+ return {
+ day,
+ scan,
+ total: rows.length,
+ valid,
+ drifted,
+ already,
+ unpriced,
+ // Every price that moved since the original, so a tariff change is visible
+ // rather than discovered on an invoice.
+ changed: valid.filter((r) => r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice))
+ };
+};
diff --git a/src/components/doormile/AddressAutocomplete.jsx b/src/components/doormile/AddressAutocomplete.jsx
index 1276e37..b6c5eda 100644
--- a/src/components/doormile/AddressAutocomplete.jsx
+++ b/src/components/doormile/AddressAutocomplete.jsx
@@ -1,121 +1,219 @@
import PropTypes from 'prop-types';
import React, { useEffect, useRef, useState } from 'react';
-const useDebouncedCallback = (callback, delay) => { const timeoutRef = React.useRef(null); return React.useCallback((...args) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { callback(...args); }, delay); }, [callback, delay]); };
+import { Loader2, MapPin, X } from 'lucide-react';
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
-const toPlace = (nom) => {
- const c = nom.address || {};
- const addr = [
- { long_name: c.house_number || '', short_name: c.house_number || '', types: ['street_number'] },
- { long_name: c.road || '', short_name: c.road || '', types: ['route'] },
- { long_name: c.city || c.town || c.village || '', short_name: c.city || c.town || c.village || '', types: ['locality', 'political'] },
- { long_name: c.county || c.state_district || '', short_name: c.county || c.state_district || '', types: ['administrative_area_level_2', 'political'] },
- { long_name: c.state || '', short_name: c.state || '', types: ['administrative_area_level_1', 'political'] },
- { long_name: c.country || '', short_name: c.country_code?.toUpperCase() || '', types: ['country', 'political'] },
- { long_name: c.postcode || '', short_name: c.postcode || '', types: ['postal_code'] }
- ].filter((p) => p.long_name);
-
- return {
- address_components: addr,
- formatted_address: nom.display_name,
- geometry: {
- location: {
- lat: () => Number(nom.lat),
- lng: () => Number(nom.lon)
- }
- }
- };
+const useDebouncedCallback = (callback, delay) => {
+ const timeoutRef = useRef(null);
+ return React.useCallback(
+ (...args) => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => {
+ callback(...args);
+ }, delay);
+ },
+ [callback, delay]
+ );
};
+const buildAddressComponents = (addr = {}) => {
+ const components = [];
+ const push = (longName, types) => {
+ if (longName) components.push({ long_name: longName, short_name: longName, types });
+ };
+ push(addr.house_number, ['street_number']);
+ push(addr.road || addr.pedestrian, ['route']);
+ push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
+ push(addr.city_district || addr.county, ['administrative_area_level_3']);
+ push(addr.city || addr.town || addr.village, ['locality']);
+ push(addr.state, ['administrative_area_level_1']);
+ push(addr.country, ['country']);
+ push(addr.postcode, ['postal_code']);
+ return components;
+};
+
+export const toPlace = (result) => ({
+ formatted_address: result.display_name || '',
+ name: result.display_name?.split(',')[0] || '',
+ geometry: {
+ location: {
+ lat: () => parseFloat(result.lat),
+ lng: () => parseFloat(result.lon)
+ }
+ },
+ address_components: buildAddressComponents(result.address)
+});
+
+const buildParams = (query, bias) => {
+ const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
+ if (bias?.lat && bias?.lng) {
+ const d = 0.5; // ~55km soft bias bounding box
+ params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
+ }
+ return params;
+};
+
+export async function geocodeAddress(address, { bias } = {}) {
+ if (!address || !address.trim()) return null;
+ try {
+ const params = buildParams(address.trim(), bias);
+ params.set('limit', '1');
+ const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
+ headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
+ });
+ if (!res.ok) return null;
+ const results = await res.json();
+ if (!results?.length) return null;
+ return toPlace(results[0]);
+ } catch (err) {
+ console.error('geocodeAddress error:', err);
+ return null;
+ }
+}
+
const AddressAutocomplete = ({
id,
label,
- placeholder,
+ placeholder = 'Search address…',
value,
onChange,
onPlaceSelected,
bias,
fullWidth = true,
disabled = false,
- status
+ className = '',
+ inputClassName = '',
+ required = false
}) => {
const [inputValue, setInputValue] = useState(value || '');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
+ /* Which suggestion the arrow keys are on. -1 is "none highlighted", the state
+ the list opens in, so Enter never picks a result the operator hasn't moved
+ to. */
+ const [activeOption, setActiveOption] = useState(-1);
const wrapRef = useRef(null);
- const abortCtrl = useRef(null);
+ const activeRef = useRef(0);
+ // Set by pick(); consumed by the search effect. See the comment there.
+ const skipNextSearchRef = useRef(false);
+ const listId = `${id || 'address'}-suggestions`;
+ const optionId = (index) => `${listId}-option-${index}`;
useEffect(() => {
- setInputValue(value || '');
+ if (value !== undefined && value !== inputValue) {
+ setInputValue(value || '');
+ }
}, [value]);
- const biasKey = bias ? `${bias.lat},${bias.lng}` : '';
+ const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
- const runSearch = useDebouncedCallback(async (query) => {
- if (!query || query.length < 3) {
- setOptions([]);
- setIsOpen(false);
- setLoading(false);
- return;
- }
-
- if (abortCtrl.current) abortCtrl.current.abort();
- abortCtrl.current = new AbortController();
-
- try {
- setLoading(true);
- const url = new URL(NOMINATIM_SEARCH_URL);
- url.searchParams.set('q', query);
- url.searchParams.set('format', 'jsonv2');
- url.searchParams.set('addressdetails', '1');
- url.searchParams.set('limit', '5');
-
- if (bias && bias.lat && bias.lng) {
- url.searchParams.set('lat', bias.lat);
- url.searchParams.set('lon', bias.lng);
- }
-
- const res = await fetch(url.toString(), {
- headers: { 'Accept-Language': 'en-GB,en;q=0.9' },
- signal: abortCtrl.current.signal
+ const fetchPredictions = useDebouncedCallback((query) => {
+ const seq = ++activeRef.current;
+ const params = buildParams(query, bias);
+ fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
+ headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
+ })
+ .then((res) => (res.ok ? res.json() : []))
+ .then((results) => {
+ if (seq !== activeRef.current) return;
+ setLoading(false);
+ setOptions(results || []);
+ setIsOpen((results || []).length > 0);
+ })
+ .catch(() => {
+ if (seq !== activeRef.current) return;
+ setLoading(false);
+ setOptions([]);
});
-
- if (!res.ok) throw new Error('Search failed');
- const data = await res.json();
- setOptions(data || []);
- setIsOpen((data || []).length > 0);
- } catch (e) {
- if (e.name !== 'AbortError') console.error('Nominatim search error:', e);
- } finally {
- setLoading(false);
- }
- }, 500);
+ }, 450);
useEffect(() => {
- if (inputValue && inputValue !== value) runSearch(inputValue);
+ /* Picking a suggestion writes the resolved address back into inputValue,
+ which used to re-trigger this search: the list reopened over the form the
+ instant it was dismissed, and every selection cost a second Nominatim
+ request against a service that asks for one per second. The pick is not a
+ new query, so skip exactly one run. */
+ if (skipNextSearchRef.current) {
+ skipNextSearchRef.current = false;
+ return;
+ }
+ if (!inputValue || inputValue.length < 3) {
+ activeRef.current++;
+ setOptions([]);
+ setLoading(false);
+ setIsOpen(false);
+ return;
+ }
+ setLoading(true);
+ fetchPredictions(inputValue);
}, [inputValue, biasKey]);
+ /* A fresh result set invalidates the highlight — otherwise index 2 of the old
+ list silently becomes index 2 of the new one. */
+ useEffect(() => {
+ setActiveOption(-1);
+ }, [options]);
+
useEffect(() => {
const onDocDown = (e) => {
- if (wrapRef.current && !wrapRef.current.contains(e.target)) setIsOpen(false);
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) {
+ setIsOpen(false);
+ }
};
document.addEventListener('mousedown', onDocDown);
return () => document.removeEventListener('mousedown', onDocDown);
}, []);
const pick = (option) => {
- setInputValue(option.display_name || '');
- onChange?.(option.display_name || '');
+ const formatted = option.display_name || '';
+ skipNextSearchRef.current = true;
+ activeRef.current++; // drop any in-flight response that would reopen the list
+ setInputValue(formatted);
+ onChange?.(formatted);
onPlaceSelected?.(toPlace(option));
setIsOpen(false);
+ setActiveOption(-1);
+ };
+
+ /**
+ * Arrow keys, Enter and Escape over the suggestion list.
+ *
+ * Not a nicety: an order cannot be created from typed text — the submit gate
+ * requires the coordinates that only picking a suggestion supplies — so
+ * without this the whole create-order flow needed a mouse.
+ */
+ const onKeyDown = (e) => {
+ const listOpen = isOpen && options.length > 0;
+ if (e.key === 'Escape') {
+ setIsOpen(false);
+ setActiveOption(-1);
+ return;
+ }
+ if (!listOpen) return;
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setActiveOption((current) => (current + 1) % options.length);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1));
+ } else if (e.key === 'Enter' && activeOption >= 0) {
+ // Only swallow Enter when a suggestion is actually highlighted, so Enter
+ // on a bare query still submits the surrounding form.
+ e.preventDefault();
+ pick(options[activeOption]);
+ }
};
return (
-
+ {options.map((option, index) => (
+ /* The option role sits on the
, which is what aria-activedescendant
+ points at; the button inside stays for the mouse. Without this the
+ popup announced as an empty listbox. */
+
))}
@@ -168,30 +310,9 @@ AddressAutocomplete.propTypes = {
bias: PropTypes.object,
fullWidth: PropTypes.bool,
disabled: PropTypes.bool,
- status: PropTypes.object
+ className: PropTypes.string,
+ inputClassName: PropTypes.string,
+ required: PropTypes.bool
};
export default AddressAutocomplete;
-
-
-export async function geocodeAddress(address, { bias } = {}) {
- const url = new URL(NOMINATIM_SEARCH_URL);
- url.searchParams.set('q', address);
- url.searchParams.set('format', 'jsonv2');
- url.searchParams.set('addressdetails', '1');
- url.searchParams.set('limit', '1');
- if (bias && bias.lat && bias.lng) {
- url.searchParams.set('lat', bias.lat);
- url.searchParams.set('lon', bias.lng);
- }
- try {
- const res = await fetch(url.toString(), { headers: { 'Accept-Language': 'en-GB,en;q=0.9' } });
- if (!res.ok) throw new Error('Search failed');
- const data = await res.json();
- if (data && data.length > 0) return toPlace(data[0]);
- return null;
- } catch (err) {
- console.error('geocodeAddress error:', err);
- return null;
- }
-}
diff --git a/src/components/ds/StatusBadge.jsx b/src/components/ds/StatusBadge.jsx
index fb02e99..68ee745 100644
--- a/src/components/ds/StatusBadge.jsx
+++ b/src/components/ds/StatusBadge.jsx
@@ -19,7 +19,8 @@ export const STATUS_MAP = {
pending_pickup: { label: 'Pending Pickup', tone: 'neutral' },
miler_assigned: { label: 'Rider Assigned', tone: 'info' },
pickup_scheduled: { label: 'Pickup Scheduled', tone: 'info' },
- converted_to_consignment: { label: 'Picked Up', tone: 'accent' },
+ converted_to_consignment: { label: 'Picked', tone: 'accent' },
+ collected_by_miler: { label: 'Picked', tone: 'accent' },
out_for_delivery: { label: 'Out for Delivery', tone: 'warning' },
delivered: { label: 'Delivered', tone: 'success' },
cancelled: { label: 'Cancelled', tone: 'destructive' },
diff --git a/src/components/third-party/ReactTable.jsx b/src/components/third-party/ReactTable.jsx
index 5081c5a..6060705 100644
--- a/src/components/third-party/ReactTable.jsx
+++ b/src/components/third-party/ReactTable.jsx
@@ -1,31 +1,21 @@
+import React from 'react';
import PropTypes from 'prop-types';
-
-// third-party
import { CSVLink } from 'react-csv';
-import { MdDownload } from 'react-icons/md';
+import { Download, Loader2 } from 'lucide-react';
-import { Button } from '@astryxdesign/core/Button';
-
-// ==============================|| CSV EXPORT ||============================== //
-// This file used to be Mantis's 596-line react-table v7 toolkit — HeaderSort,
-// TablePagination, IndeterminateCheckbox, TableRowSelection, DraggableHeader,
-// DragPreview, DraggableRow, HidingSelect, SortingSelect, EmptyTable. None of
-// them were imported anywhere: the two consumers (dispatch/Preview.js and
-// reports/ordersDetails.js) only ever pulled `CSVExport`. Rather than port ten
-// dead helpers to Astryx, they were deleted and only the export button remains.
-
-export const CSVExport = ({ data, filename, headers, label, style, btnLoading, onClick }) => (
+export const CSVExport = ({ data, filename, headers, label, style, btnLoading, onClick, className = '' }) => (
- }
- variant={btnLoading ? 'secondary' : 'primary'}
- isLoading={btnLoading}
- isDisabled={btnLoading}
- tooltip="CSV Export"
- style={style}
+
);
@@ -36,7 +26,8 @@ CSVExport.propTypes = {
label: PropTypes.any,
style: PropTypes.object,
btnLoading: PropTypes.any,
- onClick: PropTypes.func
+ onClick: PropTypes.func,
+ className: PropTypes.string
};
export default CSVExport;
diff --git a/src/globalPolish.css b/src/globalPolish.css
new file mode 100644
index 0000000..68a2ed6
--- /dev/null
+++ b/src/globalPolish.css
@@ -0,0 +1,326 @@
+/* Global visual polish that used to come from MUI's /
+ in themes/index.js (ThemeCustomization). Kept as plain CSS now that every
+ page runs on Astryx — see root CLAUDE.md §6. Astryx's own reset
+ (@astryxdesign/core/reset.css, imported in index.js) covers the base
+ element resets; this file carries the handful of things a design token
+ cannot express: font rendering, scrollbar pseudo-elements, keyframes, the
+ focus treatment, and the global reduced-motion rule.
+
+ NOTE ON COLOUR: nothing here introduces a colour. The console keeps its own
+ palette (themes/dt/tokens.js) — the KROW work adopted KROW's geometry and
+ type treatment only. Where a rule below needs an accent it reads
+ `var(--color-accent)` rather than naming one. */
+
+body {
+ font-family: var(--font-family-body, 'Inter', ui-sans-serif, system-ui, sans-serif);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+/* --------------------------------------------------------------------------
+ Motion vocabulary.
+ -------------------------------------------------------------------------- */
+:root {
+ --dt-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+ --dt-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
+ --dt-duration-fast: 120ms;
+ --dt-duration-base: 200ms;
+ --dt-duration-slow: 320ms;
+}
+
+/* --------------------------------------------------------------------------
+ Focus.
+ -------------------------------------------------------------------------- */
+.dt-focus-ring:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px var(--color-accent);
+}
+
+.dt-focus-ring-inset:focus-visible {
+ outline: none;
+ box-shadow: inset 0 0 0 2px var(--color-accent);
+}
+
+/* --------------------------------------------------------------------------
+ Scrollbars.
+ -------------------------------------------------------------------------- */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(0, 0, 0, 0.16) transparent;
+}
+
+*::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+*::-webkit-scrollbar-track {
+ background-color: transparent;
+}
+
+*::-webkit-scrollbar-thumb {
+ background-color: rgba(0, 0, 0, 0.16);
+ border-radius: 999px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(0, 0, 0, 0.34);
+}
+
+*::-webkit-scrollbar-corner {
+ background-color: transparent;
+}
+
+/* --------------------------------------------------------------------------
+ Keyframes.
+ -------------------------------------------------------------------------- */
+@keyframes dt-shimmer {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+.dt-shimmer {
+ background-image: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.65) 50%, transparent 100%);
+ background-size: 200% 100%;
+ animation: dt-shimmer 1.6s var(--dt-ease-in-out) infinite;
+}
+
+@keyframes dt-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes dt-slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.dt-fade-in {
+ animation: dt-fade-in var(--dt-duration-base) var(--dt-ease-out);
+}
+
+.dt-slide-up {
+ animation: dt-slide-up var(--dt-duration-slow) var(--dt-ease-out);
+}
+
+/* --------------------------------------------------------------------------
+ Segment card.
+ -------------------------------------------------------------------------- */
+.dt-segment-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ width: 100%;
+ min-height: 118px;
+ padding: 14px 16px 18px;
+ background: #ffffff;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
+ font: inherit;
+ text-align: left;
+ overflow: hidden;
+ transition: border-color var(--dt-duration-base) var(--dt-ease-out), box-shadow var(--dt-duration-base) var(--dt-ease-out);
+}
+
+.dt-segment-card:not(:disabled):hover {
+ box-shadow: 0 4px 12px rgba(15, 23, 42, 0.07);
+}
+
+.dt-segment-card:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.12);
+}
+
+.dt-segment-card.is-selected {
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+}
+
+.dt-segment-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+ width: 100%;
+}
+
+.dt-segment-names {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 6px;
+ min-width: 0;
+}
+
+.dt-segment-label {
+ font-size: 11.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #0f172a;
+}
+
+.dt-segment-range {
+ font-size: 11px;
+ font-weight: 500;
+ color: #94a3b8;
+}
+
+.dt-segment-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ font-size: 15px;
+}
+
+.dt-segment-icon svg {
+ width: 1em;
+ height: 1em;
+}
+
+.dt-segment-value {
+ font-family: var(--font-family-heading, 'Sora', sans-serif);
+ font-size: 24px;
+ line-height: 1;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+}
+
+.dt-segment-meta {
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ padding: 3px 10px;
+ border-radius: 999px;
+ background: #f1f5f9;
+ font-size: 11px;
+ line-height: 1.4;
+ color: #64748b;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dt-segment-rule {
+ position: absolute;
+ left: 16px;
+ right: 16px;
+ bottom: 10px;
+ height: 2px;
+ border-radius: 2px;
+}
+
+.dt-kpi-grid {
+ gap: 12px !important;
+}
+
+/* --------------------------------------------------------------------------
+ Filter row.
+ -------------------------------------------------------------------------- */
+.astryx-selector,
+.astryx-selector-trigger,
+.astryx-selector button,
+.astryx-selector [role='combobox'],
+.astryx-text-input,
+.astryx-text-input > div,
+.astryx-text-input input,
+.astryx-button,
+button.astryx-button,
+.dt-filter-toolbar .astryx-selector,
+.dt-filter-toolbar .astryx-text-input,
+.dt-filter-toolbar .astryx-button,
+.dt-deliveries-toolbar .astryx-selector,
+.dt-deliveries-toolbar .astryx-text-input,
+.dt-deliveries-toolbar .astryx-button {
+ border-radius: 6px !important;
+}
+
+.dt-filter-toolbar .astryx-selector,
+.dt-filter-toolbar .astryx-text-input,
+.dt-filter-toolbar .astryx-button,
+.dt-deliveries-toolbar .astryx-selector,
+.dt-deliveries-toolbar .astryx-text-input,
+.dt-deliveries-toolbar .astryx-button {
+ min-height: 36px;
+}
+
+.dt-filter-toolbar .astryx-selector,
+.dt-filter-toolbar .astryx-text-input,
+.dt-deliveries-toolbar .astryx-selector,
+.dt-deliveries-toolbar .astryx-text-input {
+ border-color: #e2e8f0;
+}
+
+.dt-filter-toolbar .astryx-selector,
+.dt-filter-toolbar .astryx-button,
+.dt-deliveries-toolbar .astryx-selector,
+.dt-deliveries-toolbar .astryx-button {
+ padding-inline: 12px;
+}
+
+/* --------------------------------------------------------------------------
+ KPI / StatCard.
+ -------------------------------------------------------------------------- */
+.dt-stat-card {
+ transition: box-shadow 150ms ease, border-color 150ms ease, transform 150ms ease !important;
+}
+
+.dt-stat-card.dt-stat-card-clickable:hover,
+.dt-stat-card[data-clickable='true']:hover,
+[role='button'] .dt-stat-card:hover,
+[role='button'] > .dt-stat-card:hover,
+button .dt-stat-card:hover {
+ border-color: var(--color-border-emphasized, #cbd5e1) !important;
+ box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08) !important;
+ transform: translateY(-1px);
+}
+
+.dt-stat-card.dt-stat-card-clickable:active,
+.dt-stat-card[data-clickable='true']:active,
+[role='button'] .dt-stat-card:active {
+ transform: translateY(0);
+}
+
+.dt-stat-card .dt-stat-icon svg {
+ width: 14px !important;
+ height: 14px !important;
+ font-size: 14px !important;
+}
+
+/* --------------------------------------------------------------------------
+ Reduced motion.
+ -------------------------------------------------------------------------- */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx
index 93df191..06c3842 100644
--- a/src/layouts/AdminLayout.jsx
+++ b/src/layouts/AdminLayout.jsx
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
- Bell, ChevronDown, LogOut, Menu, Search, Shield, Sparkles, User,
+ Bell, ChevronDown, LogOut, Menu, Search, Shield, User,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { DOORMILE_WORDMARK_URL } from '@/assets/brand';
@@ -16,6 +16,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
import { useAuth } from '@/lib/AuthContext';
import { useExceptions } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
+import doormileMark from 'assets/images/doormile-mark.png';
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
/**
@@ -252,7 +253,16 @@ export default function AdminLayout() {
{/* Full wordmark, not just the circular mark — matches the source
console's own TopNav, which renders this same logo at the
same relative scale inside its bar. */}
-
+ {/* Capped below `sm`: at its natural 209px the wordmark plus the
+ 170px action cluster overflowed a 375px phone and scrolled the
+ whole document sideways. Scaling the mark is the one thing here
+ that can give — the controls beside it all have to stay. */}
+
{/* Text tabs with a hairline indicator, not filled pills. Each grouped
@@ -335,7 +345,7 @@ export default function AdminLayout() {
assistantOpen ? 'bg-brand-tint text-brand' : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
-
+
diff --git a/src/lib/assistant/CLAUDE.md b/src/lib/assistant/CLAUDE.md
new file mode 100644
index 0000000..390062d
--- /dev/null
+++ b/src/lib/assistant/CLAUDE.md
@@ -0,0 +1,285 @@
+# CLAUDE.md — `src/pages/nearle/assistant/`
+
+Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `DoormileAI/`). Read this before touching either.
+
+---
+
+## 1. What this is
+
+An in-console Q&A assistant that answers operator questions about live data — "how many orders today", "morning batch orders", "how many riders are active" — by calling the same API functions every other page in this console already uses. Product name is **Doormile AI**, subtitle **Operations Copilot**. Not a standalone page: it lives as a **right-side slide-over** opened from a header icon.
+
+- **Mounted in**: `src/layout/MainLayout/AppTopNav.js` — a single ``. There is **no route and no sidebar entry** for this feature — don't add one back. If you're tempted to give it a full page, re-read §2 first; that was tried and deliberately reverted.
+- **`intents.js`** — all data logic: the intent catalog, keyword/phrase matching, and the API calls that produce answers. The UI never fetches.
+- **`DoormileAI/`** — all UI:
+ - `index.js` — the trigger button; owns open/closed state and returns focus to itself on close.
+ - `AIPanel.js` — the portal, scrim, slide-over, focus/Escape handling, message state, history persistence, and the ask() flow.
+ - `AIWelcome.js` — greeting + suggestion cards (empty-thread state only).
+ - `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
+ - `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline.
+ - `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
+ - `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
+ - `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
+ - `pageContext.js` — route → context label + suggested questions.
+- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
+
+### UI rules that are load-bearing, not cosmetic
+
+- **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot.
+- **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message.
+- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
+- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
+- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
+- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
+- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
+- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
+
+---
+
+## 2. Why this is deterministic, not LLM-based
+
+This was a deliberate, explicit product decision (not a technical limitation worked around silently): **this app has zero backend of its own** — confirmed exhaustively (no `server/`, no Firebase Cloud Functions, no `firebase-admin`/`firebase-functions` dependency, `Dockerfile` just serves a static CRA build via nginx). An LLM call needs an API key held server-side; there is nowhere in this repo's infrastructure to put one without shipping it to the browser.
+
+Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no.
+
+### RAG — rejected for the data, later built for the ROUTING
+
+RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.**
+
+What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced:
+
+```
+question → embed → Chroma → intentId + confidence → the SAME run() → live API call
+```
+
+Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase.
+
+Three rules that must hold:
+
+- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today.
+- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values.
+- **Write intents need high confidence.** A semantic near-miss must never open a create form.
+
+---
+
+## 3. The intent pattern (`intents.js`)
+
+Each entry in `INTENTS` is:
+
+```js
+{
+ id: 'someIntent',
+ label: 'Human-readable description — e.g. "example phrasing"',
+ match: (text) => params | null, // does this intent apply? extract params or refuse
+ run: async (params) => ({ headline, detail, sourceCalls }) | null // real answer, or "couldn't resolve"
+}
+```
+
+- `answerQuestion(text)` walks `INTENTS` **in order** and returns the first intent whose `match` recognises the text **and** whose `run` resolves to a non-null result. `run` returning `null` means "the pattern matched but couldn't be resolved" (e.g. no tenant name in the question actually matched a real tenant) — the loop falls through to the next intent rather than answering with a guess.
+- `sourceCalls` feeds `` behind the per-message "Sources" disclosure in `AIMessage.js` — every answer can still show which API was queried and what came back, so an operator can verify it wasn't invented. It is collapsed by default for visual quiet; **do not remove it**, that disclosure is the verifiability contract.
+- `metric` and `stats` (optional) drive the panel's headline number and breakdown grid. `stats` comes from `statusStats()`, which tallies the *same* `mapBookingStatusToDeliveryStatus` classification as the prose `headline`, so the sentence and the cards can never disagree.
+- Every `run` calls a real function from `pages/api/api.js` / `pages/api/doormileApi.js`. **Never fabricate a number** — if no existing function covers a question, either add a new intent that calls a real endpoint, or leave the question unanswered (falls through to the "I can't answer that one yet" state in `AIPanel.js`). A wrong number from this bot is worse than no answer.
+
+### Coverage grows with the console, not ahead of it
+
+The catalog covers orders/bookings, riders, tenants, and the fleet/ops resources (hubs, vehicles, tripsheets, exceptions, app users, customers, pricing, consignments, partners, competitor branches, carrier pricing). When a new admin resource gets its own page in this console, add a matching intent here too — and **always call the exact same `getX()` function that page's own table already calls** (e.g. `hubStatus` calls `getHubs()`, the same function `hubs.js` uses). Never write a bespoke fetch for the bot. This is what keeps the bot's numbers live and in agreement with what the corresponding page shows — the whole point of not hand-rolling a separate data path.
+
+### Composite questions — `orderQuery`
+
+`orderQuery` (ordered 3rd, ahead of `riderCounts`) is the one intent that composes filters: status x batch x tenant x rider, plus rankings ("top 5 tenants by orders"). Everything else in the catalog answers exactly one dimension and discards the rest of the sentence.
+
+**It claims a question only when two or more of status/batch/tenant/rider are present, or a ranking is asked for.** A date is deliberately NOT counted as a dimension — every intent already handles dates, and counting it re-routed four working questions ("how many cancelled orders today") away from the intents that answer them better. If you widen this matcher, re-run the routing probe first; over-claiming here silently changes answers across the whole catalog.
+
+`run` returns `null` when a named tenant or rider doesn't resolve, so an unrecognised name falls through rather than having its filter silently dropped — which is the exact bug this intent exists to fix.
+
+Entity names resolve through `bestNameMatch`, which is bidirectional (the question may name a shorter or longer form than the record) and prefers the longest match, so "Acme" can't beat "Acme Foods" when both exist.
+
+### Beyond single-question matching
+
+A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, all still deterministic (no LLM):
+
+- **Typo tolerance** — `correctTypos()` runs once before matching, correcting misspelled domain keywords (length ≥5, Levenshtein distance ≤1/≤2) against a fixed `KEYWORD_VOCAB`. It never touches order IDs, tenant names, or short words — only known keywords get "corrected," so it can't invent a wrong one.
+- **Richer dates** — `explicitDateFromWords` (DD/MM/YYYY, ISO), `weekdayFromWords` (most recent past occurrence of a named day), and `rangeFromWords` (this/last week, this/last month, explicit "from X to Y") feed `dayFromWords`/`rangeFromWords`. Still a fixed vocabulary, not a date-parsing library — an unrecognised phrase falls back to today, never a guessed date.
+- **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue".
+- **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched.
+- **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up.
+- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale.
+- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all.
+- **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent.
+
+### Ordering and cross-domain guards — read before adding an intent
+
+A real bug shipped here once: `statusBreakdown` matched the word "active" (a valid order status), so "how many riders are active today" was swallowed by the order-status intent and called `getBookings` instead of `getallridersummary` — because `statusBreakdown` sat earlier in `INTENTS` than `riderCounts` and its `run` never returns `null` (it always finds *some* count, even 0), so it never yielded.
+
+The fix, and the rule going forward:
+
+1. **Domain-specific intents (rider, tenant) are ordered near the top**, ahead of the generic order/status/date intents, so an unambiguous keyword like "rider" always wins first-match.
+2. **Generic intents explicitly refuse to match on another domain's keyword**, via helper guards like `mentionsRiders(text)` at the top of their `match`. This is deliberately redundant with (1) — if someone reorders `INTENTS` later without noticing the significance, the guards still hold.
+
+If you add a new intent whose trigger words could plausibly appear in an unrelated intent's question (status words, "for", generic nouns), do both: place it appropriately in the order, and add a guard to anything downstream it could shadow — don't rely on ordering alone.
+
+### Date/batch/status vocabulary — reuse, don't reinvent
+
+- **Batch bucketing** (`morning`/`afternoon`/`evening`) comes from `src/utils/batchBucket.js`, extracted from `Dispatch.js`/`deliveries.js`'s canonical model (see `dispatch/CLAUDE.md` §1). Bucketing on anything other than `orderdate` (a booking's `createdat`) will disagree with what those two pages show — don't reintroduce `expecteddeliverytime`/`assigntime` bucketing here, they were both tried and rejected for the same reasons documented there.
+- **Order status classification comes from `utils/orderStatusGroups.js`**, which is the SAME match set the Orders page's tabs count with (`orders.js` imports `statusesInGroup` for its `ORDERS_STATUS_TABS`). Use `groupForBookingStatus` / `isInGroup` / `statusesInGroup`; don't grow a third definition.
+ - It is deliberately **not** `mapBookingStatusToDeliveryStatus` (api.js), which is the *Deliveries* page's rider-centric taxonomy and keeps `miler_assigned` on `pending`. The two exist on purpose — Orders tracks the operator's action, Deliveries tracks the rider's. Don't merge them; that was tried and reverted per explicit product direction.
+ - The assistant answers order-status questions with the ORDERS taxonomy because that is the screen an operator compares its answers against. A live bug came from the mismatch: the Orders page showed 19 Assigned while the bot said 0.
+- **Date words** are a fixed, small vocabulary — not a real date-parsing library. Don't guess at "the 5th" style phrasing; an unrecognised date phrase falls back to today rather than to a wrong date.
+- **State questions vs flow questions — do not default a state question to today.** `mentionsAnyDate(text)` distinguishes "the question named a date" from "we defaulted to one":
+ - *State* ("how many orders are assigned / cancelled") describes the queue **right now** and must be unscoped, because the Orders page's tabs apply no date filter either. Scoping it to orders *created today* is what made the bot answer 0 against a page showing 19.
+ - *Flow* ("how many orders today", revenue, batches) genuinely needs a period and keeps the today default.
+ When a state question is answered unscoped, say so in the detail — the answer must never leave the operator guessing which window it covered.
+- `GET /admin/bookings` has no server-side date/status/tenant filter, so every intent fetches and filters client-side. It does **not** fetch a single page: `pagesize` is capped at 1000 server-side, so a lone `getBookings(1, 1000)` silently under-reports the moment an account passes 1000 lifetime bookings. Use `fetchBookingsInRange(start, end)` / `fetchBookingsForDay(day)` / `scanBookings()`, which drain pages via `getBookingsPage` up to `MAX_PAGES` and return `{ rows, truncated, scanned, pagesFetched, total }`.
+- **`truncated` is not optional to handle.** If you write a new intent, run its count through `countPhrase(scan, n)` ("At least 42"), append `truncationNote(scan)` to the detail, and build its audit entry with `scanCall(scan, ...)` — which reports `status: 'error'` when capped so the tool-call strip can't show a green "complete" beside a partial number. An intent that reads `scan.rows` and ignores `scan.truncated` reintroduces exactly the bug this replaced.
+- **Revenue excludes cancelled orders and is labelled "estimated"** — `revenueOf(rows)` sums every `serviceoptions[].estimatedprice` on non-cancelled rows. It is a quote, not a settled amount; don't relabel it "revenue" flat.
+- **"Assigned" does not go through the coarse bucket.** `mapBookingStatusToDeliveryStatus` collapses `miler_assigned` into `pending` alongside `pending_pickup` (orders with no rider at all), so `rawStatusFromWords` matches the backend enum directly. Any other question naming a raw enum should do the same rather than being forced into a delivery-status bucket.
+
+---
+
+### Customer creation writes to `/admin/tenantcustomers`
+
+Settled by evidence, not by reading the docs:
+
+```
+POST /admin/customers → 405 Method Not Allowed (confirmed live)
+```
+
+405 is unambiguous — the route exists and POST is not among its methods. `express-console-api.md` lists `/admin/customers` as GET + PATCH only and the server agrees. It was pointed there briefly on explicit instruction; the live 405 settled it. **Don't try it again.**
+
+**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.)
+
+**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately.
+
+Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change.
+
+The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks.
+
+Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery.
+
+The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree.
+
+---
+
+## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js`
+
+Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable.
+
+**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`.
+
+### The panel drives the conversation, not the router
+
+`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router.
+
+Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer.
+
+### One engine, three flows
+
+The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker.
+
+Step entries carry:
+
+| key | meaning |
+|---|---|
+| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. |
+| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. |
+| `type: 'text'` | answered through the composer. |
+| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). |
+| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. |
+| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. |
+| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. |
+| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. |
+
+### Two silent-NaN traps that were live
+
+- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value.
+- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was.
+
+`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate.
+
+### Bulk — a conversation, then one long pass
+
+Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient.
+
+**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked.
+
+**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept.
+
+**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column.
+
+### One row pipeline, two inputs
+
+A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else.
+
+**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column.
+
+`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row.
+
+**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional:
+- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely.
+- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197.
+- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup.
+
+**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose.
+
+**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded.
+
+Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated.
+
+### Repeat Runs — `repeatRuns.js` / `repeatFlow.js`
+
+"Same orders as yesterday." One question (which day), then a pass, then the usual gate.
+
+**It is the cheapest write here, and the reason is structural:** a booking already carries 15 of the 17 fields `buildOrderPayload` needs — including BOTH SETS OF COORDINATES. Only `customer_name` and `customer_phone` are missing, and they come from the `appcustomerid` → `/admin/customers` join. **So a repeat needs no geocoding at all** — the ~1 lookup/second Nominatim throttle that dominates the bulk-file flow simply doesn't apply.
+
+**A booking is a snapshot, not a template.** The drift check is phase one, not polish. All three of its main rules came from one live page of 36 bookings, not from imagination:
+
+| Trap | Seen on |
+|---|---|
+| `pickupaddress` absent entirely | booking 57 — has the pincode and coordinates, no address key |
+| `tenantid` is null | every `Customer_App` booking (24–27). `Number(null)` → tenant `0` |
+| pickup pincode no longer served | CityGate refuses at the middleware, before the handler |
+
+Plus: the customer record can be deleted, and a booking can lack delivery coordinates. `driftReason` returns a **reason, never a boolean** — an operator dropping a row deserves to know which field went stale.
+
+**Duplicate safety is INVERTED here.** Everywhere else near-identical orders are an error (`wasAlreadySubmitted`); a repeat deliberately creates them, so that guard would misfire every time. The question that matters is *has this run already been repeated today?* — answered by fingerprinting today's own bookings on `(phone + delivery address + pickup pincode)` and setting aside anything already present. Without it, a double-click books every customer twice, because the bulk endpoint has no idempotency key.
+
+**Prices are re-quoted at today's tariff, never copied.** `finalprice` is deliberately left blank so `priceBulkRows` fills it exactly as an unpriced bulk row. Yesterday's number is kept as `previousPrice` purely so a tariff change is *visible* rather than discovered on an invoice. Pricing runs **per row** because a day's run can span tenants, and a tenant's own pricing row decides the number.
+
+**`__pickup` travels on the ROW, not the shared draft** — which is why `executeCreateBulk` now prefers `r.__pickup ?? shared.__pickup`. A bulk file shares one kitchen; a repeated day does not, and collapsing them would silently re-address half the orders.
+
+Cancelled orders are never repeated. Lookback is 7 days — beyond that it stops being "the usual round".
+
+### Assigning a rider — `assignActions.js` / `assignFlow.js`
+
+The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run.
+
+**Two endpoints, and they are not interchangeable.** One order → `POST /admin/bookings/:id/assign-miler`. Many orders → `POST /hub/bookings/batch-assign`, which is **the only call that sequences stops** (doormile-flow.md §4): it sends each affected rider's whole active set to the route optimiser and writes step order, per-leg distance and ETA. Assigning ten orders with ten single calls leaves every route unsequenced.
+
+**⚠ Two different rider IDs on adjacent endpoints.** `assign-miler` takes a **`mileruserid`**; `/admin/milers/:id/notify` keys off a **`milerprofileid`**. Getting it wrong fails silently in both directions — the assign 404s, or the rider is never told. `buildMilerLookup` is the bridge and orders.js already uses it for exactly this; don't grow a second lookup. The assertions cover this specifically because it is invisible in review: both are small integers on the same record.
+
+**The backend already assigns riders.** Creation publishes `booking.assignment_requested`; a worker picks a rider within 10km on proximity and retries 5× over 10 minutes (§3). Everything here is an **override**, which is why the flow re-reads the booking's current assignee and asks before replacing them. Silently overwriting throws away a better-informed choice and strands a rider who has already been told the job is theirs.
+
+**The holder lookup happens inside the booking step's `resolve`, not after it.** `advanceFlow` evaluates the keep/replace step's `when` the instant the booking is applied — a lookup landing one tick later means the step is skipped and an already-assigned order is silently reassigned. That was a live bug caught by the assertions.
+
+Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed.
+
+Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 30 for `repeatRuns`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
+
+---
+
+## 4. What's deliberately out of scope right now
+
+- **Deleting or cancelling anything.** Creates and rider assignment are built (§3.5); destructive writes are not. Cancelling an order has downstream effects a confirm button doesn't cover. Note that *replacing* an already-assigned rider IS reachable — but only behind an explicit keep-or-replace question naming the current holder, never as a silent overwrite.
+- **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives.
+- **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built.
+- **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started.
+- **Automated tests for the intent matcher.** The matcher is pure functions (`match`/`run` per intent) and would be straightforward to unit-test, but the project's stated convention is "no tests of consequence, lint is the only gate" (root `CLAUDE.md`). Adding a test framework here is a scope decision for the user, not something to introduce silently.
+
+---
+
+## 5. Don'ts
+
+- Don't re-add a route/sidebar entry for this feature — it's a header slide-over, not a page.
+- Don't let a new intent's `match` fire without considering what other intents' trigger words it might contain (see §3's ordering rule).
+- Don't bucket batches or classify statuses with page-local logic — reuse `utils/batchBucket.js` and `mapBookingStatusToDeliveryStatus`.
+- Don't answer with a number that didn't come from `sourceCalls`-tracked real data — including anything rendered into a `metric` or `stats` card.
+- Don't surface a raw API error string to the operator. Errors log to `console.error` and render as the polished error state; the toast that used to leak `err.response.data.message` is gone.
diff --git a/src/lib/assistant/DoormileAI.css b/src/lib/assistant/DoormileAI.css
new file mode 100644
index 0000000..745eed3
--- /dev/null
+++ b/src/lib/assistant/DoormileAI.css
@@ -0,0 +1,1185 @@
+/* ==========================================================================
+ Doormile AI — Operations Copilot
+ --------------------------------------------------------------------------
+ Styling for the right-side slide-over assistant. Follows the same
+ convention as OrdersRedesign.css / Dispatch.css (a page-scoped stylesheet
+ imported by its own component) rather than inline styles, so the panel's
+ hover / focus / media-query / reduced-motion states are all expressible.
+
+ Every value below resolves to a design token (`--color-*`, `--spacing-*`,
+ `--radius-*`) or to a local `--dai-*` token defined once here. The local
+ tokens exist because the AI surface needs a few values the shared DT set
+ doesn't carry (the AI accent, the scrim, the panel elevation).
+ ========================================================================== */
+
+/* ---------------------------------------------------------------------------
+ Dock geometry.
+
+ These three live on :root, not on .dai-root, because the LAYOUT has to read
+ them: the assistant is docked into the page rather than floated over it, so
+ the app's content container shifts by exactly the panel's width and animates
+ on the same curve. A token only .dai-root can see cannot do that.
+
+ All three are --dai- prefixed. :root is shared with Dispatch.css and
+ globalPolish.css, and an unprefixed name there is decided by bundle order.
+ --------------------------------------------------------------------------- */
+:root {
+ /* 30vw, NOT 30%.
+
+ The two consumers resolve a percentage against different boxes: the panel
+ is position:fixed, so `width: 30%` is 30% of the VIEWPORT, while
+ `padding-right: 30%` on the content container is 30% of ITS containing
+ block — which is narrower by the side nav. At 1440px that is 432px of
+ panel against 410px of reserved strip, and the page slides 22px underneath
+ the panel. A viewport unit resolves the same for both.
+
+ max() rather than a separate min-width for the same reason: a min-width
+ that only the panel knows about would reintroduce the mismatch on narrow
+ screens. */
+ /* Default = the NORMAL step. AIPanel overwrites this property on the
+ document element when the operator toggles the width, so the panel and
+ the page's reserved strip always read the same value. */
+ --dai-dock-width: max(300px, 25vw);
+ --dai-duration: 240ms;
+ --dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.dai-root {
+ /* Brand accent — resolves to the app's accent token, which is black
+ (root CLAUDE.md §6.2: "the brand is black", one accent only). Send
+ button, active states and the trigger all read from this single
+ variable, so switching the assistant to Doormile red is a one-line
+ change here rather than a hunt through the components. */
+ --dai-accent: var(--color-accent, #0f172a);
+ --dai-accent-contrast: #ffffff;
+ /* Focus ring, tinted with the accent rather than a flat grey wash. */
+ --dai-accent-ring: color-mix(in srgb, var(--dai-accent) 14%, transparent);
+
+ /* AI identity accent — used ONLY on the spark/orb marks so the assistant
+ reads as an AI surface without turning the panel into a purple product. */
+ --dai-ai-from: #6366f1;
+ --dai-ai-to: #8b5cf6;
+ --dai-ai-glow: rgba(99, 102, 241, 0.18);
+
+ --dai-text: #0f172a;
+ --dai-text-secondary: #64748b;
+ --dai-text-muted: #94a3b8;
+ --dai-surface: #ffffff;
+ --dai-surface-alt: #f8fafc;
+ --dai-surface-hover: #f1f5f9;
+ --dai-border: rgba(15, 23, 42, 0.08);
+ --dai-border-strong: rgba(15, 23, 42, 0.14);
+ --dai-scrim: rgba(15, 23, 42, 0.08);
+ --dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
+ --dai-live: #10b981;
+
+ --dai-panel-width: 428px;
+ --dai-inset: 12px;
+ --dai-duration: 240ms;
+ --dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
+}
+/* --------------------------------------------------------------------------
+ Scrim — deliberately light. The dashboard underneath must stay readable;
+ this is a layering cue, not a modal blackout.
+ -------------------------------------------------------------------------- */
+/* The scrim exists for the MOBILE presentation only (see the media query at
+ the end of this block). On desktop the panel is docked into the layout and
+ dimming the page behind it would be a lie — the page is still live, still
+ scrollable, and still the thing the operator is working on. Dimming is also
+ the single strongest cue that something is modal, which is exactly the
+ impression this panel should not give. */
+.dai-scrim {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 1300;
+ background: var(--dai-scrim);
+ opacity: 0;
+ transition: opacity var(--dai-duration) var(--dai-ease);
+}
+.dai-scrim[data-open='true'] {
+ opacity: 1;
+}
+/* --------------------------------------------------------------------------
+ Panel
+ -------------------------------------------------------------------------- */
+/* DOCKED, not floating.
+
+ It used to be an inset card: 12px off every edge, fully rounded, drop
+ shadowed, over a scrim — every cue of a popup. It now sits flush in the
+ right 30% of the workspace, starting under the app header and running to the
+ bottom of the window, with a single hairline on its left edge. The page does
+ not move underneath it; it makes room for it.
+
+ `top` reads Astryx's own measured header height so the dock lines up with
+ the bottom of the top nav rather than guessing a number.
+
+ No radius and no drop shadow: both are what make a surface read as floating
+ ABOVE the page. A soft shadow is kept only as a left-edge falloff so the
+ seam has depth without the panel detaching. */
+.dai-panel {
+ position: fixed;
+ /* Set from JS by AIPanel — Astryx does not publish a header-height token,
+ despite --appshell-header-height looking like one. */
+ top: var(--dai-dock-top, 57px);
+ right: 0;
+ bottom: 0;
+ z-index: 1200;
+ width: var(--dai-dock-width);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--dai-surface);
+ border-left: 1px solid var(--dai-border);
+ border-radius: 0;
+ box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
+ transform: translateX(100%);
+ visibility: hidden;
+ transition:
+ transform var(--dai-duration) var(--dai-ease),
+ visibility 0s linear var(--dai-duration);
+}
+.dai-panel[data-open='true'] {
+ transform: translateX(0);
+ visibility: visible;
+ transition:
+ transform var(--dai-duration) var(--dai-ease),
+ visibility 0s;
+}
+
+/* ---- The page makes room -------------------------------------------------
+ `.dai-docked` is set on while the panel is open. Padding rather than
+ width/margin: the content container is a flex child inside AppShell's own
+ height:fill chain, and changing its width there fights that chain, whereas
+ padding leaves the box model alone and simply reserves the strip.
+
+ The transition sits on the container unconditionally so the page slides back
+ when the panel closes too — a rule that only exists while `.dai-docked` is
+ applied cannot animate its own removal. */
+.astryx-layout-content {
+ transition: padding-right var(--dai-duration) var(--dai-ease);
+}
+body.dai-docked .astryx-layout-content {
+ padding-right: var(--dai-dock-width);
+}
+
+/* ---- Mobile: there is no 30% worth having -------------------------------
+ 30% of a phone is ~120px. Below the breakpoint the panel becomes what it
+ used to be everywhere — a full-width overlay with a scrim — and the page
+ stops reserving a strip it cannot afford. */
+@media (max-width: 900px) {
+ .dai-panel {
+ top: 0;
+ width: 100%;
+ z-index: 1301;
+ border-left: none;
+ box-shadow: var(--dai-shadow);
+ }
+ .dai-scrim {
+ display: block;
+ }
+ .dai-scrim[data-open='true'] {
+ opacity: 1;
+ }
+ body.dai-docked .astryx-layout-content {
+ padding-right: 0;
+ }
+}
+.dai-panel:focus {
+ outline: none;
+}
+/* --------------------------------------------------------------------------
+ Header
+ -------------------------------------------------------------------------- */
+.dai-root .dai-header {
+ flex: 0 0 auto;
+ padding: 14px 12px 12px 14px;
+ border-bottom: 1px solid var(--dai-border);
+}
+.dai-root .dai-title {
+ font-size: 15px;
+ font-weight: 650;
+ line-height: 1.2;
+ letter-spacing: -0.01em;
+ color: var(--dai-text);
+}
+.dai-root .dai-subtitle {
+ font-size: 12px;
+ line-height: 1.3;
+ color: var(--dai-text-muted);
+}
+/* The AI mark. A soft gradient orb — not a robot face. */
+.dai-root .dai-spark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ background: var(--dai-surface);
+ overflow: hidden;
+}
+/* The mark's own canvas is only 66.8% content — a third of every edge is
+ transparent padding, measured off the PNG's alpha bounding box (x and y both
+ 170..853 of 1024, i.e. perfectly centred). Drawing it at 100% therefore
+ rendered a D two-thirds the size the box implied, which is exactly why it
+ read as too small. 150% cancels that padding so the D fills its box edge to
+ edge; the overflow:hidden above clips only transparent pixels. */
+.dai-root .dai-spark img {
+ width: 150%;
+ height: 150%;
+ max-width: none;
+ object-fit: contain;
+ display: block;
+}
+.dai-root .dai-spark[data-size='sm'] {
+ width: 30px;
+ height: 30px;
+}
+.dai-root .dai-spark[data-size='md'] {
+ width: 40px;
+ height: 40px;
+}
+.dai-root .dai-spark[data-size='lg'] {
+ width: 56px;
+ height: 56px;
+}
+/* Live indicator — subtle, not a large pill. */
+.dai-root .dai-live {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 550;
+ color: #047857;
+ background: rgba(16, 185, 129, 0.08);
+ white-space: nowrap;
+}
+.dai-root .dai-live-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--dai-live);
+ animation: dai-pulse 2.4s ease-in-out infinite;
+}
+@keyframes dai-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.55;
+ transform: scale(0.85);
+ }
+}
+/* Page-context strip — "Orders · Today · All locations" */
+.dai-root .dai-context {
+ flex: 0 0 auto;
+ padding: 7px 14px;
+ font-size: 11.5px;
+ color: var(--dai-text-muted);
+ background: var(--dai-surface-alt);
+ border-bottom: 1px solid var(--dai-border);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+/* --------------------------------------------------------------------------
+ Welcome state
+ -------------------------------------------------------------------------- */
+.dai-root .dai-welcome {
+ padding: 22px 14px 8px;
+}
+.dai-root .dai-welcome-greeting {
+ font-size: 17px;
+ font-weight: 650;
+ letter-spacing: -0.01em;
+ color: var(--dai-text);
+}
+.dai-root .dai-welcome-lead {
+ font-size: 13.5px;
+ line-height: 1.5;
+ color: var(--dai-text-secondary);
+}
+.dai-root .dai-welcome-note {
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--dai-text-muted);
+}
+.dai-root .dai-section-label {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--dai-text-muted);
+}
+/* --------------------------------------------------------------------------
+ Suggestion cards
+ -------------------------------------------------------------------------- */
+/* The suggestions strip sits between the conversation and the composer, so it
+ needs the composer's horizontal rhythm and a hairline to separate it from
+ the thread scrolling above it. */
+.dai-root .dai-suggestions-bar {
+ /* Shrinkable, with a hard cap.
+
+ At the normal 25% width the full twenty chips still stack to several rows.
+ As `flex: 0 0 auto` that block refuses to shrink, so on a short laptop it
+ pushes the composer off the bottom of the panel — the input disappears and
+ the assistant cannot be used at all. The cap is a safety valve, not a
+ layout choice: it only engages when the strip would otherwise cost more
+ than a third of the panel, and it never applies once a thread exists,
+ because the strip is four chips by then. */
+ flex: 0 1 auto;
+ min-height: 0;
+ max-height: 34vh;
+ overflow-y: auto;
+ padding: 10px 14px 8px;
+ border-top: 1px solid var(--dai-border);
+ background: var(--dai-surface);
+}
+
+/* Follow-up mode: a handful of chips, so the strip is FIXED — it never shrinks
+ and never scrolls.
+
+ The cap and `flex-shrink: 1` above exist for the cold panel's twenty chips.
+ Once a conversation starts the strip is three or four, and leaving it
+ shrinkable meant a long thread squeezed it: the flex row gave the space to
+ the conversation, `overflow-y: auto` quietly hid the remainder, and the
+ follow-ups the operator was meant to act on disappeared below a fold nobody
+ could see. Few chips must always be fully visible. */
+.dai-root .dai-suggestions-bar[data-compact='true'] {
+ flex: 0 0 auto;
+ max-height: none;
+ overflow: visible;
+}
+
+/* ---- Suggested questions: chips ------------------------------------------
+ They wrap, they never scroll. A horizontal scroller can always leave a chip
+ half-visible at the edge, and a half-visible button is a bug no amount of
+ fade masking fixes. */
+.dai-root .dai-suggestions {
+ width: 100%;
+}
+
+.dai-suggestion {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 100%;
+ padding: 5px 10px;
+ text-align: left;
+ font: inherit;
+ font-size: 12px;
+ line-height: 1.35;
+ font-weight: 500;
+ color: var(--dai-text);
+ background: var(--dai-surface);
+ border: 1px solid var(--dai-border);
+ /* 14px, pinned — NOT 999px.
+
+ On a one-line chip a fully round radius already resolves to about 14px, so
+ these look identical. The difference shows on a chip whose question wraps
+ to two lines: 999px would resolve to half of ~46px and the chip stops
+ reading as a chip and starts reading as a card, so one long suggestion
+ would look like a different component from the nineteen beside it. */
+ border-radius: 14px;
+ cursor: pointer;
+ transition:
+ background-color 140ms ease,
+ border-color 140ms ease,
+ color 140ms ease,
+ transform 140ms ease;
+}
+.dai-suggestion:hover {
+ background: var(--dai-surface-alt);
+ border-color: var(--dai-border-strong);
+ transform: translateY(-1px);
+}
+.dai-suggestion:active {
+ transform: translateY(0);
+}
+.dai-suggestion:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+}
+.dai-root .dai-suggestion-icon {
+ flex: 0 0 auto;
+ color: var(--dai-text-muted);
+ transition: color 140ms ease;
+}
+.dai-suggestion:hover .dai-suggestion-icon {
+ color: var(--dai-text-secondary);
+}
+.dai-root .dai-suggestion-text {
+ min-width: 0;
+}
+/* Plain text link-button ("View more", "Sources") */
+.dai-link {
+ font: inherit;
+ font-size: 12px;
+ color: var(--dai-text-secondary);
+ background: none;
+ border: none;
+ padding: 2px 0;
+ cursor: pointer;
+ align-self: flex-start;
+}
+.dai-link:hover {
+ color: var(--dai-text);
+ text-decoration: underline;
+}
+.dai-link:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+ border-radius: 5px;
+}
+/* --------------------------------------------------------------------------
+ Conversation
+ -------------------------------------------------------------------------- */
+/* --------------------------------------------------------------------------
+ Scroll region
+ -------------------------------------------------------------------------- */
+.dai-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ /* Never scroll sideways. A single wide child (the create form's name row)
+ used to push the whole message area horizontally. */
+ overflow-x: hidden;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ scrollbar-color: var(--dai-border-strong) transparent;
+}
+
+.dai-scroll::-webkit-scrollbar {
+ width: 8px;
+}
+
+.dai-scroll::-webkit-scrollbar-thumb {
+ background: var(--dai-border-strong);
+ border-radius: 999px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+}
+
+.dai-scroll::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.dai-scroll-wrap {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.dai-root .dai-thread {
+ padding: 16px 14px 8px;
+}
+
+/* Only the newest turn animates in. Applying this to every .dai-msg meant
+ opening a panel with a restored thread started 50 simultaneous opacity +
+ transform animations, which is a visible hitch before the first scroll. The
+ base state IS the animation's end state, so a turn that stops being last
+ simply keeps its finished appearance. */
+.dai-root .dai-thread > .dai-msg:last-child {
+ animation: dai-enter 220ms var(--dai-ease) both;
+}
+
+@keyframes dai-enter {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+.dai-root .dai-msg-user {
+ max-width: 82%;
+ margin-left: auto;
+ padding: 8px 12px;
+ border-radius: 5px;
+ border-bottom-right-radius: 5px;
+ font-size: 13.5px;
+ line-height: 1.45;
+ color: var(--dai-accent-contrast);
+ background: var(--dai-accent);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+/* Assistant — no bubble. Text sits on the panel surface. */
+.dai-root .dai-msg-ai {
+ font-size: 13.5px;
+ line-height: 1.5;
+ color: var(--dai-text);
+ overflow-wrap: anywhere;
+}
+.dai-root .dai-msg-ai-detail {
+ font-size: 12.5px;
+ line-height: 1.55;
+ color: var(--dai-text-secondary);
+ white-space: pre-line;
+}
+.dai-root .dai-msg-name {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--dai-text-secondary);
+}
+.dai-root .dai-msg-footer {
+ font-size: 11px;
+ color: var(--dai-text-muted);
+}
+/* Copy button — only revealed on hover of the message row. */
+.dai-root .dai-msg-actions {
+ opacity: 0;
+ transition: opacity 140ms ease;
+}
+.dai-root .dai-msg-row:hover .dai-msg-actions,
+.dai-root .dai-msg-row:focus-within .dai-msg-actions {
+ opacity: 1;
+}
+/* --------------------------------------------------------------------------
+ Structured metrics
+ -------------------------------------------------------------------------- */
+.dai-root .dai-stat-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px;
+ width: 100%;
+}
+.dai-root .dai-stat {
+ padding: 9px 10px;
+ border: 1px solid var(--dai-border);
+ border-radius: 5px;
+ background: var(--dai-surface-alt);
+}
+.dai-root .dai-stat-value {
+ font-size: 19px;
+ font-weight: 650;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+}
+.dai-root .dai-stat-label {
+ font-size: 11px;
+ color: var(--dai-text-muted);
+}
+/* Headline metric — the "primary number is large" rule. */
+.dai-root .dai-metric-value {
+ font-size: 28px;
+ font-weight: 680;
+ line-height: 1.05;
+ letter-spacing: -0.025em;
+ color: var(--dai-text);
+ font-variant-numeric: tabular-nums;
+}
+.dai-root .dai-metric-label {
+ font-size: 12px;
+ color: var(--dai-text-secondary);
+}
+/* --------------------------------------------------------------------------
+ Typing / loading
+ -------------------------------------------------------------------------- */
+.dai-root .dai-typing {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 18px;
+}
+.dai-root .dai-typing span {
+ width: 5px;
+ height: 5px;
+ border-radius: 999px;
+ background: var(--dai-text-muted);
+ animation: dai-bounce 1.3s ease-in-out infinite;
+}
+.dai-root .dai-typing span:nth-child(2) {
+ animation-delay: 0.16s;
+}
+.dai-root .dai-typing span:nth-child(3) {
+ animation-delay: 0.32s;
+}
+@keyframes dai-bounce {
+ 0%,
+ 70%,
+ 100% {
+ opacity: 0.3;
+ transform: translateY(0);
+ }
+ 35% {
+ opacity: 0.9;
+ transform: translateY(-3px);
+ }
+}
+.dai-root .dai-shimmer {
+ height: 9px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--dai-surface-hover) 25%, #e9eef5 37%, var(--dai-surface-hover) 63%);
+ background-size: 400% 100%;
+ animation: dai-shimmer 1.5s ease infinite;
+}
+@keyframes dai-shimmer {
+ from {
+ background-position: 100% 50%;
+ }
+ to {
+ background-position: 0 50%;
+ }
+}
+/* --------------------------------------------------------------------------
+ States (error / empty)
+ -------------------------------------------------------------------------- */
+.dai-root .dai-state {
+ padding: 12px;
+ border: 1px solid var(--dai-border);
+ border-radius: 5px;
+ background: var(--dai-surface-alt);
+}
+.dai-root .dai-state-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--dai-text);
+}
+.dai-root .dai-state-body {
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--dai-text-secondary);
+}
+.dai-root .dai-state-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 5px;
+ flex: 0 0 auto;
+}
+/* --------------------------------------------------------------------------
+ Jump-to-latest
+ -------------------------------------------------------------------------- */
+.dai-jump {
+ position: absolute;
+ left: 50%;
+ bottom: 8px;
+ transform: translateX(-50%);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 11px;
+ font: inherit;
+ font-size: 12px;
+ color: var(--dai-text);
+ background: var(--dai-surface);
+ border: 1px solid var(--dai-border-strong);
+ border-radius: 999px;
+ box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1);
+ cursor: pointer;
+ z-index: 2;
+}
+.dai-jump:hover {
+ background: var(--dai-surface-alt);
+}
+.dai-jump:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+}
+/* --------------------------------------------------------------------------
+ Composer
+ -------------------------------------------------------------------------- */
+.dai-root .dai-composer-wrap {
+ flex: 0 0 auto;
+ padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
+ border-top: 1px solid var(--dai-border);
+ background: var(--dai-surface);
+}
+.dai-root .dai-composer {
+ border: 1px solid var(--dai-border-strong);
+ /* 14px, the same corner the suggestion chips use. At 5px the input was the
+ one sharp-cornered thing in a panel of rounded surfaces, and it sat
+ directly beneath the chips where the mismatch was most visible. */
+ border-radius: 14px;
+ background: var(--dai-surface);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ padding: 10px 10px 8px 14px;
+ transition:
+ border-color 140ms ease,
+ box-shadow 140ms ease;
+}
+.dai-root .dai-composer[data-focused='true'] {
+ border-color: var(--dai-accent);
+ box-shadow: 0 0 0 3px var(--dai-accent-ring);
+}
+
+/* --------------------------------------------------------------------------
+ Conversation history.
+
+ A list of rows, not cards: these are destinations, and dense rows scan
+ faster than boxes when you are looking for one you remember.
+ -------------------------------------------------------------------------- */
+.dai-root .dai-history {
+ padding: 4px 0;
+}
+.dai-root .dai-history-row {
+ width: 100%;
+ border-radius: 10px;
+ padding-right: 4px;
+ transition: background-color 140ms ease;
+}
+.dai-root .dai-history-row:hover {
+ background: var(--dai-surface-alt);
+}
+.dai-root .dai-history-open {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ align-items: flex-start;
+ text-align: left;
+ padding: 9px 10px;
+ border: none;
+ background: transparent;
+ font: inherit;
+ cursor: pointer;
+ border-radius: 10px;
+}
+.dai-root .dai-history-open:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: -2px;
+}
+.dai-root .dai-history-title {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--dai-text);
+ display: block;
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.dai-root .dai-history-meta {
+ font-size: 11px;
+ color: var(--dai-text-muted);
+}
+.dai-root .dai-history-empty-title {
+ font-size: 13.5px;
+ font-weight: 600;
+ color: var(--dai-text);
+}
+.dai-root .dai-history-empty {
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--dai-text-muted);
+}
+
+/* Microphone.
+
+ Astryx's smallest dictation button is 28px, two short of the send button it
+ sits beside — a visible step between two controls on the same row. Pinned to
+ match, with the glyph scaled through font-size because the icon inside is
+ sized in em units (the same reason the side nav icons needed font-size
+ rather than width). */
+.dai-root .dai-mic {
+ width: 30px;
+ height: 30px;
+ min-width: 30px;
+ border-radius: 50%;
+ font-size: 16px;
+}
+.dai-root .dai-mic svg {
+ width: 1em;
+ height: 1em;
+}
+
+/* --------------------------------------------------------------------------
+ Send button.
+
+ This had NO styles at all — the class was referenced once, in a
+ reduced-motion reset, and nowhere else. It rendered as a bare 16px arrow
+ glyph on a transparent background with square corners: not a button, just an
+ icon sitting next to the microphone. Verified in the browser before fixing.
+ -------------------------------------------------------------------------- */
+.dai-root .dai-send {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: var(--dai-accent);
+ color: var(--dai-accent-contrast);
+ cursor: pointer;
+ transition:
+ background-color 140ms ease,
+ opacity 140ms ease,
+ transform 140ms ease;
+}
+.dai-root .dai-send:hover:not(:disabled) {
+ opacity: 0.86;
+}
+.dai-root .dai-send:active:not(:disabled) {
+ transform: scale(0.94);
+}
+.dai-root .dai-send:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+}
+/* Disabled is the resting state until something is typed, so it has to read as
+ "not yet", not as "broken" — a filled grey circle, same shape and weight. */
+.dai-root .dai-send:disabled {
+ background: var(--dai-surface-hover);
+ color: var(--dai-text-muted);
+ cursor: default;
+}
+.dai-root .dai-composer textarea {
+ display: block;
+ width: 100%;
+ border: none;
+ outline: none;
+ resize: none;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ font-size: 13.5px;
+ line-height: 1.45;
+ color: var(--dai-text);
+ background: transparent;
+ max-height: 108px; /* ~5 lines */
+ overflow-y: auto;
+}
+.dai-root .dai-composer textarea::placeholder {
+ color: var(--dai-text-muted);
+}
+.dai-root .dai-hint {
+ font-size: 10.5px;
+ line-height: 1.3;
+ color: var(--dai-text-muted);
+ padding-inline: 2px;
+}
+/* --------------------------------------------------------------------------
+ Trigger (lives in the app TopNav)
+ -------------------------------------------------------------------------- */
+.dai-trigger {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border-radius: 5px;
+ border: 1px solid transparent;
+ background: transparent;
+ cursor: pointer;
+ overflow: hidden;
+ transition:
+ background-color 140ms ease,
+ border-color 140ms ease,
+ transform 140ms ease;
+}
+/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is
+ 30px inside the 40px button. */
+.dai-trigger-mark {
+ width: 30px;
+ height: 30px;
+ max-width: none;
+ object-fit: contain;
+ display: block;
+ transform: scale(1.5);
+}
+.dai-trigger:hover {
+ background: rgba(0, 0, 0, 0.05);
+ border-color: rgba(0, 0, 0, 0.1);
+}
+.dai-trigger:active {
+ transform: scale(0.94);
+}
+.dai-trigger:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+}
+.dai-trigger[data-active='true'] {
+ background: rgba(0, 0, 0, 0.07);
+ border-color: rgba(0, 0, 0, 0.14);
+}
+/* --------------------------------------------------------------------------
+ Responsive
+ -------------------------------------------------------------------------- */
+@media (max-width: 1279px) {
+ .dai-root {
+ --dai-panel-width: 396px;
+ }
+}
+@media (max-width: 767px) {
+ .dai-root {
+ --dai-inset: 0px;
+ }
+ .dai-panel {
+ width: 100vw;
+ max-width: 100vw;
+ border-radius: 0;
+ border: none;
+ }
+}
+/* --------------------------------------------------------------------------
+ Reduced motion — no slide, no pulse, no shimmer. Opacity only.
+ -------------------------------------------------------------------------- */
+@media (prefers-reduced-motion: reduce) {
+ .dai-panel,
+ .dai-scrim {
+ transition: opacity 1ms linear;
+ }
+ .dai-panel {
+ transform: none;
+ }
+ .dai-msg,
+ .dai-live-dot,
+ .dai-typing span,
+ .dai-shimmer,
+ .dai-suggestion,
+ .dai-send,
+ .dai-trigger {
+ animation: none !important;
+ transition: none !important;
+ }
+}
+/* --------------------------------------------------------------------------
+ Write-action confirm card
+ -------------------------------------------------------------------------- */
+.dai-root .dai-action {
+ padding: 10px 11px;
+ border: 1px solid var(--dai-border-strong);
+ border-radius: 5px;
+ background: var(--dai-surface-alt);
+}
+.dai-root .dai-action-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 26px;
+ height: 26px;
+ border-radius: 5px;
+ color: #4f46e5;
+ background: rgba(99, 102, 241, 0.1);
+}
+.dai-root .dai-action-summary {
+ font-size: 13px;
+ line-height: 1.35;
+ font-weight: 550;
+ color: var(--dai-text);
+}
+.dai-root .dai-action-note {
+ font-size: 12px;
+ color: var(--dai-text-secondary);
+}
+.dai-root .dai-ok {
+ color: #047857;
+}
+.dai-root .dai-err {
+ color: #b91c1c;
+}
+/* --------------------------------------------------------------------------
+ Overflow containment
+ --------------------------------------------------------------------------
+ Flex items default to `min-width: auto`, which means they refuse to shrink
+ below their content's intrinsic width. Two side-by-side TextInputs in the
+ create-customer form therefore pushed the message column wider than the
+ panel and produced a horizontal scrollbar. Every link in the chain from the
+ scroll region down to the field has to opt out of that.
+ -------------------------------------------------------------------------- */
+.dai-root .dai-thread,
+.dai-root .dai-msg,
+.dai-root .dai-msg-row,
+.dai-root .dai-action,
+.dai-root .dai-form {
+ min-width: 0;
+ max-width: 100%;
+}
+/* Two fields per row that genuinely share the width. `flex-wrap` is the
+ belt-and-braces part: if a field ever can't compress far enough (a long
+ label, a narrower panel), the row drops to two lines instead of pushing the
+ panel sideways again. */
+.dai-root .dai-form-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+}
+.dai-root .dai-form-row > * {
+ flex: 1 1 140px;
+ min-width: 0;
+}
+/* Astryx's TextInput sizes itself to content unless told otherwise. */
+.dai-root .dai-form input,
+.dai-root .dai-form-row input {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+}
+.dai-root .dai-field-err {
+ font-size: 11.5px;
+ line-height: 1.4;
+ color: #b91c1c;
+}
+/* --------------------------------------------------------------------------
+ Source footer + full lists
+ -------------------------------------------------------------------------- */
+
+/* Timestamp left, Sources toggle hard right, both on ONE line. It was a
+ wrapping baseline row, and the toggle — a shrinkable flex item with inline
+ content — gave up its chevron to a second line whenever the panel got
+ narrow, which read as a stray arrow floating under the text. */
+.dai-root .dai-msg-foot {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: 10px;
+ width: 100%;
+ min-width: 0;
+}
+/* The timestamp is the only part allowed to give up space, and it ellipsises
+ rather than wrapping. */
+.dai-root .dai-msg-foot .dai-msg-footer {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+/* The toggle is one atom: label and chevron cannot be separated. */
+.dai-root .dai-msg-foot .dai-sources {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ align-self: center;
+ white-space: nowrap;
+ padding: 3px 7px;
+ margin-right: -7px;
+ border-radius: 5px;
+ color: var(--dai-text-muted);
+ transition: background 140ms ease, color 140ms ease;
+}
+.dai-root .dai-msg-foot .dai-sources:hover {
+ background: var(--dai-surface-hover);
+ color: var(--dai-text);
+ text-decoration: none;
+}
+.dai-root .dai-msg-foot .dai-sources svg {
+ flex: 0 0 auto;
+ transition: transform 160ms var(--dai-ease);
+}
+.dai-root .dai-msg-foot .dai-sources[aria-expanded='true'] svg {
+ transform: rotate(180deg);
+}
+/* A full, readable list instead of "…and 4 more". Scrolls past ~12 rows so a
+ long list can never push the panel's height around. */
+.dai-root .dai-list {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--dai-border);
+ border-radius: 5px;
+ background: var(--dai-surface-alt);
+ overflow: hidden;
+}
+.dai-root .dai-list-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 7px 10px;
+ border-bottom: 1px solid var(--dai-border);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--dai-text-muted);
+}
+.dai-root .dai-list-body {
+ max-height: 260px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ overscroll-behavior: contain;
+}
+.dai-root .dai-list-item {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 2px 8px;
+ padding: 6px 10px;
+ font-size: 12.5px;
+ line-height: 1.4;
+ color: var(--dai-text);
+ border-top: 1px solid var(--dai-divider, rgba(15, 23, 42, 0.05));
+}
+.dai-root .dai-list-item:first-child {
+ border-top: none;
+}
+.dai-root .dai-list-index {
+ flex: 0 0 auto;
+ min-width: 18px;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ color: var(--dai-text-muted);
+}
+/* The label must not be crushed to zero width by a long value — that is what
+ made "address" render one letter per line. It sizes to its content and the
+ value takes the remaining space, wrapping onto its own full-width line when
+ there isn't enough room for both. */
+.dai-root .dai-list-label {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.dai-root .dai-list-meta {
+ flex: 1 1 auto;
+ min-width: 0;
+ text-align: right;
+ font-size: 11.5px;
+ color: var(--dai-text-secondary);
+ overflow-wrap: anywhere;
+}
+/* A value that wrapped to its own line reads better left-aligned under its
+ label than pinned to the right edge. */
+.dai-root .dai-list-item > .dai-list-meta:only-child,
+.dai-root .dai-list-meta[data-wrapped='true'] {
+ text-align: left;
+}
+/* --------------------------------------------------------------------------
+ Documentation answers
+ --------------------------------------------------------------------------
+ Retrieved passages are shown VERBATIM with their source file. They are prose
+ rather than a figure, so they get a quieter, wider treatment than a metric —
+ and the source line matters as much as the text, because the operator needs
+ to know this came from a document, not from live data.
+ -------------------------------------------------------------------------- */
+.dai-root .dai-doc {
+ padding: 10px 11px;
+ border: 1px solid var(--dai-border);
+ border-left: 2px solid var(--dai-ai-from);
+ border-radius: 5px;
+ background: var(--dai-surface-alt);
+ font-size: 12.5px;
+ line-height: 1.55;
+ color: var(--dai-text);
+ white-space: pre-line;
+ overflow-wrap: anywhere;
+ max-height: 320px;
+ overflow-y: auto;
+}
+.dai-root .dai-doc-source {
+ font-size: 11px;
+ color: var(--dai-text-muted);
+ font-variant-numeric: tabular-nums;
+}
diff --git a/src/lib/assistant/RAG_PLAN.md b/src/lib/assistant/RAG_PLAN.md
new file mode 100644
index 0000000..4bc894e
--- /dev/null
+++ b/src/lib/assistant/RAG_PLAN.md
@@ -0,0 +1,306 @@
+# Doormile AI — RAG implementation plan
+
+Retrieval-augmented routing and document Q&A, backed by a local ChromaDB.
+
+**Status:** plan only. Nothing here is built.
+**Prerequisite decision:** where the sidecar runs (see §11).
+
+---
+
+## 1. What this changes, and what it deliberately does not
+
+### The problem being solved
+
+The assistant routes questions with **34 hand-written regex matchers**. That has a hard vocabulary ceiling, and we hit it repeatedly:
+
+| Phrase | What went wrong |
+|---|---|
+| "cancellation rate" | `\bcancel(led)?\b` doesn't match "cancellation" — the `\b` fails on the following `l` |
+| "8494948494" (a bare reply) | matched no intent at all; the reply was dropped |
+| "orders per day this week" | `weekOrders` answered with one number and silently dropped "per day" |
+| "delivered orders for Acme" | tenant discarded until `orderQuery` was built |
+
+Every one of those was a regex fix. The next ten will be too. **RAG removes the ceiling** — a new phrasing works because it's *semantically near* an example, not because someone wrote a pattern for it.
+
+### What RAG must NOT do here
+
+`assistant/CLAUDE.md` §2 records RAG being considered and rejected, and that reasoning stands **for the data**:
+
+> "this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions."
+
+A vector store is a snapshot. "How many orders today" changes every minute. Answering it from embeddings means answering from whenever we last indexed.
+
+**So: RAG selects the question. The existing API layer still produces every number.**
+
+```
+question ──► embed ──► Chroma ──► top-k intent examples ──► intentId + slots + confidence
+ │
+ ▼
+ the SAME deterministic run() executes
+ │
+ ▼
+ real API call ──► real number
+```
+
+The "never fabricate a number" guarantee survives untouched. Nothing in the vector store ever becomes a figure the operator reads.
+
+### Why this is not blocked by the §2 decision
+
+§2's blocker was: *an LLM call needs an API key, and a static CRA build has nowhere to put one.*
+
+This plan uses a **local embedding model running in Node** (`@xenova/transformers`). No key, no network, no per-call cost. The blocker doesn't apply. If we later want a hosted embedding model or a generation step, §2 applies again and needs its own decision.
+
+---
+
+## 2. Architecture
+
+```
+docker-compose.yml
+├── chroma chromadb/chroma:latest :8000 persistent volume ./.chroma
+└── ai-sidecar node:20 :8787 services/ai
+
+services/ai/
+├── package.json own deps — NOT added to the CRA package.json
+├── index.js Express app: /health, /route, /ask, /reindex
+├── embed.js MiniLM via @xenova/transformers, cached in-process
+├── collections.js Chroma client, collection get-or-create
+├── seed/
+│ ├── intents.js builds intent_examples from the phrasing catalog
+│ ├── docs.js chunks the markdown docs
+│ └── phrasings.json ~20 example phrasings per intent (hand-written)
+├── eval.js routing accuracy vs the regex baseline
+└── README.md how to run it locally
+```
+
+**The CRA app gains no new dependencies.** The sidecar is a separate package with its own `package.json`, so `react-scripts`, the webpack config and the `resolutions` block in the root `package.json` are untouched (root `CLAUDE.md` §4.3).
+
+### Ports and env
+
+| | |
+|---|---|
+| Chroma | `http://localhost:8000` |
+| Sidecar | `http://localhost:8787` |
+| CRA reads | `REACT_APP_AI_URL` (absent → RAG disabled, regex only) |
+
+`REACT_APP_AI_URL` being unset must be a supported state, not a broken one — that is what keeps the app deployable exactly as it is today.
+
+---
+
+## 3. Data model
+
+### Collection `intent_examples`
+
+One vector per example phrasing. ~34 intents × ~20 phrasings ≈ **700 vectors**. Trivially small; Chroma handles it in memory.
+
+```js
+{
+ id: 'statusBreakdown::07',
+ document: 'how many orders got cancelled this week',
+ metadata: {
+ intentId: 'statusBreakdown',
+ domain: 'orders', // orders | riders | tenants | hubs | vehicles | ops | write
+ isWrite: false, // write intents need a higher bar — see §6
+ slotsHint: 'status,range' // documentation only; slots still parsed deterministically
+ }
+}
+```
+
+### Collection `console_docs`
+
+Chunked markdown for genuine document Q&A — "what is CityGate", "why does dispatch reconcile before commit".
+
+```js
+{
+ id: 'express-console-api.md::conventions::2',
+ document: '',
+ metadata: {
+ source: 'express-console-api.md',
+ heading: 'Conventions across every endpoint',
+ updatedAt: '2026-08-19'
+ }
+}
+```
+
+**Chunking:** split on markdown headings, then hard-wrap at ~800 characters with ~100 characters of overlap. Heading path is prepended to each chunk so a chunk carries its own context.
+
+**Sources to index:** `express-console-api.md`, root `CLAUDE.md`, `src/pages/api/CLAUDE.md`, `src/pages/nearle/assistant/CLAUDE.md`, `src/pages/nearle/dispatch/CLAUDE.md`, `src/pages/nearle/orders/CLAUDE.md`, `ROADMAP.md`.
+
+---
+
+## 4. The routing contract
+
+### `POST /route`
+
+```jsonc
+// request
+{ "text": "how many orders got cancelled this week" }
+
+// response
+{
+ "intentId": "statusBreakdown",
+ "confidence": "high", // high | medium | low
+ "score": 0.91, // cosine similarity of top-1
+ "margin": 0.19, // top-1 minus top-2 — the honest signal
+ "alternatives": [
+ { "intentId": "orderRate", "score": 0.72 }
+ ],
+ "matchedExample": "how many orders got cancelled this week"
+}
+```
+
+### How confidence is derived — and why margin, not score
+
+Cosine similarity is **not** a probability of correctness. 0.87 does not mean "87% likely right". What actually carries information is the **margin** between the best and second-best match:
+
+| Condition | Confidence | Bot behaviour |
+|---|---|---|
+| `score ≥ 0.75` and `margin ≥ 0.10` | **high** | route and answer |
+| `score ≥ 0.60` and `margin ≥ 0.05` | **medium** | route, and name the interpretation in the answer |
+| otherwise | **low** | **don't guess** — offer the top 2–3 as buttons |
+
+Thresholds are starting values, tuned in phase 7 against the eval corpus.
+
+**What the operator sees:** *"Matched: orders by status · high confidence"* — never *"87% sure the answer is 42."* The answer's correctness comes from deterministic execution; the score only describes how sure we are which question was asked. Conflating the two would be exactly the kind of false precision this bot has avoided all along.
+
+### `POST /ask` (docs)
+
+```jsonc
+{ "text": "what is CityGate" }
+→ { "chunks": [ { "text": "...", "source": "express-console-api.md",
+ "heading": "Conventions", "score": 0.88 } ] }
+```
+
+**No generation step.** It returns the source passages with attribution and the panel renders them. Summarising them into new prose would require an LLM — which is §2's blocked decision — and would also let a paraphrase drift from what the doc says.
+
+---
+
+## 5. Embedding model
+
+**`Xenova/all-MiniLM-L6-v2`** via `@xenova/transformers`.
+
+| | |
+|---|---|
+| Size | ~25 MB, downloaded once, cached on disk |
+| Dimensions | 384 |
+| Runs | in-process in Node — no key, no network, no per-call cost |
+| Speed | ~5 ms per short query on CPU |
+
+Good enough for short operator phrases, which is the whole workload. Upgrade path if the eval shows it's not: `text-embedding-3-small`. That needs a key, which re-opens §2 — do not do it silently.
+
+**Important:** queries and documents must be embedded with the *same* model and the same normalisation. A model change means a full re-seed; `seed.js` writes the model name into collection metadata so a mismatch is detectable rather than silently wrong.
+
+---
+
+## 6. Integration with the bot
+
+`answerQuestion(text, context)` gains a routing step **in front of** the existing matcher:
+
+```js
+// 1. try semantic routing, but never let it break the bot
+const routed = await routeViaRag(text).catch(() => null);
+
+// 2. high/medium confidence → run that intent's own run()
+if (routed && routed.confidence !== 'low') {
+ const intent = INTENTS_BY_ID[routed.intentId];
+ const params = intent?.match(text) ?? deriveSlots(text, routed);
+ const result = params && (await intent.run(params));
+ if (result) return { ...result, routing: routed };
+}
+
+// 3. fall back to the 34 regex intents, exactly as today
+return matchAndRun(text);
+```
+
+### Non-negotiables
+
+**The regex matcher stays.** It is the fallback when the sidecar is down, `REACT_APP_AI_URL` is unset, or confidence is low. Today's behaviour is the floor — RAG can only improve it, never remove it.
+
+**Slots stay deterministic.** `rangeFromWords`, `statusFromWords`, `orderStatusGroups`, entity resolution all keep their jobs. Embeddings are good at *"what kind of question is this"* and bad at *"the 14th"*. RAG picks the intent; parsing extracts the values.
+
+**Write intents need a higher bar.** `createCustomer` and `createOrder` must require **high** confidence AND an explicit verb match. A semantic near-miss must never open a write form. The `isWrite` flag in metadata exists for exactly this check.
+
+**Timeout.** 400 ms budget on `/route`; past that, fall through to regex. The bot must never feel slower because a container is cold.
+
+---
+
+## 7. Evaluation — how we prove it is better
+
+The existing **40-phrase routing corpus** (`scratchpad/route_probe.mjs`) becomes the regression baseline. But it is not a fair test: those phrases were written *for* the regex matcher and it scores 40/40 on them.
+
+The real test is a **held-out set of ~60 phrasings neither implementation was tuned against**, written by someone who hasn't read the matchers. Operator language, not developer language: *"anything stuck?"*, *"what's late"*, *"how'd Kumar do"*.
+
+`eval.js` reports:
+
+| Metric | Meaning |
+|---|---|
+| Routing accuracy | correct `intentId` — the headline number |
+| Coverage | % answered at all (regex's weakness: silent no-match) |
+| False routes | wrong intent answered confidently — **the number that matters most** |
+| Write safety | zero write intents triggered by non-write phrasings |
+| p50 / p95 latency | must stay under the 400 ms budget |
+
+**Ship criterion:** RAG beats regex on accuracy *and* coverage on the held-out set, with **zero** false write routes. A false route is worse than a no-match — it's the same class of failure as the "19 assigned vs 0" bug.
+
+---
+
+## 8. Phases
+
+| # | Deliverable | Acceptance | Effort |
+|---|---|---|---|
+| 0 | **Sidecar hosting decision** (§11) | answered | — |
+| 1 | `docker-compose.yml`, Chroma up, Express `/health` | `curl :8787/health` → ok, Chroma reachable | 0.5 d |
+| 2 | `embed.js`, model cached, round-trip verified | same text → identical vector twice | 0.5 d |
+| 3 | `phrasings.json` — 20 per intent × 34 | seeded, count verified | 1.5 d |
+| 4 | `POST /route` with confidence + margin | returns correct intent for 20 hand checks | 0.5 d |
+| 5 | Bot integration + fallback + 400 ms timeout | **kill the sidecar mid-session → bot still answers** | 0.5 d |
+| 6 | `console_docs` + `POST /ask` + panel rendering | "what is CityGate" returns the right passage | 1 d |
+| 7 | `eval.js` + held-out set + threshold tuning | report produced, thresholds fixed from data | 1 d |
+
+**Total ≈ 5.5 days.** Phases 1–5 (≈3.5 d) deliver the whole routing win; 6–7 add docs and proof.
+
+---
+
+## 9. Operations
+
+- **`npm run seed:ai`** rebuilds both collections from scratch. Idempotent.
+- **Docs drift silently.** Re-seed on any change to an indexed markdown file — a CI step, or a pre-commit hook. A stale doc answer is worse than none, because it looks authoritative.
+- **Chroma persistence** is a bind-mounted `./.chroma` volume. Add to `.gitignore`.
+- **Model cache** likewise (`.cache/transformers`).
+- **Nothing in the sidecar touches the Doormile API.** It only routes text. All data access stays in the browser through the existing typed functions, which keeps the tenant scoping and the bearer token exactly where they are today.
+
+---
+
+## 10. Risks
+
+| Risk | Mitigation |
+|---|---|
+| **First backend in the project** | Dev-only to start. Deploying it is a separate decision with real ops cost. |
+| Sidecar down → bot dead | Regex fallback + timeout. Tested explicitly in phase 5. |
+| Semantic near-miss opens a write form | `isWrite` requires high confidence **and** verb match. Zero-tolerance metric in eval. |
+| Local model too weak on short phrases | Measured in phase 7. Upgrade path exists but re-opens §2. |
+| Docs go stale | Re-seed in CI. |
+| Phrasing catalog becomes a second matcher to maintain | It is data, not code — and unlike regexes, near-misses still work. |
+| Scope creep into generation | Explicitly out (§12). |
+
+---
+
+## 11. Open decisions — needed before phase 1
+
+1. **Where does the sidecar run?**
+ - **(a) Dev-only** — runs on operator machines / a dev box. Zero ops. RAG is an enhancement that's simply absent in production.
+ - **(b) Deployed alongside nginx** — the project gains a backend: hosting, monitoring, a deploy pipeline, an internal network hop. Bigger commitment than the vector DB itself.
+
+ *Recommendation: (a) first.* Prove routing accuracy with real operator language before taking on ops.
+
+2. **Does `/ask` (docs) ship to operators, or is it internal?** The indexed docs contain engineering notes, including known backend bugs.
+
+3. **Who writes the held-out eval set?** It has to be someone who hasn't read the matchers, or the test is worthless.
+
+---
+
+## 12. Explicitly out of scope
+
+- **Any generation step.** `/ask` returns source passages with attribution, never paraphrase. Generation needs a hosted model and a key — §2's blocked decision.
+- **Embedding operational data.** No bookings, riders or customers in the vector store. Numbers come from the API, always.
+- **Replacing the regex matcher.** It becomes the fallback, permanently.
+- **Semantic slot extraction.** Dates, statuses and entities stay deterministic.
diff --git a/src/lib/assistant/ROADMAP.md b/src/lib/assistant/ROADMAP.md
new file mode 100644
index 0000000..1df1b84
--- /dev/null
+++ b/src/lib/assistant/ROADMAP.md
@@ -0,0 +1,390 @@
+# Doormile Bot — v3 development plan
+
+Analysis of the shipped v2 (`BotPanel.js` + `intents.js`, 9 intents) and the staged plan to take it to an advanced operator assistant.
+
+Authority: `src/pages/nearle/assistant/CLAUDE.md` constrains this work — no route/sidebar (§5), no LLM without a key-location decision (§2), no write action without a propose→confirm gate (§4). This plan does not override any of those; where it touches one, it says so explicitly.
+
+---
+
+## Part 1 — Analysis of v2
+
+### Architecture today
+
+```
+answerQuestion(text)
+ └─ for each of 9 INTENTS, in array order
+ ├─ intent.match(text) → params | null
+ └─ intent.run(params) → { headline, detail, sourceCalls } | null
+ first match that resolves wins; otherwise "I can't answer that yet"
+```
+
+One question maps to exactly one intent. Every data intent bulk-fetches `getBookings(1, 1000)` and filters client-side.
+
+### Defects, ranked by damage
+
+#### Tier A — the bot states wrong numbers confidently
+
+**A1. The 1000-row ceiling is real and undetectable.**
+`BULK_PAGESIZE = 1000` is not a chosen page size, it is the API's hard cap (`express-console-api.md` → Conventions: "Default 500, cap 1000"). Worse, `getBookings` returns `response.data.data` and discards the envelope's `total`, so no caller can even detect truncation. Past 1000 lifetime bookings every count intent under-reports with no warning, while `sourceCalls` displays `status: 'complete'` beside it. This directly violates the stated promise in CLAUDE.md §3 ("a wrong number from this bot is worse than no answer").
+
+**A2. Range words are silently downgraded to "today".**
+Only `revenueTotal` and `weekOrders` call `rangeFromWords`. The other seven use `dayFromWords`, which ignores "this week" and returns today. "How many delivered orders this week" is answered by `statusBreakdown` as *today's* count. The headline says "today", so it is disclosed — but the operator asked something else and got an answer to a different question.
+
+**A3. `revenueTotal` is mislabelled and includes cancelled orders.**
+It sums `serviceoptions[0].estimatedprice` over every row in range with no status filter. Cancelled bookings inflate it, only the first service option is counted, and the figure is an *estimate* presented as "Total revenue".
+
+**A4. "assigned" resolves to the wrong bucket.**
+`statusFromWords` maps `assigned → 'accepted'`, but `mapBookingStatusToDeliveryStatus` maps the backend's `miler_assigned → 'pending'`. "How many assigned orders today" therefore counts `pickup_scheduled` + `converted_to_consignment` and excludes the orders the operator means.
+
+#### Tier B — the bot answers a different question
+
+**B1. `riderCounts` swallows every sentence containing "rider".**
+`match: (text) => (mentionsRiders(text) ? {} : null)` and its `run` never returns null. "Which rider has order #4821", "how many orders did rider Suresh deliver" and "rider performance this week" all return the same fleet availability summary. This is the mirror image of the bug CLAUDE.md §3 documents as fixed — the guard stopped `statusBreakdown` stealing rider questions, but nothing stops `riderCounts` stealing everything else.
+
+**B2. `orderLookup`'s fall-through answers a different question entirely.**
+It searches only page 1. An order not in the most recent 1000 returns `null`, the loop continues, and `totalOrders` answers "142 orders created today" to the question "status of order #9931". Fall-through is right when an intent *mismatched*; it is wrong when the intent matched and the lookup failed.
+
+**B3. No composite filters.** "Delivered orders for Acme this week" is answered by `statusBreakdown` alone — tenant and range discarded.
+
+**B4. `resolveTenant` is one-directional substring matching.** The question must contain the tenant's full name. "Orders for acme foods" against tenant "Acme Foods Pvt Ltd" fails, falls through, and `totalOrders` answers with the all-tenant count.
+
+**B5. `orderIdFromWords` treats any bare 4+ digit run as an order id** — years, pincodes, quantities.
+
+#### Tier C — architecture
+
+**C1. No TanStack Query.** CLAUDE.md §7 makes it the rule for all reads. `answerQuestion` calls the API functions raw. Clicking the five suggestion chips issues five separate 1000-row fetches; `tenantCount` issues two sequentially.
+
+**C2. `sourceCalls` are hand-written after the fact** with `status: 'complete'` hardcoded. They cannot represent a failed or partial call and will drift from the code the moment a `run` is edited. `ChatToolCalls` already supports `'pending' | 'running' | 'complete' | 'error'` plus `duration`, `errorMessage` and `resultDetail` — none used.
+
+**C3. The two aggregation endpoints are unused.** `GET /admin/reports` (`from`/`to`/`tenantid`/`locationid`/`hubid`, with `by_location`/`by_hub`/`by_tenant`/`by_rider` blocks per `getReports`'s comment) and `GET /admin/dashboard` ("counts + today's numbers") are server-computed, uncapped, and already wrapped in `doormileApi.js`. They are the correct source for every counting question and the answer to A1.
+
+**C4. No abort or timeout.** A 1000-row fetch cannot be cancelled; the composer is simply disabled.
+
+#### Tier D — UX
+
+- **D1.** Suggestion chips are gated on `messages.length === 0`, so they vanish permanently after the first question. No reset control.
+- **D2.** History dies when the popover closes (accepted in CLAUDE.md §1, but a liability once answers get expensive).
+- **D3.** Answers are two strings. `detail` truncates at 10 ids with "…and N more" and there is no way to see the rest, and no way to jump to the matching rows on the Orders page.
+- **D4.** Unanswered questions are dropped — no signal on what to build next.
+- **D5.** Fixed `PANEL_WIDTH = 400` / `MESSAGES_HEIGHT = 420` raw px.
+
+---
+
+## Part 2 — Target architecture
+
+Replace one-question-one-intent with a three-stage pipeline:
+
+```
+parse(text) → Query pure, no I/O, fully unit-testable
+resolve(Query) → Dataset cached, paginated, audited, abortable
+render(Query, Dataset) → Answer headline + blocks + real sourceCalls
+```
+
+`Query` is a slot bag, not an intent id:
+
+```js
+{
+ subject : 'orders' | 'riders' | 'tenants' | 'revenue' | 'order',
+ metric : 'count' | 'sum' | 'breakdown' | 'lookup' | 'top',
+ filters : { range: {start, end, label}, batch, status, tenantId, hubId, riderId, orderId },
+ groupBy : 'status' | 'batch' | 'tenant' | 'rider' | 'hour' | null,
+ limit : number
+}
+```
+
+Filters compose. "Delivered orders for Acme this week" fills three slots and runs one query instead of picking one of three intents.
+
+Proposed file layout inside `src/pages/nearle/assistant/`:
+
+```
+parse/
+ vocab.js date/range, batch, status, metric, groupBy vocabularies
+ entities.js tenant/rider/hub resolution + fuzzy scoring
+ parseQuery.js text → Query (+ confidence, + unresolved slots)
+resolve/
+ source.js cached, paginated booking source; reports/dashboard source
+ aggregate.js count / sum / breakdown / top over a normalised row set
+render/
+ answer.js Query + Dataset → { headline, blocks[], sourceCalls[] }
+intents.js thin compatibility shim → parseQuery + resolve + render
+BotPanel.js richer rendering, chips, reset, stop, persistence
+```
+
+`intents.js` keeps its exported surface (`answerQuestion`, `EXAMPLE_QUESTIONS`) so `BotPanel.js` and `AppTopNav.js` are unaffected during the swap.
+
+---
+
+## Part 3 — Phased plan
+
+### Phase 0 — Truth foundation (blocking; ship before any new capability)
+
+Nothing else matters while the numbers can be wrong.
+
+| # | Task | Files | Acceptance |
+|---|---|---|---|
+| 0.1 | Expose the envelope `total`/`page` from `getBookings` — return `{ rows, total, page }` or add `getBookingsPage`. Keep the existing signature working for `fetchDeliveries`' four callers. | `pages/api/doormileApi.js` | A caller can detect that more rows exist than were returned. |
+| 0.2 | Build a paginated booking source that drains pages until the oldest row predates the requested range, with a hard page budget. Emits a `truncated` flag when the budget is hit. | `resolve/source.js` | "How many orders today" is correct on an account with >1000 lifetime bookings. |
+| 0.3 | Route counting/aggregate questions through `getReports(from, to, tenantid, locationid, hubid)` first; fall back to the paginated booking scan only when reports can't answer the shape. | `resolve/source.js` | Counts for a date range come from one server call, not a 1000-row scan. |
+| 0.4 | Never present a truncated result as complete — if `truncated`, headline reads "at least N" and the tool call carries `status: 'error'` or an `errorMessage`. | `render/answer.js` | Truncation is visible in the answer, not just the console. |
+| 0.5 | Fix A3: exclude `cancelled` from revenue, sum all `serviceoptions`, relabel as "estimated". | `render/answer.js` | "Total revenue today" excludes cancelled and says "estimated". |
+| 0.6 | Fix A4: align status synonyms with `BOOKING_STATUS_TO_DELIVERY_STATUS`. "assigned" → the bucket `miler_assigned` actually lands in. | `parse/vocab.js` | "Assigned orders today" matches what the Orders page shows for the same filter. |
+| 0.7 | Fix B2: when an intent matched but its lookup failed, answer "I couldn't find order X" — do not fall through to a broader intent. | `resolve/` + `render/` | Asking for a nonexistent order never returns a global count. |
+
+**Risk:** 0.3 depends on the `/admin/reports` response shape, which is documented only in `jupiter2doormile.md` and is not in `express-console-api.md`. Confirm live before building on it; if the shape doesn't carry what's needed, 0.2 alone still fixes A1 at higher cost.
+
+### Phase 1 — Slot parser (the capability multiplier)
+
+| # | Task | Acceptance |
+|---|---|---|
+| 1.1 | `parseQuery(text) → Query` with independent slot extraction; unrecognised slots stay empty rather than defaulting. | Unit tests over a fixed corpus (Part 5). |
+| 1.2 | Real relative-date vocabulary: today, yesterday, this/last week, last N days, this month, explicit `DD MMM` and `YYYY-MM-DD`. Anything unparsed → *ask*, don't assume today. | "Delivered orders this week" returns the week, not today. |
+| 1.3 | Entity resolution with bidirectional + fuzzy matching and an ambiguity path: 0 matches → say so; 1 → use it; 2+ → ask which. | "Orders for acme foods" resolves to "Acme Foods Pvt Ltd". |
+| 1.4 | Confidence scoring replaces first-match-wins. Below threshold → clarifying question listing what was understood. | "Rider Suresh's orders today" no longer returns a fleet summary (fixes B1). |
+| 1.5 | Conversation context: carry the last `Query` forward; a follow-up mutates only the slots it names. Reset on explicit "start over" and on an entity switch. | "and yesterday?" / "just for Acme" work as follow-ups. |
+| 1.6 | Guard B5: a bare number is an order id only with an order-ish trigger word nearby and no date/quantity reading. | "Orders in 2026" is not treated as an id lookup. |
+
+Coverage after this phase is the product of the slots, not a list of nine — subject × range × status × batch × tenant × rider all compose.
+
+### Phase 2 — Answers that are objects, not sentences
+
+| # | Task | Notes |
+|---|---|---|
+| 2.1 | `Answer.blocks[]` — typed blocks (`stat`, `table`, `breakdown`, `link`) rendered by `BotPanel`. | Replaces the two-string shape. |
+| 2.2 | Result table for row-returning answers: `Table` + `StatusBadge` cells instead of "…and N more". | Reuse `components/nearle_components/StatusBadge`. |
+| 2.3 | Deep link — every answer carries the filter state that produced it, with a button that navigates to the Orders/Deliveries page pre-filtered. | The single biggest usability jump: answer → action. |
+| 2.4 | Breakdown answers via `groupBy` (by status / batch / tenant / rider / hour). | Feeds off `/admin/reports` `by_*` blocks where available. |
+| 2.5 | New subjects using already-exported functions: `getBookingTrack` (where is order X), `getMilerActivity` / `getMilerSummary` (what has rider X done), `getConsignments`, `getTripsheets`, `getHubs`, `getVehicles`. | No new endpoints needed. |
+| 2.6 | Real `sourceCalls`: emitted by the fetch layer, streaming `pending → running → complete/error`, with `duration` and `errorMessage`. | `ChatToolCalls` already supports all four states. |
+
+### Phase 3 — Panel UX
+
+| # | Task |
+|---|---|
+| 3.1 | Keep suggestion chips available after the first message (collapse into a `ChatComposerDrawer` or a header affordance), plus a "New chat" reset. |
+| 3.2 | Persist thread + last `Query` to `sessionStorage` so closing the popover doesn't lose it. |
+| 3.3 | `onStop` / `isStopShown` on `ChatComposer` wired to an `AbortController` through the fetch layer. |
+| 3.4 | `ChatSystemMessage` for context resets, day dividers and truncation notices. |
+| 3.5 | `ChatLayoutScrollButton` + `useChatNewMessages` for long threads. |
+| 3.6 | `useTriggerMenu` + `ChatComposerTokenElement`: `@tenant` / `@rider` / `/` commands so an operator *picks* a real entity instead of relying on fuzzy matching. Directly de-risks 1.3. |
+| 3.7 | Replace raw `PANEL_WIDTH`/`MESSAGES_HEIGHT` px with tokens; keyboard/focus check inside the `Popover` (Escape currently closes the panel mid-typing). |
+| 3.8 | Log unanswered questions locally (capped ring buffer) and surface them — this is the backlog for the next intent round. |
+| 3.9 | i18n the bot's strings into `utils/locales/en.json` like the rest of the app. |
+
+### Phase 4 — Actions, confirm-gated (needs sign-off)
+
+CLAUDE.md §4 rules this out today and specifies the shape it must take if built: propose → operator confirms → execute. Plan accordingly:
+
+1. Parse produces an `Action` (never executed at parse time).
+2. Render shows exactly what will be submitted — target rows, field values, the endpoint — as a `ChatSystemMessage` with explicit confirm/cancel.
+3. Execute only on confirm, through the same api.js functions the pages use, honouring the dispatch reconcile rule (root CLAUDE.md §4) and the notify-rider-after-mutation rule (§9).
+4. Post-action, refetch the related queries and show the new state.
+
+Safe first candidates: `notifyMiler` (broadcast to a rider), `cancelBooking` (single, with confirm). Deliberately last: order creation — CityGate pincode validation and delivery-slot windows live elsewhere and must not be bypassed.
+
+### Phase 5 — LLM as parser only (BLOCKED on a decision)
+
+CLAUDE.md §2 records that this was raised and rejected because there is nowhere to hold a key — no backend, static nginx build. That reasoning still stands, so this phase is blocked, not dismissed. If the key question is ever answered, the correct shape is narrow:
+
+- The model does **slot extraction only** — text in, a validated `Query` JSON out via tool-use / structured output. It never produces a number, a row, or a sentence the operator reads as fact.
+- Deterministic code still executes every fetch and every calculation.
+- Slots that don't resolve against real tenants/hubs/riders are rejected and the regex parser runs as fallback.
+
+This preserves the "no fabricated numbers" guarantee exactly, while removing the vocabulary ceiling. Note it also makes Phase 1 the fallback path rather than dead work.
+
+### Phase 6 — Proactive
+
+Once the query layer is trustworthy: watch for conditions rather than waiting to be asked — "14 morning-batch orders unassigned, 30 minutes to cutoff", "rider X offline mid-route". Surfaces as a badge on the bot icon and a `ChatSystemMessage`. Ties into the existing FCM path.
+
+---
+
+## Part 4 — Decisions needed
+
+1. **`/admin/reports` response shape** — confirm live. Blocks task 0.3, which is the cheap fix for the 1000-row problem.
+2. **Write actions** — in scope for this round, or stays read-only? Blocks Phase 4 entirely.
+3. **LLM key location** — unchanged from CLAUDE.md §2? Blocks Phase 5.
+4. **History persistence** — `sessionStorage` (dies with the tab) or Redux + `localStorage` (survives)? Affects 3.2.
+5. **Tenant-scoped logins** — should the bot say "across your tenant" when the token carries a tenantid, rather than implying global figures?
+
+---
+
+## Part 5 — Regression corpus
+
+Build this as a fixture the parser is tested against; every row is a question the bot must either answer correctly or explicitly decline.
+
+| Question | Must produce |
+|---|---|
+| how many orders today | count, orders, today |
+| how many orders this week | count, orders, 7-day range (currently → today) |
+| how many delivered orders this week | count + status + range (currently drops range) |
+| delivered orders for Acme this week | count + status + tenant + range (currently drops two) |
+| morning batch orders yesterday | count + batch + day |
+| how many riders are active | rider availability |
+| which rider has order #4821 | order lookup → rider (currently fleet summary) |
+| how many orders did rider Suresh deliver today | rider activity (currently fleet summary) |
+| status of order #9931 (nonexistent) | "couldn't find it" (currently a global count) |
+| orders in 2026 | not an id lookup |
+| total revenue today | estimated, cancelled excluded |
+| how many assigned orders today | must agree with the Orders page |
+| and yesterday? (follow-up) | previous query, day shifted |
+| how many tenants | tenant count |
+| where is order DM-BK-123 | tracking |
+| top 5 tenants by orders this week | breakdown + limit |
+| unassigned orders right now | pending count |
+| how many orders (account with >1000 bookings) | correct, or explicitly "at least N" |
+
+---
+
+# Part 6 — Capability levels
+
+A different cut from the phases above: not *how* to build it, but *what the bot
+could do*, ordered by how much has to exist underneath.
+
+Baseline: `doormileApi.js` exports **96 functions, 37 of them reads**. The bot
+calls **13** — all list endpoints. Every level below L6 is built from functions
+that already exist and are already used by some page in this console.
+
+---
+
+## L0 — Foundation (not a feature; blocks everything)
+
+Every count the bot gives is capped at `getBookings(1, 1000)` — page 1, at the
+API's hard cap — and `getBookings` discards the envelope's `total`, so
+truncation is undetectable. Fix pagination, surface `total`, route counts
+through `GET /admin/reports`, and say "at least N" when truncated.
+
+Until this lands, every level below inherits a silent wrong-number risk.
+
+---
+
+## L1 — Counts and lists — **SHIPPED**
+
+25 intents over 13 API functions. Single-dimension questions, plus typo
+tolerance, date vocabulary, comparisons, multi-part, and date follow-ups.
+
+**Ceiling:** one filter at a time. "Delivered orders for Acme this week"
+answers only the status.
+
+---
+
+## L2 — Composable queries
+
+Slot filling replaces first-match-wins: `subject x range x status x batch x
+tenant x rider x hub` all compose into one query.
+
+- "Delivered orders for Acme this week"
+- "Pending morning-batch orders at Coimbatore hub yesterday"
+- "Cancelled orders for Acme vs Beta last month"
+
+**New endpoints needed:** none. Coverage becomes the product of the slots
+rather than a list of 25.
+
+---
+
+## L3 — Entity intelligence
+
+Deep answers about *one* thing, using the detail endpoints the bot has never
+touched.
+
+| Subject | Functions available now | Unlocks |
+|---|---|---|
+| Order | `getBooking`, `getBookingTrack` | "Where is DM-BK-123", full status timeline, assigned rider, ETA |
+| Parcel | `getConsignment`, `getConsignmentLogs`, `trackConsignment` | Scan history, exception trail |
+| Rider | `getMiler`, `getMilerActivity`, `getMilerLogs`, `getMilerSummary` | "What has Suresh done today" — assigned/completed/rejected/cancelled, riderkms, last ping, live position |
+| Hub / vehicle | `getHub`, `getVehicle` | Per-site detail, assigned fleet |
+| Tenant | `getAdminTenant`, `getTenantLocations`, `getTenantCustomers` | Sites, customers, contact |
+| Exception | `getException` | Why a delivery failed |
+| Pricing | `quotePricing`, `simulatePricing` | "What would a 5kg parcel from 641001 to 600001 cost?" — a real calculation, not a lookup |
+
+**Warning:** `riderLookup` today reads `found.status`, `found.phonenumber`,
+`found.vehicletype`. The confirmed-live miler shape (documented in `api.js`)
+has none of those — it carries `availabilitystatus`, `phone`,
+`defaultvehicletype`. Fix against the real shape before extending this level.
+
+---
+
+## L4 — Analytics, ranking, anomaly
+
+Built on `getReports` (`by_tenant` / `by_hub` / `by_rider` blocks),
+`getLocationsSummary`, `getMilerSummary`.
+
+- "Top 5 tenants by orders this week"
+- "Which hub is busiest / which needs attention"
+- "Which riders have the most cancellations"
+- "Cancellation rate this week vs last"
+- "Orders per hour today"
+
+**"Which orders are delayed" is computable today** — `serviceoptions[0].
+estimateddeliveryat` exists on a booking, so "past estimated delivery and not
+yet delivered" is a real filter, not a guess. This is probably the single
+highest-value question on the list and nothing currently answers it.
+
+---
+
+## L5 — Navigation and UI control
+
+The assistant stops being a read-only oracle and starts driving the console.
+
+- Every answer carries the filter state that produced it → "Open in Orders"
+- "Show me cancelled orders" navigates and applies the filter, instead of
+ returning a count
+- "Open order DM-BK-123" routes to the record
+
+**Dependency:** the target pages must accept filter state from the URL. Check
+what `orders.js` supports before committing to this.
+
+---
+
+## L6 — Write actions, confirm-gated
+
+Out of scope per `CLAUDE.md` §4 until signed off, and that doc already fixes
+the required shape: propose -> show the exact payload and affected rows ->
+operator confirms -> execute -> refetch. Never straight from match to mutation.
+
+Tiered by blast radius:
+
+| Tier | Functions | Risk |
+|---|---|---|
+| T1 | `notifyMiler` | Sends a message. Reversible by sending another. |
+| T2 | `assignMilerToBooking`, `assignVehicleToBooking`, `updateBookingStatus`, `cancelBooking`, `updateExceptionStatus`, `blockMiler` | Single record, real consequence |
+| T3 | `batchAssignBookings`, `bulkCancelBookings` | Many records at once |
+| T4 | `createTripsheet`, `addTripsheetItem`, `dispatchTripsheet`, `arriveTripsheet` | Multi-step workflow with ordering rules |
+| T5 | `createExpressBooking`, `createExpressBookingBulk` | Last. CityGate pincode validation and delivery-slot windows live elsewhere and must not be bypassed. |
+
+Must honour the dispatch reconcile rule (root `CLAUDE.md` §4) and the
+notify-rider-after-mutation rule (§9) inside the executor, so the assistant
+can't become a backdoor around either.
+
+---
+
+## L7 — Proactive / watch
+
+Stops waiting to be asked. A watch loop evaluates threshold conditions and
+pushes into the thread plus a badge on the trigger.
+
+- "14 morning-batch orders unassigned, 30 minutes to cutoff"
+- "Rider offline mid-route"
+- "Hub with zero active riders"
+
+**Dependency:** the notification panel in `AppTopNav` is currently static
+scaffolding, not wired to a real alert stream.
+
+---
+
+## L8 — LLM as parser only — BLOCKED
+
+`CLAUDE.md` §2 records the decision: no backend, nowhere to hold a key. If
+that ever changes, the model does **slot extraction only** — text in, a
+validated Query out. It never produces a number, a row, or a sentence read as
+fact. Deterministic code still executes every fetch and every calculation, and
+the L2 parser becomes the fallback.
+
+---
+
+## Suggested order
+
+1. **L0** — one day, removes the wrong-number risk
+2. **L4's delay detection** — highest value per unit of work, no new endpoints
+3. **L2** — multiplies coverage, deletes code
+4. **L3** — the 24 unused read functions
+5. **L5** — makes answers actionable
+6. **L6/L7** — only after sign-off
diff --git a/src/lib/assistant/actions.js b/src/lib/assistant/actions.js
index 46fee75..4923b4b 100644
--- a/src/lib/assistant/actions.js
+++ b/src/lib/assistant/actions.js
@@ -1,4 +1,4 @@
-import { createTenantCustomer } from '@/api/doormile/endpoints';
+import { createTenantCustomer } from 'pages/api/doormileApi';
// ==============================|| Doormile AI — write actions ||============================== //
//
diff --git a/src/lib/assistant/assignActions.js b/src/lib/assistant/assignActions.js
index df5582e..c93fe8b 100644
--- a/src/lib/assistant/assignActions.js
+++ b/src/lib/assistant/assignActions.js
@@ -1,21 +1,5 @@
-import { getMilers, assignMilerToBooking } from '@/api/doormile/endpoints';
-import { notifyMiler } from '@/api/doormile/endpoints';
-
-const normMilerName = (n) => (n || '').toString().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 };
-};
-
+import { getMilers, assignMilerToBooking } from 'pages/api/doormileApi';
+import { buildMilerLookup, notifyRider } from 'pages/api/api';
// ==============================|| Doormile AI — assigning a rider ||============================== //
//
@@ -125,7 +109,7 @@ export const executeAssign = async (booking, rider) => {
if (rider.milerprofileid) {
try {
- await notifyMiler(rider.milerprofileid);
+ await notifyRider(rider.milerprofileid);
notified = true;
sourceCalls.push({
name: 'notifyRider',
@@ -240,7 +224,7 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
if (rider?.milerprofileid) {
try {
// eslint-disable-next-line no-await-in-loop
- await notifyMiler(rider.milerprofileid);
+ await notifyRider(rider.milerprofileid);
notified += 1;
} catch {
// Notification failure never fails the assignment — the order IS
diff --git a/src/lib/assistant/assignFlow.js b/src/lib/assistant/assignFlow.js
index 62bf601..b5e0570 100644
--- a/src/lib/assistant/assignFlow.js
+++ b/src/lib/assistant/assignFlow.js
@@ -1,5 +1,5 @@
import { scanBookings } from './intents';
-import { ORDER_STATUS_LABELS, groupForBookingStatus } from '@/lib/orderStatusGroups';
+import { getStatusMeta } from 'themes/dt/status';
import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
@@ -69,7 +69,7 @@ export const ASSIGN_STEPS = [
// A raw booking carries `status`; `orderstatus` is the mapped
// field the LIST pages add. Reading the wrong one made every
// option in this dropdown say "Unknown" — seen live.
- ORDER_STATUS_LABELS[groupForBookingStatus(b.status ?? b.orderstatus)] || 'Unknown',
+ getStatusMeta(b.status ?? b.orderstatus).label,
holder ? `held by ${describeRider(holder)}` : 'unassigned'
]
.filter(Boolean)
diff --git a/src/lib/assistant/bulkFile.js b/src/lib/assistant/bulkFile.js
index b8d6db0..c29ff09 100644
--- a/src/lib/assistant/bulkFile.js
+++ b/src/lib/assistant/bulkFile.js
@@ -1,7 +1,7 @@
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
-import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from '@/lib/bulkOrderColumns';
+import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from 'utils/bulkOrderColumns';
// ==============================|| Doormile AI — bulk order file upload ||============================== //
//
diff --git a/src/lib/assistant/bulkFlow.js b/src/lib/assistant/bulkFlow.js
index 3d6ffef..aebeb1b 100644
--- a/src/lib/assistant/bulkFlow.js
+++ b/src/lib/assistant/bulkFlow.js
@@ -1,6 +1,6 @@
-import { getTenantLocations } from '@/api/doormile/endpoints';
-import { getAdminTenants } from '@/api/doormile/endpoints';
-import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
+import { getTenantLocations } from 'pages/api/doormileApi';
+import { getalltenants } from 'pages/api/api';
+import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
diff --git a/src/lib/assistant/bulkOrderActions.js b/src/lib/assistant/bulkOrderActions.js
index 8e8b5d0..5e36bf7 100644
--- a/src/lib/assistant/bulkOrderActions.js
+++ b/src/lib/assistant/bulkOrderActions.js
@@ -1,5 +1,5 @@
-import { createExpressBookingBulk, getAdminPricing } from '@/api/doormile/endpoints';
-import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
+import { createExpressBookingBulk, getAdminPricing } from 'pages/api/doormileApi';
+import { calculateDrivingDistance, calculateTotalCharge } from 'utils/distance';
import { buildOrderPayload } from './orderActions';
// ==============================|| Doormile AI — bulk order creation ||============================== //
diff --git a/src/lib/assistant/intents.js b/src/lib/assistant/intents.js
index ba17739..c1fd5de 100644
--- a/src/lib/assistant/intents.js
+++ b/src/lib/assistant/intents.js
@@ -20,11 +20,11 @@ import {
getConsignmentLogs,
getAdminTenant,
getTenantLocations
-} from '@/api/doormile/endpoints';
-import { getAdminTenants } from '@/api/doormile/endpoints';
-import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
-import { getRowBatchId, getBatchLabel, BATCHES } from '@/lib/batchBucket';
-
+} from 'pages/api/doormileApi';
+import { getalltenants, getallridersummary } from 'pages/api/api';
+import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
+import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
+import { STATUS } from 'themes/dt/tokens';
// Only the read-only half of actions.js belongs here. Tenant resolution,
// payload building and execution live in the panel's submit handler — an
// intent must have no route to a write.
@@ -34,7 +34,7 @@ import { ASSIGN_TRIGGER } from './assignActions';
import { REPEAT_TRIGGER } from './repeatRuns';
import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
-import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from '@/lib/orderStatusGroups';
+import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
// ==============================|| Doormile Bot — intent catalog ||============================== //
//
@@ -279,7 +279,7 @@ const batchFromWords = (text) => {
// "done"/"completed" alongside "delivered", "declined"/"rejected" alongside
// "cancelled". Order matters: more specific phrases are checked before the
// broader "delivered" pattern so "undelivered" doesn't false-match it.
-// Returns an ORDER STATUS GROUP key (@/lib/orderStatusGroups.js) — the same
+// Returns an ORDER STATUS GROUP key (utils/orderStatusGroups.js) — the same
// taxonomy the Orders page's tabs count with. It previously returned api.js's
// delivery-status buckets, which is the Deliveries page's rider-centric view,
// so "how many assigned orders" never agreed with the Assigned tab.
@@ -460,15 +460,6 @@ const summarizeStatuses = (rows) => {
// STATUS is the raw palette and has no 'assigned' key; the group taxonomy and
// the colour palette are separate concerns and shouldn't be forced to match
// names.
-const STATUS = {
- pending: '#f59e0b',
- accepted: '#6366f1',
- active: '#14b8a6',
- delivered: '#10b981',
- cancelled: '#ef4444',
- muted: '#94a3b8'
-};
-
const GROUP_COLOR = {
pending: STATUS.pending,
assigned: STATUS.accepted,
@@ -512,7 +503,7 @@ const summarizeByField = (rows, field) => {
};
const resolveTenant = async (text) => {
- const tenants = (await getAdminTenants()) || [];
+ const tenants = (await getalltenants()) || [];
const lower = text.toLowerCase();
return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase()));
};
@@ -737,7 +728,7 @@ const ABOUT_TRIGGER =
// (in-transit) — statusBreakdown's match used to fire on that word alone and
// never returns null on a match (it always finds *some* count, even 0), so
// it never yielded to riderCounts and every rider question silently called
-// getBookings instead of getMilerSummary. Fixed two ways, deliberately
+// getBookings instead of getallridersummary. Fixed two ways, deliberately
// redundant: riderCounts/tenantList are ordered ahead of the generic
// intents below (INTENTS is checked in order, first match wins), AND the
// generic intents explicitly refuse to match when "rider" is mentioned, so
@@ -1175,7 +1166,7 @@ const INTENTS = [
return name ? { name } : null;
},
run: async ({ name }) => {
- const tenants = (await getAdminTenants()) || [];
+ const tenants = (await getalltenants()) || [];
const match = bestNameMatch(name, tenants, (t) => t.tenantname);
if (!match) return null;
const [detail, locations] = await Promise.all([
@@ -1194,7 +1185,7 @@ const INTENTS = [
? { title: 'Locations', items: sites.map((l) => ({ label: l.locationname || `Location ${l.locationid}`, meta: l.pincode })) }
: undefined,
sourceCalls: [
- { name: 'getAdminTenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` },
+ { name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` },
{ name: 'getAdminTenant', target: `/admin/tenants/${match.tenantid}`, status: detail ? 'complete' : 'error' },
{
name: 'getTenantLocations',
@@ -1339,8 +1330,8 @@ const INTENTS = [
let tenant = null;
let tenants = null;
if (tenantName || ranking?.groupBy === 'tenant') {
- tenants = (await getAdminTenants()) || [];
- sourceCalls.push({ name: 'getAdminTenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` });
+ tenants = (await getalltenants()) || [];
+ sourceCalls.push({ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` });
if (tenantName) {
tenant = bestNameMatch(tenantName, tenants, (t) => t.tenantname);
// Unrecognised name — don't quietly drop the filter and answer a
@@ -1457,13 +1448,13 @@ const INTENTS = [
label: 'Rider availability — e.g. "how many riders are active"',
match: (text) => (mentionsRiders(text) ? {} : null),
run: async () => {
- const summary = await getMilerSummary();
+ const summary = await getallridersummary();
return {
headline: `${summary?.active ?? 0} active riders (${summary?.available ?? 0} available, ${
summary?.onDelivery ?? 0
} on a delivery) out of ${summary?.total ?? 0} total.`,
detail: `${summary?.inactive ?? 0} inactive/offline.`,
- sourceCalls: [{ name: 'getMilerSummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }]
+ sourceCalls: [{ name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }]
};
}
},
@@ -1472,7 +1463,7 @@ const INTENTS = [
label: 'Tenant count — e.g. "how many tenants do we have"',
match: (text) => (/\btenants?\b/i.test(text) && /\bhow many\b|\blist\b|\ball\b/i.test(text) ? {} : null),
run: async () => {
- const tenants = (await getAdminTenants()) || [];
+ const tenants = (await getalltenants()) || [];
return {
headline: `${tenants.length} tenant${tenants.length === 1 ? '' : 's'} total.`,
metric: { value: tenants.length, label: 'Tenants' },
@@ -1484,7 +1475,7 @@ const INTENTS = [
.filter((t) => t.tenantname)
.map((t) => ({ label: t.tenantname, meta: t.tenantid != null ? `#${t.tenantid}` : undefined }))
},
- sourceCalls: [{ name: 'getAdminTenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }]
+ sourceCalls: [{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }]
};
}
},
@@ -1870,7 +1861,7 @@ const INTENTS = [
// rather than failing the whole answer.
const scan = await fetchBookingsInRange(start, end);
const rows = scan.rows;
- const riders = await getMilerSummary().catch(() => null);
+ const riders = await getallridersummary().catch(() => null);
return {
headline: `${countPhrase(scan, rows.length)} order${rows.length === 1 ? '' : 's'} ${rangeLabel}.`,
metric: { value: rows.length, label: `Orders ${rangeLabel}${scan.truncated ? ' (at least)' : ''}` },
@@ -1885,8 +1876,8 @@ const INTENTS = [
sourceCalls: [
scanCall(scan, `${rows.length} orders ${rangeLabel}`),
riders
- ? { name: 'getMilerSummary', target: '/admin/milers', status: 'complete', stats: `${riders.total} riders` }
- : { name: 'getMilerSummary', target: '/admin/milers', status: 'error', errorMessage: 'Rider summary unavailable' }
+ ? { name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${riders.total} riders` }
+ : { name: 'getallridersummary', target: '/admin/milers', status: 'error', errorMessage: 'Rider summary unavailable' }
]
};
}
@@ -2019,7 +2010,7 @@ const INTENTS = [
stats: statusStats(matched),
detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.` + truncationNote(scan),
sourceCalls: [
- { name: 'getAdminTenants', target: '/admin/tenants', status: 'complete' },
+ { name: 'getalltenants', target: '/admin/tenants', status: 'complete' },
scanCall(scan, `${rows.length} total → ${matched.length} for ${tenant.tenantname}`)
]
};
diff --git a/src/lib/assistant/orderActions.js b/src/lib/assistant/orderActions.js
index 0658b2a..7a6c691 100644
--- a/src/lib/assistant/orderActions.js
+++ b/src/lib/assistant/orderActions.js
@@ -1,5 +1,5 @@
-import { createExpressBooking, getTenantLocations } from '@/api/doormile/endpoints';
-import { getAdminTenants } from '@/api/doormile/endpoints';
+import { createExpressBooking, getTenantLocations } from 'pages/api/doormileApi';
+import { getalltenants } from 'pages/api/api';
// ==============================|| Doormile AI — create order ||============================== //
//
@@ -43,7 +43,7 @@ export const cityGateFor = (pincode) => {
return OPEN_CITY_PREFIXES[prefix] || null;
};
-export const loadOrderTenants = async () => (await getAdminTenants()) || [];
+export const loadOrderTenants = async () => (await getalltenants()) || [];
// Saved pickup sites for a tenant. Each carries address / city / pincode /
// coordinates — which is what the payload copies onto the booking, since
diff --git a/src/lib/assistant/orderFlow.js b/src/lib/assistant/orderFlow.js
index f59de5c..8c19785 100644
--- a/src/lib/assistant/orderFlow.js
+++ b/src/lib/assistant/orderFlow.js
@@ -1,8 +1,7 @@
-import { getAdminPricing, getAdminCustomers, getTenantLocations } from '@/api/doormile/endpoints';
-import { getAdminTenants } from '@/api/doormile/endpoints';
-import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
-import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
-
+import { getAdminPricing, getAdminCustomers, getTenantLocations } from 'pages/api/doormileApi';
+import { getalltenants } from 'pages/api/api';
+import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from 'utils/distance';
+import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
diff --git a/src/lib/assistant/ragRouter.js b/src/lib/assistant/ragRouter.js
index 3190678..4092ae5 100644
--- a/src/lib/assistant/ragRouter.js
+++ b/src/lib/assistant/ragRouter.js
@@ -13,7 +13,7 @@
//
// Today's behaviour is the floor. This can raise it, never lower it.
-const BASE = import.meta.env.VITE_AI_URL || import.meta.env.REACT_APP_AI_URL || '';
+const BASE = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AI_URL) || (typeof process !== 'undefined' && process.env?.REACT_APP_AI_URL) || '';
const ROUTE_TIMEOUT_MS = 400;
export const isRagEnabled = () => Boolean(BASE);
diff --git a/src/lib/assistant/repeatRuns.js b/src/lib/assistant/repeatRuns.js
index 33a10ec..df41b48 100644
--- a/src/lib/assistant/repeatRuns.js
+++ b/src/lib/assistant/repeatRuns.js
@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
-import { getAdminCustomers } from '@/api/doormile/endpoints';
-import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
-import { groupForBookingStatus } from '@/lib/orderStatusGroups';
+import { getAdminCustomers } from 'pages/api/doormileApi';
+import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
+import { groupForBookingStatus } from 'utils/orderStatusGroups';
import { scanBookings } from './intents';
import { cityGateFor } from './orderActions';
import { priceBulkRows } from './bulkOrderActions';
diff --git a/src/lib/assistant/scan.js b/src/lib/assistant/scan.js
index 320c653..1e7dff8 100644
--- a/src/lib/assistant/scan.js
+++ b/src/lib/assistant/scan.js
@@ -66,39 +66,47 @@ const getPageCached = (page) => {
return promise;
};
-export const fetchBookingsInRange = async (start, end) => {
- const firstPage = await getPageCached(1);
+/**
+ * Drain pages up to the budget and return every row, unfiltered.
+ *
+ * `makeStop` receives page 1's rows once and returns the per-page early-stop
+ * predicate, so a range scan can bail as soon as it has read past its window
+ * while an unfiltered drain simply reads to the budget.
+ *
+ * `fetchPage` is injectable because the page cache below is the wrong layer for
+ * a caller that already has one. A TanStack-managed screen sets its own refetch
+ * interval, and serving it from a 20s module-level cache would silently cap how
+ * fresh that screen can ever be.
+ */
+const drainPages = async (makeStop, fetchPage = getPageCached) => {
+ const firstPage = await fetchPage(1);
const total = firstPage.total;
- const pageCount = Math.max(1, Math.ceil(total / BULK_PAGESIZE));
+ const pageSize = Math.max(1, firstPage.rows?.length || 100);
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
const budget = Math.min(pageCount, MAX_PAGES);
- const collected = [...firstPage.rows];
- const descending = isDescendingByCreatedAt(firstPage.rows);
+ const collected = [...(firstPage.rows || [])];
+ const shouldStop = makeStop ? makeStop(firstPage.rows) : () => false;
- /* Newest-first and this page already ends before the window opens → every
- later page is older still, so there is nothing left to find. */
- const pageEndsBeforeRange = (rows) => {
- if (!descending || !rows.length) return false;
- const oldest = parseDoormileTimestamp(rows[rows.length - 1].createdat);
- return oldest.isValid() && oldest.format('YYYY-MM-DD') < start;
- };
-
- let stoppedEarly = pageEndsBeforeRange(firstPage.rows);
+ let stoppedEarly = shouldStop(firstPage.rows) || collected.length >= total;
let lastPageFetched = 1;
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
- const next = await getPageCached(page);
+ const next = await fetchPage(page);
lastPageFetched = page;
- if (!next.rows.length) {
+ if (!next.rows?.length) {
stoppedEarly = true;
break;
}
collected.push(...next.rows);
- stoppedEarly = pageEndsBeforeRange(next.rows);
+ if (collected.length >= total) {
+ break;
+ }
+ stoppedEarly = shouldStop(next.rows);
}
return {
- rows: collected.filter((booking) => inRange(booking, start, end)),
+ rows: collected,
/* Truncated only if the budget ran out with pages still unread AND the scan
did not stop early because it had already passed the window. */
truncated: !stoppedEarly && pageCount > budget,
@@ -108,6 +116,35 @@ export const fetchBookingsInRange = async (start, end) => {
};
};
+export const fetchBookingsInRange = async (start, end) => {
+ const scan = await drainPages((firstRows) => {
+ const descending = isDescendingByCreatedAt(firstRows);
+ /* Newest-first and this page already ends before the window opens → every
+ later page is older still, so there is nothing left to find. */
+ return (rows) => {
+ if (!descending || !rows.length) return false;
+ const oldest = parseDoormileTimestamp(rows[rows.length - 1].createdat);
+ return oldest.isValid() && oldest.format('YYYY-MM-DD') < start;
+ };
+ });
+
+ /* `scanned` deliberately stays the pre-filter count — it describes how much of
+ the account was read, which is what `truncated` has to be judged against. */
+ return { ...scan, rows: scan.rows.filter((booking) => inRange(booking, start, end)) };
+};
+
+/**
+ * Every booking the account has, up to the page budget, with **no date filter**.
+ *
+ * For screens that show the whole list rather than a window. Deliberately not
+ * `fetchBookingsInRange` with sentinel bounds: that path still runs `inRange`,
+ * which drops any row whose `createdat` will not parse. Dropping a row from a
+ * date-scoped answer is defensible; dropping it from "every order" is not — the
+ * row exists and the operator has to be able to see it.
+ */
+export const drainBookings = ({ cached = true } = {}) =>
+ drainPages(undefined, cached ? getPageCached : (page) => getBookingsPage(page, BULK_PAGESIZE));
+
export const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day);
/**
diff --git a/src/lib/orderStatusGroups.js b/src/lib/orderStatusGroups.js
index 8c9faa6..4370e9c 100644
--- a/src/lib/orderStatusGroups.js
+++ b/src/lib/orderStatusGroups.js
@@ -22,14 +22,11 @@
// ============================================================================
export const ORDER_STATUS_GROUPS = {
- pending: ['pending_pickup'],
- assigned: ['converted_to_consignment', 'miler_assigned', 'pickup_scheduled'],
- // The Orders page has no tab for this one — a booking that is out for
- // delivery has left the operator's queue. It stays in the map so a status
- // breakdown accounts for every row rather than silently dropping some.
- active: ['out_for_delivery'],
- delivered: ['delivered'],
- cancelled: ['cancelled']
+ pending: ['pending_pickup', 'pending', 'created', 'new', 'booked', 'order_placed', 'unassigned', ''],
+ assigned: ['converted_to_consignment', 'collected_by_miler', 'miler_assigned', 'pickup_scheduled', 'arrived', 'picked', 'assigned', 'rider_assigned'],
+ active: ['out_for_delivery', 'active', 'in_transit', 'picked_up', 'in_progress'],
+ delivered: ['delivered', 'completed', 'success'],
+ cancelled: ['cancelled', 'rejected', 'failed']
};
export const ORDER_STATUS_LABELS = {
diff --git a/src/lib/query-client.js b/src/lib/query-client.js
index e71a248..0433406 100644
--- a/src/lib/query-client.js
+++ b/src/lib/query-client.js
@@ -5,6 +5,8 @@ export const queryClientInstance = new QueryClient({
queries: {
refetchOnWindowFocus: false,
retry: 1,
+ refetchInterval: 8000, // Auto-refresh every 8 seconds across all pages
+ staleTime: 4000,
},
},
});
diff --git a/src/main.jsx b/src/main.jsx
index 6037e58..abac273 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,7 +1,11 @@
import ReactDOM from 'react-dom/client'
+import '@astryxdesign/core/reset.css'
+import '@astryxdesign/core/astryx.css'
+import '@/globalPolish.css'
import App from '@/App.jsx'
import '@/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
)
+
diff --git a/src/pages/doormile/clients/Tenants.jsx b/src/pages/doormile/clients/Tenants.jsx
index 584c1af..31a324d 100644
--- a/src/pages/doormile/clients/Tenants.jsx
+++ b/src/pages/doormile/clients/Tenants.jsx
@@ -9,7 +9,6 @@ import {
Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Stack, StatusBadge, Surface, Switch, Tabs,
} from '@/components/ds';
-import { ListToolbar } from '@/components/doormile/ListToolbar';
import {
useCreateTenantLocation, usePricing, useTenantCustomers, useTenantLocations,
useTenants, useUpdateTenant, useUpdateTenantLocation,
@@ -449,20 +448,24 @@ export default function Tenants() {
-
- ({ ...entry, count: stats[entry.value] }))}
- value={tab}
- onChange={setTab}
- className="shrink-0"
- />
+ {/* Wraps, and the tab strip scrolls in its own track — see Orders.jsx
+ for why two `shrink-0` halves scrolled the whole page on a phone. */}
+
diff --git a/src/pages/doormile/deliveries/Deliveries.jsx b/src/pages/doormile/deliveries/Deliveries.jsx
index c4b88d2..e36b31e 100644
--- a/src/pages/doormile/deliveries/Deliveries.jsx
+++ b/src/pages/doormile/deliveries/Deliveries.jsx
@@ -12,7 +12,7 @@ import {
useUpdateDeliveryStatus,
} from '@/lib/doormileHooks';
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
-import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
+import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
/**
@@ -111,7 +111,13 @@ export default function Deliveries() {
const cancelDelivery = useCancelDelivery();
const notifyMiler = useNotifyMiler();
- const allRows = data?.rows || [];
+ const allRows = useMemo(() => {
+ return [...(data?.rows || [])].sort((a, b) => {
+ const ta = parseDoormileTimestamp(a.orderdate || a.assigntime).valueOf() || 0;
+ const tb = parseDoormileTimestamp(b.orderdate || b.assigntime).valueOf() || 0;
+ return tb - ta;
+ });
+ }, [data?.rows]);
/* Batch, client and rider first, then status, then search — the counts under
the tabs have to describe the slice the operator has chosen, not the whole
@@ -373,7 +379,12 @@ export default function Deliveries() {
/>
-
+ {/* Eight status tabs are far wider than a phone. Scrolled in their own
+ track so the strip moves instead of the whole document — see
+ Orders.jsx for the same fix. */}
+
diff --git a/src/pages/doormile/dispatch/ActiveSection.jsx b/src/pages/doormile/dispatch/ActiveSection.jsx
index bfd004b..3e9b8a0 100644
--- a/src/pages/doormile/dispatch/ActiveSection.jsx
+++ b/src/pages/doormile/dispatch/ActiveSection.jsx
@@ -6,78 +6,17 @@ import {
MdRestaurant,
MdMyLocation,
MdAccessTime,
- MdInventory2
+ MdInventory2,
+ MdStraighten,
} from 'react-icons/md';
-import { List, ListItem } from '@astryxdesign/core/List';
-import { VStack } from '@astryxdesign/core/VStack';
-import { HStack } from '@astryxdesign/core/HStack';
-import { Center } from '@astryxdesign/core/Center';
-import { Text } from '@astryxdesign/core/Text';
-import { EmptyState } from '@astryxdesign/core/EmptyState';
-import { Tooltip } from '@astryxdesign/core/Tooltip';
-
-import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
-import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
-import StatusBadge from 'components/nearle_components/StatusBadge';
-import { AccentAvatar } from 'themes/dt/primitives';
+import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { getActiveOrder } from './dispatchShared';
-import { OpenToast } from 'components/third-party/OpenToast';
-
-// ==============================|| ACTIVE DELIVERIES (sidebar) ||============================== //
-// Rows, not cards. Astryx's design rule is that dense operator data reads as
-// edge-to-edge rows — the old `.adcard` bordered cards cost ~16px of chrome per
-// delivery, which is a lot of scrolling once a slot has 20+ riders out.
-//
-// Status comes from (themes/dt/status.js), which is now the only
-// status-colour table in this folder — dispatchShared's rival copy has been
-// deleted and its getStatusStyle reduced to an adapter over the same source.
-
-// Small labelled metric — icon + value, used for the distance/ETA pair.
-// The old markup carried a native `title` for the label ("Distance to drop");
-// Astryx's BaseProps deliberately omits `title`, so the affordance moves to the
-// real rather than silently disappearing onto a dropped attribute.
-const Metric = ({ icon, children, label }) => (
-
-
-
- {icon}
-
-
- {children}
-
-
-
-);
-
-Metric.propTypes = {
- icon: PropTypes.node,
- children: PropTypes.node,
- label: PropTypes.string
-};
-
-// One line of secondary context (rider / drop area / pickup) under the customer.
-// `maxLines={1}` turns on Text's own truncate tooltip, which replaces the
-// `title` the old spans used to show the full value when clipped.
-const ContextLine = ({ icon, children }) => (
-
-
- {icon}
-
-
- {children}
-
-
-);
-
-ContextLine.propTypes = {
- icon: PropTypes.node,
- children: PropTypes.node
-};
+import { OpenToast } from '@/components/third-party/OpenToast';
const ActiveSection = ({
- visibleRiders,
- riders,
+ visibleRiders = [],
+ riders = [],
focusedStop,
handleRiderFocus,
setFocusedStop,
@@ -116,17 +55,14 @@ const ActiveSection = ({
Object.entries(currentMap).forEach(([oid, cur]) => {
const old = prev[oid];
if (!old) {
- // Brand-new order that is already active → rider has taken a new order
if (cur.status === 'active') {
OpenToast(`🛵 ${cur.riderName} is now delivering to ${cur.customer}`, 'success', 3000);
}
return;
}
- // Rider transitioned into active → started heading to customer
if (old.status !== 'active' && cur.status === 'active') {
OpenToast(`🛵 ${cur.riderName} is now delivering to ${cur.customer}`, 'success', 3000);
}
- // Rider delivered → reached customer location
if (old.status === 'active' && cur.status === 'delivered') {
OpenToast(`${cur.riderName} has reached ${cur.customer}'s location`, 'info', 3000);
}
@@ -138,108 +74,179 @@ const ActiveSection = ({
if (isLoading) {
return (
-
-
- Loading active deliveries…
-
+