Compare commits

..

4 Commits

Author SHA1 Message Date
5284fc75bc Doormile AI: fix two silent-failure bugs in entity handling
Both were found while re-checking the branch, not from a failing build —
each fails quietly and would have looked like "the bot just doesn't know".

riderLookup read `status`, `phonenumber` and `vehicletype` off a miler.
None of those fields exist: api.js documents the confirmed live shape as
`availabilitystatus`, `phone` and `defaultvehicletype`, and states outright
that no `status` field is present. So "where is rider Kumar" always rendered
"status unknown" with no phone and no vehicle. Now reads the real names and
adds the hub.

correctTypos could rewrite a named entity into a vocabulary word. It runs
before matching, and orderQuery extracts tenant/rider names from the
corrected text, so a tenant called "Partnerz" (one edit from "partners") or
"Ordero" (one from "orders") was silently renamed and then failed to
resolve — with no indication that a filter had been dropped. Words
following rider/tenant/hub/vehicle/customer/partner/named/called/for are
now protected from correction; genuine keyword typos still get fixed.

The comment above correctTypos already claimed names were never touched.
Nothing enforced it. The L2 composite-query work made the gap matter,
because before it entity names barely reached the matcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:58:03 +05:30
1ea936c7d8 Doormile AI: document the rules that are load-bearing
CLAUDE.md for the assistant folder, plus the capability roadmap.

The rules worth reading before editing either file:
- `truncated` is not optional to handle; an intent that reads scan.rows and
  ignores it reintroduces the silent under-reporting this replaced.
- Order status comes from utils/orderStatusGroups.js, not api.js's
  Deliveries taxonomy, and the two must not be merged.
- State questions ("how many are assigned") must not default to today;
  flow questions ("how many orders today") still do.
- orderQuery must not widen to claim single-dimension questions.
- Never put a React element in message state — localStorage round-trips it.

ROADMAP.md carries the analysis and the L0-L8 capability ladder, including
what is deliberately still out of scope: write actions pending a confirm
gate, and any LLM step pending a decision on where its key would live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:54:12 +05:30
51667d1c9f Doormile AI: make the numbers trustworthy, then composable
Data-layer work on the assistant, in the order it mattered.

Correctness first:

- getBookingsPage keeps the envelope's `total`/`page`. getBookings threw
  them away, so no caller could tell a full result from a truncated one.
- Every booking read now drains pages up to a budget instead of taking page
  one at the API's 1000-row cap. Past 1000 lifetime bookings, every count
  and sum in this file silently under-reported while the source line beside
  it still read "complete". Row order is detected per call, so a backend
  that stops returning newest-first degrades to a full scan rather than to
  a wrong answer.
- A capped scan now says "At least N", appends what it scanned versus the
  total, and marks its source call as failed.
- Revenue excludes cancelled orders, sums every service option rather than
  the first, and is labelled estimated — it is a quote, not settled money.
- A strong order reference that isn't found is answered "I couldn't find
  it" instead of falling through to a broader intent, which used to answer
  "142 orders created today" to a question about one order.

Then agreement with the Orders page:

- utils/orderStatusGroups.js is now the single definition of which raw
  booking enums make up each status; orders.js builds its tabs from it and
  the assistant matches against it. The assistant had been using api.js's
  Deliveries taxonomy, which keeps miler_assigned on `pending`, so the
  Orders page showed 19 Assigned while the bot answered 0. The two
  taxonomies stay separate on purpose — Orders tracks the operator's
  action, Deliveries tracks the rider's.
- A status question with no date named is no longer scoped to today. "How
  many orders are assigned" describes the queue right now, which is what
  the Orders page's tabs show; they apply no date filter either.
- The Orders header said "Today" over counts that were never date-filtered.
  Corrected the label rather than adding a filter, since filtering would
  hide currently-visible rows — a product decision, not a bug fix.

Then capability:

- delayedOrders answers "which orders are delayed" from the promised
  delivery time. deliveries.js and Dispatch.js both rejected that field for
  batch bucketing because an ETA is not the wave an order belongs to; that
  reasoning does not carry over to lateness, where a promise that never
  gets re-stamped is exactly the right baseline. If no order carries an
  ETA it says so rather than reporting a reassuring "0 delayed".
- orderQuery composes status x batch x tenant x rider, plus rankings. It
  claims a question only when two or more of those are present, so
  single-dimension questions keep their proven intents. A date is not
  counted as a dimension — counting it re-routed four working questions.
- An unresolved tenant/rider name falls through instead of having its
  filter silently dropped, which was the original defect.
- orderLookup resolves the rider's name and reads the tracking trail
  defensively, since that endpoint's response shape is undocumented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:54:01 +05:30
5165e9d697 Doormile AI: replace the bot popover with a right-side slide-over
Renames the operator assistant to "Doormile AI / Operations Copilot" and
moves it from a header Popover to a portalled slide-over panel.

- DoormileAI/ — trigger, panel, welcome, message, composer and shared
  primitives. Assistant turns are deliberately NOT bubbles; that is what
  keeps this reading as part of the dashboard rather than a bolted-on
  chatbot.
- pageContext.js — route-aware suggested questions. Every suggestion is a
  phrasing the matcher actually resolves; a chip that returns "I can't
  answer that" is worse than no chip.
- DoormileAI.css — panel styling, animation, responsive and reduced-motion.
  Selectors that style an Astryx Stack are written `.dai-root .x` because
  `padding={0}` emits a StyleX atomic at the same specificity as a bare
  class, so a single class can lose on stylesheet order.
- Messages are JSON round-tripped through localStorage, so icons are
  referenced by key rather than stored as React elements — an element does
  not survive the trip and the rehydrated value crashes the next render.
- Errors now render a polished state and log the real error to the console,
  instead of surfacing raw API messages in a toast.

The route, sidebar entry and i18n key for the old standalone Assistant page
are removed with it — this is a header surface, not a page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:53:40 +05:30
19 changed files with 4000 additions and 279 deletions

View File

@@ -34,6 +34,7 @@ import { clearFcmToken } from 'store/reducers/fcmSlice';
import { logoutUser } from 'store/reducers/loginUserSlice';
import { performSessionLogout } from 'utils/session';
import { DT } from 'themes/dt/tokens';
import DoormileAITrigger from 'pages/nearle/assistant/DoormileAI';
// doormile-logo.png is a white asset; recolour to brand red for this
// light TopNav surface (same trick login.js uses for its white-card logo).
@@ -130,6 +131,8 @@ const AppTopNav = () => {
]}
/>
<DoormileAITrigger />
<Popover placement="below" alignment="end" content={<NotificationPanel />} label="Notifications">
<IconButton label="Notifications" tooltip="Notifications" variant="ghost" icon={<BellOutlined />} />
</Popover>

View File

@@ -32,8 +32,7 @@ import {
TruckOutlined,
WarningOutlined,
RadarChartOutlined,
RiseOutlined,
RobotOutlined
RiseOutlined
} from '@ant-design/icons';
// icons
@@ -67,8 +66,7 @@ const icons = {
TruckOutlined,
WarningOutlined,
RadarChartOutlined,
RiseOutlined,
RobotOutlined
RiseOutlined
};
// ==============================|| MENU ITEMS - SUPPORT ||============================== //
@@ -128,13 +126,6 @@ const nearle = {
url: '/doormile/customers',
icon: icons.ContactsOutlined
},
{
id: 'assistant',
title: <FormattedMessage id="assistant" />,
type: 'item',
url: '/doormile/assistant',
icon: icons.RobotOutlined
},
{
id: 'fleetops',
title: <FormattedMessage id="fleetops" />,

View File

@@ -323,6 +323,33 @@ export const getBookings = async (pageno, pagesize) => {
return response.data.data;
};
// Same request as getBookings, but keeps the envelope's `total`/`page` instead
// of throwing them away.
//
// getBookings returns `response.data.data` — just the rows — which means no
// caller can tell a full result from a truncated one. `pagesize` is capped at
// 1000 server-side (express-console-api.md, "Conventions": default 500, cap
// 1000), so any account with more than 1000 bookings silently gets a partial
// list from a single call. That's tolerable for a paginated table (it fetches
// the next page as you scroll) but NOT for anything that counts or sums, which
// would report a confidently wrong number.
//
// Kept as a separate export rather than changing getBookings' return shape:
// four callers (fetchDeliveries, fetchCountAPI, orders.js, the assistant's own
// per-order lookup) already destructure it as a bare array.
export const getBookingsPage = async (pageno, pagesize) => {
const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`);
const body = response.data || {};
return {
rows: body.data || [],
// `total` is documented as present on list responses; fall back to the row
// count so a backend that omits it degrades to "this is everything" rather
// than to NaN-driven pagination.
total: Number.isFinite(Number(body.total)) ? Number(body.total) : (body.data || []).length,
page: Number(body.page) || pageno
};
};
export const createExpressBooking = async (data) => {
// tenantid is forced to the caller's own tenant server-side on client logins.
// CityGate applies: pickup pincode prefix must be an open city (641/600/560/500/629).

View File

@@ -0,0 +1,131 @@
# 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 `<DoormileAITrigger />`. 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.
- `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.
- **`--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 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 (retrieval-augmented generation over a document/vector store) was also explicitly considered and rejected as the wrong tool: this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions. The correct "grounded answer" pattern for that is what's already here — a fixed catalog of `{match, run}` pairs, not a vector search.
---
## 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 `<ChatToolCalls>` 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.
- **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.
---
## 4. What's deliberately out of scope right now
- **Write actions.** The original ask included "if I say create an order, it should create it" — deliberately **not built**. Giving a keyword-matched bot the ability to mutate data (order creation has real validation elsewhere: CityGate pincode checks, delivery-slot windows, the dispatch reconcile-before-commit rule) is a materially bigger risk than read-only Q&A. If this gets built, it needs its own guardrail — the bot proposes what it would submit, the operator explicitly confirms, only then does a real create-order call fire. Don't wire a write action straight from intent match to a mutation call.
- **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.

View File

@@ -0,0 +1,776 @@
/* ==========================================================================
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).
========================================================================== */
.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;
/* 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.
-------------------------------------------------------------------------- */
.dai-scrim {
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
-------------------------------------------------------------------------- */
.dai-panel {
position: fixed;
top: var(--dai-inset);
right: var(--dai-inset);
bottom: var(--dai-inset);
z-index: 1301;
width: var(--dai-panel-width);
max-width: calc(100vw - (var(--dai-inset) * 2));
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--dai-surface);
border: 1px solid var(--dai-border);
border-radius: 16px;
box-shadow: var(--dai-shadow);
transform: translateX(calc(100% + var(--dai-inset) * 2));
opacity: 0;
transition:
transform var(--dai-duration) var(--dai-ease),
opacity var(--dai-duration) var(--dai-ease);
}
.dai-panel[data-open='true'] {
transform: translateX(0);
opacity: 1;
}
.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;
color: #ffffff;
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
box-shadow: 0 0 0 3px var(--dai-ai-glow);
}
.dai-root .dai-spark[data-size='sm'] {
width: 22px;
height: 22px;
}
.dai-root .dai-spark[data-size='md'] {
width: 30px;
height: 30px;
}
.dai-root .dai-spark[data-size='lg'] {
width: 44px;
height: 44px;
box-shadow: 0 0 0 6px var(--dai-ai-glow);
}
/* 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;
}
/* --------------------------------------------------------------------------
Scroll region
-------------------------------------------------------------------------- */
.dai-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
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;
}
/* --------------------------------------------------------------------------
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
-------------------------------------------------------------------------- */
.dai-suggestion {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 11px;
text-align: left;
font: inherit;
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
border-radius: 12px;
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.dai-suggestion:hover {
background: var(--dai-surface-alt);
border-color: var(--dai-border-strong);
}
.dai-suggestion:active {
transform: scale(0.99);
}
.dai-suggestion:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-root .dai-suggestion-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 8px;
background: var(--dai-surface-hover);
color: var(--dai-text-secondary);
}
.dai-root .dai-suggestion-text {
flex: 1 1 auto;
font-size: 13px;
line-height: 1.35;
}
.dai-root .dai-suggestion-arrow {
flex: 0 0 auto;
color: var(--dai-text-muted);
opacity: 0;
transform: translateX(-3px);
transition:
opacity 140ms ease,
transform 140ms ease;
}
.dai-suggestion:hover .dai-suggestion-arrow,
.dai-suggestion:focus-visible .dai-suggestion-arrow {
opacity: 1;
transform: translateX(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: 4px;
}
/* --------------------------------------------------------------------------
Conversation
-------------------------------------------------------------------------- */
.dai-root .dai-thread {
padding: 16px 14px 8px;
}
.dai-root .dai-msg {
animation: dai-enter 220ms var(--dai-ease) both;
}
@keyframes dai-enter {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
/* User — compact, right aligned, brand surface. */
.dai-root .dai-msg-user {
max-width: 82%;
margin-left: auto;
padding: 8px 12px;
border-radius: 14px;
border-bottom-right-radius: 6px;
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: 10px;
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: 12px;
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: 8px;
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;
}
.dai-scroll-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
/* --------------------------------------------------------------------------
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);
border-radius: 16px;
background: var(--dai-surface);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
padding: 8px 8px 6px 12px;
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 rgba(15, 23, 42, 0.06);
}
.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-send {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
border: none;
border-radius: 999px;
color: var(--dai-accent-contrast);
background: var(--dai-accent);
cursor: pointer;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.dai-send:hover:not(:disabled) {
opacity: 0.88;
}
.dai-send:active:not(:disabled) {
transform: scale(0.93);
}
.dai-send:disabled {
opacity: 0.28;
cursor: default;
}
.dai-send:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-root .dai-hint {
font-size: 11px;
color: var(--dai-text-muted);
}
/* --------------------------------------------------------------------------
Trigger (lives in the app TopNav)
-------------------------------------------------------------------------- */
.dai-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border-radius: 9px;
border: 1px solid transparent;
background: transparent;
color: #4f46e5;
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.dai-trigger:hover {
background: rgba(99, 102, 241, 0.09);
border-color: rgba(99, 102, 241, 0.16);
}
.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'] {
color: #ffffff;
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
border-color: transparent;
}
/* --------------------------------------------------------------------------
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;
}
.dai-msg-user {
max-width: 88%;
}
}
/* --------------------------------------------------------------------------
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;
}
}

View File

@@ -0,0 +1,90 @@
import { useLayoutEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { LuArrowUp } from 'react-icons/lu';
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 ||============================== //
//
// Auto-growing textarea (capped at ~5 lines by the stylesheet's max-height),
// Enter to send, Shift+Enter for a newline. Hand-rolled rather than Astryx's
// <ChatComposer> because the panel needs the compact anchored treatment and
// a circular send button, neither of which that component exposes — the
// dictation button it ships IS reused here rather than reinvented.
const MAX_ROWS_PX = 108;
const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
const textareaRef = useRef(null);
const [isFocused, setIsFocused] = useState(false);
const dictation = useChatDictation({ onResult: (transcript) => onChange(transcript) });
// Grow with content, then let the stylesheet's max-height take over and
// scroll. Runs before paint so there's no visible jump on the first line
// break.
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
}, [value]);
const canSend = value.trim().length > 0 && !isBusy;
const submit = () => {
if (!canSend) return;
onSubmit(value);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
return (
<VStack className="dai-composer-wrap" gap={1} padding={0}>
<VStack className="dai-composer" data-focused={isFocused} gap={1} padding={0}>
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
aria-label="Ask Doormile AI about operations"
/>
<HStack gap={1} padding={0} vAlign="center" justify="between">
<ChatDictationButton dictation={dictation} size="sm" />
<button
type="button"
className="dai-send"
onClick={submit}
disabled={!canSend}
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
>
<LuArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
</button>
</HStack>
</VStack>
<Text className="dai-hint">Enter to send · Shift + Enter for a new line</Text>
</VStack>
);
};
AIComposer.propTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
isBusy: PropTypes.bool,
placeholder: PropTypes.string
};
export default AIComposer;

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { CopyOutlined } from '@ant-design/icons';
import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
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 { ChatToolCalls } from '@astryxdesign/core/Chat';
import { Spark, Metric, StatGrid, StateBlock } from './AIParts';
// ==============================|| Doormile AI — a single turn ||============================== //
//
// User turns are compact right-aligned bubbles. Assistant turns are NOT
// bubbles — text sits directly on the panel surface with a small spark mark,
// which is what keeps this from reading as a consumer chat app.
//
// The `sources` disclosure is the one thing that must not be dropped in the
// name of visual quiet: it's the existing verifiability contract (every
// number traceable to a real call). It's collapsed by default and expands to
// the same <ChatToolCalls> the previous panel showed inline.
const UserMessage = ({ text }) => (
<VStack className="dai-msg" gap={0} padding={0}>
<Text className="dai-msg-user">{text}</Text>
</VStack>
);
UserMessage.propTypes = { text: PropTypes.string.isRequired };
const AssistantMessage = ({ message, onCopy, onAsk }) => {
const [showSources, setShowSources] = useState(false);
const sourceCount = message.sourceCalls?.length || 0;
return (
<VStack className="dai-msg dai-msg-row" gap={1.5} padding={0}>
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
<HStack gap={1} padding={0} vAlign="center">
<Spark size="sm" />
<Text className="dai-msg-name">Doormile AI</Text>
</HStack>
<HStack className="dai-msg-actions" gap={0} padding={0}>
<IconButton
size="sm"
variant="ghost"
label="Copy answer"
tooltip="Copy answer"
icon={<CopyOutlined />}
onClick={() => onCopy(message)}
/>
</HStack>
</HStack>
{message.state ? (
<StateBlock iconKey={message.state.iconKey} tone={message.state.tone} title={message.state.title} body={message.state.body} />
) : (
<VStack gap={1.5} padding={0}>
{message.metric ? (
<VStack gap={1.5} padding={0}>
<Metric value={message.metric.value} label={message.metric.label} />
</VStack>
) : (
<Text className="dai-msg-ai">{message.text}</Text>
)}
<StatGrid stats={message.stats} />
{message.detail && <Text className="dai-msg-ai-detail">{message.detail}</Text>}
</VStack>
)}
{/* Live-data footer + source disclosure. Small and muted — the point is
that it's available, not that it's announced on every turn. */}
{sourceCount > 0 && (
<VStack gap={1} padding={0}>
<HStack gap={1} padding={0} vAlign="center">
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
<button type="button" className="dai-link" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
<LuChevronDown
size={11}
style={{ marginLeft: 3, verticalAlign: '-1px', transform: showSources ? 'rotate(180deg)' : 'none' }}
aria-hidden="true"
/>
</button>
</HStack>
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
</VStack>
)}
{message.followUps?.length > 0 && (
<VStack gap={1} padding={0}>
{message.followUps.map((q) => (
<button key={q} type="button" className="dai-suggestion" onClick={() => onAsk(q)}>
<Text className="dai-suggestion-text">{q}</Text>
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
</button>
))}
</VStack>
)}
</VStack>
);
};
AssistantMessage.propTypes = {
message: PropTypes.object.isRequired,
onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired
};
const AIMessage = ({ message, onCopy, onAsk }) =>
message.sender === 'user' ? <UserMessage text={message.text} /> : <AssistantMessage message={message} onCopy={onCopy} onAsk={onAsk} />;
AIMessage.propTypes = {
message: PropTypes.object.isRequired,
onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired
};
export default AIMessage;

View File

@@ -0,0 +1,304 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
import dayjs from 'dayjs';
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
import { LuChevronDown } from 'react-icons/lu';
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 { OpenToast } from 'components/third-party/OpenToast';
import { STATUS } from 'themes/dt/tokens';
import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '../intents';
import { getPageContext } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import AIWelcome from './AIWelcome';
import AIMessage from './AIMessage';
import AIComposer from './AIComposer';
import '../DoormileAI.css';
// ==============================|| Doormile AI — slide-over panel ||============================== //
//
// Right-side slide-over rendered into a portal so it layers over the page
// without the dashboard reflowing underneath it. Data logic is unchanged —
// every answer still comes from intents.js's answerQuestion(), which only
// calls the same API functions the pages themselves use.
//
// The panel stays mounted through its closing transition (`isMounted` vs
// `isShown`) so the exit animation actually plays instead of the element
// disappearing on the first frame.
const ANIMATION_MS = 260;
const HISTORY_KEY = 'doormileBotHistory';
const HISTORY_LIMIT = 50;
const PIN_THRESHOLD_PX = 48;
let nextId = 1;
const makeId = () => nextId++;
const now = () => dayjs().format('hh:mm A');
const loadHistory = () => {
try {
const raw = localStorage.getItem(HISTORY_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const saveHistory = (messages) => {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(messages.slice(-HISTORY_LIMIT)));
} catch {
// localStorage unavailable (private mode, quota) — history just won't persist
}
};
const AIPanel = ({ isOpen, onClose }) => {
const { pathname } = useLocation();
const pageContext = getPageContext(pathname);
const [isMounted, setIsMounted] = useState(isOpen);
const [isShown, setIsShown] = useState(false);
const [messages, setMessages] = useState(loadHistory);
const [value, setValue] = useState('');
const [isSending, setIsSending] = useState(false);
const [context, setContext] = useState({ lastIntentId: null, lastParams: null });
const [isPinned, setIsPinned] = useState(true);
const panelRef = useRef(null);
const scrollRef = useRef(null);
const lastQuestionRef = useRef(null);
// ---- open / close transition ----
useEffect(() => {
if (isOpen) {
setIsMounted(true);
const raf = requestAnimationFrame(() => setIsShown(true));
return () => cancelAnimationFrame(raf);
}
setIsShown(false);
const timer = setTimeout(() => setIsMounted(false), ANIMATION_MS);
return () => clearTimeout(timer);
}, [isOpen]);
// ---- Escape closes ----
useEffect(() => {
if (!isOpen) return undefined;
const onKeyDown = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
// ---- move focus into the panel on open ----
useEffect(() => {
if (!isShown) return;
const textarea = panelRef.current?.querySelector('textarea');
(textarea || panelRef.current)?.focus();
}, [isShown]);
useEffect(() => {
saveHistory(messages);
}, [messages]);
// ---- auto-scroll, but never yank the view away from someone reading ----
useEffect(() => {
if (!isPinned) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, isSending, isPinned]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
setIsPinned(el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX);
};
const scrollToLatest = () => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
setIsPinned(true);
};
const push = (message) => setMessages((prev) => [...prev, { id: makeId(), timestamp: now(), ...message }]);
const ask = useCallback(
async (text) => {
const question = String(text || '').trim();
if (!question || isSending) return;
lastQuestionRef.current = question;
setValue('');
setIsPinned(true);
setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
setIsSending(true);
try {
const result = await answerQuestion(question, context);
if (result) {
setContext({ lastIntentId: result.intentId, lastParams: result.params });
push({
sender: 'assistant',
text: result.headline,
detail: result.detail,
metric: result.metric,
stats: result.stats,
sourceCalls: result.sourceCalls,
followUps: FOLLOW_UP_SUGGESTIONS[result.intentId] || null
});
} else {
// Matched nothing. This is a coverage state, not a failure — say so
// plainly rather than inventing an answer.
push({
sender: 'assistant',
state: {
tone: STATUS.muted,
iconKey: 'info',
title: "I can't answer that one yet",
body: 'Try rephrasing around orders, riders, hubs, vehicles, batches, status, tenants or revenue — or pick one of the suggestions.'
}
});
}
} catch (err) {
// Developers get the real error; the operator gets a clean state.
console.error('[Doormile AI] answerQuestion failed', err);
push({
sender: 'assistant',
state: {
tone: STATUS.error,
iconKey: 'warning',
title: 'Something went wrong',
body: "I couldn't retrieve the latest operational data. Try that question again in a moment."
}
});
} finally {
setIsSending(false);
}
},
[context, isSending]
);
const copyMessage = (m) => {
const text = [m.text, m.detail].filter(Boolean).join('\n');
navigator.clipboard
.writeText(text)
.then(() => OpenToast('Copied to clipboard', 'success', 1500))
.catch(() => OpenToast('Could not copy', 'error', 1500));
};
const clearConversation = () => {
setMessages([]);
setContext({ lastIntentId: null, lastParams: null });
setIsPinned(true);
};
if (!isMounted) return null;
const hasThread = messages.length > 0;
return createPortal(
<>
<HStack className="dai-root dai-scrim" data-open={isShown} padding={0} gap={0} onClick={onClose} aria-hidden="true" />
<VStack
as="aside"
ref={panelRef}
className="dai-root dai-panel"
data-open={isShown}
gap={0}
padding={0}
role="dialog"
aria-label="Doormile AI — Operations Copilot"
tabIndex={-1}
>
{/* ---- header ---- */}
<VStack as="header" className="dai-header" gap={0} padding={0}>
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
<HStack gap={1.5} padding={0} vAlign="center">
<Spark size="md" />
<VStack gap={0} padding={0}>
<Text className="dai-title">Doormile AI</Text>
<Text className="dai-subtitle">Operations Copilot</Text>
</VStack>
</HStack>
<HStack gap={1} padding={0} vAlign="center">
<LiveIndicator />
<DropdownMenu
hasChevron={false}
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
items={[
{ label: 'New conversation', onClick: clearConversation },
{ label: 'Clear conversation', onClick: clearConversation }
]}
/>
<IconButton
size="sm"
variant="ghost"
label="Close assistant"
tooltip="Close assistant"
icon={<CloseOutlined />}
onClick={onClose}
/>
</HStack>
</HStack>
</VStack>
{/* ---- page context ---- */}
<Text className="dai-context">{pageContext.label} · Today · All locations</Text>
{/* ---- conversation / welcome ---- */}
<VStack className="dai-scroll-wrap" gap={0} padding={0}>
<VStack ref={scrollRef} className="dai-scroll" gap={0} padding={0} onScroll={handleScroll}>
{hasThread ? (
<VStack className="dai-thread" gap={4} padding={0}>
{messages.map((m) => (
<AIMessage key={m.id} message={m} onCopy={copyMessage} onAsk={ask} />
))}
{isSending && (
<VStack className="dai-msg" gap={1.5} padding={0}>
<HStack gap={1} padding={0} vAlign="center">
<Spark size="sm" />
<Text className="dai-msg-name">Doormile AI</Text>
</HStack>
<TypingIndicator />
</VStack>
)}
</VStack>
) : (
<AIWelcome context={pageContext} onAsk={ask} />
)}
</VStack>
{hasThread && !isPinned && (
<button type="button" className="dai-jump" onClick={scrollToLatest}>
<LuChevronDown size={13} aria-hidden="true" />
Jump to latest
</button>
)}
</VStack>
{/* ---- composer ---- */}
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, riders, hubs…" />
</VStack>
</>,
document.body
);
};
AIPanel.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired
};
export default AIPanel;

View File

@@ -0,0 +1,124 @@
import PropTypes from 'prop-types';
import { LuSparkles } from 'react-icons/lu';
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
// ==============================|| Doormile AI — shared primitives ||============================== //
//
// Small presentational pieces shared by the panel's states. Kept in one file
// because each is a handful of lines and they are only ever used together.
// The AI mark — a soft gradient orb, deliberately not a robot face.
export const Spark = ({ size = 'sm' }) => (
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
<LuSparkles size={size === 'lg' ? 20 : size === 'md' ? 15 : 12} strokeWidth={2.1} />
</HStack>
);
Spark.propTypes = { size: PropTypes.oneOf(['sm', 'md', 'lg']) };
// "● Live" — subtle, not a large pill.
export const LiveIndicator = ({ label = 'Live' }) => (
<HStack className="dai-live" padding={0} gap={0} vAlign="center" aria-label="Answers are read from live operational data">
<HStack className="dai-live-dot" padding={0} gap={0} aria-hidden="true" />
{label}
</HStack>
);
LiveIndicator.propTypes = { label: PropTypes.string };
// Thinking state. Three dots plus two shimmer bars — enough to read as
// "working", not enough to be a light show.
export const TypingIndicator = () => (
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="Doormile AI is thinking">
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
<span />
<span />
<span />
</HStack>
<VStack gap={1} padding={0} style={{ width: '100%' }} aria-hidden="true">
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '78%' }} />
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '52%' }} />
</VStack>
</VStack>
);
// The headline number for a counting answer — "the primary number is large".
export const Metric = ({ value, label }) => (
<VStack gap={0.25} padding={0}>
<Text className="dai-metric-value">{Number.isFinite(value) ? value.toLocaleString('en-IN') : value}</Text>
<Text className="dai-metric-label">{label}</Text>
</VStack>
);
Metric.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
label: PropTypes.string.isRequired
};
// Compact breakdown grid. `color` is a status hex supplied by the caller —
// the one case the token system genuinely can't express (see CLAUDE.md §6.5),
// so it stays an inline style rather than a class.
export const StatGrid = ({ stats }) => {
if (!stats?.length) return null;
return (
<HStack className="dai-stat-grid" padding={0} gap={0} role="list">
{stats.map((s) => (
<VStack key={s.label} className="dai-stat" gap={0.25} padding={0} role="listitem">
<Text className="dai-stat-value" style={s.color ? { color: s.color } : undefined}>
{s.value}
</Text>
<Text className="dai-stat-label">{s.label}</Text>
</VStack>
))}
</HStack>
);
};
StatGrid.propTypes = {
stats: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
color: PropTypes.string
})
)
};
// Icons are looked up by KEY, never passed in as a node. A message carrying a
// React element would be destroyed by the JSON round-trip through
// localStorage (elements don't serialise — $$typeof is a Symbol and is
// silently dropped), and the rehydrated "element" would then crash the render
// on the next panel open.
const STATE_ICONS = {
warning: <WarningOutlined />,
info: <InfoCircleOutlined />
};
// Shared shell for the error / no-data states, so both read as the same kind
// of thing rather than two separately-invented blocks.
export const StateBlock = ({ iconKey, tone, title, body, action }) => (
<VStack className="dai-state" gap={1.5} padding={0}>
<HStack gap={1.5} padding={0} vAlign="start">
<HStack className="dai-state-icon" padding={0} gap={0} aria-hidden="true" style={{ background: `${tone}14`, color: tone }}>
{STATE_ICONS[iconKey] || STATE_ICONS.info}
</HStack>
<VStack gap={0.5} padding={0}>
<Text className="dai-state-title">{title}</Text>
<Text className="dai-state-body">{body}</Text>
</VStack>
</HStack>
{action}
</VStack>
);
StateBlock.propTypes = {
iconKey: PropTypes.oneOf(['warning', 'info']),
tone: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
action: PropTypes.node
};

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { LuArrowRight } from 'react-icons/lu';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import { Spark } from './AIParts';
// ==============================|| Doormile AI — welcome state ||============================== //
//
// Shown only when the thread is empty. Deliberately top-weighted and compact
// so the panel never opens on a large blank region: greeting + one line of
// what it can do, then straight into four suggestions.
const greeting = () => {
const h = dayjs().hour();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
};
const SuggestionCard = ({ item, onAsk }) => {
const Icon = item.icon;
return (
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)}>
<HStack className="dai-suggestion-icon" padding={0} gap={0} aria-hidden="true">
<Icon size={14} strokeWidth={2} />
</HStack>
<Text className="dai-suggestion-text">{item.text}</Text>
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
</button>
);
};
SuggestionCard.propTypes = {
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
onAsk: PropTypes.func.isRequired
};
const AIWelcome = ({ context, onAsk }) => {
const [expanded, setExpanded] = useState(false);
const more = context.more || [];
const shown = expanded ? [...context.suggestions, ...more] : context.suggestions;
return (
<VStack className="dai-welcome" gap={3} padding={0}>
<VStack gap={1.5} padding={0}>
<Spark size="lg" />
<VStack gap={0.5} padding={0}>
<Text className="dai-welcome-greeting">{greeting()}</Text>
<Text className="dai-welcome-lead">How can I help with today&rsquo;s operations?</Text>
</VStack>
<Text className="dai-welcome-note">
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
</Text>
</VStack>
<VStack gap={1.5} padding={0}>
<Text className="dai-section-label">Suggested questions</Text>
<VStack gap={1} padding={0}>
{shown.map((item) => (
<SuggestionCard key={item.text} item={item} onAsk={onAsk} />
))}
</VStack>
{more.length > 0 && (
<button type="button" className="dai-link" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded}>
{expanded ? 'Show fewer' : `View ${more.length} more`}
</button>
)}
</VStack>
</VStack>
);
};
AIWelcome.propTypes = {
context: PropTypes.shape({
suggestions: PropTypes.array.isRequired,
more: PropTypes.array
}).isRequired,
onAsk: PropTypes.func.isRequired
};
export default AIWelcome;

View File

@@ -0,0 +1,44 @@
import { useCallback, useRef, useState } from 'react';
import { LuSparkles } from 'react-icons/lu';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import AIPanel from './AIPanel';
import '../DoormileAI.css';
// ==============================|| Doormile AI — trigger ||============================== //
//
// Mounted in AppTopNav. Owns only the open/closed state; everything else
// lives in AIPanel. Focus returns here when the panel closes, so keyboard
// users land back where they started.
const DoormileAITrigger = () => {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef(null);
const close = useCallback(() => {
setIsOpen(false);
buttonRef.current?.focus();
}, []);
return (
<>
<Tooltip content="Doormile AI">
<button
ref={buttonRef}
type="button"
className="dai-root dai-trigger"
data-active={isOpen}
aria-label="Open Doormile AI"
aria-expanded={isOpen}
onClick={() => (isOpen ? close() : setIsOpen(true))}
>
<LuSparkles size={16} strokeWidth={2.1} aria-hidden="true" />
</button>
</Tooltip>
<AIPanel isOpen={isOpen} onClose={close} />
</>
);
};
export default DoormileAITrigger;

View File

@@ -0,0 +1,138 @@
import { LuClock3, LuPackage, LuBike, LuTruck, LuBuilding2, LuCircleDot, LuBanknote, LuUsers, LuLayers, LuTimerOff } from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== //
//
// Maps the current route to (a) a short context label shown under the panel
// header and (b) the suggested questions offered in the welcome state, so the
// assistant opens on questions relevant to what the operator is looking at
// rather than a fixed Orders-shaped list.
//
// Every suggestion here MUST be a phrasing the deterministic matcher in
// intents.js actually resolves — a suggestion chip that returns "I can't
// answer that yet" is worse than no chip. When adding one, check it against
// the INTENTS catalog first.
const ORDERS = {
label: 'Orders',
suggestions: [
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
{ icon: LuBanknote, text: 'Total revenue today' }
],
more: [
{ icon: LuCircleDot, text: 'How many pending orders today?' },
{ icon: LuPackage, text: 'How many cancelled orders today?' },
{ icon: LuPackage, text: 'How many delivered orders today?' },
{ icon: LuLayers, text: 'Morning Batch orders today' },
{ icon: LuClock3, text: 'Orders today vs yesterday' }
]
};
const RIDERS = {
label: 'Riders',
suggestions: [
{ icon: LuBike, text: 'How many riders are active?' },
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuTruck, text: 'How many vehicles are available?' }
],
more: [
{ icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuBanknote, text: 'Total revenue today' }
]
};
const VEHICLES = {
label: 'Vehicles',
suggestions: [
{ icon: LuTruck, text: 'How many vehicles are available?' },
{ icon: LuBike, text: 'How many riders are active?' },
{ icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuClock3, text: "Give me today's operations summary" }
],
more: [{ icon: LuPackage, text: 'How many orders today?' }]
};
const HUBS = {
label: 'Hubs',
suggestions: [
{ icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
{ icon: LuTruck, text: 'How many vehicles are available?' },
{ icon: LuBike, text: 'How many riders are active?' }
],
more: [{ icon: LuPackage, text: 'How many orders today?' }]
};
const DISPATCH = {
label: 'Dispatch',
suggestions: [
{ icon: LuLayers, text: 'Morning Batch orders today' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
{ icon: LuBike, text: 'How many riders are active?' },
{ icon: LuClock3, text: "Give me today's operations summary" }
],
more: [
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuCircleDot, text: 'How many pending orders today?' }
]
};
const TENANTS = {
label: 'Tenants',
suggestions: [
{ icon: LuUsers, text: 'How many tenants do we have?' },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuClock3, text: "Give me today's operations summary" }
],
more: []
};
const REPORTS = {
label: 'Reports',
suggestions: [
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuClock3, text: 'Orders today vs yesterday' },
{ icon: LuPackage, text: 'How many orders this week?' },
{ icon: LuClock3, text: "Give me today's operations summary" }
],
more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
};
const DEFAULT_CONTEXT = {
label: 'Operations',
suggestions: [
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuBike, text: 'How many riders are active?' },
{ icon: LuBuilding2, text: 'Current hub status' }
],
more: [
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuTruck, text: 'How many vehicles are available?' }
]
};
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
// with the wrong label.
const ROUTES = [
['/doormile/dispatch', DISPATCH],
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
['/doormile/orders', ORDERS],
['/doormile/riders', RIDERS],
['/doormile/vehicles', VEHICLES],
['/doormile/hubs', HUBS],
['/doormile/tenants', TENANTS],
['/doormile/reports', REPORTS],
['/doormile/customers', { ...DEFAULT_CONTEXT, label: 'Customers' }],
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
];
export const getPageContext = (pathname = '') => {
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
return match ? match[1] : DEFAULT_CONTEXT;
};

View File

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

View File

@@ -1,150 +0,0 @@
import { useState } from 'react';
import dayjs from 'dayjs';
import { MdSmartToy } from 'react-icons/md';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import { Center } from '@astryxdesign/core/Center';
import {
ChatLayout,
ChatMessageList,
ChatMessage,
ChatMessageBubble,
ChatMessageMetadata,
ChatComposer,
ChatToolCalls
} from '@astryxdesign/core/Chat';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import { OpenToast } from 'components/third-party/OpenToast';
import { DT } from 'themes/dt/tokens';
import { answerQuestion, SUPPORTED_QUESTIONS } from './intents';
// ==============================|| Doormile Bot ||============================== //
//
// Deterministic Q&A over live order/rider data — see intents.js for the
// intent catalog and the plan this was built from (no LLM: every answer
// comes from a real API call, never a generated guess). This file owns only
// the chat UI + message state; all data logic lives in intents.js.
let nextId = 1;
const makeId = () => nextId++;
const now = () => dayjs().format('hh:mm A');
export default function Assistant() {
const [messages, setMessages] = useState([]);
const [value, setValue] = useState('');
const [isSending, setIsSending] = useState(false);
const handleSubmit = async (text) => {
const question = text.trim();
if (!question || isSending) return;
setValue('');
setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
setIsSending(true);
try {
const result = await answerQuestion(question);
if (result) {
setMessages((prev) => [
...prev,
{
id: makeId(),
sender: 'assistant',
text: result.headline,
detail: result.detail,
sourceCalls: result.sourceCalls,
timestamp: now()
}
]);
} else {
setMessages((prev) => [
...prev,
{
id: makeId(),
sender: 'assistant',
text: "I can't answer that yet.",
detail: `Right now I can answer:\n${SUPPORTED_QUESTIONS.map((q) => `• ${q}`).join('\n')}`,
timestamp: now()
}
]);
}
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to fetch an answer', 'error', 2500);
setMessages((prev) => [
...prev,
{
id: makeId(),
sender: 'assistant',
text: 'Something went wrong fetching that — try again in a moment.',
timestamp: now()
}
]);
} finally {
setIsSending(false);
}
};
return (
<PageShell fill>
<PageHeader title="Assistant" subtitle="Live · Ask Doormile" live />
<VStack gap={0} padding={0} style={{ flex: 1, minHeight: 0 }}>
<ChatLayout
density="balanced"
composer={
<ChatComposer
value={value}
onChange={setValue}
onSubmit={handleSubmit}
isDisabled={isSending}
placeholder="Ask about today's orders, batches, riders…"
/>
}
emptyState={
<Center style={{ height: '100%' }}>
<VStack gap={2} padding={4} vAlign="center" style={{ textAlign: 'center', maxWidth: 420 }}>
<MdSmartToy size={32} color={DT.textSecondary} />
<Text weight="bold">Ask Doormile</Text>
<Text type="supporting" color="secondary">
I answer from live data only — no guessing. Try:
</Text>
<VStack gap={0.5} padding={0}>
{SUPPORTED_QUESTIONS.map((q) => (
<Text key={q} type="supporting" color="secondary">
• {q}
</Text>
))}
</VStack>
</VStack>
</Center>
}
>
<ChatMessageList isStreaming={isSending}>
{messages.map((m) => (
<ChatMessage key={m.id} sender={m.sender}>
<ChatMessageBubble metadata={<ChatMessageMetadata timestamp={m.timestamp} />}>
<VStack gap={1} padding={0}>
<Text>{m.text}</Text>
{m.detail && (
<Text type="supporting" color="secondary" style={{ whiteSpace: 'pre-line' }}>
{m.detail}
</Text>
)}
</VStack>
</ChatMessageBubble>
{/* Audit trail — shows exactly what was queried and how many
rows came back, so the answer is verifiable, not a claim
the operator has to take on faith. */}
{m.sourceCalls && <ChatToolCalls calls={m.sourceCalls} />}
</ChatMessage>
))}
</ChatMessageList>
</ChatLayout>
</VStack>
</PageShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,7 @@ import { useNavigate } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
import { getBookings, getAdminCustomers, batchAssignBookings, getMilers } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { statusesInGroup } from 'utils/orderStatusGroups';
import { DT, STATUS, tint } from 'themes/dt/tokens';
import { TableScroll } from 'themes/dt/primitives';
@@ -104,18 +105,30 @@ import { TableScroll } from 'themes/dt/primitives';
// match set; each tab's singular `status` stays the tab's
// primary/representative value for `currentStatus`, the React key, and the
// few `currentStatus === 'pending_pickup'` checks elsewhere in this file.
//
// The `statuses` match sets now come from utils/orderStatusGroups.js so the
// Doormile AI assistant counts "assigned orders" with the exact same set this
// page does — it previously had its own idea and disagreed with these tabs.
// Labels, colours and icons stay here; only the match sets are shared.
const ORDERS_STATUS_TABS = [
{ idx: 0, status: 'pending_pickup', statuses: ['pending_pickup'], label: 'Pending', color: STATUS.pending, icon: MdHourglassEmpty },
{
idx: 0,
status: 'pending_pickup',
statuses: statusesInGroup('pending'),
label: 'Pending',
color: STATUS.pending,
icon: MdHourglassEmpty
},
{
idx: 1,
status: 'converted_to_consignment',
statuses: ['converted_to_consignment', 'miler_assigned', 'pickup_scheduled'],
statuses: statusesInGroup('assigned'),
label: 'Assigned',
color: STATUS.accepted,
icon: MdCheckCircle
},
{ idx: 2, status: 'delivered', statuses: ['delivered'], label: 'Delivered', color: STATUS.delivered, icon: MdCheckCircle },
{ idx: 3, status: 'cancelled', statuses: ['cancelled'], label: 'Cancelled', color: STATUS.cancelled, icon: MdCancel }
{ idx: 2, status: 'delivered', statuses: statusesInGroup('delivered'), label: 'Delivered', color: STATUS.delivered, icon: MdCheckCircle },
{ idx: 3, status: 'cancelled', statuses: statusesInGroup('cancelled'), label: 'Cancelled', color: STATUS.cancelled, icon: MdCancel }
];
// TanStack Table v9 registers features explicitly — only what is listed here
@@ -323,7 +336,15 @@ const Orders = () => {
// No date-filter UI on this page — the toolbar is search-only. These stay
// fixed to "today" (still read by percentageData's query key and by the
// solver hand-off's deliverydate/assigntime below).
const datestatus = 'Today';
// Was hardcoded 'Today', which was simply untrue: the tab counts and the
// table below come from `allBookings` (getBookings with no date parameter),
// so this header has always described an all-time list as today's. That
// mislabel is what made the assistant look wrong — it answered for today,
// the page showed all-time, and the two numbers disagreed with no visible
// reason. Changed the label rather than adding a date filter: filtering
// would hide currently-visible rows, which is a product decision, not a
// bug fix.
const datestatus = 'All time';
const startdate = dayjs().format('YYYY-MM-DD');
const enddate = dayjs().format('YYYY-MM-DD');
const [searchword, setSearchword] = useState('');

View File

@@ -46,7 +46,6 @@ const AppUsers = Loadable(lazy(() => import('pages/nearle/appUsers/appUsers')));
const Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
const Assistant = Loadable(lazy(() => import('pages/nearle/assistant/assistant')));
// ==============================|| MAIN ROUTING ||============================== //
@@ -169,10 +168,6 @@ const MainRoutes = {
{
path: 'competitive-intel',
element: <CompetitiveIntel />
},
{
path: 'assistant',
element: <Assistant />
}
]
},

View File

@@ -17,7 +17,6 @@
"profitability": "Profitability",
"dispatch": "Dispatch",
"customers": "Customers",
"assistant": "Assistant",
"fleetops": "Fleet & Ops",
"hubs": "Hubs",
"vehicles": "Vehicles",

View File

@@ -0,0 +1,67 @@
// ============================================================================
// Order status groups — which RAW `GET /admin/bookings` enums make up each
// operator-facing status the Orders page shows as a tab.
//
// Extracted from orders.js's ORDERS_STATUS_TABS so the Doormile AI assistant
// can answer "how many assigned orders" with the SAME match set the Orders
// page counts, instead of growing a second definition that drifts. Same reason
// utils/batchBucket.js exists.
//
// ⚠ This is deliberately NOT the same taxonomy as api.js's
// BOOKING_STATUS_TO_DELIVERY_STATUS, and the two must not be merged:
//
// • Orders page (this file) — tracks the OPERATOR's workflow. "Assigned"
// means the operator picked a rider, the moment assign-miler succeeds, so
// miler_assigned counts as Assigned.
// • Deliveries page (api.js) — tracks the RIDER's engagement. Its "Accepted"
// means the rider accepted, a deliberately later and narrower bar, so it
// keeps miler_assigned on 'pending'.
//
// That split is explicit product direction (see the long comment above
// ORDERS_STATUS_TABS in orders.js — it was unified once and reverted). The
// assistant answers order-status questions with the ORDERS taxonomy, because
// that is the screen an operator is comparing its answers against.
// ============================================================================
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. The assistant still needs it so
// "how many orders are in transit" resolves, and so statusBreakdown's
// totals account for every row rather than silently dropping some.
active: ['out_for_delivery'],
delivered: ['delivered'],
cancelled: ['cancelled']
};
export const ORDER_STATUS_LABELS = {
pending: 'Pending',
assigned: 'Assigned',
active: 'Out for delivery',
delivered: 'Delivered',
cancelled: 'Cancelled'
};
// Display order — matches the lifecycle, and the order the Orders page's tabs
// appear in.
export const ORDER_STATUS_ORDER = ['pending', 'assigned', 'active', 'delivered', 'cancelled'];
const RAW_TO_GROUP = Object.entries(ORDER_STATUS_GROUPS).reduce((acc, [group, raws]) => {
raws.forEach((raw) => {
acc[raw] = group;
});
return acc;
}, {});
export const statusesInGroup = (group) => ORDER_STATUS_GROUPS[group] || [];
// Raw booking enum → group key. Returns the lowercased raw value itself for an
// enum this map has never seen, so an unmapped backend status stays visible in
// a breakdown rather than vanishing from the totals.
export const groupForBookingStatus = (raw) => {
const key = String(raw || '').toLowerCase();
return RAW_TO_GROUP[key] || key;
};
export const isInGroup = (raw, group) => statusesInGroup(group).includes(String(raw || '').toLowerCase());