Compare commits

...

38 Commits

Author SHA1 Message Date
5faf0e1deb updates on the ui and updates agents 2026-10-09 13:39:14 +05:30
3d07ea8170 updates on the miler page and the create order calculations as well 2026-10-07 17:18:42 +05:30
ff1b795292 updates on the reverse logistics 2026-10-07 13:00:43 +05:30
0d5edf94bf updates on the pricing page and navbar updates 2026-10-07 12:06:49 +05:30
8dca169b83 updates on the logic on the skills and settings tab 2026-10-06 20:58:15 +05:30
21775fe6f9 updates on the zone fix 2026-10-06 19:33:02 +05:30
7aca6a6a48 updates on the admin and zone things and minor changes 2026-10-06 19:21:06 +05:30
6e73b59ca9 updates on the reverse logistics 2026-10-06 17:30:06 +05:30
806f4a2b3f updates on the agentstudio 2026-09-30 18:05:38 +05:30
d33fff01d3 updates on the hook and endpoints for the ai agents 2026-09-30 15:08:33 +05:30
ede1731fa5 updates on the design and the onboarding things 2026-09-30 14:45:49 +05:30
53199127ba update on the agents and registry side 2026-09-30 12:03:13 +05:30
92e8da2842 updates on the minor changes like zone updates and datepicker fix 2026-09-28 20:13:40 +05:30
05a4cbdf8a updates on the addressautocomplete and geocodingservice and home page as been updated 2026-09-28 19:08:17 +05:30
5bf0104b61 updates on the minor changes like zone updates and datepicker fix 2026-09-28 15:36:59 +05:30
3bde09839c updates on the queries page and dispatch page section 2026-09-28 12:41:08 +05:30
912f3cbcd4 updates on the responsiveness 2026-09-28 12:02:28 +05:30
d288197c35 updateson the create ordera nd testfile as done 2026-09-25 17:02:08 +05:30
981f0566a6 updates on the navbar 2026-09-25 16:21:42 +05:30
f86e4b853b upates on the toast and createorder and allthose things 2026-09-25 16:02:57 +05:30
3e7aaf1e25 updates on the customer app and map stuck issue 2026-09-25 15:10:17 +05:30
d875900cb0 updates on the saved contacts and fix on the logo in the home page section 2026-09-25 13:06:12 +05:30
beff5a1cbe updates on the agent ui and all those things 2026-09-25 11:19:13 +05:30
9c50de1142 updates on the agent flow 2026-09-24 19:25:19 +05:30
cbce47f949 updates on bugs on the dispatch and ai button integrations 2026-09-24 11:50:04 +05:30
5eb4a6466b updates on the lint errors and more stuffs 2026-09-23 12:28:38 +05:30
7544a07066 updates on the issue and the another thing updates on the ai optimization 2026-09-23 11:58:24 +05:30
c6dfa3cb8f updates on the orderdetails page and implemented the download option 2026-09-22 18:01:48 +05:30
b9b0380927 updates on the design and chatbot fix 2026-09-22 17:32:07 +05:30
6585a8da00 updates on the ui changes and new changes as well 2026-09-22 16:56:10 +05:30
16278d3893 updates on the color change 2026-09-21 14:10:00 +05:30
1bfbdb59ef updates on the redirection and dispatch aligments 2026-09-21 13:39:06 +05:30
7992208361 updates on the ui changes 2026-09-19 15:50:02 +05:30
3e2e9cad3a updates on the setting page 2026-09-19 13:00:27 +05:30
a044afc614 updates on the changes and fix on the all the pages 2026-09-19 12:09:45 +05:30
8e87947c5f updates on the ui changes and more things 2026-09-19 11:43:00 +05:30
b1747480df docs: CLAUDE.md dock width follows the code to 20vw
The assistant rules file documented a 70/30 split and
`--dai-dock-width: max(340px, 30vw)`. The code now reads
`clamp(320px, 20vw, 460px)`, so the doc was describing a layout that no
longer exists — and it is an instruction file, so a stale value there
gets implemented rather than questioned.

Records the three width regimes, the 900px overlay behaviour, that the
floor means 1024px renders at 31% and not 20%, and that DOCK_NORMAL in
AIPanel.jsx must stay byte-identical or the page jumps on first paint.

Also notes in the src/lib/assistant/ copy that it is a duplicate and that
its DoormileAI.css is dead — nothing imports it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:27:43 +05:30
1671a5f4b6 Milers rebrand, /doormile/home, and a set of console fixes
Two bodies of work that arrived in one working tree and are intertwined in
four files, so they commit together rather than pretending to a split the
diffs do not have.

TEAM WORK (pre-existing in the tree, uncommitted)

- "Riders" -> "Milers" across en.json and ~20 pages. Done carefully:
  Deliveries.jsx checks BOTH prefixes ("Miler #" and "Rider #") so rows
  written before the rename still render, and routes and query keys stay
  rider*/riderssummary — renaming those would have broken bookmarks and
  cache keys.
- MileTruth assistant rework: rename from "Doormile AI", HStack composer,
  maximise/minimise/reset controls, open state persisted to localStorage.
- New /doormile/home landing page; / and /doormile now redirect there
  instead of /doormile/dispatch. AdminLayout nav restructured with icons
  and descriptions.

FIXES

Tab strip was unreachable (Deliveries: Delivered and Cancelled)
  The pill variant is one non-wrapping inline-flex row. Given less width
  than it needs, flex COMPRESSED it — 872px squeezed into 780px, truncating
  labels inside the buttons — and with nothing scrollable no gesture could
  recover the last two tabs. Four of six tab pages had each hand-rolled the
  same wrapper; Deliveries and CompetitiveIntel had not. Fixed in the
  component, so no page can forget it. Verified in a browser at the real
  content width: 102px of scroll range, "Cancelled" reachable. 11 tests.

Home.jsx crash: Cannot read properties of null (reading 'flow')
  Introduced by the change below, which made deriveVisualData return null.
  All 12 .flow/.table dereferences are now behind visual?. gates.

Home.jsx presented fabricated figures as live operations
  deriveVisualData keyword-matched the prompt and returned hardcoded values
  for whole topics — revenue as a flat Rs 48,650, a workforce of 46 with 38
  active, six named hubs, four booking numbers — ignoring the real result it
  had been handed. Those branches sat ABOVE the one reading res.stats, so
  the correct code was unreachable: asking about revenue could not return
  the real number because a literal answered first.

  Two were worse than wrong figures. The default branch returned a staffing
  table for ANY unmatched question. And the no-answer path built a
  sourceCalls entry claiming /admin/milers had been called, status
  "complete", "46 milers scanned" — forging the provenance trail that exists
  so an operator can check where a number came from. A fabricated figure is
  a bug; a fabricated citation defeats the mechanism for catching one.

  190 lines removed. Every branch now reads the result and returns null when
  there is nothing to draw. No-answer says so; errors report as errors.

  Found while verifying: 'Inactive'.includes('active') is true, so every hub
  counted as active — carried from the original, whose own sample data
  tripped it (six hubs, one Inactive, shown as 6/6). Now an explicit
  vocabulary match, with unknown treated as down: a hub wrongly shown
  offline gets investigated, one wrongly shown online hides an outage.

Home launcher tiles named pages that do not exist
  Every tile now names its destination. Task Board -> Tripsheets,
  Staff -> Milers, Availability -> Milers Summary (it pointed at the same
  page as the tile beside it), Compliance -> Exceptions,
  Invoices -> Bookings, Payroll -> Profitability,
  AI Reports -> Orders Summary. Group headings: WORKFORCE SUITE -> FLEET,
  FINANCE & CRM -> CLIENTS & REVENUE, AI TOOLS -> REPORTS & AI. "Invoices",
  "Payroll" and "CRM" named systems Doormile does not run.

  Four hardcoded badges removed (3, 38, 7, "New"). None was computed. A red
  badge means "this many things need you", and one that never changes
  teaches operators to ignore the real ones.

Navbar MileTruth control misaligned, hover dead
  One cause: a 36px image (h-9 w-9 max-w-none) inside a 32px button. It
  stood proud of the bell and avatar AND covered its own button, leaving
  hover:bg-surface-sunken nowhere to render. Now 20px.

Assistant dock 30% -> 20%, and responsive
  clamp(320px, 20vw, 460px) rather than max(): the cap is what makes a large
  monitor work, since 20vw is 768px at 4K. Verified across seven screen
  classes — 384px/20% at 1920, capped at 460px beyond 2300px, full-width
  overlay at or below 900px. At 1024px the 320px floor wins (31%), because
  20vw would be 205px and too narrow for the composer.

Suggestion chips took a third of the panel
  .dai-suggestions was flex-direction:column, overriding the component's own
  wrap="wrap", so four chips became four full rows. Row + wrap, trimmed
  padding, ellipsis on the text span where text-overflow can act. Measured
  at the 320px floor: 4 rows/177px -> 2 rows/84px, nothing clipped.

Pricing and Customers were unreachable on mobile
  Moving them off the nav bar into the account dropdown removed them from
  the phone entirely — the mobile sheet builds from NAV plus NAV_GROUPS and
  they were in neither. Both stayed routed, so only a typed URL reached
  them. Now one SETTINGS_NAV array that the dropdown and the sheet share.

calculateDrivingDistance was called and never imported
  CreateOrder.jsx:371 — a ReferenceError on every customer-pickup order with
  a pinned collection address, thrown synchronously inside a useEffect so
  the .catch() on that chain could never see it. Neither the build nor the
  lint config catches an unbound identifier in a .jsx file: eslint.config.js
  spreads pluginJs.configs.recommended and then declares its own rules
  object, which replaces the spread rules wholesale, so no-undef has never
  run. A repo-wide sweep with it enabled found this was the only instance.
  Pre-existing; found while auditing the coordinate changes.

Dead code and stale docs
  ALL_DESTINATIONS (declared, never read; its comment claimed it was what
  the mobile sheet renders). --dai-panel-width (declared twice, read
  nowhere) and the max-width:1279px block that only set it. AdminLayout's
  header comment said "three grouped menus (Fleet Ops, Reports, Settings)"
  when there are two, and Settings is a section in the account menu — that
  difference in routing is what caused the mobile gap above.

VERIFIED

878 tests across 26 suites, build clean, no new lint problems. The tab
strip, dock width, chip wrapping and navbar sizing were measured in a real
browser against the shipped stylesheet. deriveVisualData is module-private,
so it was extracted and driven through 16 logic checks.

NOT VERIFIED

/doormile/home has never been rendered in a browser, and the responsive
pass covers the assistant panel only — no page has been viewed at any
breakpoint. Both need a signed-in session. DataTable carries its own
overflow-x-auto and only two fixed widths above 390px exist in src/pages,
but that is grep, not eyes.

STILL OPEN

customerAppBookings.js lost its customerstatus/customerstage grouping in
49ee0c5 and has not been restored, so the Bookings tabs still read only the
operational status. The Status column (Bookings.jsx:98, :415) was never
stage-aware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:21:21 +05:30
216 changed files with 29816 additions and 5018 deletions

View File

@@ -60,6 +60,15 @@ src/
pages/ Login + doormile/ (the console)
```
## Operations Copilot (MileTruth AI) & Workspace Layout
The console integrates a docked operations copilot (**MileTruth**) across all admin routes via `AdminLayout.jsx`:
- **70% Content / 30% AI Sidebar**: Main workspace occupies 70% width on the left and the MileTruth copilot occupies 30% width on the right (`--dai-dock-width: max(340px, 30vw)`).
- **Physical Separation**: An extra 24px visible gutter (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the tables/cards from the AI sidebar card.
- **Elevated Inset Card**: The sidebar card floats with `rounded-2xl` corners (20px), subtle borders (`1px solid #e2e8f0`), soft shadow, and top/right/bottom margins.
- **Default Open & Dismissal**: Opens by default on desktop (`window.innerWidth >= 1024`, persisted in `localStorage`). The close button (`×`) smoothly collapses the sidebar, animating the page content to 100% full width. Re-opening is accessible anytime via the Doormile 'D' mark in the top navbar.
- **Home Page (`Home.jsx`)**: Prompt composer features a dedicated **Send** button (`Send` icon + "Send") triggering `handleAsk()`.
## Routes
The app opens on **`/login`**. Everything else lives under `/doormile/*` behind `ProtectedRoute`,
@@ -219,6 +228,8 @@ Statuses are looked up case-insensitively, and an unmapped value renders as a ne
the humanised raw string — so a missing entry is visible rather than silent. Add states there, never
in a page-local table.
`PageHeader` renders clean icon, title, and action slots across all console pages without explanatory descriptive subtitles, eliminating vertical clutter for operators who already know the console.
## Environment
| Variable | Purpose |

View File

@@ -0,0 +1,777 @@
# Doormile Agent Platform — Phase 7: Decision Memory, Deployment, Executors
Status: **mostly implemented 2026-10-08 (uncommitted, not deployed, no migration run)**
Written 2026-10-08 · Scope: `krow_talent_app`, `doormile_backend`, `AI_engine`, `kubernetes`
---
## Status (2026-10-08)
Verified: backend `go build`/`go vet` clean, 20 packages pass · engine 148 tests
(3 pre-existing import errors) · console 1397 pass (13 pre-existing
`agentStudio.test.jsx` failures) · `kubectl kustomize manifests/doormile` renders
clean. **Nothing committed. No migration has run. No SQL in this work has ever
executed against a database.**
| Item | State |
|---|---|
| A1 config/secrets manifest, `envFrom`, probes, resources | **built** — placeholders unreplaced |
| A2 secrets out of git | **not done** — documented in `manifests/doormile/SECRETS.md`, including the kustomize-clobbers-hand-created-secrets trap. Five more placeholder keys were added. |
| A3 `anthropic>=0.49.0` · A4 `claude-opus-5-5` | **done** |
| B0.1 `CREATE EXTENSION vector` · B0.2 `/similar` → POST | **done** |
| B1 provider (OpenAI `text-embedding-3-small`, 1536) | **done** — my call, flagged |
| B2 `core/embeddings.py` + decision wiring | **done** |
| B3 outcome sweeper + rules + 14 tests | **done** |
| B4 `core/memory.py` + both agents | **done** |
| B5 retention/prune | **done** — ivfflat `lists` re-tune still needs a row count |
| B6 `tenantid` scoping | **done** |
| B2.5 historical backfill | **done** — `internal/ai/outcomes/backfill.go` |
| B7 persist console findings | **done** — `aiskillfindings`, 4 routes, `findingReport.js`, 15 tests |
| C1 health surface + `ai-engine.yaml` + Dockerfile/compose port | **done** — image not built or pushed |
| C2 kustomization + `deploy-doormile.sh` | **done** |
| C3 probes/resources/PDB | **done** — StatefulSet→Deployment not done |
| C4 ingress | **built** — apply order matters, see the file header |
| C5 NATS port note | **done** — `docs/ARCHITECTURE.md` |
| C6 engine replica safety | **see correction 1 — was already safe for a different reason** |
| D1 admin batch-assign + console executor | **done** |
| D2 `alert_low_battery_rider` | **done** |
| D3 five review-only tools | **1 of 5 done** — `trigger_auto_dispatch` turned out to BE batch-assign under another name. The other four need endpoints that do not exist. |
| Agents-page snapshot → live endpoint | **backend done** — engine serves `GET /agents/status`; the console still reads the snapshot, so the swap is one fetch away |
## Two things this plan got wrong
**Correction 1 — C6. The engine's stall dedup was never per-pod.**
This plan claimed `ExceptionAgent` kept its dedup in an in-process TTL set and
that moving it to Redis was the prerequisite for scaling out. Wrong:
`_claim_stall` / `_claim_stall_handled` (`agents/exception_agent.py:336`) have
been Redis `SET NX` with a TTL all along — cross-pod, self-expiring, durable
across restarts. The comment there saying "replaces the old unbounded in-memory
set" describes what it REPLACED; I read it as current state.
The real blocker is different and still real: `core/message_bus.py:284` and
`:312` bind **durable push consumers with fixed names**, and a durable push
consumer admits one active subscriber unless created with a deliver group. A
second replica does not duplicate work — it fails to bind. Scaling out means
giving those subscriptions a deliver group (or moving to pull consumers).
`replicas: 1` stands, for the corrected reason.
**Correction 2 — the registry was already honest about the simulated agents.**
This plan said the three fictional-data agents were "presented as live" and
should be marked. They already were: `seed.go` has `StatusSimulation` for
`HUB_AGENT`, `FLEET_AGENT` and `ROUTE_OPTIMIZER`, with purposes reading "an
in-memory fleet of 19 fake vehicles" and "8 hard-coded fictional hubs". The
console snapshot (`src/lib/agentNetwork.js`) was also honest about activity
("Zero tasks received", `publishes: 'Nothing on the bus.'`, `api: 'No.'`) but
silent on the data being invented — so the three entries now say so, and the two
surfaces agree.
I overstated that surface as "telling anyone something untrue". It was
incomplete, not false.
---
## Track A — Unblock (do this first; nothing else matters until it lands)
### A1. `INTERNAL_API_KEY` is missing from the cluster — **everything engine↔backend is 401**
`middlewares/internal_auth.go:13` reads `INTERNAL_API_KEY` and **fails closed when
unset**:
```go
expected := os.Getenv("INTERNAL_API_KEY")
if expected == "" || c.Get("X-Internal-Key") != expected {
```
`kubernetes/manifests/doormile/miletruth.yaml` never sets it. The engine's
`docker-compose.yml` *does* pass it. So the engine sends a key the backend
rejects, and in-cluster **every** `/api/v1/internal/*` call 401s:
- `GET /internal/ai/registry` — the registry poll (this is why "engine reads
registry in prod" was never confirmable)
- `POST /internal/agent-decisions` — the decision log, so Insights sees nothing
- `GET /internal/express/riders`, `/express/bookings`, `POST /express/assign`
- `POST /internal/bookings/:id/reassign`, `POST /internal/notify`
**Step 1 — find out whether git matches reality.** The `kubernetes` repo's log is
full of `Restore…` / `Recreate…` commits, so the manifest may already be fiction:
```bash
kubectl -n doormile get statefulset doormile -o jsonpath='{range .spec.template.spec.containers[0].env[*]}{.name}{"\n"}{end}' | sort
```
`config/config.go` reads 29 env vars. If that list is much shorter, the live
cluster has hand-applied drift and **the next `kubectl apply -f` wipes it.**
**Step 2 — close the gap in git, not by hand.** Add to `doormile-secrets`
(`stringData`) and reference from the StatefulSet. Absent from the manifest today
and read by `config.go`:
`INTERNAL_API_KEY`, `JWT_SECRET_KEY`, `APP_PORT`, `ENV`, `TRUSTED_PROXIES`,
`AI_LAYER_BASE_URL`, `ROUTE_OPTIMIZER_URL`, `GEOCODER_URL`, `GEOCODER_EMAIL`,
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`,
`CLIENT_ONBOARDING_OWNERS`, `PLAYGROUND_LLM_API_KEY`, `PLAYGROUND_LLM_BASE_URL`,
`PLAYGROUND_LLM_MODEL`, `REDIS_USER`.
Secrets vs ConfigMap: `INTERNAL_API_KEY`, `JWT_SECRET_KEY`, `SMTP_PASSWORD`,
`PLAYGROUND_LLM_API_KEY` are secrets. The rest belong in a `doormile-config`
ConfigMap so a URL change is not a secret edit.
**Files**
| File | Change |
|---|---|
| `kubernetes/manifests/doormile/miletruth.yaml` | modify — add the 19 env vars to the StatefulSet + `doormile-secrets` |
| `kubernetes/manifests/doormile/doormile-config.yaml` | **new** — ConfigMap for the non-secret vars |
| `AI_engine/.env` (server-local, untracked content) | modify — same `INTERNAL_API_KEY` value |
Read-only, no change: `doormile_backend/middlewares/internal_auth.go:13`,
`doormile_backend/config/config.go:60-102` (the contract being satisfied).
> `INTERNAL_API_KEY` must be **byte-identical** in the backend Secret and the
> engine's env. Generate once (`openssl rand -hex 32`), set both.
**Acceptance:** from inside the cluster,
`curl -H "X-Internal-Key: $KEY" http://doormile-service.doormile:8081/api/v1/internal/ai/registry`
returns 200 with a body and an `ETag`; a second call with `If-None-Match` returns
304. Backend logs show `ai playground: enabled`. `GET /admin/ai/status` reports
the engine as following the registry (`aiRegistryController.go:208` counts the
poll).
### A2. Plaintext secrets are committed
`miletruth.yaml:19-21` has `DB_PASSWORD`, `REDIS_PASSWORD`, `NATS_PASSWORD` as
literal in git (the same password reused for all three). Same class as the committed Firebase keys from the
CX audit. Pick one and apply it before A1 adds *more* secrets to that file:
Sealed Secrets, SOPS, or an out-of-git `kubectl create secret` with the manifest
holding only a reference.
Do **not** delete or untrack anything here without per-file approval — see the
standing rule. Rotating that shared password is a separate decision; this item is only
about stopping new secrets entering git.
**Files**
| File | Change |
|---|---|
| `kubernetes/manifests/doormile/miletruth.yaml` | modify — `stringData` block becomes a reference |
| `kubernetes/manifests/doormile/sealed-secrets.yaml` | **new** — if Sealed Secrets is the chosen route |
| `kubernetes/docs/DEPLOY.md` | modify — document how secrets are supplied now |
Same pattern already in git at `kubernetes/manifests/core/core-secrets.yaml` and
`manifests/nearle/nearle-secrets.yaml` — whatever is chosen should cover those too,
but that is outside this phase.
### A3. `anthropic>=0.21.0` floor is wrong
`AI_engine/requirements.txt` pins `anthropic>=0.21.0`, but `core/llm.py:117`
sends `thinking: {"type": "adaptive"}` and `output_config.effort` — parameters
that old SDK does not know. There is no lockfile, so a fresh `pip install` in a
rebuilt image can resolve to something that 400s on every LLM call.
Bump to a current floor and pin the image build. `openai>=1.0.0` is already
present (unused today — Track B uses it).
**Files**
| File | Change |
|---|---|
| `AI_engine/requirements.txt` | modify — raise the `anthropic` floor |
| `AI_engine/Dockerfile` | modify — optional: `pip install` from a lockfile instead |
| `AI_engine/requirements-dev.txt` | check — may pin the same packages |
### A4. `LLM_MODEL` default is a generation behind
`core/llm.py:22` defaults to `claude-opus-4-8` ($5/$25 per MTok). `claude-opus-5-5`
is both cheaper ($4/$20) and more capable. One-line change; the registry's
per-agent `model` pin already overrides it (`registry.model(agent_id)`), so this
only moves the floor.
**Files**
| File | Change |
|---|---|
| `AI_engine/core/llm.py:22` | modify — default `LLM_MODEL` |
| `AI_engine/core/llm.py:11` | modify — the docstring naming the default |
| `AI_engine/docker-compose.yml:45` | modify — `LLM_MODEL` fallback |
| `AI_engine/tests/test_llm.py` | check — may assert the old default |
Note `core/llm.py:109` special-cases Haiku 4.5 (rejects adaptive thinking and
`effort`). Leave that branch alone; it is correct.
---
## Track B — RAG decision memory
The skeleton exists and is wired to nothing. Four pieces are already built:
| Piece | Where |
|---|---|
| `context_embedding vector(1536)` on `agent_decisions` | `migrations/migrate.go:92` |
| ivfflat cosine index, `lists = 100` | `migrations/migrate.go:98` |
| `POST /internal/agent-decisions` accepts `context_embedding` | `controllers/agentDecisionController.go:23` |
| cosine kNN query + `PATCH /:id/outcome` | `agentDecisionController.go:74,120` |
Nothing produces an embedding, nothing calls `/similar`, nothing records an
outcome. There are exactly **two** decision types to cover —
`assignment_failure` (`dispatch_agent.py:335`) and `stall_response`
(`exception_agent.py:456`).
### B0. Three defects to fix before writing any new code
**B0.1 — `CREATE EXTENSION vector` appears nowhere in the repo.**
`migrate.go:92` runs `ALTER TABLE agent_decisions ADD COLUMN … vector(1536)` and
logs failure *non-fatally*. If the extension is not installed on `logistics`,
both the column and the index silently fail and every retrieval 500s.
```sql
SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';
```
If absent, add **before** line 92 in `Migrate()`:
```go
if res := db.Exec(`CREATE EXTENSION IF NOT EXISTS vector`); res.Error != nil {
utils.Error("❌ pgvector extension unavailable — decision memory disabled", "error", res.Error)
}
```
Needs the `pgvector` extension available on the server and a role with rights to
create it. On a managed Postgres this may be an admin action, not a migration —
check before assuming.
**B0.2 — `/similar` is a `GET` that requires a JSON body.**
`routes.go:594` registers it as `GET`, and `agentDecisionController.go:84` calls
`BodyParser` demanding an `embedding` array. nginx and most HTTP clients drop GET
bodies — and doormile is served *through* host nginx (see C4). Change to:
```go
internal.Post("/agent-decisions/similar", controllers.FindSimilarDecisions)
```
No caller exists yet, so this breaks nothing.
**B0.3 — `/similar` filters `WHERE outcome IS NOT NULL`, and nothing writes
outcomes.** Even with embeddings flowing it returns zero rows forever. **B2 is
not optional** — it is the half that makes retrieval worth anything.
**Files**
| File | Change |
|---|---|
| `doormile_backend/migrations/migrate.go:92` | modify — add `CREATE EXTENSION` above the `ALTER TABLE` |
| `doormile_backend/routes/routes.go:594` | modify — `internal.Get` → `internal.Post` for `/agent-decisions/similar` |
| `doormile_backend/controllers/agentDecisionController.go:74` | modify — comment the method change; body parsing already correct |
| `doormile_backend/routes/routes_ai_registry_pg_test.go` | modify — add coverage for the POST shape |
### B1. Embedding provider — decision required
Anthropic has no embeddings endpoint, so this needs a second provider.
| Option | Dims | Column change | Notes |
|---|---|---|---|
| **OpenAI `text-embedding-3-small`** | 1536 | **none** | ~$0.02/MTok. `openai>=1.0.0` already a dependency. Column was sized for it. |
| Voyage `voyage-3` | 1024 | yes | Anthropic-recommended; new key, new vendor. |
| Local `bge-small` / `MiniLM` | 384 | yes | Free, no egress, no key. +~400MB RAM, model in image, slower cold start. |
**Recommendation: OpenAI `text-embedding-3-small`.** The schema already matches,
the dependency is already there, and the second-provider line is already crossed
(the playground runs on Groq). Revisit if data egress is a constraint — then take
the local model and migrate the column to `vector(384)`.
### B2. Write path — `AI_engine/core/embeddings.py`
One function, modelled on `core/decisions.py`'s fire-and-forget discipline:
```python
async def embed(text: str) -> Optional[List[float]]:
"""None on any failure. An embedding must never delay an agent's reaction."""
```
Requirements:
- **Embed the same dict `build_payload` already stores.** Serialise the `facts`
dict deterministically (sorted keys) so the embedded text and the stored
`context` cannot drift apart. Add a `_context_text(facts)` helper and test it
pure, the way `build_payload` is tested.
- Fail open — return `None`, log once, never raise into the agent path.
- Small LRU cache: repeated stalls on one booking produce near-identical facts.
- Gate on `EMBEDDINGS_ENABLED` **and** a registry skill flag, so it can be
switched off from Agent Studio without a redeploy
(`registry.skill_enabled(...)` already exists).
Then extend `build_payload`/`record_decision` (`core/decisions.py:33,53`) to carry
`context_embedding`. Both call sites (`dispatch_agent.py:335`,
`exception_agent.py:456`) keep their signatures.
**Acceptance:** after one stall,
`SELECT count(*) FROM agent_decisions WHERE context_embedding IS NOT NULL` > 0.
**Files**
| File | Change |
|---|---|
| `AI_engine/core/embeddings.py` | **new** — `embed()`, `_context_text()`, LRU cache, fail-open |
| `AI_engine/core/decisions.py:33` | modify — `build_payload` carries `context_embedding` |
| `AI_engine/core/decisions.py:53` | modify — `record_decision` awaits the embed before posting |
| `AI_engine/config/system_config.py` | modify — `EMBEDDINGS_ENABLED`, provider key, model name |
| `AI_engine/docker-compose.yml` | modify — pass the embedding env through |
| `AI_engine/tests/test_embeddings.py` | **new** — `_context_text` determinism, fail-open returns `None` |
| `AI_engine/tests/test_registry_phase5.py:194` | modify — asserts the `record_decision` payload shape |
Not touched: `agents/dispatch_agent.py:335` and `agents/exception_agent.py:456`
keep their call signatures — the embedding is added inside `decisions.py`, so
neither agent changes.
### B3. Outcome loop — the part that makes retrieval useful
`PATCH /internal/agent-decisions/:id/outcome` exists with no caller. Define, per
decision type, what "it worked" means. Starting proposal:
| Type | Outcome = `success` when | `failure` when |
|---|---|---|
| `stall_response` | booking reaches `Delivered` within its SLA window after the decision | SLA breached, or cancelled |
| `assignment_failure` | booking gets an assignment within N minutes of the decision | still unassigned after N, or cancelled |
Implement as a backend sweeper following the **established pattern** in
`internal/assignment/sweeper.go:88` — ticker, `recover()` per tick, and the Redis
lock that keeps one replica sweeping (`sweeper.go:104`). Wire it in `main.go`
beside `go assignment.StartPendingSweeper()` (`main.go:244`).
Two gotchas from the existing code:
- Use `Receivedat`-style true instants, not `utils.DBNow` — `models/ai_runs.go:26`
documents that `DBNow` returns IST digits labelled UTC and is 5h30m off for
`timestamptz`. The same trap applies to `outcome_recorded_at`.
- Leave `outcome` NULL while undecided. `/similar` already treats NULL as
"no evidence yet", which is correct.
**Acceptance:** rows acquire non-NULL `outcome` within one sweep interval of
their window closing, and `/insights` decision-outcome counts stop being all
`pending`.
**Files**
| File | Change |
|---|---|
| `doormile_backend/internal/ai/outcomes/sweeper.go` | **new** — ticker + Redis lock, modelled on `internal/assignment/sweeper.go:88` |
| `doormile_backend/internal/ai/outcomes/rules.go` | **new** — the per-decision-type success/failure predicates |
| `doormile_backend/internal/ai/outcomes/sweeper_test.go` | **new** — interval, window, and both predicates |
| `doormile_backend/main.go:244` | modify — `go outcomes.StartOutcomeSweeper()` beside the pending sweeper |
| `doormile_backend/models/agentdecision.go` | modify — only if B6 adds `Tenantid` |
Reference, not modified: `internal/assignment/sweeper.go:88-110` (the ticker +
`recover()` + Redis single-replica lock pattern to copy),
`models/ai_runs.go:26` (the `utils.DBNow` timezone trap to avoid).
### B4. Read path — precedent in the prompt
Before the LLM call in `exception_agent` / `dispatch_agent`, fetch the top-5
similar **resolved** decisions and include them as precedent — "the last 5
comparable situations and whether the action worked."
- Feature-flag it on a registry skill so it is switchable from Agent Studio.
- Hard timeout (~300ms) with fail-open to today's prompt. The current behaviour
is the floor; this can only raise it. (`core/registry.py` already follows this
rule; `ragRouter.js:9-14` documents the same discipline on the console side.)
- Keep precedent **out** of the structured-output schema. It informs the prompt;
it must not become a field the model can invent.
**Acceptance:** an eval run shows the decision quality moving. `AI_engine/evals/`
already has the harness and cases (`stall_cases.jsonl`,
`assignment_cases.jsonl`) — extend those rather than judging by eye.
**Files**
| File | Change |
|---|---|
| `AI_engine/core/memory.py` | **new** — `recall(decision_type, embedding, k)` → POST `/internal/agent-decisions/similar`, timeout + fail-open |
| `AI_engine/core/llm.py:157` | modify — `decide_stall_response` accepts optional precedent |
| `AI_engine/core/llm.py:227` | modify — `decide_assignment_failure` accepts optional precedent |
| `AI_engine/agents/exception_agent.py:456` | modify — recall before the decide call |
| `AI_engine/agents/dispatch_agent.py:335` | modify — recall before the decide call |
| `AI_engine/tests/test_memory.py` | **new** — timeout fails open, empty recall changes nothing |
| `AI_engine/evals/stall_eval.py` | modify — run with and without precedent |
| `AI_engine/evals/assignment_eval.py` | modify — same |
| `AI_engine/evals/stall_cases.jsonl` | modify — cases where precedent should change the answer |
| `AI_engine/evals/assignment_cases.jsonl` | modify — same |
| `doormile_backend/internal/ai/registry/seed.go:114` | modify — seed a `recall_similar_decisions` read tool + the skill flag that gates B4 |
The registry seed row matters: without it the feature cannot be switched off from
Agent Studio, which is the whole point of gating it on `registry.skill_enabled`.
### B5. Hygiene
- **`agent_decisions` has no retention.** `aiagentruns` purges at 30 days
(`telemetry/recorder.go:28,128`); decisions grow forever, and this is the table
retrieval scans. Decide a window — longer than 30 days, since old precedent is
the point. Suggest 180 days, or keep resolved rows and purge unresolved ones.
- **Re-tune `lists = 100`.** That is right for roughly 100k–1M rows. Below ~10k
it over-partitions and recall drops. Check `count(*)` once embeddings flow;
consider HNSW instead if the pgvector version supports it.
**Files**
| File | Change |
|---|---|
| `doormile_backend/internal/ai/outcomes/sweeper.go` | modify — fold the decision purge into the same tick |
| `doormile_backend/migrations/migrate.go:98` | modify — index tuning, once row count is known |
### B6. Tenant isolation — decide before B4 ships, not after
`agent_decisions` has **no tenant column**. If retrieved precedent crosses
tenants, one client's operational history shapes decisions made for another.
Given that console logins are already unscoped on `tenantid` NULL
(`doormile-console-logins-unscoped`), this needs deciding up front.
Recommendation: add `tenantid` to `agent_decisions`, have the engine populate it,
and filter in the `/similar` query. Cheap now, expensive after the table fills.
**Files**
| File | Change |
|---|---|
| `doormile_backend/models/agentdecision.go` | modify — add `Tenantid *uint64` with an index |
| `doormile_backend/controllers/agentDecisionController.go:18` | modify — accept `tenant_id` on create |
| `doormile_backend/controllers/agentDecisionController.go:104` | modify — add `AND tenantid = ?` to the kNN query |
| `AI_engine/core/decisions.py:33` | modify — `build_payload` carries the tenant |
| `AI_engine/agents/exception_agent.py` · `dispatch_agent.py` | modify — source the tenant from the booking facts |
| `doormile_backend/routes/routes_ai_registry_pg_test.go` | modify — a cross-tenant recall must return nothing |
Nullable, because the engine will not always know the tenant. Decide whether a
NULL tenant row is recallable by everyone or by no one — given
`doormile-console-logins-unscoped`, **by no one** is the safer default.
---
## Track C — Kubernetes
### C1. `AI_engine` is not in Kubernetes at all
No manifest, no kustomization, no deploy script, and `docker-compose.yml` uses
`build: .` with no registry push. It runs on a VM by compose.
**Prerequisite: the engine has no HTTP server in production mode.**
`main.py --production` starts no listener, so there is no liveness/readiness
target and no `/metrics`. Without it, Kubernetes can only restart on process
exit — a NATS-disconnected engine looks healthy forever.
`fastapi` and `uvicorn` are already in `requirements.txt`. Add a small surface in
`production_mode()`:
- `GET /healthz` — process alive (event loop responsive)
- `GET /readyz` — NATS connected **and** `registry.loaded` is true
- `GET /metrics` — optional; decisions recorded, tool calls, LLM failures
Readiness must include `registry.loaded`, otherwise a pod that cannot reach the
backend serves traffic on env defaults while reporting healthy — exactly the A1
failure mode, invisible again.
Then: push the image to a registry (compose builds locally), and write
`manifests/doormile/ai-engine.yaml` as a `Deployment` (it is stateless;
`replicas: 1` to start — the agents are not yet idempotent across replicas, see
C6).
**Files**
| File | Change |
|---|---|
| `AI_engine/core/health.py` | **new** — the aiohttp/FastAPI surface (`/healthz`, `/readyz`, `/metrics`) |
| `AI_engine/main.py:130` | modify — start the health server inside `production_mode()` beside `registry.run(...)` |
| `AI_engine/main.py` (`print_help`) | modify — document the health port |
| `AI_engine/Dockerfile` | modify — `EXPOSE` the health port |
| `AI_engine/docker-compose.yml` | modify — publish the port so compose and k8s behave alike |
| `AI_engine/tests/test_health.py` | **new** — `/readyz` is red while `registry.loaded` is false |
| `kubernetes/manifests/doormile/ai-engine.yaml` | **new** — Deployment + Service + probes + resources |
`fastapi` and `uvicorn` are already in `requirements.txt` — no new dependency.
Readiness must check `registry.loaded` (`core/registry.py:45`), not just the
process, or A1's failure mode becomes invisible again.
### C2. `doormile/` has no `kustomization.yaml`
`alaska/`, `core/` and `nearle/` all have one. `doormile/` does not, and there is
no `deploy-doormile.sh` alongside `deploy-core-stack.sh` / `deploy-nearle-stack.sh`.
That is *why* it drifts. Add both.
**Files**
| File | Change |
|---|---|
| `kubernetes/manifests/doormile/kustomization.yaml` | **new** — list miletruth, config, ai-engine, pdb |
| `kubernetes/deploy-doormile.sh` | **new** — copy the shape of `deploy-nearle-stack.sh` |
| `kubernetes/scripts/sync_manifests.py` | check — may need the new namespace registering |
| `kubernetes/docs/DEPLOY_CHECKLIST.md` | modify — add the doormile stack |
Pattern to copy: `manifests/nearle/kustomization.yaml` + `deploy-nearle-stack.sh`.
### C3. The `doormile` StatefulSet has no probes, resources or PDB
`replicas: 3` with **no resource requests** means the scheduler can stack all
three on one node, and **no readinessProbe** means a pod receives traffic before
Postgres/Redis/NATS are connected. `core/` has `worker-pdb.yaml`; doormile has
nothing.
Add `resources.requests`/`limits`, a readinessProbe and livenessProbe against the
backend's health route, and a PodDisruptionBudget (`minAvailable: 2`).
Also: a `StatefulSet` for a stateless Go API is the wrong kind — it gives serial
rollouts and no benefit. Switching to `Deployment` is low-risk and makes deploys
faster. Not urgent; flagging because it is why rollouts feel slow.
**Files**
| File | Change |
|---|---|
| `kubernetes/manifests/doormile/miletruth.yaml:23` | modify — `resources`, `readinessProbe`, `livenessProbe` |
| `kubernetes/manifests/doormile/doormile-pdb.yaml` | **new** — `minAvailable: 2`, copy `manifests/core/worker-pdb.yaml` |
**No backend change needed** — the probe targets already exist and are correct:
`GET /api/v1/health` (`routes/routes.go:46`, unauthenticated, always 200) for
liveness, and `GET /api/v1/ready` (`routes/routes.go:50`) for readiness, which
already returns **503** when Postgres or Redis is unreachable
(`routes.go:68-70`). Point the probes at those; do not write new ones.
Note `/ready` reports Redis GEO status without gating on it — deliberate, per the
comment at `routes.go:73`. A readinessProbe on `/ready` therefore will not pull a
pod out of service for a broken rider search, which is the intended behaviour.
### C4. No ingress for `doormile`
`nearle` and `alaska` are on `manifests/core/ingress-unified.yaml`. `doormile` is
NodePort 30830 plus host nginx (`conf/nginx-doormile.conf`) — half-migrated. This
also makes B0.2 (GET-with-body) a certainty rather than a risk.
**Files**
| File | Change |
|---|---|
| `kubernetes/manifests/core/ingress-unified.yaml` | modify — add a `doormile` rule (needs a ReferenceGrant if it stays cross-namespace, cf. `manifests/nearle/nearle-reference-grant.yaml`) |
| `kubernetes/manifests/doormile/miletruth.yaml:91` | modify — `NodePort` → `ClusterIP` once the ingress serves it |
| `kubernetes/conf/nginx-doormile.conf` | modify — retire or repoint, **only after** the ingress is verified |
Do these in that order. Flipping the Service type before the ingress works takes
the API offline.
### C5. NATS is outside the cluster on two ports
Backend uses `nats://66.116.226.161:4223`; `core-config.yaml:10` uses `:4222`.
Worth a line in `docs/ARCHITECTURE.md` on which port is which and why, before the
engine joins and needs to pick one.
### C6. Decide replica safety before scaling the engine
The telemetry recorder is already replica-safe (NATS queue group +
`uq_aiagentruns_agent_task`). The **agents** are not obviously so: `exception_agent`
has an in-process TTL dedup set (`exception_agent.py:339`), which is per-pod. Two
engine replicas would each decide on the same stall. Keep `replicas: 1` until
dedup moves to Redis.
**Files** (only if scaling past 1 replica)
| File | Change |
|---|---|
| `AI_engine/agents/exception_agent.py:339` | modify — TTL set → Redis `SET NX EX` |
| `AI_engine/tests/test_stall_dedup.py` | modify — covers the current in-process behaviour |
| `kubernetes/manifests/doormile/ai-engine.yaml` | modify — raise `replicas` |
C5 (the NATS port note) is documentation only: `kubernetes/docs/ARCHITECTURE.md`.
---
## Track D — Executor backlog
9 of 22 seeded tools are marked `REVIEW ONLY … No executor` in
`internal/ai/registry/seed.go`. The registry is honest about it and the console
renders them disabled. This is the feature list, in value order.
### D1. `assign_riders` — needs an admin auto-assign route (not just auth)
**Correcting an earlier assumption:** this is *not* a free auth fix.
`actions.js:96-107` already explains why — `POST /admin/bookings/:id/assign-miler`
(`routes.go:413` → `adminController.go:2965`) requires a **chosen rider per
booking** (`{mileruserid}`), and a finding does not pick one. The hub route that
*does* pick (`POST /hub/bookings/:id/auto-assign`, `routes.go:520`) is behind
`HubStaffAuth` and 403s for every console login.
**The cleanest route is an admin batch-assign**, better than the per-booking
auto-assign first considered. `HubBatchAssign` (`controllers/hubController.go:1963`)
already does exactly what a finding needs: it takes `bookingids[]`, picks riders
via Redis GEO + scoring, and commits server-side in one call. Its only hub-specific
parts — `c.Locals("hubid")` and `hubPincodePrefix(hubID)` (`hubController.go:1964-1966`)
— are used **solely as a fallback when `bookingids` is empty** (`hubController.go:1981-1985`).
The console always passes explicit ids, so that branch never runs.
So: extract the body into a shared helper taking `(bookingIDs, capPerRider, actorID, scopeFn)`
and have both the hub route and a new admin route call it. `scopeBookingsToOwnTenant`
(`hubController.go:1986`) already works for admin logins.
The console side is then nearly free — `batchAssignBookings` already exists at
`src/api/doormile/endpoints.js:554` and already sends `{bookingids, max_per_rider}`.
It just points at the hub URL that 403s. One URL change.
Option (b), having the skill pick a rider via `nearby_milers` and call the
existing `assign-miler`, is worse: more console work and it puts solver logic in
the browser.
**Files**
| File | Change |
|---|---|
| `doormile_backend/controllers/hubController.go:1963` | modify — extract the shared assign helper out of `HubBatchAssign` |
| `doormile_backend/controllers/adminController.go` | **add** `AdminBatchAssign` calling that helper |
| `doormile_backend/routes/routes.go:413` | modify — register `adminAuth.Post("/bookings/batch-assign", …)` |
| `krow_talent_app/src/api/doormile/endpoints.js:554` | modify — `/hub/bookings/batch-assign` → `/admin/bookings/batch-assign` |
| `krow_talent_app/src/lib/assistant/agent/actions.js:122` | modify — add the `assignMiler` executor |
| `krow_talent_app/src/lib/assistant/agent/actions.js:96-107` | modify — delete the "deliberately NOT an executor" note |
| `krow_talent_app/tests/lib/agentActions.test.js` | modify — asserts the current executor set |
| `doormile_backend/internal/ai/registry/seed.go` | modify — `assign_riders` description stops saying REVIEW ONLY |
Check before starting: `endpoints.js:547-553` warns that Doormile-native batch
assign commits with no preview/reconcile step and leaves multi-stop riders
unsequenced. An agent-proposed assignment firing straight to commit is a
behaviour decision, not just a wiring one — confirm that is wanted.
This takes the console from 1 working verb to 2 and makes the highest-severity
SLA finding actionable.
### D2. `alert_low_battery_rider` — nearly free
`seed.go` already targets `POST /admin/milers/:id/notify`, which is the endpoint
`notify_riders` already uses successfully. This is a message-text change and an
`EXECUTORS` entry, not a new capability.
**Files**
| File | Change |
|---|---|
| `krow_talent_app/src/lib/assistant/agent/actions.js:122` | modify — add the `alertLowBatteryRider` executor |
| `krow_talent_app/src/lib/assistant/skills/definitions/RiderBatterySafetySkill.js` | check — confirm the proposal carries `milerId` |
| `krow_talent_app/tests/lib/agentActions.test.js` | modify — same assertion as D1 |
| `doormile_backend/internal/ai/registry/seed.go` | modify — drop REVIEW ONLY from the description |
No backend change. `notifyMiler` already exists at
`src/api/doormile/endpoints.js:389` → `POST /admin/milers/:id/notify`, which is
the endpoint `seed.go` already names as the target.
### D3. The rest need endpoints that do not exist
`enforce_otp_verification`, `dispatch_hub_idle_parcels`, `trigger_auto_dispatch`,
`enforce_cash_handoff`, `rebalance_riders` — all marked `Target: "none yet"`.
Each is a product decision first. Not in this phase.
---
## Two more loose ends
- **`src/lib/agentNetwork.js` is a hand-maintained snapshot** dated 16–20 Sep, and
the file says so honestly. Once C1 gives the engine an HTTP surface, add
`GET /agents/status` and swap the source. The file is deliberately shaped like
that response, so it is a change of source, not a rewrite.
- **`src/lib/assistant/ragRouter.js` points at a `services/ai` sidecar that does
not exist in any repo.** `VITE_AI_URL` appears nowhere, so `isRagEnabled()` is
permanently false and the module is dead code. **Do not conflate this with
Track B** — ragRouter is semantic *intent routing* for the console assistant,
not decision memory. Lower value. Leave it dormant (it is correctly fail-open)
or decide to build the sidecar as its own piece of work.
---
## Consolidated file manifest
**14 new files, 46 modified, across 4 repos.** Per-item detail is in the tracks above.
### `doormile_backend` — 4 new, 17 modified
| File | New? | Items |
|---|---|---|
| `internal/ai/outcomes/sweeper.go` | **new** | B3, B5 |
| `internal/ai/outcomes/rules.go` | **new** | B3 |
| `internal/ai/outcomes/sweeper_test.go` | **new** | B3 |
| `migrations/migrate.go` | | B0.1 (:92), B5 (:98) |
| `routes/routes.go` | | B0.2 (:594), D1 (:413) |
| `controllers/agentDecisionController.go` | | B0.2 (:74), B6 (:18, :104) |
| `controllers/hubController.go` | | D1 (:1963 — extract helper) |
| `controllers/adminController.go` | | D1 (**add** `AdminBatchAssign`) |
| `models/agentdecision.go` | | B6 |
| `internal/ai/registry/seed.go` | | B4 (:114), D1, D2 |
| `main.go` | | B3 (:244) |
| `routes/routes_ai_registry_pg_test.go` | | B0.2, B6 |
### `AI_engine` — 5 new, 20 modified
| File | New? | Items |
|---|---|---|
| `core/embeddings.py` | **new** | B2 |
| `core/memory.py` | **new** | B4 |
| `core/health.py` | **new** | C1 |
| `tests/test_embeddings.py` | **new** | B2 |
| `tests/test_memory.py` · `tests/test_health.py` | **new** | B2, C1 |
| `core/decisions.py` | | B2 (:33, :53), B6 |
| `core/llm.py` | | A4 (:11, :22), B4 (:157, :227) |
| `core/registry.py` | | — read-only (`loaded` consumed by C1) |
| `agents/exception_agent.py` | | B4 (:456), B6, C6 (:339) |
| `agents/dispatch_agent.py` | | B4 (:335), B6 |
| `main.py` | | C1 (:130, `print_help`) |
| `config/system_config.py` | | B2 |
| `requirements.txt` · `Dockerfile` · `docker-compose.yml` | | A3, A4, C1 |
| `evals/stall_eval.py` · `assignment_eval.py` · both `.jsonl` | | B4 |
| `tests/test_registry_phase5.py` · `test_llm.py` · `test_stall_dedup.py` | | B2, A4, C6 |
### `kubernetes` — 5 new, 7 modified
| File | New? | Items |
|---|---|---|
| `manifests/doormile/doormile-config.yaml` | **new** | A1 |
| `manifests/doormile/ai-engine.yaml` | **new** | C1, C6 |
| `manifests/doormile/kustomization.yaml` | **new** | C2 |
| `manifests/doormile/doormile-pdb.yaml` | **new** | C3 |
| `deploy-doormile.sh` | **new** | C2 |
| `manifests/doormile/miletruth.yaml` | | A1, A2, C3 (:23), C4 (:91) |
| `manifests/core/ingress-unified.yaml` | | C4 |
| `conf/nginx-doormile.conf` | | C4 (last) |
| `scripts/sync_manifests.py` | | C2 |
| `docs/DEPLOY.md` · `DEPLOY_CHECKLIST.md` · `ARCHITECTURE.md` | | A2, C2, C5 |
### `krow_talent_app` — 0 new, 4 modified
The console barely changes. Everything it needs already exists.
| File | Items |
|---|---|
| `src/api/doormile/endpoints.js` | D1 (:554 — one URL) |
| `src/lib/assistant/agent/actions.js` | D1 (:96-107, :122), D2 (:122) |
| `tests/lib/agentActions.test.js` | D1, D2 |
| `src/lib/assistant/skills/definitions/RiderBatterySafetySkill.js` | D2 — check only |
Untouched on purpose: `src/lib/agentNetwork.js` (until C1 ships an endpoint) and
`src/lib/assistant/ragRouter.js` (dormant, out of scope — see loose ends).
### Files deliberately not touched
- `src/lib` / `src/utils` duplicate trees — both have live importers, do not consolidate.
- `AI_engine/core/tool_registry.py` — the 2-vs-22 tool gap is a separate decision, not this phase.
- `AI_engine/customer_portal/`, `dashboard/` — not on any path this phase touches.
## Decisions needed before coding
1. **Embedding provider** — OpenAI 1536 (no column change), Voyage 1024, or local
384? (B1)
2. **Is `pgvector` installed on `logistics`?** If creating extensions needs an
admin, that is a prerequisite, not a migration. (B0.1)
3. **Outcome definitions** — are the two in B3 right, and what is N for
`assignment_failure`?
4. **`tenantid` on `agent_decisions`** — add it now, or accept cross-tenant
precedent? (B6)
5. **`assign_riders`** — route (a) admin auto-assign, or (b) console picks the
rider? (D1)
6. **Decision retention window** — 180 days, or keep-resolved-purge-unresolved? (B5)
## Sequencing
```
A1 ──> A3, A4 ──┬──> B0 ──> B2 ──> B3 ──> B4 ──> B5, B6
│
└──> C1(health) ──> C1(manifest) ──> C2 ──> C3 ──> C4
A2 (independent, before A1 adds more secrets to git)
D1, D2 (independent of everything above)
```
**A1 first and alone.** Until the internal key is set, the engine is not talking
to the backend, so every Track B acceptance check would fail for the wrong
reason. Run the `kubectl` check in A1 before writing any code — if git does not
match the cluster, that changes the shape of Track C.
Cheapest real step up a level, once A1 is in: **D1 + D2.** Two executors, no new
LLM work, and it moves autonomy off the floor.
---
## Standing constraints
- Nothing in this plan is committed, pushed, or deployed without being asked.
- No migrations run against a real database without being asked.
- No files deleted or untracked without per-file approval.
- `src/lib` and `src/utils` in the console **both** have live importers — neither
tree is dead, do not consolidate them as part of this work.

724
docs/agent-platform-plan.md Normal file
View File

@@ -0,0 +1,724 @@
# Doormile Agent Platform — Plan & Agent Registry
Status: **Phases 0–4 done (uncommitted, not deployed), Phase 5 next** · Written 2026-09-29 · Scope: `krow_talent_app` (the
Doormile console), `doormile_backend` (Go API), `AI_engine` (Python agent swarm).
This is the document `src/pages/doormile/settings/Settings.jsx` has been pointing
at ("Phase 6 of docs/agent-platform-plan.md"). That file never existed until now,
so the phase numbers below replace the ones the comment assumed.
---
## 1. Where things actually stand (verified 2026-09-29)
### Console — `krow_talent_app`
- **Settings → Skills & Tools (Agent Studio) is a mock.** `agentRegistryData.js`
holds 2 agents, 3 surfaces, 4 skills and 6 tools. Everything is saved to
`localStorage` and nowhere else. The Test tab (`AgentPlayground.jsx:37`) is a
`setTimeout` that always replies "succeeded with 100% confidence". Configure and
Insights use hard-coded figures ("99.4%", "540 calls") and a model
`claude-3-5-sonnet` that nothing reads.
- **Three of the six mock tools are Krow leftovers:** `open_add_skill_training`,
`open_add_training` and `query_learning_analytics` (workforce training, not
logistics). Delete them; don't migrate them.
- **The Agents page** (`/doormile/agents`) runs on a static snapshot in
`src/lib/agentNetwork.js`, dated 16–20 Sep 2026. It is honest about being a
snapshot, but its test (`tests/integration/agentsPage.test.jsx`) is stale: that
is the 1 failing suite and 9 failing tests out of 45 suites.
- **Unmerged branch `feat/agentic-ops-layer`** (dharaneesh, 2026-09-01) is the only
real client-side agent work:
- `SkillRegistry` with 8 rule-based skills: SlaGuardian, DoorstepStall,
FleetBalancer, HighValueCod, RiderBatterySafety, HubCongestion, LateDispatch,
CashExposure.
- `tools.js` with 5 tools, following the rule "read-only tools execute, mutating
tools return a Proposal".
- AgentFactory, the ops briefing, proposal executors with a verify pass, and 20+
test files.
- It is **29 commits behind main and conflicts in 10 files**. Its skill config is
also stored in `localStorage`.
- **The Home assistant** runs on the regex catalogue `src/lib/assistant/intents.js`
(109 KB) and has no concept of skills.
- **Other state:** lint shows 35 auto-fixable errors, and `Deliveries.jsx` has an
uncommitted cosmetic change (a tidy-up of the batch filter).
### Backend — `doormile_backend`
- Build, vet and test all pass (Go 1.26.4). There are 233 routes; CLAUDE.md is
stale on counts, retry, routing, PIN auth and SMS (see its drift list).
- **Nothing for an agent registry exists yet.** `internal/ai/` is an empty,
untracked directory.
- **What does exist:**
- `models.AgentDecision` (table `agent_decisions`, pgvector 1536-dim — the
384-vs-1536 question is still open).
- Internal routes, all behind `X-Internal-Key`:
- `POST /internal/agent-decisions`
- `GET /internal/agent-decisions/similar`
- `PATCH /internal/agent-decisions/:id/outcome`
- `/internal/express/{riders,bookings,assign}`
- **Admin authorization is flat.** Roles 1, 3 and 4 get identical access, and no
handler checks the role further. Many admin write handlers have no tenant guard.
So any registry write endpoint must add its own role check (see §5).
### Engine — `AI_engine`
- **It has no registry to read from.** Agents are hard-coded classes in
`main.py:55-70`. `core/tool_registry.py` is an in-memory dict holding 3 tools,
and none of them load in production. The two "skills" in `core/skills/` are
stubs that nothing imports.
- **The agent process has no HTTP API.** The Command Center (FastAPI, :8600) is a
separate, unauthenticated NATS tap.
- **Runs, escalations and decisions live in memory only.** They are capped at 500
and lost on restart.
- **Tests:** 72 pass, but only with pytest installed by hand; `requirements.txt`
lacks pytest.
**Conclusion:** the registry must be **built**, not wired up. It belongs in
`doormile_backend` (Postgres). The console reads it, and the engine reads it.
Neither of them owns it.
---
## 2. The Agent Registry — what gets seeded
This is the real inventory. Every row here becomes a seed row in Phase 1.
"Status" is what the code does today, not what the docs claim.
### 2.1 Agents
| id | Class / file | Purpose | Trigger | Writes to system | LLM | Status |
|---|---|---|---|---|---|---|
| `JARVIS` | `MasterAgent` · `core/agent.py:196` | Orchestrator, escalation inbox | `logistics.direct.JARVIS` | none | — | Partial. The inbox works; `orchestrate_order` sends the wrong payload key |
| `DISPATCH_AGENT` | `agents/dispatch_agent.py:72` | Watches assignment outcomes, flags coverage gaps | JetStream `booking.assigned`, `booking.assignment_failed` | Alerts; customer notify only when autonomous | `decide_assignment_failure` | **Production-grade.** Idle until `assignment_failed` is confirmed as published (see §7) |
| `EXCEPTION_AGENT` | `agents/exception_agent.py:106` | Stalled-rider detection and response | TRACKING `miler.location.updated`, `miler.stalled`, plus a 60 s DB sweep | `POST /internal/bookings/:id/reassign` (needs autonomy flag and confidence ≥ 0.7), `/internal/notify` | `decide_stall_response` | **Production-grade** (the stall path only) |
| `EXPRESS_DISPATCH_AGENT` | `agents/express_dispatch_agent.py:80` | Tenant batch assign plus road sequencing | JetStream `express.dispatch_requested` | `POST /internal/express/assign` | — (greedy) | Implemented. Autonomy defaults to `true` in code and `false` in compose |
| `CUSTOMER_AGENT` | `agents/customer_agent.py:50` | Customer notifications | Direct tasks, only from an autonomous Dispatch | `/internal/notify` | — | Implemented, rarely reached |
| `ORDER_AGENT` | `agents/order_agent.py:15` | Order intake and validation | Direct tasks (no sender in production) | `/admin/crmbooking` — a **dead path, no auth header** | — | Broken. `update_status` crashes (`order_agent.py:182`) |
| `HUB_AGENT` | `agents/hub_agent.py:35` | Hub capacity | Direct tasks | none | — | **Simulation** (8 fictional hubs) |
| `FLEET_AGENT` | `agents/fleet_agent.py:34` | Vehicles | Direct tasks | none | — | **Simulation** (19 fake vehicles) |
| `ROUTE_OPTIMIZER` | `agents/route_optimizer_agent.py:41` | Routing | Direct tasks | none | — | **Simulation** (haversine only) |
| `CONSOLE_ASSISTANT` | `src/lib/assistant/*` (console) | Home chat: orders, bulk, assign, repeat | Operator prompt | Via proposals the operator confirms | RAG sidecar (off) | Live, regex-based |
| `CONSOLE_OPS_AGENT` | `feat/agentic-ops-layer` | Ops briefing plus 8 monitoring skills | Page load / poll | Via proposals the operator confirms | — (rules) | **Unmerged** |
The registry shows `status` as one of `live | partial | simulation | broken |
unmerged | retired`. **The console must display this badge.** A simulation agent
that looks live is the exact failure the Agents page comment warns about.
### 2.2 Tools (the real capabilities, not the stubs)
`kind` is `read`, `write` or `notify`. Every `write` or `notify` tool has
`requires_confirmation = true` unless its agent is explicitly autonomous.
| name | Owner agent(s) | Kind | Target | Today lives at |
|---|---|---|---|---|
| `reassign_booking` | EXCEPTION | write | Go `/internal/bookings/:id/reassign` | `exception_agent.py:466` |
| `notify_customer` | EXCEPTION, CUSTOMER | notify | Go `/internal/notify` | `exception_agent.py:476`, `customer_agent.py:349` |
| `list_express_bookings` | EXPRESS | read | Go `/internal/express/bookings` | `express_dispatch_agent.py:351` |
| `list_express_riders` | EXPRESS | read | Go `/internal/express/riders` | `express_dispatch_agent.py:342` |
| `assign_express_batch` | EXPRESS | write | Go `/internal/express/assign` | `express_dispatch_agent.py:222` |
| `sequence_stops` | EXPRESS | read (compute) | `routes.workolik.com /optimization/doormile/sequence` | `express_dispatch_agent.py:308` |
| `get_booking_cache` | CUSTOMER | read | Go `/bookings/cache/:id` | `customer_agent.py:243` |
| `nearby_milers` | DISPATCH, EXCEPTION | read | Redis GEO `milers:locations` | `dispatch_agent.py:170-240` |
| `publish_miler_stalled` | EXCEPTION | write (event) | NATS `miler.stalled` | `exception_agent.py:377` |
| `decide_stall_response` | EXCEPTION | read (LLM) | Claude | `core/llm.py:157` |
| `decide_assignment_failure` | DISPATCH | read (LLM) | Claude | `core/llm.py:227` |
| `record_agent_decision` | all | write | Go `/internal/agent-decisions` | Go route exists |
| `diagnose_operations` | CONSOLE_OPS | read | Console queries | branch `tools.js` |
| `lookup_order` | CONSOLE_OPS | read | `/admin/bookings` | branch `tools.js` |
| `lookup_miler` | CONSOLE_OPS | read | `/admin/milers` | branch `tools.js` |
| `propose_miler_reassignment` | CONSOLE_OPS | write → proposal | `/admin/bookings/:id/assign-miler` | branch `tools.js` |
| `simulate_pricing_quote` | CONSOLE_OPS, CONSOLE_ASSISTANT | read | `/admin/pricing/simulate` | branch `tools.js` |
| `create_single_order` | CONSOLE_ASSISTANT | write → proposal | `/admin/expressbooking` | current mock |
| `rebalance_riders` | CONSOLE_OPS | write → proposal | `/hub/bookings/batch-assign` | current mock (no backing code) |
**Not seeded:**
- `ask_question`, `order_intake_skill`, `repeat_run_skill` (engine stubs that are
never loaded).
- The 3 Krow training tools.
- ORDER_AGENT's `crmbooking` calls: that route was renamed to `expressbooking`, so
those calls hit nothing.
### 2.3 Skills
A skill is a named behaviour that belongs to one agent and uses one or more tools.
It has a switch (`enabled`) and tunable `thresholds` (JSON, validated against a
per-skill schema).
| id | Agent | Tools | Source |
|---|---|---|---|
| `stall_response` | EXCEPTION | nearby_milers, decide_stall_response, reassign_booking, notify_customer | engine |
| `assignment_failure_triage` | DISPATCH | nearby_milers, decide_assignment_failure | engine |
| `express_batch_dispatch` | EXPRESS | list_express_*, assign_express_batch, sequence_stops | engine |
| `sla_guardian` · `doorstep_stall` · `fleet_balancer` · `high_value_cod` · `rider_battery_safety` · `hub_congestion` · `late_dispatch` · `cash_exposure` | CONSOLE_OPS | branch `tools.js` set | branch (thresholds move from localStorage to the registry) |
| `order_intake_auto_schedule` | CONSOLE_ASSISTANT | create_single_order, simulate_pricing_quote | current mock, real behaviour in `orderFlow.js` |
| `dispatch_rebalance` | CONSOLE_OPS | rebalance_riders | current mock. **Keep disabled** until a backing endpoint exists |
### 2.4 Surfaces
`/doormile/home` (assistant), `/doormile/control-x` (dispatch board),
`/doormile/agents` (status board), and the ops banner (branch
`AgentOperationsBanner`). `/doormile/dispatch` is only a redirect to Control X, so
it is not a separate surface.
---
## 3. Schema (Phase 1) — ⚠ additive schema change, review before deploy
New tables in `doormile_backend`, added through GORM AutoMigrate. They are
additive only and touch no existing table.
```
ai_agents id text PK, name, class_ref, runtime ('engine'|'console'),
purpose, trigger jsonb, status, autonomous bool,
model text NULL, created_at, updated_at
ai_tools name text PK, description, kind ('read'|'write'|'notify'),
target text, input_schema jsonb, requires_confirmation bool,
enabled bool, created_at, updated_at
ai_skills id text PK, agent_id FK→ai_agents, title, category,
description, sample_prompt, enabled bool,
thresholds jsonb, thresholds_schema jsonb,
version int, updated_by int NULL, updated_at
ai_skill_tools skill_id FK, tool_name FK, PRIMARY KEY (skill_id, tool_name)
ai_registry_audit id bigserial, entity, entity_id, field, old jsonb, new jsonb,
changed_by int, changed_at
```
Rules:
- **Tools and agents are code-defined.** The console can toggle `enabled` and
`autonomous`, but it cannot invent a tool. A tool with no implementation is a lie
on screen. "New skill" in the console therefore picks from existing tools only.
- **Every write goes into `ai_registry_audit` in the same transaction.**
- **Seeding is idempotent** (upsert by id) and lives in `migrations/`, not
`scratch/`.
- **Never store a secret.** Env var *names* only; the engine's `.env` stays out of
the registry.
---
## 4. API contract (Phase 1)
All handlers go in `controllers/aiRegistryController.go` and the logic in
`internal/ai/registry`. Responses use `utils.OK` / `utils.List`, following the
console conventions.
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/admin/ai/agents` | admin (1,3,4), Doormile staff only | Includes a `status` badge and skill/tool counts |
| GET | `/admin/ai/agents/:id` | same | Agent plus its skills and tools |
| GET | `/admin/ai/skills` | same | `?agent=` filter |
| GET | `/admin/ai/tools` | same | `?kind=` filter |
| PATCH | `/admin/ai/skills/:id` | **roleid 1 only** | Allowed fields: `enabled`, `thresholds` (validated against schema). Bumps `version` and writes an audit row |
| POST | `/admin/ai/skills` | roleid 1 only | New skill from existing tools only |
| PATCH | `/admin/ai/agents/:id` | roleid 1 only | Allowed fields: `autonomous`, `model`. **Extra confirm in UI** — this changes what an agent does without a human |
| GET | `/admin/ai/audit` | admin | Registry change history |
| GET | `/admin/ai/decisions` | admin | Paged `agent_decisions` (Phase 4) |
| GET | `/internal/ai/registry` | `X-Internal-Key` | The engine pulls its config from here (Phase 5). It sends an ETag so the engine can poll cheaply |
"Doormile staff only" means `consoleTenantID == 0`. A tenant or client login gets
403. It does **not** get an empty list, because an empty list would hide a
misconfiguration.
---
## 5. Phases
Each phase ships on its own and leaves the system working. Nothing is committed or
pushed without an explicit ask.
### Phase 0 — Prerequisites and hygiene (small, do first)
1. **Rotate secrets and untrack them.**
- `doormile_backend`: `.env` and `doormile-abee7-*.json` are tracked; the
`.gitignore` line `#.env` is commented out.
- `AI_engine`: `.env` is tracked.
- `config/config.go:60-71` has hard-coded fallback secrets. Fail at boot
instead.
- This is a **user action** (rotation needs the providers' consoles). I can do
the untracking and ignore rules.
2. **Decide `feat/agentic-ops-layer`** (see §6, decision A).
3. **Console:** fix the stale `agentsPage.test.jsx`, run `lint:fix` for the 35
unused imports, and delete the 3 Krow training tools from
`agentRegistryData.js`.
4. **Add a PREVIEW banner** on the Agent Studio tab itself. Today only a code
comment says so; Agents shows a snapshot label, Agent Studio shows nothing.
5. **Backend:** remove the broken `.claude/skills/*` symlink stubs, `.agents/`,
`skills.md` and `skills-lock.json`. The global plugin already provides these
skills.
**Done when:** tests are green, lint is clean, and no secret is in `git ls-files`.
**Phase 0 result (2026-09-29, uncommitted):**
- Console: lint clean; **46/46 suites, 1192 tests pass**.
- Agent Studio: 4 Krow tools and 2 Krow skills removed (the plan said 3 tools;
`open_miletruth_ai` was a fourth). `dispatch_rebalance` ships disabled.
Storage keys moved to `_v3`. An on-screen Preview note was added.
- Agents page: this was not just a stale test. The 24–25 Sep rebuild presented
a simulation as live. Per decision, the design was kept and labelled:
- a `Snapshot · 16–20 Sep 2026` stamp;
- "Sample Activity — Simulation · not live data";
- no pulsing dot;
- status counts taken from `networkStats()`;
- "Autonomy gates on: 0 / 3", where the old "0 / 8" implied 8 gates.
The tests were rewritten, keeping the honesty checks.
- Backend:
- **Reverted 2026-09-29 at Suriya's request:** these are back in git
exactly as at HEAD:
- the untracking of `.env` and the service-account key (the key is
still committed, so rotation still stands);
- the `.gitignore` edit;
- the removal of `skills.md`, `skills-lock.json`, `.agents/` and
`.claude/skills/`.
AI_engine's `.env` is tracked again too. Do not redo any of this without
asking.
- `main.go` now refuses to boot when `ENV=production` and `JWT_SECRET_KEY`,
`DB_PASSWORD` or `NATS_PASSWORD` is unset.
- build, vet and test are green.
- AI_engine: `.env` was untracked (it was already in `.gitignore`).
- **Still yours:**
- Rotate every secret that was committed. Git history still holds the
values.
- **Before the next backend deploy,** confirm that production sets all
three secrets. If it has been running on a fallback, the new check stops
it from starting.
### Phase 1 — Registry in the backend
- Add the §3 tables, the idempotent seed from §2, and the §4 read endpoints plus
PATCH/POST with the role check and audit trail.
- **Tests:** a seed-idempotency test, a role test (roles 3 and 4 get 403 on PATCH;
a tenant login gets 403 on GET), a threshold-schema validation test, and an audit
test.
- **Done when:** `go build/vet/test` is green and `curl` against a staging DB
returns the §2 inventory.
**Phase 1 result (2026-09-29, uncommitted, NOT deployed, no real DB touched):**
- **Tables.** They follow the codebase's naming, not the names in §3:
`aiagents`, `aitools`, `aiskills`, `aiskilltools`, `airegistryaudit`.
The columns are as in §3, with a few changes:
- The agent's trigger column is named `wakeon`.
- `hasautonomygate` is new. Autonomy can only be set on Dispatch,
Exception and Express.
- `source` (engine/console/custom) is on skills.
- There is no `enabled` flag on tools.
- **Code.**
- `internal/ai/registry`: seed, rules, store.
- `controllers/aiRegistryController.go`
- `middlewares/staff_only.go` (`DoormileStaffOnly`)
- Routes are under `/admin/ai/*` and `/internal/ai/registry`, as in §4.
- The seed runs in `migrations.Migrate`. It logs a failure and does not
stop the boot.
- **Seed.** 11 agents, 26 tools and 15 skills.
- Four skills were added to §2.3 so that every tool belongs to a skill:
`customer_notifications`, `ops_briefing`, and the 8 branch tools
folded into their skills.
- The console ops skills keep the branch ids and threshold keys, so
Phase 3 is a 1:1 mapping.
- `record_agent_decision` was dropped. It is a log the Go side writes, not
a capability.
- **Rules enforced server-side.**
- Switching autonomy ON needs `confirm` set to the agent id.
- Model ids must match `claude-*`, and only engine agents have one.
- Thresholds are validated for range and step, and a patch is all-or-nothing.
- Custom skills can be added to console agents only, and only from existing
tools.
- A patch that changes nothing does not bump the version or write an audit
row.
- **Tests.**
- 26 unit tests and 6 HTTP gate tests always run.
- 10 Postgres integration tests and 1 HTTP end-to-end test run only when
`REGISTRY_TEST_DSN` is set. They need a throwaway database; each package
uses its own schema.
- All of them passed against a disposable `postgres:16-alpine` container.
- **Bug the Postgres run caught.** With `enabled` tagged `default:true`, gorm
dropped `false` from the INSERT, so `dispatch_rebalance` came up
**enabled**. Fixed by removing the column default.
- **Not yet proven.** The migration has not run against the real database;
that happens on your next deploy. It is 5 new tables and touches nothing
existing.
### Phase 2 — Console Agent Studio reads the registry
- Replace `agentRegistryData.js` and its `localStorage` with React Query hooks
(`useAiAgents`, `useAiSkills`, `useAiTools`) in `src/lib/doormileHooks.js`, and
add the endpoints to `src/api/doormile/endpoints.js`.
- Keep the existing components; only the data source changes. Also show the status
badge, the tool `kind`, and a confirmation marker.
- The skill toggle and "New skill" become real PATCH and POST calls. Add a
`loading`/`error` state; remove the optimistic toast that claims success before
the server answers.
- Configure tab: model and autonomy from the registry. The temperature slider is
**dropped**, because nothing reads it.
- Insights and Test stay behind the PREVIEW banner until Phases 4 and 6.
- Rewrite `tests/integration/agentStudio.test.jsx` against mocked hooks.
- **Done when:** a toggle made in one browser shows in another, and survives a
reload.
**Phase 2 result (2026-09-29, uncommitted, NOT deployed):**
- **Console.**
- Endpoints were added to `api/doormile/endpoints.js`: `getAiAgents`,
`getAiSkills`, `getAiTools`, `updateAiSkill`, `createAiSkill`,
`updateAiAgent` and `getAiRegistryAudit`.
- Hooks were added in `lib/doormileHooks.js`: `useAiAgents`, `useAiSkills`,
`useAiTools` and three mutations. They share one `['doormile','ai']` key.
- The adapter layer is `agentStudio/registryAdapters.js`, a set of pure
functions.
- `agentRegistryData.js` now keeps only the surfaces list and the
selected-agent preference.
- **Changes on screen.**
- Skills are filtered to the selected agent; before, every agent's skills
showed.
- Agent status badges appear in the switcher.
- The skill drawer has a real enable toggle, a threshold editor (range and
step checked in the browser, then on the server), and shows source and
version.
- The tool table shows the kind, "Used by", the system each tool touches
and where it is implemented. It no longer calls a read-only tool
"Autonomous".
- Configure shows the agent's record, a model picker (engine agents only)
and an autonomy switch (gated agents only) with a typed confirmation.
The fake temperature slider and GPT/DeepSeek list are gone.
- Insights shows "No run data yet" instead of invented figures.
- "New skill" is disabled on AI_engine agents and for anyone who is not an
admin.
- With no saved choice the page opens on the first agent that has skills,
not on JARVIS, which has none.
- **Backend addition.** `airegistryaudit.changedbyemail` records the token's
email. The end-to-end run showed every audit row with `changedby = 0`: an
admin login without an appusers row carries user id 0.
- **Tests.**
- Console: 18 Agent Studio tests (adapters plus the page with only HTTP
mocked). The full suite is 46/46 suites and 1193 tests, and lint is clean.
- Backend: everything is green with the database attached, including the
parallel run that clashed before per-package schemas.
- **End-to-end, in a real browser, fully local.**
- Setup: a throwaway Postgres; the backend running with no `.env` and every
host pinned to localhost; a second console on :5174; throwaway admin and
manager logins.
- Checked in the database: seed counts, the toggle, the threshold change,
autonomy on (with confirmation) and off, and custom skill creation, each
with its audit row.
- Checked in the browser: the manager view is read-only.
- Checked by direct API call: a manager's write gets 403.
- Everything was removed afterwards: container, image, scripts, test
credentials and temporary launch entries.
### Phase 3 — Land the ops-layer skills on main
- Port the 8 rule-based skills, `tools.js`, the proposal executors and the banner
from the branch onto current main. Resolve the 10 conflicts; the branch's
`Deliveries.jsx` edits collide with today's uncommitted change.
- `SkillRegistry` then reads `enabled`/`thresholds` from `/admin/ai/skills` and
falls back to code defaults when offline. It stops reading `localStorage`.
- **Keep the branch invariant:** write tools return Proposals, and a human
confirms.
- **Done when:** the branch's 20+ test files pass on main, and a threshold changed
in Agent Studio changes the banner's output.
**Phase 3 result (2026-09-29, uncommitted, NOT deployed):**
- **Ported, as unstaged file copies (no merge).**
- The 8 skill definitions.
- `agent/{AgentFactory,signals,normalise,briefing,actions}.js`.
- `SlaRemediationCard`.
- `AgentOperationsBanner`, mounted on the Exceptions page.
- The `opsBriefing` chat intent in `lib/assistant/intents.js`.
- The "Needs attention" chip on the Exceptions context.
- 11 branch test suites.
- **Settings.** `SkillRegistry` now takes enabled and thresholds from
`/admin/ai/skills` through `useSkillRegistrySync`, mounted once in
`AdminLayout`. Nothing is kept in localStorage. It falls back to code
defaults, and says so on the banner, when the registry cannot be read.
- **Not ported.**
- `tools.js`: nothing imported it.
- `AgentStudioModal`: a second, localStorage-only settings UI. "Configure
skills" goes to Settings → Skills & Tools instead.
- `AgentDecisionDrawer` and the `/internal/agent-decisions` endpoints: they
return 403 from the console.
- The AI-panel "Autonomous Fleet Agent" card, and the branch's cosmetic
edits.
- **Defects found and fixed while porting.**
1. `OpenToast('success', msg)` has its arguments swapped; the signature is
`(message, variant)`. Every successful action would have shown a red
error toast reading "success". The branch's tests asserted the same
wrong order.
2. The `assignMiler` executor posted to `/hub/bookings/batch-assign`, which
is behind `HubStaffAuth` (role 6 only). Every console click would 403.
It is now review-only, with the reason in `actions.js`.
3. **Three skills could never fire.** High-Value COD, Cash Exposure and
Battery Safety read payment and battery fields that `/admin/bookings`
rows do not carry. They would report a false all-clear. They ship **off**
(`dataGap` in code, `Enabled: false` plus the reason in the seed).
4. The chat trigger was narrowed. It no longer claims "late/delayed orders"
or "operations summary", which would have replaced existing answers.
Routing tests pin both directions.
5. `SlaRemediationCard.test.jsx` could not have run: no `lucide-react` stub.
- **Registry seed updated to match main.**
- `CONSOLE_OPS_AGENT` is `live`.
- Paths point at main.
- The console tools are now the proposal verbs: `scan_bookings`,
`notify_riders` (the only executor), `assign_riders` (review-only), and
five review-only actions, each labelled REVIEW ONLY.
- New backend tests guard the no-data skills and the review-only labels.
- **Tests.**
- Console: 64/64 suites, 1308 tests; lint is clean and the build is green.
- Backend: build, vet and all tests are green. The Postgres-gated tests were
not re-run; the seed change is data-only and unit-tested.
- `Deliveries.jsx` was untouched; the uncommitted change there is still only
Suriya's.
- **Open items.**
- Feeding the three off skills needs `/admin/bookings` to include payment
amounts and mode, and the rider's battery. That is a backend
response-shape change.
- "Assign riders" needs an admin batch-assign route. `useBatchAssignBookings`
has the same 403 problem, but nothing calls it.
- `lib/assistant/CLAUDE.md` is now stale: it says proactive alerts were
"not started" and that the `components/assistant` copies are live, but
`AIPanel` imports `lib/assistant`.
### Phase 4 — Persist decisions and runs (makes Insights real)
- A Go NATS consumer on `telemetry.task` plus the engine's LLM decisions, written to
`agent_decisions`, plus a new `ai_agent_runs` table (⚠ schema change).
- `/admin/ai/decisions` and run stats feed the Insights tab and the Agents page,
replacing the `agentNetwork.js` snapshot.
- **Resolve before relying on vector search:** the `context_embedding`
1536-vs-384 dimension question.
**Phase 4 result (2026-09-29, uncommitted, NOT deployed):**
- **Finding.** Nothing in doormile_backend or AI_engine writes
`agent_decisions`. routemate (external) returns an `agent_decision_id` from
`/decide-assignment`, so it presumably writes through
`POST /internal/agent-decisions`. Whether production has rows is unverified.
AI_engine's two LLM decisions (stall, assignment-failure) are **not**
persisted anywhere; they appear only in logs.
- **Backend.**
- One new table, `aiagentruns`: append-only, unique on (agentid, taskid),
pruned after 30 days.
- `internal/ai/telemetry` queue-subscribes (`doormile-backend-telemetry`) to
`telemetry.task`, and writes runs in batches (2 s / 200). The NATS callback
never blocks: a full buffer drops the event, counts it and logs it.
- `telemetry.agent` heartbeats go to Redis (`ai:agent:state:<id>`, 5-minute
TTL), not Postgres.
- New endpoints, Doormile staff only: `GET /admin/ai/insights?days=1..30`
(runs, failures and average time per agent; decisions by type and outcome;
live state; a `receiving` flag) and `GET /admin/ai/decisions`
(keyset-paged; the `context` column is excluded because it holds rider
data).
- Windows use the backend clock (`utils.DBNow`). The engine's naive
timestamp is stored for display only.
- **Console.** Insights shows those figures with a 24 h / 7 d / 30 d window.
Silent agents appear as "silent" with zero runs rather than being left out.
When `receiving` is false, the page says telemetry is not received rather
than showing "0 runs" as if nothing happened.
- **Tests.**
- Backend: 11 telemetry unit tests, a Postgres-gated suite, and the new
routes in the gate tests.
- All 14 Postgres-gated tests (Phases 1 and 4) pass against a throwaway
`postgres:16-alpine`. This includes the parallel `go test ./...` run.
- Console: 64/64 suites, 1310 tests, lint clean, build green.
- **Live end-to-end run (2026-09-29, local, throwaway; all removed after).**
- Setup: throwaway Postgres and NATS; the real backend binary with no
`.env`; engine-shaped telemetry published over raw NATS.
- Six messages produced three rows. The redelivered task was ignored by the
unique index. The malformed event was dropped. The heartbeat was not
written to Postgres.
- `/admin/ai/insights` returned the right totals, failures and averages.
`/admin/ai/decisions` paged correctly and did not leak `context`.
- The Insights tab rendered the same figures with correct IST times.
- **Bugs the live run caught, fixed.**
1. The recorder stamped `utils.DBNow()` into a **timestamptz** column, which
AutoMigrate creates for new tables. A run received at 20:57 IST read back
as 02:27 the next day. It now uses `time.Now()`, and the window cutoffs
do too. `TestRecorderStampsARealInstant` fails with "5h30m off" if
DBNow comes back.
**Wider note:** `utils.DBNow` is only correct for the legacy
timestamp-WITHOUT-zone columns. Any table AutoMigrate creates fresh is
timestamptz, so audit other new tables before using DBNow in them.
2. Two Phase 1 Postgres fixtures still used `lookup_order`/`lookup_miler`,
which Phase 3 removed from the seed.
3. The Skills & Tools note still said the console skills were "not merged".
It now says they run on these settings, and that AI_engine does not
read them yet.
- **Deploy prerequisites.**
1. The backend's `NATS_URL` must point at the same NATS server AI_engine
publishes to (`NATS_HOST`/`NATS_PORT` there). Otherwise Insights shows
"Not receiving agent telemetry".
2. The Agents page still uses the 16–20 Sep snapshot. Moving it onto
`/admin/ai/insights` is a follow-up; it is dharaneesh's page.
3. To see AI_engine's LLM decisions in Insights, the engine would have to
POST them to `/internal/agent-decisions`. That is engine work (Phase 5).
### Phase 5 — Engine reads the registry
- The engine polls `GET /internal/ai/registry` (ETag, around 30 s) and applies
`enabled`, `autonomous`, `model` and thresholds without a restart. Today the
autonomy flags are read once at import.
- **Fix before any agent is shown as live:**
- `order_agent.py:182` (the enum does not exist)
- the JARVIS→ORDER payload key (`agent.py:279` vs `order_agent.py:144`)
- the DISPATCH→HUB id mismatch (`dispatch_agent.py:268` vs `hub_agent.py:151`)
- `release_vehicle_for_cancel` has no handler (`exception_agent.py:692`)
- messages without a `task_type` are silently dropped
- ORDER_AGENT still calls the renamed `crmbooking` route, with no auth header
- Mark HUB, FLEET and ROUTE_OPTIMIZER as `simulation` in the seed, or retire them.
- Add `pytest` and `pytest-asyncio` to `requirements.txt`.
#### Phase 5 — result (2026-09-29, uncommitted, not deployed)
- **Registry client** (`AI_engine/core/registry.py`).
- Polls `/internal/ai/registry` every `REGISTRY_POLL_SECONDS` (30 by default)
with `If-None-Match`. It is started from `main.py --production`.
- Precedence: once the registry has loaded, its value applies. Before that,
or if it never loads, the old env default applies.
- The last good copy survives 401/5xx/timeouts, so autonomy cannot flip
mid-shift.
- With no `INTERNAL_API_KEY`, it logs once and the engine runs on env
defaults.
- **What each agent now reads.**
| Agent | Skill gate | Autonomy | Other settings |
|---|---|---|---|
| Exception | `stall_response` | `EXCEPTION_AGENT` | `stallMinutes`, `reassignConfidence`, model |
| Dispatch | `assignment_failure_triage` | `DISPATCH_AGENT` | `realertEvery`, model |
| Express Dispatch | `express_batch_dispatch` | `EXPRESS_DISPATCH_AGENT` | `maxPerRider`, `maxRadiusKm`, `loadPenaltyKm` |
| Customer | `customer_notifications` | — | — |
A disabled skill means the agent logs the event and does nothing.
- **Model override.**
- `core/llm.request_params(model)` uses the agent's pinned model, or
`LLM_MODEL` when none is pinned.
- For a Haiku pin, thinking and effort are left out, because Haiku rejects
them (400).
- The console picker offers Opus 5.5, Sonnet 5.5 and Opus 4.8, plus "Engine
default". Haiku is left out because it is too weak for these decisions.
- **Decisions logged.**
- Stall and assignment-failure decisions are POSTed to
`/internal/agent-decisions` as `{decision_type, booking_id, context:{facts, model}, decision:{action, confidence}, reasoning}`.
- The post is fire-and-forget, so a slow backend never delays the reaction.
- These decisions now appear in Insights → Latest decisions.
- **Behaviour fix.** When the LLM is down and the agent is *not* autonomous,
it now escalates to a human. Before, it reassigned regardless of the
autonomy flag.
- **Message bugs fixed.**
1. `ORDER_STATUS_UPDATE` was added to the enum.
2. JARVIS→ORDER now sends `order_id`.
3. DISPATCH no longer forwards to HUB `prepare_receiving`. It sent a booking
id to a fictional-hub simulation.
4. FLEET handles `release_vehicle_for_cancel`, finding the vehicle by order
id.
5. CUSTOMER records `ORDER_CANCELLED` and `NOTIFICATION_SENT` instead of
dropping them. These come from simulated records, so no real customer
message is sent.
6. ORDER_AGENT refuses its backend calls and logs why. The seed now says it
is not connected. It stays `broken`.
- **Simulation agents.** HUB, FLEET and ROUTE_OPTIMIZER stay seeded as
`simulation`, and the Studio note says so.
- **Tests.**
- AI_engine: 95 unittest tests. 28 are new, in
`tests/test_registry_phase5.py`, all with no network. Three
`test_dispatch_agent` mocks were updated for the `model` argument.
- The only failures are the two modules that import pytest, and they failed
before this work. `pytest` and `pytest-asyncio` are now in
`requirements.txt` but are **not installed** in the venv.
- Console: Agent Studio suite green. Backend: `internal/ai/...` green.
- **Deploy prerequisites.**
1. The engine needs `GO_API_BASE_URL` and `INTERNAL_API_KEY`, the same key
the backend checks.
2. Before deploying, check the registry's current `enabled` and
`autonomous` values. On deploy they replace the env flags
(`AUTONOMOUS_REASSIGN` and the others).
### Phase 6 — Real Test playground
- Replace the `setTimeout` simulation with a backend endpoint that runs one prompt
through Claude tool-use, using the tools from the selected skill (their
`input_schema` from the registry).
- **Read tools execute. Write tools return a Proposal only** — the playground never
mutates production.
- Show the real trace: tool calls, arguments, results, latency and tokens. The
model comes from the registry.
#### Phase 6 — result (2026-09-29, uncommitted, not deployed)
- **Decisions:** use the official Go SDK, and redact personal data before
anything reaches Claude. **The SDK download was blocked by this machine's
permission check.** Everything else is built behind a `Model` interface.
The one missing piece is the ~80-line adapter from `playground.Request` to
`anthropic.MessageNewParams`. It needs
`go get github.com/anthropics/anthropic-sdk-go`, run or approved by a person.
Until then, `controllers.PlaygroundModel` is nil. The endpoint answers 503
`PLAYGROUND_NOT_CONFIGURED` and the Test tab says so; it never pretends to
run.
- **Backend** (`internal/ai/playground`, `controllers/aiPlaygroundController.go`).
- `POST /admin/ai/playground/run {agentid, skillid?, prompt}`, for Doormile
staff with roleid 1 only. The limit is 10 runs per user per 10 minutes,
prompts are at most 2000 characters, and a run times out after 120 s.
- The loop runs at most 6 turns with max_tokens 4096. The model comes from
the agent's registry pin, or `claude-opus-5-5` if none. The skill's tools
come from the registry, with `input_schema` taken from `inputschema`.
- Tool outcomes:
| Tool | Outcome |
|---|---|
| read, served by the backend (`get_booking_cache`, `scan_bookings`, `nearby_milers`) | **executed**, 5 s timeout |
| read, engine-only or external (`decide_*`, `sequence_stops`, `simulate_pricing_quote`, `list_express_*`) | **unavailable** |
| write / notify / event | **proposed**. Never executed; the model gets `{executed:false, proposal}` |
| not in the selected skill | **rejected** |
- **Redaction:**
- Executors select named non-personal columns only. There is no address,
name, phone or notes column, and coordinates are rounded to 2 dp.
- `Redact` then masks personal keys (name, phone, address, email, note,
reason, …) plus any email or Indian mobile number found in a string.
- Results are capped at 16 KB.
- The seed now gives `nearby_milers` (lat, lon, radius_km) and
`scan_bookings` (status, limit) real input schemas.
- **Console:**
- The Test tab calls the endpoint and shows the server's trace: each tool
call with its input, outcome, result and ms, plus the model, turns,
tokens and time.
- It has a skill picker, and "Test in Playground" preselects that skill.
Non-admins can't run it.
- 503, 429 and 403 errors each show a plain message.
- The setTimeout simulation is gone.
- **Tests:**
- Backend: 11 unit tests. They cover Prepare, every outcome, the turn limit,
model errors, truncation and redaction, including that dates are not
masked.
- A Postgres-gated test proves that personal columns in the table are never
selected. It passed on a throwaway `postgres:16-alpine`, which was removed
afterwards.
- Route tests: the gates, the 503 with no client, and bad input refused
before the model is called.
- Console: 5 new tests. Now 64/64 suites and 1315 tests; build green.
- **To switch it on:**
1. Add the SDK and the adapter.
2. Set `ANTHROPIC_API_KEY` on the backend.
3. Wire `controllers.PlaygroundModel` in `main.go`.
- **Earlier notes:**
1. **Official Go SDK or raw HTTP.** The Go SDK
(`github.com/anthropics/anthropic-sdk-go`) is not in the module cache, so
using it means a module download plus a new `go.mod` dependency.
2. **Data leaving for the Claude API.** The read tools (`scan_bookings`,
`get_booking_cache`, `nearby_milers`, `list_express_*`) return live
customer names, phones and addresses. Running them in the playground sends
that data to Anthropic. The options are: allow it; redact PII before it is
sent; or run against fixtures only.
- The backend also needs `ANTHROPIC_API_KEY`. Without it, the endpoint
returns 503 and the Test tab stays a labelled simulation.
---
## 6. Decisions
**Ratified 2026-09-29:** A = port, C = roleid 1 only. The Agents page is kept
and labelled. The secret check fails at boot in production only. B, D and E
follow the recommendations below unless changed.
| # | Decision | My recommendation |
|---|---|---|
| A | `feat/agentic-ops-layer`: merge or port? | **Port** the skills, tools and executors onto current main (Phase 3). Don't merge a 29-commit-stale branch with 10 conflicts. First confirm with dharaneesh that nothing newer exists elsewhere |
| B | Where does the registry live? | **`doormile_backend`/Postgres.** The engine has no API or persistence, and the console must not be the source of truth |
| C | Who may edit skills and autonomy? | **roleid 1 only.** Roles 3 and 4 read only. Today 1, 3 and 4 are identical everywhere, so this needs an explicit check |
| D | May the console toggle agent autonomy (auto-reassign riders, auto-notify customers)? | Yes, but only with a typed confirmation and an audit row. It stays **off** by default, matching compose |
| E | Delete or relabel the simulation agents (HUB, FLEET, ROUTE_OPTIMIZER)? | Seed them as `simulation`. Delete later if nobody objects |
---
## 7. Open questions (need checking, not guessing)
- **Is `booking.assignment_failed` published?** The engine's handoff doc says Go
doesn't publish it yet. Backend CLAUDE.md §4 names `publishAssignmentFailed`, and
the new retry window says `assignment_failed` fires after the first round. Check
which stream and subject it actually uses against what DISPATCH_AGENT binds to.
- **Which model is live?** The engine defaults to `LLM_MODEL=claude-opus-4-8`; the
console mock shows `claude-3-5-sonnet`. The registry's `model` field should hold
the id that is actually deployed. Set per agent, a cheaper model such as Haiku is
enough for the stall and assignment decisions.
- **Registry write safety:** admin handlers today are not tenant-guarded for
global data (pricing, hubs, app users). The registry endpoints must not copy that
pattern.

View File

@@ -0,0 +1,300 @@
# Reverse Logistics — Analysis & Implementation Plan
Status: **Phases 1–3 built 2026-10-05 (uncommitted, not deployed)** — see §10.
Drafted 2026-10-05.
Scope: `doormile_backend` (rules + endpoints), `krow_talent_app` (ops console),
and the rider app (Flutter, separate team). The customer app is touched only
where noted.
Defaults below are **proposals**; every one marked ⚑ is an open decision
listed in §9.
---
## 1. What exists today (verified in code)
| Area | Finding | Where |
|---|---|---|
| Return statuses | `RTO_Initiated`, `Returned_to_Sender` are defined and **allowed by the DB constraint** — but **nothing in the code ever sets them** | `doormile_backend/constants/constants.go:91-92`, `migrations/migrate.go:124` |
| Return fields on the parcel | `Consignment` already has `Returnreason`, `Returninitiatedat`, `Returndeliveredat`, `Parentconsignmentid` — **all unused** | `doormile_backend/models/audit.go:70-73` |
| Failed delivery | Rider taps *skip* → `MilerSkipDelivery` adds 1 to `Attemptcount`, logs `Delivery_Skipped` history. After **3** attempts it opens an `Undeliverable` exception — and stops there. The parcel stays `Out_for_Delivery` with the rider; no return leg, no owner, client not told | `doormile_backend/controllers/milerAppController.go:990-1070` |
| Exception types | `Receiver_Refused`, `Undeliverable`, `Damaged`, `Lost`, `Misrouted`, `Missing_Contents` exist | `constants.go:186-195` |
| Rider "what next" | `nextActionForConsignment` maps status → rider action. `RTO`/`Returned` fall into `none` ("past this rider's leg") — so a rider is never asked to bring a parcel back | `controllers/logisticsHandoverController.go:233-251` |
| Admin status change | `PUT /admin/consignments/:id/status` accepts **any** status string with no transition rules (a separate bug: it can set `Delivered` on a cancelled parcel) | `controllers/adminController.go:3133-3190` |
| Console | Only traces: an `rto` badge tone (`components/ds/StatusBadge.jsx:47`), `rto → skipped` mapping (`api/doormile/queries.js:110`), a comment in `lib/orderFlow.js:108`. Status update offers only Out_for_Delivery / Delivered / Cancelled | `krow_talent_app/src/...` |
| Pricing | `Pricing` has base/per-km/per-kg/handling — **no return charge** | `models` Pricing |
| Customer returns (reverse pickup) | Not present anywhere | — |
**Conclusion:** the data model is mostly ready; the lifecycle, the rules, the
endpoints, the console screens and the rider task are all missing.
---
## 2. Scope
| Flow | Description | Phase |
|---|---|---|
| **A. RTO — Return to Origin** | Delivery fails → parcel goes back to the sender's pickup point (⚑ or a hub) | **Phase 1–3 (this plan)** |
| **B. Customer return** | Receiver sends a delivered item back; rider picks up from receiver, returns to sender | Phase 5 (later) |
| **C. Exchange** | Deliver new + collect old in one visit | Out of scope for now |
---
## 3. RTO lifecycle (proposed)
```
Collected_By_Miler / Out_for_Delivery
│ failed attempt (rider skip) → Attemptcount++ (as today)
│
├─ attempts ≥ N (⚑ default 3) ─┐
├─ ops "Initiate RTO" (console) ─┼─▶ RTO_Initiated
└─ (⚑ later) client request ───┘ returnreason, returninitiatedat set
history: RTO_Initiated
rider next action: return_to_sender
│
┌──────────────┼───────────────────────────┐
▼ ▼ ▼
rider returns ops "Re-attempt delivery" ops "Mark returned"
to sender (cancels the RTO) (manual close, e.g. hub drop)
│ │ │
▼ ▼ ▼
Returned_to_Sender Out_for_Delivery Returned_to_Sender
returndeliveredat (attempts kept) returndeliveredat
```
Rules:
- RTO only from `Collected_By_Miler`, `Out_for_Delivery` (or `Inwarded_at_Hub` ⚑).
- `Returned_to_Sender` is terminal.
- Every transition writes `ConsignmentHistory` with actor + reason.
- Opening RTO resolves the linked `Undeliverable` exception (if any) with
resolution "RTO initiated".
- COD: a returned parcel collects nothing; `Codcollected` stays 0.
---
## 4. Backend — `doormile_backend`
| # | Change | Detail |
|---|---|---|
| B1 | **Transition guard** | One function `canTransition(from, to)` used by every consignment status write; `PUT /admin/consignments/:id/status` refuses illegal moves (also fixes the any-status bug). |
| B2 | `POST /admin/consignments/:id/rto` `{reason, note}` | Staff only. Sets `RTO_Initiated`, `returnreason`, `returninitiatedat`; history; resolves the Undeliverable exception; publishes NATS `consignment.rto_initiated`; notifies the rider. Idempotent. |
| B3 | `POST /admin/consignments/:id/rto/cancel` `{note}` | Back to `Out_for_Delivery` (re-attempt). |
| B4 | `POST /admin/consignments/:id/rto/complete` `{note}` | Ops closes it manually → `Returned_to_Sender`, `returndeliveredat`. |
| B5 | `GET /admin/returns?status&from&to&tenantid&hubid&pageno` | List for the Returns page: tracking no, client, sender, reason, attempts, initiated/returned times, rider, age. Tenant-scoped like other admin lists. |
| B6 | **Auto-RTO** in `MilerSkipDelivery` | When `Attemptcount ≥ RTO_AUTO_AFTER_ATTEMPTS` (env, ⚑ default 3; `0` = off) → call the same B2 logic instead of only opening an exception. |
| B7 | **Rider task** | New next action `return_to_sender`; `nextActionForConsignment(RTO_Initiated)` returns it; rider queue includes the return stop (sender's pickup coords). New `POST /miler/consignments/:id/return-complete` `{lat, lon, photourl?, receivedby}` → `Returned_to_Sender`. **Behind flag `MILER_RTO_FLOW_ENABLED`** (default off) — the deployed rider app doesn't know `return_to_sender`, same pattern as `MILER_HUB_HANDOVER_ENABLED`. |
| B8 | Booking/customer stage | For customer-app bookings, record the return in `cxstage`. ⚠ The customer app renders unknown stage keys as `booked`, so a new "returning" stage needs a client release — until then do **not** add a new key. |
| B9 | Tests | Transition table, each endpoint's gates (staff/tenant/owner), auto-RTO at N attempts, flag off/on rider queue, idempotency. Postgres-gated tests for the SQL. |
Schema: **none for Phase 1–3** (columns exist). ⚑ Return-to-hub needs one
additive nullable column (`returnhubid`) — Phase 4.
---
## 5. Console — `krow_talent_app`
| # | Change | Where |
|---|---|---|
| C1 | API + hooks: `initiateRto`, `cancelRto`, `completeRto`, `getReturns` + `useInitiateRto` … with invalidation of deliveries/returns keys | `src/api/doormile/endpoints.js`, `src/lib/doormileHooks.js` |
| C2 | **Deliveries page actions**: on failed / out-for-delivery rows — *Initiate RTO* (reason picker: Receiver refused, Address not found, Customer unavailable, Attempts exhausted, Other + note), *Re-attempt*, *Mark returned*. Show attempt count | `src/pages/doormile/deliveries/Deliveries.jsx` |
| C3 | **Status tabs**: add *RTO* and *Returned* tabs/counts (today `rto` collapses into `skipped`) | `queries.js` status map, `StatusBadge` |
| C4 | **Returns page** `/doormile/returns`: tabs Initiated · In return · Returned; filters (date, client, hub, reason); age/SLA column; XLSX export; nav entry under Fleet Ops (staff) | new `src/pages/doormile/returns/Returns.jsx`, `App.jsx`, `AdminLayout.jsx` |
| C5 | **Exceptions page**: an `Undeliverable` / `Receiver_Refused` exception gets a *Start RTO* button | `src/pages/doormile/exceptions/Exceptions.jsx` |
| C6 | **Order / consignment timeline** shows attempts, RTO start, return | booking detail drawer |
| C7 | **Reports**: return rate per client and per reason on Orders Summary | `src/pages/doormile/reports/` |
| C8 | Client (tenant) logins: read-only view of their own returns (no actions) | Returns page + role check |
| C9 | Tests: actions call the right endpoints, reason required, tabs/counts, Returns filters, client read-only | `tests/integration/` |
---
## 6. Rider app (Flutter — separate team)
- Handle next action `return_to_sender`: show the return stop, navigate to the
sender, "Returned" button → `POST /miler/consignments/:id/return-complete`
with location (+ optional photo / receiver name).
- Release before turning on `MILER_RTO_FLOW_ENABLED`. Until then, ops close
returns from the console (B4) and riders are told by push.
---
## 7. Phases & order
| Phase | Content | Repos | Depends on |
|---|---|---|---|
| **1** | B1 guard, B2–B5 endpoints, B9 tests | backend | — |
| **2** | C1–C5, C9 | console | Phase 1 deployed |
| **3** | B6 auto-RTO, B7 rider task (flag off) + rider app release, then flag on | backend + rider app | Phase 1 |
| **4** | Return-to-hub option (`returnhubid`), C6 timeline, C7 reports, C8 client view, return charges | all | decisions ⚑ |
| **5** | Flow B — customer returns (reverse pickup booking linked by `Parentconsignmentid`) | all + customer app | product spec |
Rough effort: Phase 1 ≈ 1.5–2 days · Phase 2 ≈ 2 days · Phase 3 ≈ 1 day backend
(+ rider app team) · Phase 4/5 to estimate after decisions.
---
## 8. Risks
- **Rider app compatibility** — a new next action unknown to the deployed app
must stay behind a flag (B7).
- **Customer app stage keys** — unknown keys render as `booked` (B8).
- **Ops discipline** — until the rider task ships, returns depend on ops
closing them in the console.
- **Existing any-status endpoint** — B1 changes its behaviour: callers that
relied on setting arbitrary statuses will now get 400. The console only
sends Out_for_Delivery / Delivered / Cancelled, which stay allowed.
- **Deploy** — no migration in Phase 1–3; Phase 4 adds one nullable column
(needs approval).
---
## 9. Open decisions ⚑
1. **Flows first:** A (RTO) only, then B? *(proposed: yes)*
2. **Return destination:** sender's pickup point, nearest hub, or per client?
*(proposed: sender for Phase 1; hub option in Phase 4)*
3. **Who starts RTO:** auto after N attempts (N = ?), ops, client, or all?
*(proposed: ops + auto after 3; client later)*
4. **Can RTO start from a hub** (`Inwarded_at_Hub`)? *(proposed: yes)*
5. **Return charges:** billed to the client? Same rate, fixed fee, or free?
*(proposed: decide before Phase 4; Phase 1–3 record only)*
6. **Rider app:** is the Flutter team available for B7, and when?
7. **Client visibility:** may tenants see their own returns? *(proposed: read-only)*
---
## 10. Implementation status (2026-10-05)
Built with the proposed defaults: return to the **sender**, started by **ops or
automatically after 3 failed attempts**, rider task **behind a flag (off)**.
Nothing is committed or pushed.
### Backend — `doormile_backend`
| Item | Where |
|---|---|
| RTO core: `startRTO`, `completeRTO`, cancel → previous status (recorded as `[from:<status>]` in the history remark), exception auto-resolve, rider push | `controllers/consignmentReturn.go` |
| `POST /admin/consignments/:id/rto` · `/rto/cancel` · `/rto/complete` (Doormile staff only) | `routes/routes.go` |
| `GET /admin/returns?status=initiated\|returned\|all&from&to&pageno&pagesize` (tenant-scoped; client logins read their own) | same |
| `POST /miler/consignments/:id/return-complete` — 403 `RTO_FLOW_DISABLED` unless `MILER_RTO_FLOW_ENABLED=true` | same |
| Status guard on `PUT /admin/consignments/:id/status` (unknown status, leaving Delivered/Cancelled/Returned, RTO statuses → 400) | `controllers/adminController.go` |
| Auto-RTO after `RTO_AUTO_AFTER_ATTEMPTS` (default 3, `0` = off) | `MilerSkipDelivery` in `controllers/milerAppController.go` |
| `MilerSkipDelivery` ownership now multi-destination safe (`milerConsignmentForRider`) — fixes skip on orders 2..N | same |
| Next action `return_to_sender` (flagged); rider consignment read adds `returning`, `can_return`, `return_reason`, `return_to` | `constants`, `logisticsHandoverController.go`, `milerAppController.go` |
| Tests: 7 unit + 5 route-gate tests | `controllers/consignmentReturn_test.go`, `routes/routes_rto_test.go` |
New env vars: `RTO_AUTO_AFTER_ATTEMPTS` (default 3), `MILER_RTO_FLOW_ENABLED`
(default off). **No schema change.** No customer-app stage key added (B8).
### Console — `krow_talent_app`
| Item | Where |
|---|---|
| API + hooks: `initiateRto`, `cancelRto`, `completeRto`, `getReturns`, `RTO_REASONS`; `useReturns`, `useInitiateRto`, `useCancelRto`, `useCompleteRto` | `src/api/doormile/endpoints.js`, `src/lib/doormileHooks.js` |
| Status mapping: `RTO_Initiated → rto`, `Returned_to_Sender → returned` (legacy `rto`/`returned` keys unchanged) + counts | `src/api/doormile/queries.js` |
| Shared dialogs: Start return (reason + note, pre-select), Re-attempt / Mark returned | `src/components/doormile/RtoDialogs.jsx` |
| Deliveries: **In return** + **Returned** tabs; Return to sender / Re-attempt / Mark returned (staff only); status-update and cancel hidden on returning rows | `src/pages/doormile/deliveries/Deliveries.jsx` |
| **Returns page** `/doormile/returns` (tabs, date range, search, export, pagination; read-only for clients); nav under Fleet Ops | `src/pages/doormile/returns/Returns.jsx`, `App.jsx`, `AdminLayout.jsx` |
| Exceptions: **Start return** on open Undeliverable / Receiver_Refused | `src/pages/doormile/exceptions/Exceptions.jsx` |
| Tests: 13 new; deliveries counts test updated | `tests/integration/returns.test.jsx`, `tests/api/deliveries.test.js` |
### Verified / not verified
- ✅ `go build`, `go vet`, all 17 backend packages; console suites pass except
the 13 Agent Studio tests already failing since the 2026-09-30 redesign;
console production build OK.
- ✅ **End-to-end on a real database (2026-10-05).** This ran on a throwaway
local stack: PostgreSQL 17.10 on 127.0.0.1, the backend built from this tree
with `MILER_RTO_FLOW_ENABLED=true`, and the console on a local port pointed
at it. Production was not touched.
- Browser, as staff:
- Return to sender (reason + note), then Re-attempt (back to
Out_for_Delivery), then Start again, then Mark returned. Result:
`Returned_to_Sender`, the rider's assignment `Completed`, and the full
status history.
- Exceptions → Start return pre-selects the reason, and the exception
leaves the list.
- The Returns page lists rows with correct India times.
- API:
- 3 skips start RTO automatically and resolve the Undeliverable exception.
- The rider read gives `next_action=return_to_sender` with `return_to`
set to the sender's coordinates.
- Rider return-complete closes the assignment.
- Every guard answers 400 / `INVALID_STATE` as designed.
- Client login: no return buttons on Deliveries; Returns is read-only;
`rto/cancel` and `rto/complete` return 403.
- Bugs the browser test found, all fixed:
1. Notify rider showed on returned and delivered rows. It is now hidden.
2. Exceptions didn't refresh after Start return. `RETURN_KEYS` now includes
the exceptions key.
3. The dialog subtitle from Exceptions read "Order consignment #…". It now
uses a `label` prop.
- Backend review (2026-10-05), fixed:
1. **Race conditions.** Start, complete and cancel used to read the parcel
and then save the whole row. A rider delivering at the same moment, or
a double click, could be overwritten. Each one is now a compare-and-set
(`moveConsignment`: update only if the status is still the one read).
A lost race either becomes the same no-op as a repeat, or returns
"changed, refresh".
2. **Re-attempt clears the return.** `returnreason` and
`returninitiatedat` are cleared, so a parcel delivered afterwards doesn't
carry a stale reason. The history keeps the reason.
3. **"OTHER" check.** A reason sent as "OTHER" or "Other " skipped the
note-required check. The check now uses the normalised reason
(`rtoReasonText`).
4. **Note length.** The 500 limit is counted in characters, not bytes.
Before, 200 Tamil characters were refused even though the console
allows 500.
5. **Wrong rider notified.** Starting a return on a parcel already handed
over at a base pushed "do not attempt delivery" to its old pickup rider.
Now only a rider holding the parcel is notified
(`riderHoldsParcel`: Created, Collected_By_Miler, Out_for_Delivery).
- Impact on existing users (checked 2026-10-05). Decide these before
deploying:
- **Rider app (deployed build):** it shows a parcel from the *booking*
status, which a return doesn't change. So a parcel in return still looks
deliverable; tapping deliver or skip returns a 400. The rider also can't go
off duty until ops press **Mark returned**, because the assignment stays
open. Automatic return is **on by default** (3 attempts), so this starts
on deploy. Either set `RTO_AUTO_AFTER_ATTEMPTS=0` until the rider app
supports returns, or make sure ops close returns daily.
- **Console "Update status":** Delivered and Cancelled parcels can no longer
be changed (400). Before, ops could undo a wrong "Delivered" or reopen a
cancelled parcel. That is now refused by design.
- **Customer app:** a returned parcel keeps showing "Out for delivery"
(there is no customer stage for returns).
- **Rider pay:** closing a return marks the assignment Completed with 0 km
and 0 charges. The return trip isn't paid.
- **Not affected:** rider delivered counts and reports (Delivered only), the
hub console (a parcel in return simply drops out of its status-based
lists), the B2C booking flow, the existing status values, and the
database schema (no change).
- Tests added: `TestRiderHoldsParcel`, `TestRTOReasonText`, plus `controllers/consignmentReturn_pg_test.go`
(5 tests on real Postgres: lifecycle, refusals, no overwrite of a
concurrent delivery, 10 simultaneous starts → one return, cancel). These
skip unless `REGISTRY_TEST_DSN` is set, and passed on PostgreSQL 17.
- Timestamps stay `time.Now()`, not `utils.DBNow()`. `AutoMigrate` creates
`timestamptz` columns, where `DBNow()` would store a time 5 h 30 m ahead.
`time.Now()` is correct for both column types because the Dockerfile sets
`TZ=Asia/Kolkata`.
- Seen, not fixed (pre-existing, outside RTO): the Exceptions page shows
"Raised" times about 6 h ahead of IST.
- Not built: Phase 4 (return-to-hub, timeline, reports, charges), Phase 5
(customer returns), the rider-app UI (Flutter team).
## 11. Phase 4, first part (2026-10-07)
Built the two Phase 4 items that need no product decision. Uncommitted.
| Item | Where |
|---|---|
| `GET /admin/consignments/:id/history`: every event of one parcel, oldest first, with who did it and at which base. The `[from:<status>]` bookkeeping tag is removed from the remark and returned as `fromstatus`. Client logins read their own parcels only. | `doormile_backend/controllers/returnInsights.go`, `routes/routes.go` |
| `GET /admin/returns/summary?from&to`: of the parcels created in the period (cancelled ones excluded), the return rate overall, per client and per reason, plus the average days a completed return took. Defaults to the last 30 days. Client logins get their own figures only. | same |
| **C6 timeline:** shared `ConsignmentTimeline`, shown in the Deliveries order drawer and behind a **History** button on every Returns row (clients included). | `src/components/doormile/ConsignmentTimeline.jsx`, `Deliveries.jsx`, `Returns.jsx` |
| **C7 report:** a summary on the **Returns page** (KPIs, return rate by client, reasons), driven by the page's date range. Clients don't get the per-client table. Placed on Returns rather than Orders Summary so that clients see it too. | `src/pages/doormile/returns/Returns.jsx` |
| Tests: 2 Postgres route tests (timeline order, actor, hub, scoping; summary rates, reasons, window, scoping) and 4 console tests. | `routes/routes_return_insights_pg_test.go`, `tests/integration/returns.test.jsx` |
Verified in the browser against a throwaway local database: summary figures, per-client and per-reason tables, and the timeline drawer.
Decision 2026-10-07 on return charges (open decision 5): **none for now.** Returns are free for every client. Revisit after 2–3 months using the return rates the Returns summary now shows. If a charge is introduced later, it should be per client, only for receiver- or client-caused returns, and never for damage or Doormile errors.
Still open in Phase 4 (needs a decision ⚑): return-to-hub (the `returnhubid` column).
Decision 2026-10-07 on Phase 5 (customer returns after delivery): **deferred.** Current clients are mainly food and medicine businesses, where returns after delivery are rare, and refunds are handled by the client. Build it when a client asks for it or when parcel/e-commerce clients are onboarded. Until then, ops handle the rare case by booking a normal order with the customer's address as pickup and the client as drop. The return button appears only on active orders (RTO, before delivery); `Delivered` stays terminal.

View File

@@ -30,6 +30,10 @@ module.exports = {
'\.(css|less|scss|sass)$': '<rootDir>/tests/setup/styleMock.cjs',
'\.(png|jpe?g|gif|svg|webp|woff2?|ttf|eot)$': '<rootDir>/tests/setup/fileMock.cjs',
'^@/(.*)$': '<rootDir>/src/$1',
// Vite resolves these bare specifiers from src/ (jsconfig baseUrl).
// Jest needs telling, or any test importing a file that uses them fails
// to resolve rather than failing an assertion.
'^(components|pages|utils|themes|assets|lib|api)/(.*)$': '<rootDir>/src/$1/$2',
},
collectCoverageFrom: [
'src/api/doormile/**/*.js',

1357
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,9 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.23",
"framer-motion": "^11.16.4",
"gsap": "^3.15.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^0.475.0",
"papaparse": "^5.6.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
public/miletruth-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
public/preloader.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -1,6 +1,5 @@
import { Suspense, lazy } from 'react';
import { Toaster } from '@/components/ui/toaster';
import { Toaster as HotToaster } from 'react-hot-toast';
import AppToaster from '@/components/third-party/AppToaster';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClientInstance } from '@/lib/query-client';
import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom';
@@ -15,9 +14,17 @@ import Login from '@/pages/Login';
/* Every console page is code-split. The Dispatch board alone pulls the map,
the solver payload builders and the comparison panels; loading that on the
way to the Hubs table would make every first paint pay for it. */
/* Doormile-only pages. A client login is sent home rather than shown a page
whose every request the server refuses. */
function StaffOnly({ children }) {
const { isClient } = useAuth();
return isClient ? <Navigate to="/doormile/home" replace /> : children;
}
const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx'));
const Home = lazy(() => import('@/pages/doormile/home/Home.jsx'));
const Orders = lazy(() => import('@/pages/doormile/orders/Orders'));
const Bookings = lazy(() => import('@/pages/doormile/bookings/Bookings'));
const OrdersPreview = lazy(() => import('@/pages/doormile/orders/OrdersPreview'));
@@ -25,9 +32,11 @@ const CreateOrder = lazy(() => import('@/pages/doormile/orders/CreateOrder'));
const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders'));
const Deliveries = lazy(() => import('@/pages/doormile/deliveries/Deliveries'));
const Returns = lazy(() => import('@/pages/doormile/returns/Returns'));
const Tenants = lazy(() => import('@/pages/doormile/clients/Tenants'));
const CreateClient = lazy(() => import('@/pages/doormile/clients/CreateClient'));
const ClientOnboarding = lazy(() => import('@/pages/doormile/clients/ClientOnboarding'));
const CreateCustomer = lazy(() => import('@/pages/doormile/clients/CreateCustomer'));
const Pricing = lazy(() => import('@/pages/doormile/pricing/Pricing'));
@@ -50,6 +59,8 @@ const RidersSummary = lazy(() => import('@/pages/doormile/reports/RidersSummary'
const Profitability = lazy(() => import('@/pages/doormile/reports/Profitability'));
const ViewProfile = lazy(() => import('@/pages/doormile/ViewProfile'));
const Settings = lazy(() => import('@/pages/doormile/settings/Settings'));
const Agents = lazy(() => import('@/pages/doormile/agents/Agents'));
const PageFallback = () => (
<div className="flex min-h-[60vh] items-center justify-center">
@@ -78,13 +89,35 @@ const AuthenticatedApp = () => {
{/* Dispatch is the console's home: it is the board operators sit on
all day, and the one page that answers "what is happening right
now" without a filter being set first. */}
<Route path="/" element={<Navigate to="/doormile/dispatch" replace />} />
<Route path="/" element={<Navigate to="/doormile/home" replace />} />
<Route path="/home" element={<Navigate to="/doormile/home" replace />} />
<Route path="/customers" element={<Navigate to="/doormile/customers" replace />} />
<Route path="/customer" element={<Navigate to="/doormile/customers" replace />} />
<Route path="/doormile" element={<AdminLayout />}>
<Route index element={<Navigate to="/doormile/dispatch" replace />} />
<Route index element={<Navigate to="/doormile/home" replace />} />
<Route path="dispatch" element={<Dispatch />} />
<Route path="dispatch/preview" element={<DispatchPreview />} />
<Route path="home" element={<Home />} />
{/* Control X — the board formerly routed at /doormile/dispatch.
The directory, the component and the .dispatch-container CSS
namespace keep their old names on purpose: they are internal,
and renaming a 5,800-line stylesheet's root class is a large
diff with no user-visible benefit and real risk of missing a
selector. Only what an operator sees and types changes. */}
<Route path="control-x" element={<Dispatch />} />
<Route path="control-x/preview" element={<DispatchPreview />} />
{/* The old paths keep working.
This board is the page operators sit on all day, so it is the
one most likely to be bookmarked, pinned in a browser tab
group, or pasted into a message. Renaming the route without
this would turn every one of those into a 404 — a support
problem with no upside, avoided by two lines. `replace` so the
dead path does not linger in history behind a Back press. */}
<Route path="dispatch" element={<Navigate to="/doormile/control-x" replace />} />
<Route path="dispatch/preview" element={<Navigate to="/doormile/control-x/preview" replace />} />
{/* Static before dynamic, so `orders/create` cannot be read as an
order whose id is "create". */}
@@ -97,9 +130,13 @@ const AuthenticatedApp = () => {
<Route path="bookings" element={<Bookings />} />
<Route path="deliveries" element={<Deliveries />} />
{/* Reverse logistics: parcels going back to the sender (RTO). */}
<Route path="returns" element={<Returns />} />
<Route path="tenants" element={<Tenants />} />
<Route path="clients/create" element={<CreateClient />} />
<Route path="tenants" element={<StaffOnly><Tenants /></StaffOnly>} />
<Route path="clients/create" element={<StaffOnly><CreateClient /></StaffOnly>} />
{/* Owner-only (admin@doormile.com); the page and the server both check. */}
<Route path="clients/onboard" element={<StaffOnly><ClientOnboarding /></StaffOnly>} />
<Route path="customer/create" element={<CreateCustomer />} />
<Route path="pricing" element={<Pricing />} />
@@ -115,11 +152,11 @@ const AuthenticatedApp = () => {
<Route path="customers" element={<Customers />} />
<Route path="hubs" element={<Hubs />} />
<Route path="vehicles" element={<Vehicles />} />
<Route path="app-users" element={<AppUsers />} />
<Route path="tripsheets" element={<Tripsheets />} />
<Route path="vehicles" element={<StaffOnly><Vehicles /></StaffOnly>} />
<Route path="app-users" element={<StaffOnly><AppUsers /></StaffOnly>} />
<Route path="tripsheets" element={<StaffOnly><Tripsheets /></StaffOnly>} />
<Route path="exceptions" element={<Exceptions />} />
<Route path="competitive-intel" element={<CompetitiveIntel />} />
<Route path="competitive-intel" element={<StaffOnly><CompetitiveIntel /></StaffOnly>} />
<Route path="reports/orderssummary" element={<OrdersSummary />} />
<Route path="reports/ordersdetails" element={<OrdersDetails />} />
@@ -127,6 +164,12 @@ const AuthenticatedApp = () => {
<Route path="reports/profitability" element={<Profitability />} />
<Route path="profile" element={<ViewProfile />} />
<Route path="settings" element={<Settings />} />
{/* The AI_engine swarm. Read-only: it reports what the agents are
and what they last did, and has no control surface, because
every autonomy gate lives in that repository's own env. */}
<Route path="agents" element={<StaffOnly><Agents /></StaffOnly>} />
</Route>
</Route>
@@ -145,11 +188,11 @@ function App() {
<ScrollToTop />
<AuthenticatedApp />
</Router>
<Toaster />
{/* The design system's `toast` helper wraps react-hot-toast, which needs
its own renderer mounted. Without this every toast in the app fires
into nothing — including the data layer's failure notices. */}
<HotToaster position="bottom-right" />
{/* Every toast in the app (OpenToast, the design system's `toast`,
direct react-hot-toast calls) renders here: top-right, one card
design. Without a mounted renderer every toast fires into
nothing — including the data layer's failure notices. */}
<AppToaster />
</ZoneProvider>
</QueryClientProvider>
</AuthProvider>

View File

@@ -38,7 +38,10 @@ export const DOORMILE_SESSION_KEYS = ['authname', 'firstname', 'userid', 'roleid
* terminal — leaving the transcript behind hands the next operator the last
* one's work. The panel width is a preference and stays.
*/
export const DOORMILE_ASSISTANT_KEYS = ['doormileBotHistory', 'doormileBotConversations'];
/** The home page's MileTruth chat history (Home.jsx). Same reasoning as above. */
export const MILETRUTH_HOME_CHATS_KEY = 'miletruthHomeChats';
export const DOORMILE_ASSISTANT_KEYS = ['doormileBotHistory', 'doormileBotConversations', MILETRUTH_HOME_CHATS_KEY];
/** Clears every key that makes up a Doormile session. */
export const clearStoredSession = () => {
@@ -120,4 +123,39 @@ export const readStoredToken = () => localStorage.getItem(DOORMILE_TOKEN_KEY);
export const errorMessage = (err, fallback = 'Something went wrong') =>
err?.message || err?.response?.data?.message || fallback;
/**
* A failed call explained for the person at the screen, not the developer.
*
* `action` finishes the sentence "We couldn't …", e.g. 'create this order'.
* The server's own message is kept where it describes the user's input
* (400/409/422 — e.g. a CityGate pincode rejection), because that is the
* part they can fix. Everything else — no connection, a timeout, a 5xx, raw
* axios text like "Network Error" — is replaced with what happened and what
* to do next.
*/
export const friendlyErrorMessage = (err, action = 'complete this request') => {
const status = err?.httpStatus ?? err?.response?.status;
const serverMsg = String(err?.response?.data?.message || (status ? err?.message : '') || '').trim();
if (!status) {
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return `We couldn't ${action} because you're offline. Check your internet connection and try again.`;
}
if (err?.code === 'ECONNABORTED' || /timeout/i.test(err?.message || '')) {
return `We couldn't ${action} — the server took too long to respond. Please try again in a moment.`;
}
return `We couldn't ${action} because the Doormile server couldn't be reached. Check your internet connection and try again.`;
}
if (status === 401) return 'Your session has expired. Please sign in again.';
if (status === 403) return `You don't have permission to ${action}. Ask your admin for access.`;
if (status === 404) return `We couldn't ${action} — the record it refers to no longer exists. Refresh the page and try again.`;
if (status === 429) return `Too many requests at once. Wait a few seconds, then try to ${action} again.`;
if (status >= 500) {
return `We couldn't ${action} because of a problem on our server. Your details are still here — please try again in a minute. If it keeps happening, contact Doormile support.`;
}
return serverMsg
? `We couldn't ${action}: ${serverMsg}`
: `We couldn't ${action}. Please check the details you entered and try again.`;
};
export default doormileAxios;

View File

@@ -166,6 +166,50 @@ export const createAdminTenant = async (data) => {
return response.data;
};
/**
* Onboard a client: the tenant, its console login (doormile_auth) and its
* appusers row, in one server transaction. Only the onboarding owner login
* (admin@doormile.com) may call it; anyone else gets 403.
* @param {{ companyname: string, contactname: string, email: string, phone: string,
* password: string, applocationid: number, requiredeliveryotp?: boolean }} data
* @returns {Promise<{ success: boolean, data: { tenant: object, login: object } }>}
*/
export const onboardClient = async (data) => {
const response = await doormileAxios.post('/admin/clients/onboard', data);
return response.data;
};
/**
* Edit an onboarded client and its login (owner login only). `authid` is the
* login's id from GET /admin/clients/onboarded. Only fields sent change;
* `password` resets the login's password when non-empty.
*/
export const updateOnboardedClient = async (authid, data) => {
const response = await doormileAxios.put(`/admin/clients/${encodeURIComponent(authid)}`, data);
return response.data;
};
/**
* Remove a client's console login (owner login only). The company and its
* order history are kept; the client is marked Inactive if no login remains.
*/
export const deleteOnboardedClient = async (authid) => {
const response = await doormileAxios.delete(`/admin/clients/${encodeURIComponent(authid)}`);
return response.data;
};
/** Operating cities a new client can be placed in (applocations; owner login only). */
export const getOnboardingCities = async () => {
const response = await doormileAxios.get('/admin/clients/cities');
return response.data.data;
};
/** Clients that have a console login, newest first (owner login only). */
export const getOnboardedClients = async () => {
const response = await doormileAxios.get('/admin/clients/onboarded');
return response.data.data;
};
export const getAdminTenant = async (id) => {
const response = await doormileAxios.get(`/admin/tenants/${id}`);
return response.data.data;
@@ -331,6 +375,12 @@ export const blockMiler = async (id, data) => {
return response.data;
};
/** Lift a block: the rider can sign in again and is Offline until they start duty. */
export const unblockMiler = async (id) => {
const response = await doormileAxios.put(`/admin/milers/${id}/unblock`, {});
return response.data;
};
export const assignMilerVehicle = async (id, data) => {
const response = await doormileAxios.put(`/admin/milers/${id}/assign-vehicle`, data);
return response.data;
@@ -495,14 +545,27 @@ export const assignMilerToBooking = async (id, data) => {
return response.data;
};
// Doormile-native bulk assign: picks real milers via Redis GEO + AI scoring, no
// external solver involved. Commits server-side in this one call — unlike the
// workolik solver flow, there's no separate preview/reconcile/commit step.
// Sequencing (POST /optimization/doormile/sequence) is not deployed yet, so
// riders with more than one stop come back unsequenced (step: 0) — see
// doormile-flow.md's State table.
// Doormile-native bulk assign: greedy nearest on-duty rider by haversine,
// capped per rider, committed server-side in this one call — there is no
// separate preview/reconcile/commit step.
//
// ADMIN endpoint, not the hub one. It used to point at
// POST /hub/bookings/batch-assign, which sits behind HubStaffAuth and 403s for
// every token this console issues — so this function could never succeed from
// here. /admin/bookings/batch-assign runs the SAME solver
// (controllers/batchAssignService.go) with admin auth, which is what makes the
// ops layer's assignMiler proposal executable at all.
//
// Unlike the hub route, an empty bookingids is refused rather than meaning
// "clear the queue": an admin login has no hub bound, so empty would mean every
// pending booking in the system.
//
// Stop sequencing runs after the assignments commit and is best-effort. It
// needs ROUTE_OPTIMIZER_URL set in the cluster; without it riders with more
// than one stop come back unsequenced (step: 0) — see doormile-flow.md's State
// table.
export const batchAssignBookings = async (bookingIds, maxPerRider = 5) => {
const response = await doormileAxios.post('/hub/bookings/batch-assign', {
const response = await doormileAxios.post('/admin/bookings/batch-assign', {
bookingids: bookingIds,
max_per_rider: maxPerRider
});
@@ -728,3 +791,183 @@ export const deleteCarrierPricing = async (id) => {
const response = await doormileAxios.delete(`/admin/carrier-pricing/${id}`);
return response.data;
};
// ==============================|| AI agent registry ||============================== //
// /admin/ai/* — Doormile staff only (a partner-tenant login gets 403). Reads
// are open to admin/manager/executive; every write is admin-only and audited
// server-side. See docs/agent-platform-plan.md.
export const getAiAgents = async () => {
const response = await doormileAxios.get('/admin/ai/agents');
return response.data.data;
};
export const getAiSkills = async () => {
const response = await doormileAxios.get('/admin/ai/skills');
return response.data.data;
};
export const getAiTools = async () => {
const response = await doormileAxios.get('/admin/ai/tools');
return response.data.data;
};
export const getAiRegistryAudit = async (limit) => {
const response = await doormileAxios.get(`/admin/ai/audit${buildQuery({ limit })}`);
return response.data.data;
};
/** @param {string} id @param {{ enabled?: boolean, thresholds?: Record<string, number> }} patch */
export const updateAiSkill = async (id, patch) => {
const response = await doormileAxios.patch(`/admin/ai/skills/${encodeURIComponent(id)}`, patch);
return response.data;
};
/** @param {{ agentid: string, title: string, category?: string, description?: string, sampleprompt?: string, tools: string[] }} skill */
export const createAiSkill = async (skill) => {
const response = await doormileAxios.post('/admin/ai/skills', skill);
return response.data;
};
/**
* Autonomy ON needs `confirm` set to the agent id — the server refuses it
* otherwise, so a stray click cannot let an agent act without a human.
* @param {string} id @param {{ autonomous?: boolean, model?: string, confirm?: string }} patch
*/
export const updateAiAgent = async (id, patch) => {
const response = await doormileAxios.patch(`/admin/ai/agents/${encodeURIComponent(id)}`, patch);
return response.data;
};
/**
* What the agents did over the last `days` (1–30): runs and failures per
* AI_engine agent, decisions by type/outcome, live heartbeat. `receiving` is
* false when the backend is not subscribed to AI_engine telemetry.
* @param {number} days
*/
/**
* What is actually wired, for the Skills & Tools banner: whether AI_engine is
* reading the registry (it polls ~every 30 s; the backend records each read),
* telemetry, live agents, and whether the Test tab has a model.
*/
export const getAiStatus = async () => {
const response = await doormileAxios.get('/admin/ai/status');
return response.data.data;
};
export const getAiInsights = async (days = 7) => {
const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`);
return response.data.data;
};
/**
* Agent Studio's Test tab: one prompt through Claude with a skill's tools.
* Read tools run with personal data redacted; write tools come back as
* proposals and are never executed. Admin only. 503 (code
* PLAYGROUND_NOT_CONFIGURED) when the server has no Claude client; 429 when
* the per-user limit is reached.
* @param {{ agentid: string, skillid?: string, prompt: string }} body
*/
export const runAiPlayground = async (body) => {
const response = await doormileAxios.post('/admin/ai/playground/run', body);
return response.data.data;
};
/* Skill findings. The console's rule output, reported so it survives the render
that produced it — see lib/assistant/agent/findingReport.js for why. Called
fire-and-forget: a failed report must never surface to an operator looking at
a stalled parcel. */
export const reportFindings = async (payload) => {
const response = await doormileAxios.post('/admin/ai/findings', payload);
return response.data;
};
export const reportFindingActed = async (fingerprint, result) => {
const response = await doormileAxios.post(
`/admin/ai/findings/${encodeURIComponent(fingerprint)}/acted`, { result }
);
return response.data;
};
export const getAiFindings = async ({ days, skill, limit } = {}) => {
const response = await doormileAxios.get(`/admin/ai/findings${buildQuery({ days, skill, limit })}`);
return response.data.data;
};
/** Per skill: how often it fires, how long findings stay open, how often anyone
acts, and whether acting cleared them. "Acted and cleared" vs "cleared on
its own" is what separates a skill that helps from one that narrates. */
export const getAiFindingStats = async (days = 30) => {
const response = await doormileAxios.get(`/admin/ai/findings/stats${buildQuery({ days })}`);
return response.data.data;
};
/** Expected pickups per zone with the STAFFING GAP against riders on duty.
A bare forecast number is not actionable — nobody knows whether 42 is fine.
The gap is: "641 expects 42, has 6 riders at 5 stops each, short by 12".
Produced daily by AI_engine/prediction and stored by the backend. */
export const getDemandForecast = async (days = 7) => {
const response = await doormileAxios.get(`/admin/ai/forecast/demand${buildQuery({ days })}`);
return response.data.data;
};
/** Recent agent decisions, newest first. `before` is the last id of the previous page. */
export const getAiDecisions = async ({ type, before, limit } = {}) => {
const response = await doormileAxios.get(`/admin/ai/decisions${buildQuery({ type, before, limit })}`);
return response.data.data;
};
/* ── Reverse logistics (RTO) ─────────────────────────────────────────────────
Start, cancel (re-attempt delivery) and close a return to sender; list
returns. Start/cancel/close are Doormile-staff actions (a client login gets
403); a client login may read its own returns. Plan:
docs/reverse-logistics-plan.md. */
/** Reasons the backend accepts for starting a return. */
export const RTO_REASONS = [
{ value: 'receiver_refused', label: 'Receiver refused' },
{ value: 'address_not_found', label: 'Address not found' },
{ value: 'customer_unavailable', label: 'Customer unavailable' },
{ value: 'attempts_exhausted', label: 'Delivery attempts exhausted' },
{ value: 'damaged', label: 'Damaged in transit' },
{ value: 'other', label: 'Other (describe)' },
];
/** @param {number|string} consignmentId @param {{reason: string, note?: string}} body */
export const initiateRto = async (consignmentId, body) => {
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto`, body);
return response.data;
};
/** Cancel a return and put the parcel back to delivery. */
export const cancelRto = async (consignmentId, note = '') => {
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto/cancel`, { note });
return response.data;
};
/** Ops confirm the parcel is back with the sender. */
export const completeRto = async (consignmentId, note = '') => {
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto/complete`, { note });
return response.data;
};
/**
* Parcels in or through a return, newest first.
* @param {{status?: 'initiated'|'returned'|'all', from?: string, to?: string, tenantid?: number, pageno?: number, pagesize?: number}} params
*/
export const getReturns = async (params = {}) => {
const response = await doormileAxios.get(`/admin/returns${buildQuery(params)}`);
return response.data;
};
/** Return rates per client and per reason for a date range (GET /admin/returns/summary). */
export const getReturnsSummary = async (params = {}) => {
const response = await doormileAxios.get(`/admin/returns/summary${buildQuery(params)}`);
return response.data;
};
/** Every recorded event of one parcel, oldest first (GET /admin/consignments/:id/history). */
export const getConsignmentHistory = async (consignmentId) => {
const response = await doormileAxios.get(`/admin/consignments/${consignmentId}/history`);
return response.data;
};

View File

@@ -3,6 +3,7 @@ import { OpenToast } from './notify';
import logger from '@/lib/logger';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { buildAnchors, classifyBooking } from '@/lib/orderFlow';
import { makeLocationFilter } from '@/lib/locationScope';
import {
getMilers,
getMiler,
@@ -107,6 +108,10 @@ const BOOKING_STATUS_TO_DELIVERY_STATUS = {
canceled: 'cancelled',
skipped: 'skipped',
rto: 'skipped',
// Reverse logistics: the backend's real RTO statuses get their own tabs.
// (The generic legacy keys rto/returned above keep their old mapping.)
rto_initiated: 'rto',
returned_to_sender: 'returned',
returned: 'cancelled',
failed: 'cancelled'
};
@@ -304,10 +309,36 @@ export const fetchAppLocations = async () => {
try {
const hubs = await getHubs();
const seen = new Map();
// Each city also carries a map centre, so Control X can move the map
// there when the operator switches city and there are no orders yet to
// zoom to. It is the MEDIAN of the city's hub coordinates, not the mean:
// one mis-filed hub (a Chennai hub tagged as Coimbatore, say) would drag a
// mean tens of km off, while the median ignores it. Hubs with no
// coordinates (0,0 / missing) are left out.
const points = new Map();
(hubs || []).forEach((hub) => {
if (hub.applocationid != null && !seen.has(hub.applocationid)) {
if (hub.applocationid == null) return;
if (!seen.has(hub.applocationid)) {
seen.set(hub.applocationid, { applocationid: hub.applocationid, locationname: hub.city || hub.hubname });
}
const lat = Number(hub.latitude);
const lng = Number(hub.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0) {
const list = points.get(hub.applocationid) || [];
list.push([lat, lng]);
points.set(hub.applocationid, list);
}
});
const median = (values) => {
const v = [...values].sort((a, b) => a - b);
const mid = Math.floor(v.length / 2);
return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
};
points.forEach((list, id) => {
const loc = seen.get(id);
if (loc && list.length) {
Object.assign(loc, { latitude: median(list.map((p) => p[0])), longitude: median(list.map((p) => p[1])) });
}
});
const allLocations = [...seen.values()];
@@ -789,7 +820,13 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
// parameter is documented in express-console-api.md and guessing one risks a
// silent 400 or, worse, a silently-ignored filter), so the range is applied
// client-side below, after the rows are normalised.
const [, , , , startdate, enddate, rowsPerPage] = queryKey;
//
// `appLocationId` is slot 1 and had the same fate startdate/enddate did: read
// out of the key and then ignored, so Control X's hub picker rebuilt the
// query, refetched, and returned the same nationwide rows. The chip read
// "Coimbatore Neptune Hub" over a map of Bangalore. It is applied below,
// client-side, for the same reason the dates are.
const [, appLocationId, , , startdate, enddate, rowsPerPage] = queryKey;
// Unlike the 3 joins below (customers/milers/tenants — each individually
// guarded so a failed join just degrades a display field, not the whole
// page), a failed bookings call is the one thing this function can't
@@ -812,11 +849,30 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
// Guarded like the other three joins: if the call fails, or the response
// carries no recognisable status, the row falls back to the booking's status
// and behaviour is exactly what it was before this join existed.
const [customers, milers, tenants, consignments] = await Promise.all([
const [customers, milers, tenants, consignments, hubs] = await Promise.all([
getAdminCustomers().catch(() => []),
getMilers().catch(() => []),
getAdminTenants().catch(() => []),
getConsignments().catch(() => [])
getConsignments().catch(() => []),
// Only when a city is actually selected.
//
// This runs on every page of every fetch, and Dispatch polls on an 8s
// interval while auto-paging through the whole day — so an unconditional
// hub call would add a request per page per tick, on a list that four
// pages read and three of them never scope. `appLocationId` is falsy for
// "All locations" and for every caller outside Control X, and those skip it.
//
// Guarded like the rest: with no hub list there is no city footprint to
// test against, and makeLocationFilter answers "everything" — the old
// behaviour — rather than an empty board. Wrapped rather than `.catch()`ed
// directly because a caller handing back a non-promise would take the whole
// Promise.all down with a TypeError, turning a missing hub list into a
// blank board — the failure this filter exists to avoid.
Number(appLocationId)
? Promise.resolve()
.then(() => getHubs())
.catch(() => [])
: Promise.resolve([])
]);
// The id field on a consignment record has never been captured, so both
// plausible names are indexed rather than guessing one.
@@ -1060,8 +1116,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
return true;
};
// Null when no city is picked, so "All locations" skips the pass outright
// instead of running a filter that is always true.
const inLocation = makeLocationFilter(hubs, appLocationId);
return {
rows: rows.filter(inRange),
rows: inLocation ? rows.filter(inRange).filter(inLocation) : rows.filter(inRange),
nextPage: (bookings || []).length === Number(rowsPerPage) ? pageParam + 1 : undefined
};
};
@@ -1126,7 +1186,9 @@ export const fetchCountAPI = async () => {
activeLength: data.active || 0,
coveredLength: data.delivered || 0,
cancelLength: data.cancelled || 0,
skippedLength: data.skipped || 0
skippedLength: data.skipped || 0,
rtoLength: data.rto || 0,
returnedLength: data.returned || 0
};
};

View File

@@ -8,9 +8,13 @@
* page use. Reach for the wordmark only on a dark ground.
*/
/** The circular red D — the brand mark, legible on light surfaces. */
export const DOORMILE_MARK_URL = '/doormile-mark.png';
/** The red D brand mark, legible on light surfaces. */
export const DOORMILE_MARK_URL = '/preloader.png';
export const DOORMILE_D_LOGO_URL = '/preloader.png';
/** The white wordmark. Dark backgrounds only. */
export const DOORMILE_WORDMARK_URL = '/doormile-logo.png';
/** The MileTruth wordmark, already in brand red (#C8102E). Light backgrounds. */
export const MILETRUTH_WORDMARK_URL = '/miletruth-logo-red.png';

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -6,21 +6,28 @@ Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `Doo
## 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.
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 **MileTruth** (or **Doormile AI**), subtitle **Operations Copilot**.
- **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.
- **Mounted in**: `src/layouts/AdminLayout.jsx` — rendered alongside the page content.
- **80% / 20% Split Layout**: By default on desktop (`window.innerWidth >= 1024`), the assistant is **open on initial load**, occupying 20% of the viewport (`--dai-dock-width: clamp(320px, 20vw, 460px)`), while the page content occupies the remaining 80%. Narrowed from 30vw on request — that width is what pushed the Deliveries tab strip past its container, so the 10vw goes back to the page.
- `clamp` rather than `max` because the **cap** is what makes a large monitor work: 20vw is 768px at 4K, a chat column wider than most documents. Three regimes — a 320px floor below ~1600px (20vw would be 256px on a 1280 laptop, too narrow for the composer and thread), 20vw between ~1600–2300px, and a 460px cap above that. At 1024px the floor wins, so the panel is 31% there, not 20%.
- At or below 900px the panel is a **full-width overlay with a scrim** and reserves no padding at all.
- `DOCK_NORMAL` in `AIPanel.jsx` must stay **byte-identical** to this value. The CSS applies before the effect that sets the property runs on first paint, so any difference between the two shows as the page jumping sideways on load.
- **Visual Separation**: An intentional **24px visible gutter** (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the page content/tables from the AI card so both surfaces are distinct.
- **Smooth Dismissal**: Closing via the header `×` button or navbar toggles `assistantOpen` off, smoothly expanding the page content to 100% full width. State is persisted in `localStorage ('doormileAssistantOpen')`.
- **`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).
- `AIPanel.js` — the portal, slide-over, focus/Escape handling, message state, history persistence, 4 clean action icons (History, Reset, Expand, Close), and the ask() flow.
- `AIWelcome.js` — greeting + suggestion pill chips (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.
- `AIComposer.js` — clean horizontal capsule pill container with textarea, mic, and `<LuSend />` button.
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
- `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
- `AIParts.js` — Spark / TypingIndicator / Metric / StatGrid / StateBlock.
- `pageContext.js` — route → context label + suggested questions.
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
- **`DoormileAI.css`** — the panel's stylesheet. Defines inset card geometry (`border-radius: 20px`, elevated shadow, floating margins off top/bottom/right edges on desktop).
- **`Home.jsx` Composer**: Prompt bar features a functional **Send** button (`Send` icon + "Send") replacing the previous Speak button. Top navigation bar features the Doormile red "D" mark.
### UI rules that are load-bearing, not cosmetic
@@ -29,7 +36,8 @@ An in-console Q&A assistant that answers operator questions about live data —
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
- **Each page offers ONLY its own suggestions.** `getPageContext` returns the route's context list and nothing else — no global set is appended. Asked for directly: on Orders the strip should be Orders questions, and likewise everywhere. This REVERSES the previous rule ("every page offers every suggestion"), which had already been walked back once from all twenty-one questions to a thirteen-question global set because Orders was offering "How many tenants do we have?" above the composer. Note it was barely observable before the change: every context declares at least four suggestions and the panel slices to four, so the globals mostly sat just past the cut — the behaviour was an accident of counting, not a rule. It is structural now. Dropping a question from a page's list does not remove it from the assistant: it stays typeable, stays a follow-up, and still leads on its own page.
- **A route missing from `ROUTES` falls back to `DEFAULT_CONTEXT`**, which is the generic operations set — and that is how a page ends up offering hub questions beside a table of bookings. `/doormile/bookings` did exactly that until it was given its own context. `/doormile/app-users` and `/doormile/competitive-intel` still fall back deliberately: the intent catalog has no questions about app users or competitor branches, and inventing chips that resolve to nothing would break the rule two lines down. Give a page its own context when real intents exist for it, not before.
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.

View File

@@ -24,22 +24,41 @@
globalPolish.css, and an unprefixed name there is decided by bundle order.
--------------------------------------------------------------------------- */
:root {
/* 30vw, NOT 30%.
/* 20vw, NOT 20%.
The two consumers resolve a percentage against different boxes: the panel
is position:fixed, so `width: 30%` is 30% of the VIEWPORT, while
`padding-right: 30%` on the content container is 30% of ITS containing
block — which is narrower by the side nav. At 1440px that is 432px of
panel against 410px of reserved strip, and the page slides 22px underneath
the panel. A viewport unit resolves the same for both.
is position:fixed, so `width: 20%` is 20% of the VIEWPORT, while
`padding-right: 20%` on the content container is 20% of ITS containing
block — which is narrower by the side nav. The page would slide underneath
the panel by the difference. A viewport unit resolves the same for both.
max() rather than a separate min-width for the same reason: a min-width
that only the panel knows about would reintroduce the mismatch on narrow
screens. */
screens.
The floor matters more at 20vw than it did at 30vw: 20vw is 256px on a
1280px laptop, which is too narrow for the composer and a message thread.
320px is the floor, so below 1600px the panel stops shrinking and holds —
and the page keeps the rest. On a 1920px monitor 20vw is 384px.
Narrowed from 30vw on request. It is the reason the Deliveries tab strip
started overflowing, so the extra 10vw goes back to the page. */
/* Default = the NORMAL step. AIPanel overwrites this property on the
document element when the operator toggles the width, so the panel and
the page's reserved strip always read the same value. */
--dai-dock-width: max(300px, 25vw);
/* clamp, not max: the cap is what makes this work on a large monitor.
With `max(320px, 20vw)` the panel just kept growing — 20vw is 512px at
2560px and 768px at 4K, which is a chat column wider than most documents,
while the board beside it gets squeezed for no benefit. A conversation
does not read better past ~460px.
So three regimes, one expression:
< 1600px floor at 320px (20vw would be 256px on a 1280 laptop —
too narrow for the composer and the thread)
1600-2300 20vw (scales with the screen, as asked)
> 2300px cap at 460px (the page keeps everything above this) */
--dai-dock-width: clamp(320px, 20vw, 460px);
--dai-duration: 240ms;
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -73,7 +92,10 @@
--dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
--dai-live: #10b981;
--dai-panel-width: 428px;
/* --dai-panel-width was declared here and at the 1279px breakpoint, and read
nowhere: the panel and the page's reserved strip both size from
--dai-dock-width. Removed so nobody tunes it expecting the panel to
change. */
--dai-inset: 12px;
--dai-duration: 240ms;
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
@@ -119,22 +141,22 @@
seam has depth without the panel detaching. */
.dai-panel {
position: fixed;
/* Set from JS by AIPanel — Astryx does not publish a header-height token,
despite --appshell-header-height looking like one. */
top: var(--dai-dock-top, 57px);
right: 0;
bottom: 0;
/* Inset card geometry matching the reference design:
Floating cleanly off the top nav, bottom edge, and right edge with rounded corners. */
top: calc(var(--dai-dock-top, 57px) + 14px);
right: 16px;
bottom: 14px;
z-index: 1200;
width: var(--dai-dock-width);
width: calc(var(--dai-dock-width) - 24px);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--dai-surface);
border-left: 1px solid var(--dai-border);
border-radius: 0;
box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
transform: translateX(100%);
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 20px;
box-shadow: 0 4px 24px -2px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04);
transform: translateX(calc(100% + 32px));
visibility: hidden;
transition:
transform var(--dai-duration) var(--dai-ease),
@@ -157,11 +179,15 @@
The transition sits on the container unconditionally so the page slides back
when the panel closes too — a rule that only exists while `.dai-docked` is
applied cannot animate its own removal. */
.dai-page,
.astryx-layout-content {
transition: padding-right var(--dai-duration) var(--dai-ease);
}
body.dai-docked .dai-page,
body.dai-docked .astryx-layout-content {
padding-right: var(--dai-dock-width);
/* Reserves the dock width plus a generous 20px gutter so the page content
(tables, cards) never collides with the sidebar and stays distinct. */
padding-right: calc(var(--dai-dock-width) + 16px);
}
/* ---- Mobile: there is no 30% worth having -------------------------------
@@ -171,10 +197,14 @@ body.dai-docked .astryx-layout-content {
@media (max-width: 900px) {
.dai-panel {
top: 0;
right: 0;
bottom: 0;
width: 100%;
z-index: 1301;
border-left: none;
border: none;
border-radius: 0;
box-shadow: var(--dai-shadow);
transform: translateX(100%);
}
.dai-scrim {
display: block;
@@ -182,6 +212,7 @@ body.dai-docked .astryx-layout-content {
.dai-scrim[data-open='true'] {
opacity: 1;
}
body.dai-docked .dai-page,
body.dai-docked .astryx-layout-content {
padding-right: 0;
}
@@ -194,20 +225,22 @@ body.dai-docked .astryx-layout-content {
-------------------------------------------------------------------------- */
.dai-root .dai-header {
flex: 0 0 auto;
padding: 14px 12px 12px 14px;
border-bottom: 1px solid var(--dai-border);
padding: 14px 16px;
border-bottom: 1px solid #f1f5f9;
background: #ffffff;
}
.dai-root .dai-title {
font-size: 15px;
font-weight: 650;
font-size: 16px;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--dai-text);
color: #0f172a;
}
.dai-root .dai-subtitle {
font-size: 12px;
line-height: 1.3;
color: var(--dai-text-muted);
color: #64748b;
font-weight: 500;
}
/* The AI mark. A soft gradient orb — not a robot face. */
.dai-root .dai-spark {
@@ -215,8 +248,9 @@ body.dai-docked .astryx-layout-content {
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: 999px;
background: var(--dai-surface);
border-radius: 10px;
background: #fff1f2;
border: 1px solid #ffe4e6;
overflow: hidden;
}
/* The mark's own canvas is only 66.8% content — a third of every edge is
@@ -277,15 +311,7 @@ body.dai-docked .astryx-layout-content {
}
/* 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;
display: none;
}
/* --------------------------------------------------------------------------
Welcome state
@@ -360,61 +386,87 @@ body.dai-docked .astryx-layout-content {
They wrap, they never scroll. A horizontal scroller can always leave a chip
half-visible at the edge, and a half-visible button is a bug no amount of
fade masking fixes. */
.dai-root .dai-suggestions-bar .dai-section-label {
display: none;
}
.dai-root .dai-suggestions {
/* Row + wrap, which is what the comment above always claimed and what the
component asks for — SuggestionChips renders an HStack with wrap="wrap".
This rule said `column`, overriding it, so four chips became four full
rows and the strip ate a third of the panel before a single question had
been asked. Two short chips now share a row and only long ones take one to
themselves. */
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
align-content: flex-start;
}
.dai-suggestion {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 8px;
max-width: 100%;
padding: 5px 10px;
/* Trimmed from 8px 18px. At the 320px floor the old horizontal padding put
36px of empty pill around every label, which is what stopped two chips
ever fitting side by side. */
padding: 7px 13px;
text-align: left;
/* A chip is one line. Without this the flex row can hand a chip a width
narrower than its label and the text wraps inside the pill, which is what
made the strip look ragged. The ellipsis itself is on
.dai-suggestion-text — see there. */
white-space: nowrap;
overflow: hidden;
font: inherit;
font-size: 12px;
line-height: 1.35;
font-size: 13px;
line-height: 1.4;
font-weight: 500;
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
/* 14px, pinned — NOT 999px.
On a one-line chip a fully round radius already resolves to about 14px, so
these look identical. The difference shows on a chip whose question wraps
to two lines: 999px would resolve to half of ~46px and the chip stops
reading as a chip and starts reading as a card, so one long suggestion
would look like a different component from the nineteen beside it. */
border-radius: 14px;
color: #334155;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 9999px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
transition: all 140ms ease;
}
.dai-suggestion:hover {
background: var(--dai-surface-alt);
border-color: var(--dai-border-strong);
background: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06);
}
.dai-suggestion:active {
transform: translateY(0);
}
.dai-suggestion:focus-visible {
outline: 2px solid var(--dai-accent);
outline: 2px solid #0f172a;
outline-offset: 2px;
}
.dai-root .dai-suggestion-icon {
flex: 0 0 auto;
color: var(--dai-text-muted);
color: #64748b;
transition: color 140ms ease;
}
.dai-suggestion:hover .dai-suggestion-icon {
color: var(--dai-text-secondary);
color: #0f172a;
}
.dai-root .dai-suggestion-text {
/* The ellipsis lives here, not on the button: text-overflow needs the
element that actually holds the text, and the button is a flex container
whose children are the icon and this span. min-width:0 is what lets the
span shrink below its content width so the ellipsis can engage at all —
a flex item defaults to min-width:auto and would push the pill wider
instead. The button already carries title={item.text}, so the full
question is still available when a label is cut. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Plain text link-button ("View more", "Sources") */
.dai-link {
@@ -695,26 +747,29 @@ body.dai-docked .astryx-layout-content {
-------------------------------------------------------------------------- */
.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);
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid #f1f5f9;
background: #ffffff;
}
.dai-root .dai-composer {
border: 1px solid var(--dai-border-strong);
/* 14px, the same corner the suggestion chips use. At 5px the input was the
one sharp-cornered thing in a panel of rounded surfaces, and it sat
directly beneath the chips where the mismatch was most visible. */
border-radius: 14px;
background: var(--dai-surface);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
padding: 10px 10px 8px 14px;
border: 1px solid #e2e8f0;
border-radius: 9999px;
background: #f8fafc;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
padding: 6px 6px 6px 16px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
transition:
border-color 140ms ease,
box-shadow 140ms ease;
box-shadow 140ms ease,
background-color 140ms ease;
}
.dai-root .dai-composer[data-focused='true'] {
border-color: var(--dai-accent);
box-shadow: 0 0 0 3px var(--dai-accent-ring);
background: #ffffff;
border-color: #cbd5e1;
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.05);
}
/* --------------------------------------------------------------------------
@@ -811,13 +866,13 @@ body.dai-docked .astryx-layout-content {
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dai-accent);
color: var(--dai-accent-contrast);
background: #2563eb;
color: #ffffff;
cursor: pointer;
transition:
background-color 140ms ease,
@@ -825,29 +880,32 @@ body.dai-docked .astryx-layout-content {
transform 140ms ease;
}
.dai-root .dai-send:hover:not(:disabled) {
opacity: 0.86;
background: #1d4ed8;
transform: scale(1.05);
}
.dai-root .dai-send:active:not(:disabled) {
transform: scale(0.94);
transform: scale(0.96);
}
.dai-root .dai-send:focus-visible {
outline: 2px solid var(--dai-accent);
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Disabled is the resting state until something is typed, so it has to read as
"not yet", not as "broken" — a filled grey circle, same shape and weight. */
.dai-root .dai-send:disabled {
background: var(--dai-surface-hover);
color: var(--dai-text-muted);
background: #f1f5f9;
color: #94a3b8;
cursor: default;
}
.dai-root .dai-composer textarea {
display: block;
flex: 1 1 auto;
width: 100%;
min-width: 0;
border: none;
outline: none;
resize: none;
padding: 0;
padding: 4px 0;
margin: 0;
font: inherit;
font-size: 13.5px;
@@ -867,62 +925,47 @@ body.dai-docked .astryx-layout-content {
padding-inline: 2px;
}
/* --------------------------------------------------------------------------
Trigger (lives in the app TopNav)
The trigger's styles lived here and are gone with it.
`.dai-trigger` / `.dai-trigger-mark` styled DoormileAI/index.jsx, a trigger
button component that nothing rendered — AdminLayout has always declared
its own button in JSX with Tailwind classes. Component and CSS removed
together so neither is left looking like the thing that controls the
header button. To change that button, edit AdminLayout.
-------------------------------------------------------------------------- */
.dai-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border-radius: 5px;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
overflow: hidden;
transition:
background-color 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is
30px inside the 40px button. */
.dai-trigger-mark {
width: 30px;
height: 30px;
max-width: none;
object-fit: contain;
display: block;
transform: scale(1.5);
}
.dai-trigger:hover {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.1);
}
.dai-trigger:active {
transform: scale(0.94);
}
.dai-trigger:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-trigger[data-active='true'] {
background: rgba(0, 0, 0, 0.07);
border-color: rgba(0, 0, 0, 0.14);
}
/* --------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@media (max-width: 1279px) {
.dai-root {
--dai-panel-width: 396px;
/* Tablet and small laptop, 768-900px: the panel is a full-width overlay with a
scrim (see the max-width:900px block above), so the only thing left to do is
stop the suggestion strip from taking a third of a short screen — there is
much less height to spend here than on a desktop. */
@media (max-width: 900px) {
.dai-root .dai-suggestions-bar {
max-height: 26vh;
}
}
/* The block that used to sit here set `--dai-panel-width: 396px` at 1279px.
That variable is declared and never read — the panel sizes from
--dai-dock-width — so the rule had no effect on anything. Removed rather
than left looking load-bearing. */
@media (max-width: 767px) {
.dai-root {
--dai-inset: 0px;
}
/* Phone: chips get a touch target and room to breathe, and the strip is
capped harder still because the on-screen keyboard takes the bottom half
of the viewport the moment the composer is focused. */
.dai-root .dai-suggestions-bar {
max-height: 22vh;
padding: 8px 12px 6px;
}
.dai-suggestion {
padding: 9px 14px;
font-size: 13px;
}
.dai-panel {
width: 100vw;
max-width: 100vw;
@@ -946,8 +989,7 @@ body.dai-docked .astryx-layout-content {
.dai-typing span,
.dai-shimmer,
.dai-suggestion,
.dai-send,
.dai-trigger {
.dai-send {
animation: none !important;
transition: none !important;
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
// ==============================|| Doormile AI — the address turn ||============================== //
//
// The address step, as a real search field rather than a line typed into the
// chat box.
//
// It used to be an ordinary `text` step: the assistant asked for "the full
// address" and the operator typed one into the composer, which was then
// geocoded blind. Two things were wrong with that. They got no suggestions, so
// an area or street had to be remembered and spelled the way the geocoder
// wanted; and when it missed, all that came back was "I couldn't find that
// address", with no sight of what it HAD found.
//
// This is the same `AddressAutocomplete` the Create Order page uses, so the
// two surfaces resolve an address the same way and cannot drift.
//
// Picking from the list also hands the flow the coordinates the operator
// actually chose, rather than whatever a second geocode of the same words
// returns — see the `place` branch in orderFlow's resolve. That matters: the
// dispatch optimiser routes on coordinates, and re-geocoding a label can
// legitimately return a different place.
//
// Typing free text and pressing the composer still works. This is an extra way
// in, not a replacement for one.
const AIAddressStep = ({ step, isBusy, onChoose, onCancel }) => {
const [text, setText] = useState('');
const [picked, setPicked] = useState(null);
return (
<div className="w-full border border-slate-200/90 rounded-2xl bg-white p-3.5 sm:p-4 mb-3.5 shadow-3xs space-y-3">
<div className="text-xs font-bold text-slate-800">{step.ask}</div>
<AddressAutocomplete
id={`ai-address-${step.id}`}
value={text}
disabled={isBusy}
onChange={(v) => {
setText(v);
// Editing after a pick invalidates it. Those coordinates belonged to
// the old selection and must not ride along with new words — that is
// how a booking ends up located somewhere nobody chose.
setPicked(null);
}}
onPlaceSelected={(place) => {
setPicked(place);
setText(place?.formatted_address || place?.name || '');
}}
/>
{picked && (
<div className="text-[11px] text-emerald-700 font-medium">
Location set — {picked.city || picked.suburb || 'coordinates captured'}
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={isBusy || !text.trim()}
onClick={() => onChoose(text.trim(), picked ? { place: picked } : undefined)}
className="px-4 py-2 bg-brand hover:bg-brand-dark text-white font-semibold text-xs rounded-xl shadow-xs active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer"
>
Continue
</button>
<button
type="button"
onClick={onCancel}
className="px-3 py-2 text-slate-500 hover:text-slate-800 text-xs font-medium cursor-pointer"
>
Cancel
</button>
</div>
</div>
);
};
AIAddressStep.propTypes = {
step: PropTypes.shape({ id: PropTypes.string, ask: PropTypes.string }).isRequired,
isBusy: PropTypes.bool,
onChoose: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired
};
export default AIAddressStep;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { LuArrowUp } from 'react-icons/lu';
import { LuSend } from 'react-icons/lu';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
@@ -111,7 +111,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
return (
<VStack className="dai-composer-wrap" gap={1} padding={0}>
<VStack className="dai-composer" data-focused={isFocused} gap={1} padding={0}>
<HStack className="dai-composer" data-focused={isFocused} gap={1} padding={0} vAlign="center">
<textarea
ref={textareaRef}
rows={1}
@@ -120,13 +120,9 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
aria-label="Ask Doormile AI about operations"
placeholder={placeholder || 'Ask MileTruth anything...'}
aria-label="Ask MileTruth about operations"
/>
{/* Both controls group on the RIGHT. `justify="between"` pushed the mic
to the far left and the send button to the far right, leaving a
wide dead gap between two things that do the same job — put a
message in. Together they read as one action cluster. */}
<HStack gap={1} padding={0} vAlign="center" justify="end">
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
<button
@@ -136,10 +132,10 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
disabled={!canSend}
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
>
<LuArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
<LuSend size={15} aria-hidden="true" />
</button>
</HStack>
</VStack>
</HStack>
<Text className="dai-hint">
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
</Text>

View File

@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@/components/ui/button';
import { Selector } from '@astryxdesign/core/Selector';
@@ -38,7 +37,7 @@ const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step.id]);
if (failed) {

View File

@@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import { PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import doormileMark from '@/assets/images/doormile-mark.png';
// ==============================|| Doormile AI — the launcher ||============================== //
//
// The floating control that opens MileTruth. It replaces the 32px icon that sat
// in the header between the search box and the notification bell.
//
// The header is a poor home for it. Everything else in that row is a utility
// the operator reaches for occasionally — search, alerts, the account menu —
// and the assistant was a bare brand mark among them, with nothing saying what
// it opened. Down here it is a named control that reads as one thing: a pill
// with the mark, the word, and the panel glyph that shows where it will appear.
//
// It is NOT rendered on the home page, where the assistant is the page rather
// than a panel beside it. That rule lives with the caller, next to the matching
// rule for the panel itself, so the two cannot disagree about where it applies.
const AILauncher = ({ isOpen, onToggle, className }) => {
// Hidden while the panel is open.
//
// The panel docks to the right edge and this button is pinned to the bottom
// right, so an open panel sits underneath it on a wide screen and covers it
// completely on a narrow one, where the panel goes full-width behind a scrim.
// Rather than chase the dock width across breakpoints, the launcher steps
// aside: the panel's own header carries the close control, so nothing is lost.
if (isOpen) return null;
return (
<button
type="button"
onClick={onToggle}
aria-label="Open MileTruth"
aria-expanded={false}
title="MileTruth — Operations Copilot"
className={cn(
// Sits under the panel's own layer (1200) so a panel opening over it is
// never a z-index race, and above ordinary page chrome.
'fixed bottom-5 right-5 z-[1100]',
'inline-flex items-center gap-2 rounded-full',
'border border-border bg-surface py-1.5 pl-1.5 pr-3',
'shadow-lg shadow-black/10',
'cursor-pointer transition-all duration-150',
'hover:-translate-y-0.5 hover:shadow-xl',
// The outline is the brand crimson, on hover AND on keyboard focus.
//
// `ring-brand` at full strength, not the `/20` wash the header icon
// used: on a white pill a 20% tint of #C8102E reads as a pale pink
// smudge rather than as an outline, which is the whole affordance.
'hover:border-brand/40 hover:ring-2 hover:ring-brand',
'focus-visible:outline-none focus-visible:border-brand/40 focus-visible:ring-2 focus-visible:ring-brand',
className
)}
>
<img
src={doormileMark}
alt=""
aria-hidden="true"
// `object-contain` and no `max-w-none`, so the mark can never outgrow
// its slot however the asset is redrawn — the bug the header icon hit
// twice before it settled.
className="h-7 w-7 shrink-0 object-contain"
/>
<span className="text-body-sm font-bold text-ink-1">Doormile</span>
<PanelRight className="h-4 w-4 shrink-0 text-ink-4" aria-hidden="true" />
</button>
);
};
AILauncher.propTypes = {
isOpen: PropTypes.bool,
onToggle: PropTypes.func.isRequired,
className: PropTypes.string,
};
export default AILauncher;

View File

@@ -12,6 +12,7 @@ import { IconButton } from '@/components/ds';
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
import AIFlowStep from './AIFlowStep';
import AIAddressStep from './AIAddressStep';
import AIRowsStep from './AIRowsStep';
import { failedRowsCsv, downloadCsv } from '@/lib/assistant/bulkFile';
@@ -43,7 +44,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
<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>
<Text className="dai-msg-name">MileTruth</Text>
</HStack>
<HStack className="dai-msg-actions" gap={0} padding={0}>
<IconButton
@@ -127,6 +128,19 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
/>
)}
{/* The address turn. Without this branch the step passed the gate in
AIPanel and nothing drew it — the question appeared with an empty
space beneath it, which is worse than the free-text version it
replaced. */}
{message.flowStep?.type === 'address' && !message.flowStep.__answered && (
<AIAddressStep
step={message.flowStep}
isBusy={message.flowStep.__answered}
onChoose={(value, option) => onChooseStep(message, value, option)}
onCancel={() => onCancelAction(message)}
/>
)}
{message.flowStep?.type === 'select' && (
<AIFlowStep
step={message.flowStep}

View File

@@ -4,13 +4,20 @@ import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { AiOutlineClose as CloseOutlined, AiOutlineMore as MoreOutlined } from 'react-icons/ai';
import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose, LuHistory, LuTrash2, LuMessageSquare } from 'react-icons/lu';
import {
LuChevronDown,
LuHistory,
LuTrash2,
LuMessageSquare,
LuRotateCcw,
LuMaximize2,
LuMinimize2,
LuX
} from 'react-icons/lu';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
import { IconButton } from '@/components/ds';
import { OpenToast } from 'components/third-party/OpenToast';
@@ -27,7 +34,7 @@ import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assista
import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow';
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
import { getPageContext, getFollowUps, toChips, ORDER_CREATED, ORDER_CREATED_ASSIGNED } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import { Spark, TypingIndicator } from './AIParts';
import AIWelcome, { SuggestionChips } from './AIWelcome';
import AIMessage from './AIMessage';
import AIComposer from './AIComposer';
@@ -51,14 +58,17 @@ const CONVERSATIONS_KEY = 'doormileBotConversations';
const CONVERSATIONS_LIMIT = 25;
const PIN_THRESHOLD_PX = 48;
// Dock widths. Each is a percentage of the viewport with a pixel floor — 25vw
// is only 320px on a 1280 laptop, and the floor catches anything narrower than
// that, below which the panel stops being readable. Both values are also what
// the page reserves beside the panel, so they must stay a single expression
// (see the --dai-dock-width comment in DoormileAI.css for why a percentage
// cannot be used here).
const DOCK_NORMAL = 'max(300px, 25vw)';
const DOCK_WIDE = 'max(340px, 30vw)';
// Dock widths. Each is a viewport fraction with a pixel floor — 20vw is only
// 256px on a 1280 laptop, and the floor catches anything narrower than that,
// below which the composer and the thread stop being readable. Both values are
// also what the page reserves beside the panel, so they must stay a single
// expression (see the --dai-dock-width comment in DoormileAI.css for why a
// percentage cannot be used here).
// Must stay byte-identical to the --dai-dock-width default in DoormileAI.css:
// the CSS value applies before this effect runs on first paint, and any
// difference between the two shows as the page jumping sideways on load.
const DOCK_NORMAL = 'clamp(320px, 20vw, 460px)';
const DOCK_WIDE = 'clamp(420px, 30vw, 680px)';
const WIDTH_KEY = 'doormileBotWidth';
// Seeded from the clock, not from 1.
@@ -390,7 +400,11 @@ const AIPanel = ({ isOpen, onClose }) => {
text: next.ask || step.ask,
// A `select` or `rows` step carries the step definition so AIMessage
// can render its control; a `text` step is answered in the composer.
flowStep: step.type === 'select' || step.type === 'rows' ? step : undefined,
// 'address' joins the list so the sidebar gets the same search
// field as Home. A step type absent here falls through to the plain
// composer, which is how the address step ended up asking for a full
// address as free text.
flowStep: ['select', 'rows', 'address'].includes(step.type) ? step : undefined,
flowDraft: next.flow.draft
});
return;
@@ -1378,7 +1392,7 @@ const AIPanel = ({ isOpen, onClose }) => {
? nextSteps.length > 0
? nextSteps
: pageContext.suggestions.slice(0, 4)
: [...pageContext.suggestions, ...(pageContext.more || [])];
: pageContext.suggestions.slice(0, 4);
// Few chips => the strip is fixed and always fully visible. Twenty => it may
// shrink and scroll. See the [data-compact] rule in DoormileAI.css.
@@ -1401,7 +1415,7 @@ const AIPanel = ({ isOpen, onClose }) => {
stays live and operable, so announcing it as a dialog would be a
lie to exactly the users who cannot see that it is docked. */
role="complementary"
aria-label="Doormile AI — Operations Copilot"
aria-label="MileTruth — Operations Copilot"
tabIndex={-1}
>
{/* ---- header ---- */}
@@ -1410,30 +1424,17 @@ const AIPanel = ({ isOpen, onClose }) => {
<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-title">MileTruth</Text>
<Text className="dai-subtitle">Operations Copilot</Text>
</VStack>
</HStack>
<HStack gap={1} padding={0} vAlign="center">
<LiveIndicator />
{/* No `size` prop on the icon — deliberately.
Its two siblings here are Ant Design icons, which render at
1em and so inherit the button's 16px. Pinning this one to 15
made it a pixel smaller than the ⋯ and × beside it, which is
exactly the kind of one-pixel mismatch that reads as "off"
without being obviously wrong. Left unset, react-icons also
defaults to 1em and all three track the button together.
Panel icons rather than chevrons: a bare chevron next to a
close button is ambiguous (collapse? navigate? close?),
whereas these draw the side panel itself getting wider or
narrower, which is literally what the control does. */}
<IconButton
size="sm"
variant="ghost"
label={view === 'history' ? 'Back to the conversation' : 'Conversation history'}
tooltip={view === 'history' ? 'Back to the conversation' : 'History'}
side="bottom"
label={view === 'history' ? 'Back to conversation' : 'Conversation history'}
tooltip={view === 'history' ? 'Back to conversation' : 'History'}
aria-pressed={view === 'history'}
icon={view === 'history' ? <LuMessageSquare /> : <LuHistory />}
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
@@ -1441,39 +1442,35 @@ const AIPanel = ({ isOpen, onClose }) => {
<IconButton
size="sm"
variant="ghost"
label={isWide ? 'Narrow the assistant' : 'Widen the assistant'}
tooltip={isWide ? 'Narrow to 25%' : 'Widen to 30%'}
aria-pressed={isWide}
icon={isWide ? <LuPanelRightClose /> : <LuPanelRightOpen />}
onClick={() => setIsWide((v) => !v)}
/>
<DropdownMenu
hasChevron={false}
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
items={[
// These were both wired to clearConversation, so the safe
// option and the destructive one did the same thing. Now
// "New" keeps the thread and "Delete" says that it does not.
{ label: 'New conversation', onClick: newConversation },
{ label: 'Conversation history', onClick: () => setView('history') },
{ label: 'Delete this conversation', onClick: clearConversation }
]}
side="bottom"
label="New conversation"
tooltip="New conversation"
icon={<LuRotateCcw />}
onClick={newConversation}
/>
<IconButton
size="sm"
variant="ghost"
side="bottom"
label={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
tooltip={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
aria-pressed={isWide}
icon={isWide ? <LuMinimize2 /> : <LuMaximize2 />}
onClick={() => setIsWide((v) => !v)}
/>
<IconButton
size="sm"
variant="ghost"
side="bottom"
label="Close assistant"
tooltip="Close assistant"
icon={<CloseOutlined />}
icon={<LuX />}
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}>
@@ -1525,7 +1522,7 @@ const AIPanel = ({ isOpen, onClose }) => {
<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>
<Text className="dai-msg-name">MileTruth</Text>
</HStack>
<TypingIndicator />
</VStack>
@@ -1563,7 +1560,7 @@ const AIPanel = ({ isOpen, onClose }) => {
)}
{/* ---- composer ---- */}
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, riders, hubs…" />
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, milers, hubs…" />
</VStack>
</>,
document.body

View File

@@ -38,7 +38,7 @@ 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">
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="MileTruth is thinking">
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
<span />
<span />

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@/components/ui/button';
import { TextArea } from '@astryxdesign/core/TextArea';

View File

@@ -77,7 +77,7 @@ const AIWelcome = () => {
<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.
I read live orders, milers, vehicles and hubs. Every number comes from a real call — I never estimate one.
</Text>
</VStack>
);

View File

@@ -1,44 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import doormileMark from 'assets/images/doormile-mark.png';
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))}
>
<img className="dai-trigger-mark" src={doormileMark} alt="" aria-hidden="true" />
</button>
</Tooltip>
<AIPanel isOpen={isOpen} onClose={close} />
</>
);
};
export default DoormileAITrigger;

View File

@@ -12,7 +12,8 @@ import {
LuUserPlus,
LuPackagePlus,
LuListPlus,
LuRepeat
LuRepeat,
LuShieldAlert
} from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== //
@@ -45,6 +46,7 @@ import {
// suggestion with no entry here simply shows its full text.
// ---------------------------------------------------------------------------
export const CHIP_LABELS = {
'What needs attention right now?': 'Needs attention',
"Give me today's operations summary": "Today's summary",
'How many orders today?': 'Orders today',
'Which orders are delayed?': 'Delayed orders',
@@ -66,7 +68,15 @@ export const CHIP_LABELS = {
'Total revenue this week': 'Revenue this week',
'How many customers do we have?': 'Customers',
'Assign a rider': 'Assign order',
"Repeat yesterday's orders": 'Repeat yesterday'
'How many tripsheets are dispatched?': 'Tripsheets',
'How many open exceptions?': 'Open exceptions',
'How many pricing rules are configured?': 'Pricing rules',
"Repeat yesterday's orders": 'Repeat yesterday',
'How many consignments do we have?': 'Consignments',
'How many app users do we have?': 'App users',
'How many partners do we have?': 'Partners',
'How many competitor branches are tracked?': 'Competitor branches',
'How many carriers do we have?': 'Carriers'
};
// The one follow-up phrasing that is not already a page suggestion. Verified
@@ -118,62 +128,144 @@ const ORDERS = {
};
const RIDERS = {
label: 'Riders',
label: 'Milers',
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?' }
{ icon: LuUserPlus, text: 'Assign a rider' }
],
more: [
{ icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuBanknote, text: 'Total revenue today' }
]
more: []
};
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?' }]
suggestions: [{ icon: LuTruck, text: 'How many vehicles are available?' }],
more: []
};
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?' }
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' }
],
more: [{ icon: LuPackage, text: 'How many orders today?' }]
more: []
};
const DISPATCH = {
label: 'Dispatch',
label: 'Control X',
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" }
{ icon: LuUserPlus, text: 'Assign a rider' }
],
more: [
more: []
};
// Linehaul and middle-mile runs.
const TRIPSHEETS = {
label: 'Tripsheets',
suggestions: [{ icon: LuLayers, text: 'How many tripsheets are dispatched?' }],
more: []
};
// The register of things that went wrong.
const EXCEPTIONS = {
label: 'Exceptions',
suggestions: [
// The ops briefing (intents.js opsBriefing) — the same rules as the early
// warnings banner on this page, asked for in words.
{ icon: LuShieldAlert, text: 'What needs attention right now?' },
{ icon: LuCircleDot, text: 'How many open exceptions?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' }
],
more: []
};
// Rate cards. Reached from the account menu rather than the bar, but it is a
// real page and deserves its own questions like any other.
const PRICING = {
label: 'Pricing',
suggestions: [{ icon: LuBanknote, text: 'How many pricing rules are configured?' }],
more: []
};
// Bookings — what customers raised in the app, before it becomes an order.
//
// This page was not in ROUTES at all, so it fell through to DEFAULT_CONTEXT
// and offered "How many riders are active?" and "Current hub status" beside a
// table of B2C bookings. It is a main nav destination, so that was the most
// visible instance of the problem this change fixes.
//
// No create actions: a booking here originates in the customer app, not in
// this console, so "Create an order" would offer to make a different kind of
// thing than the page shows.
//
// One honest limitation: these intents count ALL bookings, not only the
// customer-app ones this page lists. They are the right questions to ask from
// here and every one of them resolves, but the number will not match the row
// count when the page is filtered to a source.
const BOOKINGS = {
label: 'Bookings',
suggestions: [
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuCircleDot, text: 'How many pending orders today?' }
]
{ icon: LuCircleDot, text: 'How many pending orders today?' },
{ icon: LuPackage, text: 'How many cancelled orders today?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' }
],
more: []
};
// Deliveries — an order after it has been picked up and become a consignment.
//
// This page used to be `{ ...ORDERS, label: 'Deliveries' }`: the Orders
// question set under a different name, so a table of consignments offered
// "Create an order" and "Repeat yesterday's orders". The label made it look
// deliberate, which is the same trap the Tripsheets/Exceptions/Pricing note
// below describes.
//
// Every phrasing here is checked against its intent's own `match`:
// consignmentStatus is /\bconsignments?\b/, parcelTrack wants a tracking
// number, and the delivered/delayed counts are the order intents narrowed to
// the statuses this page actually shows.
const DELIVERIES = {
label: 'Deliveries',
suggestions: [
{ icon: LuTruck, text: 'How many consignments do we have?' },
{ icon: LuPackage, text: 'How many delivered orders today?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
{ icon: LuBike, text: 'How many riders are active?' }
],
more: []
};
// Reference data behind the console — not an operational page, so it gets the
// one question that is actually about it rather than the operations summary.
const APP_USERS = {
label: 'App users',
suggestions: [{ icon: LuUsers, text: 'How many app users do we have?' }],
more: []
};
const COMPETITIVE_INTEL = {
label: 'Competitive intel',
suggestions: [
{ icon: LuBuilding2, text: 'How many competitor branches are tracked?' },
// NOT "how many carrier pricing rules", which is the phrasing
// carrierPricingCount documents for itself and which never reaches it:
// pricingCount matches /\bpricing\b/ and runs 57 lines earlier, so that
// wording is answered with the general pricing-rule count. Its actual
// predicate is /\bcarriers?\b/.
{ icon: LuBanknote, text: 'How many carriers do we have?' }
],
more: []
};
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" }
{ icon: LuUsers, text: 'How many partners do we have?' }
],
more: []
};
@@ -281,8 +373,9 @@ const FOLLOW_UPS = {
// 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/control-x', DISPATCH],
['/doormile/deliveries', DELIVERIES],
['/doormile/bookings', BOOKINGS],
['/doormile/orders', ORDERS],
['/doormile/milers', RIDERS],
['/doormile/riders', RIDERS],
@@ -303,9 +396,23 @@ const ROUTES = [
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
}
],
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
// These three were `{ ...DEFAULT_CONTEXT, label: '…' }` — a page-specific
// NAME over the generic operations questions, so Tripsheets offered "Active
// riders" and "Hub status" and nothing about tripsheets. The label made it
// look intentional.
//
// Each now leads with its own resource. The phrasings come from the intents'
// own documented examples (tripsheetStatus, exceptionStatus, pricingCount)
// and were checked against their `match` predicates before being added here,
// per the rule at the top of this file. None contains an order/booking word,
// so none is shadowed by the order intents that run earlier in the catalog.
['/doormile/tripsheets', TRIPSHEETS],
['/doormile/exceptions', EXCEPTIONS],
['/doormile/pricing', PRICING],
// Missing from this list entirely until now, so both fell through to
// DEFAULT_CONTEXT and offered rider and hub questions over their own data.
['/doormile/app-users', APP_USERS],
['/doormile/competitive-intel', COMPETITIVE_INTEL]
];
// ---- every question, from every page ---------------------------------------
@@ -318,12 +425,26 @@ const ROUTES = [
//
// `more` is retired: AIWelcome renders one flat list now, so a second array
// would only be a second place for a question to hide.
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
const ALL_CONTEXTS = [
ORDERS,
RIDERS,
VEHICLES,
HUBS,
DISPATCH,
DELIVERIES,
APP_USERS,
COMPETITIVE_INTEL,
TENANTS,
REPORTS,
DEFAULT_CONTEXT,
...ROUTES.map(([, c]) => c)
];
// Every question declared anywhere. Two jobs: it is the icon index behind
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS
// filters. Do not narrow it — narrow GLOBAL_TEXTS instead.
// still renders with the right icon). It no longer feeds a global suggestion
// pool — see the note below. Do not narrow it: a question dropped from here
// loses its icon everywhere, including as a follow-up.
const EVERY_QUESTION = (() => {
const seen = new Set();
const out = [];
@@ -338,44 +459,38 @@ const EVERY_QUESTION = (() => {
})();
// ---------------------------------------------------------------------------
// The questions that ride along on EVERY page.
// There is no longer a set of questions that rides along on every page.
//
// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the
// Orders page offered "How many tenants do we have?" and "Current hub status"
// above the composer, questions an operator working orders is not asking. The
// strip is a shortcut, and a shortcut that lists everything is a menu.
// GLOBAL_TEXTS / GLOBAL_SUGGESTIONS lived here: a thirteen-question daily
// operating picture appended to every context. It had already been narrowed
// once, from all twenty-one, because the Orders page was offering "How many
// tenants do we have?" above the composer. It is gone entirely now — a page
// offers its own questions and nothing else.
//
// What is left is the daily operating picture: what came in, what is stuck,
// what got out, what it earned, and the three ways to put work into the system.
//
// Dropping a question from THIS list does not remove it from the assistant —
// it stays typeable, it stays a follow-up, and it still leads on its own page,
// because withGlobalQuestions puts each page's own suggestions first. Hubs
// still opens on hub questions; they just no longer follow you to Orders.
// EVERY_QUESTION above stays. It is still the icon index behind
// QUESTION_BY_TEXT, so a follow-up naming a question that no longer appears
// on any page strip still renders with the right icon.
// ---------------------------------------------------------------------------
const GLOBAL_TEXTS = new Set([
"Give me today's operations summary",
'How many orders today?',
'Which orders are delayed?',
'How many pending orders today?',
'How many delivered orders today?',
'How many cancelled orders today?',
'Morning Batch orders today',
'Orders today vs yesterday',
'Total revenue today',
'How many riders are active?',
'Create an order',
REPEAT_TEXT,
'Create multiple orders'
]);
const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text));
// The page's own suggestions lead; the global set follows, deduplicated.
const withGlobalQuestions = (context) => {
// A page offers its OWN questions, and only those.
//
// The global set no longer rides along. Asked for directly: on Orders the
// strip should be Orders questions, and the same on every other page.
//
// Worth being precise about what changed, because it is easy to read this as
// a no-op. It very nearly was: every context happens to declare at least four
// own suggestions and the panel slices to four, so in practice the globals sat
// just past the cut on most pages. That made the current behaviour an accident
// of counting rather than a rule — declare a context with three suggestions
// and a hub question would surface on it. Now it cannot, whatever the counts.
//
// Dropping a question from a page's list does not remove it from the
// assistant. It stays typeable, it stays a follow-up, and it still leads on
// its own page. The strip is a shortcut, not the index.
const ownQuestionsOnly = (context) => {
const seen = new Set();
const suggestions = [];
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
[...(context.suggestions || []), ...(context.more || [])].forEach((q) => {
if (seen.has(q.text)) return;
seen.add(q.text);
suggestions.push(q);
@@ -385,7 +500,7 @@ const withGlobalQuestions = (context) => {
export const getPageContext = (pathname = '') => {
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
return ownQuestionsOnly(match ? match[1] : DEFAULT_CONTEXT);
};
// ---------------------------------------------------------------------------

View File

@@ -191,10 +191,10 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// Sequential on purpose. These are writes against real dispatch records, and
// firing a burst of them concurrently makes a partial failure much harder to
// report accurately — which order did not land, and to whom.
// eslint-disable-next-line no-restricted-syntax
for (const t of targets) {
try {
// eslint-disable-next-line no-await-in-loop
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
assigned += 1;
assignedRiders.add(String(t.mileruserid));
@@ -218,12 +218,12 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// drops back should feel one buzz, not ten.
let notified = 0;
if (lookup) {
// eslint-disable-next-line no-restricted-syntax
for (const userid of assignedRiders) {
const rider = lookup.byUserId.get(userid);
if (rider?.milerprofileid) {
try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(rider.milerprofileid);
notified += 1;
} catch {

View File

@@ -124,18 +124,18 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
if (hasCoords(row)) {
located.push(row);
// eslint-disable-next-line no-continue
continue;
}
const key = cacheKey(row);
if (cache?.has(key)) {
located.push({ ...row, ...cache.get(key) });
// eslint-disable-next-line no-continue
continue;
}
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
// eslint-disable-next-line no-await-in-loop
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
const found = {
deliverylatitude: place?.geometry?.location?.lat?.(),
@@ -147,7 +147,7 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
// Only wait after a real request. A cache hit or a sheet coordinate costs
// nothing, which is what makes a re-run fast.
// eslint-disable-next-line no-await-in-loop
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
}

View File

@@ -128,15 +128,15 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (String(row.finalprice ?? '') !== '') {
out.push(row);
// eslint-disable-next-line no-continue
continue;
}
if (!match) {
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
// eslint-disable-next-line no-continue
continue;
}
// eslint-disable-next-line no-await-in-loop
const km = await calculateDrivingDistance(
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
@@ -144,7 +144,7 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (km == null) {
out.push({ ...row, priceError: 'could not measure the distance' });
// eslint-disable-next-line no-continue
continue;
}
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
@@ -209,7 +209,7 @@ export const executeCreateBulk = async (rows, shared) => {
const batch = batches[b];
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
try {
// eslint-disable-next-line no-await-in-loop
const res = await createExpressBookingBulk(batch);
// Three shapes, most-nested first. The live endpoint returns
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
@@ -236,7 +236,7 @@ export const executeCreateBulk = async (rows, shared) => {
status: 'error',
errorMessage: res.message || 'Rejected'
});
// eslint-disable-next-line no-continue
continue;
}

View File

@@ -103,7 +103,7 @@ export const advance = (flow) => {
const already = s.field === 'name' ? draft.firstname : draft[s.field];
if (already) {
step += 1;
// eslint-disable-next-line no-continue
continue;
}
return { flow: { ...flow, step }, ask: s.ask, done: false };

View File

@@ -37,18 +37,18 @@ export const advanceFlow = async (steps, flow) => {
const s = steps[step];
if (!applicable(s, draft) || draft[s.id] !== undefined) {
step += 1;
// eslint-disable-next-line no-continue
continue;
}
if (s.auto) {
// eslint-disable-next-line no-await-in-loop
const auto = await s.auto(draft);
if (auto?.patch) draft = { ...draft, ...auto.patch };
if (auto?.value !== undefined) {
draft = s.apply(draft, auto.value);
step += 1;
// eslint-disable-next-line no-continue
continue;
}
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };

View File

@@ -386,7 +386,7 @@ const fetchBookingsInRange = async (start, end) => {
let lastPageFetched = 1;
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
// eslint-disable-next-line no-await-in-loop
const next = await getBookingsPageCached(page);
lastPageFetched = page;
if (!next.rows.length) {
@@ -2126,7 +2126,7 @@ const matchAndRun = async (text) => {
for (const intent of INTENTS) {
const params = intent.match(text);
if (!params) continue;
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params };
}
@@ -2194,7 +2194,7 @@ const answerMultiPart = async (text) => {
if (segments.length < 2) return null;
const results = [];
for (const segment of segments) {
// eslint-disable-next-line no-await-in-loop
const r = await matchAndRun(segment);
if (r) results.push(r);
}
@@ -2243,7 +2243,7 @@ export async function answerQuestion(text, context = {}) {
// WHAT. Embeddings are good at the former and unreliable at the latter.
const params = intent?.match(normalized);
if (intent && params) {
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params, routing: routed };
}

View File

@@ -220,7 +220,7 @@ export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
if (shouldStop?.()) break;
const row = candidates[i];
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
// eslint-disable-next-line no-await-in-loop
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
priced.push(out);
}

View File

@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { useEffect, useRef, useState } from 'react';
import { Clock, Compass, Crosshair, Loader2, MapPin, Navigation, X } from 'lucide-react';
import {
cityNamedIn,
extractLandmark,
getAddressSuggestions,
geocodeAddress as serviceGeocodeAddress,
@@ -38,9 +39,17 @@ const AddressAutocomplete = ({
value,
onChange,
onPlaceSelected,
// Fired when the text stops describing the place that was picked — cleared,
// or rewritten to another area. The page should drop that place's pin: it
// used to stay, so the form saved one address with another address's pin.
onPlaceCleared,
onOpenMapPin,
onLandmarkExtracted,
bias,
// A city scope from buildCityScope(): suggestions only from that city. The
// operator can widen one search to all cities from the list's footer, for
// an intercity drop.
scope,
fullWidth = true,
disabled = false,
className = '',
@@ -54,38 +63,124 @@ const AddressAutocomplete = ({
const [isOpen, setIsOpen] = useState(false);
const [activeOption, setActiveOption] = useState(-1);
const [isRecentMode, setIsRecentMode] = useState(false);
// A finished search that found nothing. The list used to just close, which
// reads as "the box is broken" rather than "try a pincode or the map".
const [noResults, setNoResults] = useState(false);
const wrapRef = useRef(null);
const activeRef = useRef(0);
const skipNextSearchRef = useRef(false);
// True only while the text is the operator's typing. Text the page sets (a
// hub location filling the pickup, a chosen customer's address) or a picked
// suggestion is not searched, not even when the hub's city scope changes
// underneath it: that re-search opened an unasked-for list of
// "124 Gandhipuram Main Rd" matches on every Create Order load.
const typedRef = useRef(false);
// The in-flight search, aborted when a newer one starts. Ignoring stale
// answers was not enough: the requests still ran against the free services'
// allowance and slowed the one that mattered.
const abortRef = useRef(null);
// Name of the picked place. Typing that keeps it ("12/A, " + it) keeps the
// pin; text that no longer contains it means the pin no longer applies.
const pickedNameRef = useRef('');
const editText = (text) => {
typedRef.current = true;
setInputValue(text);
onChange?.(text);
const picked = pickedNameRef.current;
if (picked && !text.toLowerCase().includes(picked)) {
pickedNameRef.current = '';
onPlaceCleared?.();
}
};
useEffect(() => () => abortRef.current?.abort(), []);
const listId = `${id || 'address'}-suggestions`;
const optionId = (index) => `${listId}-option-${index}`;
useEffect(() => {
if (value !== undefined && value !== inputValue) {
typedRef.current = false;
setInputValue(value || '');
}
}, [value]);
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''},${bias?.city ?? ''}`;
// "Search all cities" for this field. Reset when the scope itself changes —
// choosing another hub location means searching that city again.
const [scopeOff, setScopeOff] = useState(false);
const scopeCity = scope?.city || '';
useEffect(() => setScopeOff(false), [scopeCity]);
const activeScope = scope && !scopeOff ? scope : undefined;
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''},${bias?.city ?? ''}|${activeScope ? activeScope.city : 'all'}`;
// A city named at the end of the text ("gandhi nagar coimbatore") is searched
// in that city whatever the hub; the footer says so rather than naming the hub.
const typedCity = cityNamedIn(inputValue);
const scopeFooter = scope ? (
<div className="px-3 py-1.5 bg-slate-50 border-t border-slate-100 flex items-center justify-between gap-2 text-[11px] text-slate-600">
<span className="flex items-center gap-1.5 min-w-0">
<MapPin className="w-3 h-3 text-slate-500 shrink-0" />
<span className="truncate">
{typedCity && typedCity !== scope.city
? `Showing ${typedCity}, from your text`
: activeScope
? `Showing ${scope.city || 'this hub’s city'} only`
: 'Showing all cities'}
</span>
</span>
<button
type="button"
// mousedown, so the input keeps focus and the list stays open.
onMouseDown={(e) => {
e.preventDefault();
setScopeOff((off) => !off);
}}
className="font-semibold text-blue-600 hover:underline cursor-pointer shrink-0"
>
{activeScope ? 'Search all cities' : `Only ${scope.city || 'this city'}`}
</button>
</div>
) : null;
const fetchPredictions = useDebouncedCallback((query) => {
const seq = ++activeRef.current;
getAddressSuggestions(query, { bias, limit: 8 })
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
getAddressSuggestions(query, {
bias,
scope: activeScope,
limit: 12,
signal: controller.signal,
// Recents, Nominatim and India Post answer in ~1-2s; Photon can take 8s
// when throttled. Show the fast ones now; the spinner stays until the rest.
onPartial: (partial) => {
if (seq !== activeRef.current) return;
setOptions(partial);
setIsRecentMode(false);
setNoResults(false);
setIsOpen(true);
}
})
.then((results) => {
if (seq !== activeRef.current) return;
setLoading(false);
setOptions(results || []);
setIsRecentMode(false);
setIsOpen((results || []).length > 0);
setNoResults(!(results || []).length);
setIsOpen(true);
})
.catch(() => {
if (seq !== activeRef.current) return;
setLoading(false);
setOptions([]);
setIsOpen(false);
setNoResults(true);
setIsOpen(true);
});
}, 200);
// 350ms, not 200: every pause fires a Nominatim request, and Nominatim
// allows one a second before it starts refusing.
}, 350);
// Handle Focus: Show Recent Addresses if field is empty
const handleFocus = () => {
@@ -102,12 +197,11 @@ const AddressAutocomplete = ({
// Trigger search when input changes
useEffect(() => {
if (skipNextSearchRef.current) {
skipNextSearchRef.current = false;
return;
}
if (!typedRef.current) return;
setNoResults(false);
if (!inputValue || inputValue.trim().length < 2) {
activeRef.current++;
abortRef.current?.abort();
const recents = getRecentAddresses(5);
if (recents.length > 0 && (!inputValue || inputValue.length === 0)) {
setOptions(recents);
@@ -120,6 +214,12 @@ const AddressAutocomplete = ({
return;
}
// Drop the previous search's list. It stayed on screen, clickable, while
// the new search ran: typing a Coimbatore address after a Bengaluru one
// showed Bengaluru results under "Showing Coimbatore only".
setOptions([]);
setIsRecentMode(false);
setIsOpen(true);
setLoading(true);
fetchPredictions(inputValue);
}, [inputValue, biasKey]);
@@ -140,8 +240,10 @@ const AddressAutocomplete = ({
const pick = (option) => {
const formatted = option.formatted_address || option.name || '';
skipNextSearchRef.current = true;
typedRef.current = false;
activeRef.current++;
abortRef.current?.abort();
pickedNameRef.current = String(option.name || formatted.split(',')[0] || '').trim().toLowerCase();
setInputValue(formatted);
saveRecentAddress(option);
onChange?.(formatted);
@@ -204,9 +306,11 @@ const AddressAutocomplete = ({
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1));
} else if (e.key === 'Enter' && activeOption >= 0) {
} else if (e.key === 'Enter') {
// Enter with nothing highlighted takes the top suggestion, so the field
// can be filled without leaving the keyboard.
e.preventDefault();
pick(options[activeOption]);
pick(options[activeOption >= 0 ? activeOption : 0]);
}
};
@@ -261,10 +365,7 @@ const AddressAutocomplete = ({
aria-autocomplete="list"
aria-activedescendant={activeOption >= 0 ? optionId(activeOption) : undefined}
onFocus={handleFocus}
onChange={(e) => {
setInputValue(e.target.value);
onChange?.(e.target.value);
}}
onChange={(e) => editText(e.target.value)}
onKeyDown={(e) => {
onKeyDown(e);
if (e.key === 'Escape') setIsOpen(false);
@@ -282,8 +383,11 @@ const AddressAutocomplete = ({
<button
type="button"
onClick={() => {
setInputValue('');
onChange?.('');
// Cleared text is always a cleared place, picked here or filled
// in by the page (a chosen customer's saved address).
const hadPick = Boolean(pickedNameRef.current);
editText('');
if (!hadPick) onPlaceCleared?.();
setOptions([]);
setIsOpen(false);
}}
@@ -307,6 +411,59 @@ const AddressAutocomplete = ({
</div>
</div>
{isOpen && loading && options.length === 0 && (
<div
role="status"
className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl px-3.5 py-2.5 text-[11px] text-slate-500 flex items-center gap-2"
>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Searching{activeScope?.city ? ` in ${activeScope.city}` : ''}…</span>
</div>
)}
{isOpen && noResults && options.length === 0 && !loading && (
<div
id={listId}
role="status"
className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl px-3.5 py-3 text-[11px] text-slate-600"
>
<div className="font-semibold text-slate-800">
{activeScope ? `No matching address in ${activeScope.city || 'this city'}` : 'No matching address found'}
</div>
{activeScope && (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
setScopeOff(true);
}}
className="mt-1 font-semibold text-blue-600 hover:underline cursor-pointer"
>
Search all cities
</button>
)}
<div className="mt-0.5">
Try just the area name or the 6-digit pincode
{onOpenMapPin ? (
<>
, or{' '}
<button
type="button"
onClick={() => {
setIsOpen(false);
onOpenMapPin();
}}
className="font-semibold text-blue-600 hover:underline cursor-pointer"
>
drop a pin on the map
</button>
</>
) : null}
.
</div>
</div>
)}
{isOpen && options.length > 0 && (
<div
id={listId}
@@ -327,7 +484,8 @@ const AddressAutocomplete = ({
<ul className="max-h-72 overflow-y-auto divide-y divide-slate-100">
{options.map((option, index) => {
const isHighlight = index === activeOption;
const mainTitle = option.name || option.formatted_address.split(',')[0];
const mainTitle = option.name || (option.formatted_address || '').split(',')[0];
const areaOnly = option.provider === 'indiapost';
return (
<li
@@ -353,6 +511,12 @@ const AddressAutocomplete = ({
{mainTitle}
</span>
{option.provider === 'recent' && !isRecentMode && (
<span className="text-[10px] font-semibold px-1.5 py-0.2 rounded bg-sky-50 text-sky-700 border border-sky-100 shrink-0">
Recent
</span>
)}
{option.suburb && option.suburb !== mainTitle && (
<span className="text-[10px] font-medium px-1.5 py-0.2 rounded bg-violet-50 text-violet-700 border border-violet-100 shrink-0">
{option.suburb}
@@ -374,12 +538,18 @@ const AddressAutocomplete = ({
<div className="text-[11px] text-slate-500 line-clamp-1 mt-0.5">
{option.formatted_address}
</div>
{areaOnly && (
<div className="text-[10px] text-amber-700 mt-0.5">
Pincode area. Fills city and state; pin the exact spot on the map.
</div>
)}
</div>
</button>
</li>
);
})}
</ul>
{!isRecentMode && scopeFooter}
</div>
)}
</div>
@@ -393,9 +563,11 @@ AddressAutocomplete.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
onPlaceSelected: PropTypes.func,
onPlaceCleared: PropTypes.func,
onOpenMapPin: PropTypes.func,
onLandmarkExtracted: PropTypes.func,
bias: PropTypes.object,
scope: PropTypes.object,
fullWidth: PropTypes.bool,
disabled: PropTypes.bool,
className: PropTypes.string,

View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Sparkles, SlidersHorizontal, RefreshCw } from 'lucide-react';
import SlaRemediationCard from './SlaRemediationCard';
import { scanBookings } from '@/lib/assistant/scan';
import { toAgentRows } from '@/lib/assistant/agent/normalise';
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
import { reportScan, reportActed } from '@/lib/assistant/agent/findingReport';
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
import { wallClockNow } from '@/lib/assistant/agent/signals';
import { executeProposal, canExecuteProposal } from '@/lib/assistant/agent/actions';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/lib/AuthContext';
import { canOnboardClients } from '@/lib/clientOnboarding';
/**
* AgentOperationsBanner
*
* Early warnings from the console's monitoring skills. It evaluates the live
* booking scan against every skill ENABLED IN THE AGENT REGISTRY, with the
* registry's thresholds (Settings → Skills & Tools). Nothing here acts on its
* own: a finding's action runs only when an operator clicks it, and actions
* with no working endpoint render "Review only".
*
* Ported from feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3).
* The branch opened a localStorage-only settings modal from here; that modal
* was not ported — "Configure skills" goes to the registry page instead.
*
* Props:
* - category: string | string[] (e.g. 'sla_management', 'rider_safety', 'fleet_optimization', 'loss_prevention')
* - skillIds: string[] (e.g. ['skill_sla_guardian', 'skill_fleet_balancer'])
* - title: string (Custom banner title)
* - maxItems: number (Default 4)
* - hideWhenClean: boolean (If true, renders nothing when all clear)
*/
export default function AgentOperationsBanner({
category,
skillIds,
title = 'Early warnings',
maxItems = 4,
hideWhenClean = false,
className = ''
}) {
const navigate = useNavigate();
// The Skills & Tools tab is the owner login's only; nobody else gets a
// button into it.
const { user, isClient } = useAuth();
const canConfigure = canOnboardClients(user, isClient);
// Which settings the rules are running on — the registry's, or the code
// defaults while it is unreachable. Said on screen, so a tuned threshold is
// never assumed when it is not in force.
const [settingsSource, setSettingsSource] = useState(() => SkillRegistry.getSource());
const {
data: allFindings = [],
isLoading,
refetch,
isFetching
} = useQuery({
queryKey: ['doormile', 'agent-operations-briefing'],
queryFn: async () => {
const scan = await scanBookings();
const agentRows = toAgentRows(scan?.rows);
const agent = AgentFactory.synthesizeDefaultAgent();
const findings = agent.evaluateTelemetry(agentRows, wallClockNow());
// Reported AFTER evaluating and deliberately NOT awaited: persistence is
// observability and must never sit in front of what the operator sees.
// reportScan never throws.
reportScan(findings);
return findings;
},
staleTime: 30_000,
refetchInterval: 60_000,
});
// Carry out a proposal, then re-evaluate.
//
// The refetch is the point, not politeness: an agent that fires an action and
// never looks again is a button. Re-running the scan is what turns this into
// a loop — the finding either disappears, proving the action landed, or it
// survives and stays on the board.
//
// Errors are re-thrown so SlaRemediationCard shows its failure state; a
// partial success (six riders reached out of eight) resolves ok and the
// message names the ones that were missed.
const handleRemediate = useCallback(
async (finding) => {
const result = await executeProposal(finding);
// Recorded before the throw: a failed action is exactly the outcome worth
// keeping, and recording only successes would make every skill look
// perfect.
reportActed(finding, result);
if (!result?.ok) {
throw new Error(result?.message || 'The action did not complete.');
}
await refetch();
return result;
},
[refetch]
);
// Re-evaluate when the registry's settings arrive or change (a save in
// Settings → Skills & Tools refetches the registry, which lands here).
useEffect(() => {
const unsubscribe = SkillRegistry.subscribe(() => {
setSettingsSource(SkillRegistry.getSource());
refetch();
});
return unsubscribe;
}, [refetch]);
// Contextual page-level filtering
const findings = useMemo(() => {
if (!category && !skillIds) {
return allFindings;
}
const targetCategories = category
? Array.isArray(category)
? category
: [category]
: null;
const targetSkills = skillIds ? new Set(skillIds) : null;
return allFindings.filter((finding) => {
const skill = SkillRegistry.getSkill(finding.skillId);
if (targetSkills && finding.skillId && !targetSkills.has(finding.skillId)) {
return false;
}
if (targetCategories && skill && !targetCategories.includes(skill.category)) {
return false;
}
return true;
});
}, [allFindings, category, skillIds]);
if (isLoading) {
return null;
}
const hasFindings = findings.length > 0;
if (hideWhenClean && !hasFindings) {
return null;
}
return (
<>
<div className={`space-y-3 p-4 rounded-2xl bg-surface-2 border border-line ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-mono font-bold uppercase tracking-wider text-accent-ink">
<Sparkles className="h-4 w-4" />
<span>{title}</span>
<span className="text-ink-4">({findings.length} detected)</span>
</div>
<div className="flex items-center gap-2">
{canConfigure && <Button
variant="outline"
size="sm"
onClick={() => navigate('/doormile/settings?tab=skills')}
className="h-7 px-2.5 text-xs text-ink-2 hover:text-ink flex items-center gap-1.5 border-line shadow-none"
title="Settings → Skills & Tools: enable skills and tune thresholds"
>
<SlidersHorizontal className="h-3.5 w-3.5 text-accent-ink" />
<span>Configure skills</span>
</Button>}
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="h-7 px-2 text-xs text-ink-3 hover:text-ink flex items-center gap-1.5"
title="Re-run telemetry diagnosis"
>
<RefreshCw className={`h-3.5 w-3.5 ${isFetching ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</Button>
</div>
</div>
{settingsSource !== 'registry' && (
<p role="note" className="text-[11px] text-ink-3">
Running on default thresholds — the skill settings from Settings → Skills &amp; Tools could not be loaded.
</p>
)}
{hasFindings ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{findings.slice(0, maxItems).map((finding, idx) => (
<SlaRemediationCard
key={finding.id || idx}
finding={finding}
// Only proposals with a real executor become clickable. The rest
// render disabled as "Review only" rather than implying an action.
onRemediate={canExecuteProposal(finding) ? handleRemediate : undefined}
/>
))}
</div>
) : (
<div className="text-xs text-ink-3 py-1 flex items-center gap-1.5 font-mono">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Nothing flagged by the {SkillRegistry.getActiveSkills().length} enabled skills.
</div>
)}
</div>
</>
);
}

View File

@@ -81,7 +81,7 @@ export function BatchEfficiency({ batch, orders = [], tenantId }) {
) : entries.length === 0 ? (
/* A response that is not a flat object — an array, a scalar, an error
envelope. Shown raw rather than dropped. */
<pre className="overflow-x-auto rounded-lg bg-surface-sunken p-3 text-caption text-ink-2">
<pre className="no-scrollbar overflow-x-auto rounded-lg bg-surface-sunken p-3 text-caption text-ink-2">
{JSON.stringify(result, null, 2)}
</pre>
) : (

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { useConsignmentHistory } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
/**
* ConsignmentTimeline — everything recorded about one parcel, oldest first:
* pickup, base hand-overs, failed attempts, a return starting, being
* re-attempted, or completed (reverse logistics plan, C6).
*
* Reads GET /admin/consignments/:id/history, which a client login may call for
* its own parcels only. The return actions invalidate this query, so the
* timeline updates as soon as ops act.
*/
/** Plain words for each status a history row can carry. */
export const EVENT_LABELS = {
Created: 'Parcel created',
Collected_By_Miler: 'Collected by the rider',
Inwarded_at_Hub: 'Received at the base',
Tripsheet_Loaded: 'Loaded for transfer',
In_Transit: 'In transit between bases',
Out_for_Delivery: 'Out for delivery',
Delivered: 'Delivered',
Delivery_Skipped: 'Delivery attempt failed',
RTO_Initiated: 'Return to sender started',
Returned_to_Sender: 'Returned to sender',
Missing: 'Marked missing',
Damaged: 'Marked damaged',
Cancelled: 'Cancelled',
};
const TONE = {
Delivered: 'bg-success',
Delivery_Skipped: 'bg-warning',
RTO_Initiated: 'bg-warning',
Returned_to_Sender: 'bg-brand',
Missing: 'bg-destructive',
Damaged: 'bg-destructive',
Cancelled: 'bg-ink-4',
};
export const eventLabel = (status) =>
EVENT_LABELS[status] || String(status || 'Update').replace(/_/g, ' ');
/** The secondary line: who or where, then any note. */
export function eventDetail(event) {
const parts = [];
if (event?.actorname) parts.push(`by ${event.actorname}`);
if (event?.hubname) parts.push(`at ${event.hubname}`);
// A re-attempt is recorded as the status the parcel went back to; say so.
if (event?.fromstatus && event.eventstatus === 'RTO_Initiated') {
parts.push(`was ${eventLabel(event.fromstatus).toLowerCase()}`);
}
return parts.join(' · ');
}
export default function ConsignmentTimeline({ consignmentId }) {
const { data, isLoading, isError } = useConsignmentHistory(consignmentId);
const events = Array.isArray(data?.data) ? data.data : [];
if (!consignmentId) {
return <p className="text-body-sm text-ink-3">No parcel yet — the timeline starts once the order is picked up.</p>;
}
if (isLoading) return <p className="text-body-sm text-ink-3">Loading timeline…</p>;
if (isError) return <p className="text-body-sm text-ink-3">The timeline could not be loaded.</p>;
if (!events.length) return <p className="text-body-sm text-ink-3">Nothing has been recorded for this parcel yet.</p>;
return (
<ol className="relative space-y-3 border-l border-line pl-4" aria-label="Parcel timeline">
{events.map((event) => {
const detail = eventDetail(event);
return (
<li key={event.historyid} className="relative">
<span
aria-hidden="true"
className={`absolute -left-[21px] top-1.5 h-2.5 w-2.5 rounded-full ring-2 ring-surface ${TONE[event.eventstatus] || 'bg-ink-3'}`}
/>
<p className="text-body-sm font-medium text-ink-1">{eventLabel(event.eventstatus)}</p>
<p className="text-caption text-ink-3">
{formatDoormileTimestamp(event.createdat)}
{detail ? ` · ${detail}` : ''}
</p>
{event.remarks ? <p className="text-caption text-ink-2">{event.remarks}</p> : null}
</li>
);
})}
</ol>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { Building2, Check, ChevronDown, MapPin, Search, Sparkles } from 'lucide-react';
import { Building2, Check, ChevronDown, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
DropdownMenu,

View File

@@ -89,11 +89,15 @@ export default function MapPinPickerModal({
initialLat,
initialLng,
initialAddress = '',
title = 'Pin Exact Location on Map'
title = 'Pin Exact Location on Map',
// Where to open when there is no pin yet: the hub's city. It always opened
// on Bengaluru, so a Coimbatore operator started every pin 350 km away.
defaultCenter
}) {
// Default coordinates (Bengaluru / Central fallback if none provided)
const defaultLat = Number(initialLat) || 12.9716;
const defaultLng = Number(initialLng) || 77.5946;
const fallbackLat = Number(defaultCenter?.lat) || 12.9716;
const fallbackLng = Number(defaultCenter?.lng) || 77.5946;
const defaultLat = Number(initialLat) || fallbackLat;
const defaultLng = Number(initialLng) || fallbackLng;
const [coords, setCoords] = useState({ lat: defaultLat, lng: defaultLng });
const [mapCenter, setMapCenter] = useState([defaultLat, defaultLng]);
@@ -109,8 +113,8 @@ export default function MapPinPickerModal({
useEffect(() => {
if (isOpen) {
const lat = Number(initialLat) || 12.9716;
const lng = Number(initialLng) || 77.5946;
const lat = Number(initialLat) || fallbackLat;
const lng = Number(initialLng) || fallbackLng;
setCoords({ lat, lng });
setMapCenter([lat, lng]);
setZoomLevel(initialLat && initialLng ? 16 : 13);
@@ -346,5 +350,6 @@ MapPinPickerModal.propTypes = {
initialLat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
initialLng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
initialAddress: PropTypes.string,
defaultCenter: PropTypes.shape({ lat: PropTypes.number, lng: PropTypes.number }),
title: PropTypes.string
};

View File

@@ -0,0 +1,196 @@
import React, { useEffect, useState } from 'react';
import { PackageCheck, RotateCcw, Undo2 } from 'lucide-react';
import { Alert, Button, Field, Modal, Textarea } from '@/components/ds';
import { inputVariants } from '@/components/ui/input';
import { RTO_REASONS } from '@/api/doormile/endpoints';
import { useCancelRto, useCompleteRto, useInitiateRto } from '@/lib/doormileHooks';
/**
* Reverse logistics (RTO) dialogs, shared by Deliveries, Returns and
* Exceptions. Plan: docs/reverse-logistics-plan.md.
*
* A return can start while the parcel is in a rider's hands or at a base; the
* backend refuses anything else (delivered, cancelled, already returning) and
* its message is shown in the dialog. Every action needs the CONSIGNMENT id —
* a booking that has not been picked up has no parcel to return yet.
*/
/** Delivery-list statuses (queries.js) a return can be started from. */
export const RTO_STARTABLE = ['picked', 'active', 'skipped'];
/** What a row may do, from its delivery-list status. */
export function rtoActionsFor(row) {
const status = String(row?.orderstatus || '').toLowerCase();
const hasParcel = row?.consignmentid != null && row?.consignmentid !== '';
return {
canStart: hasParcel && RTO_STARTABLE.includes(status),
canResolve: hasParcel && status === 'rto',
};
}
const errorText = (err, fallback) => err?.response?.data?.message || err?.message || fallback;
/** The dialog subtitle: a caller's own `label`, else "Order <no>". */
const subtitleFor = (row) =>
row ? row.label || `Order ${row.orderid ?? row.trackingno ?? row.consignmentid}` : undefined;
/** Start a return to sender. `row` needs `consignmentid` and an order label. */
export function StartRtoModal({ row, onClose }) {
const start = useInitiateRto();
const [reason, setReason] = useState('');
const [note, setNote] = useState('');
const [error, setError] = useState('');
// `row.defaultReason` pre-selects a reason (e.g. from an exception's type).
useEffect(() => {
setReason(row?.defaultReason || '');
setNote('');
setError('');
}, [row]);
const noteRequired = reason === 'other';
const canSubmit = Boolean(reason) && (!noteRequired || note.trim().length > 0);
const submit = () => {
if (!canSubmit) return;
setError('');
start.mutate(
{ consignmentId: row.consignmentid, reason, note: note.trim() },
{
onSuccess: (res) => {
if (res?.success !== false) onClose();
else setError(res?.message || 'The return was not started.');
},
onError: (err) => setError(errorText(err, 'The return was not started.')),
}
);
};
return (
<Modal
open={Boolean(row)}
onOpenChange={(open) => !open && onClose()}
title="Return to sender"
description={subtitleFor(row)}
icon={Undo2}
busy={start.isPending}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={start.isPending}>
Keep delivering
</Button>
<Button variant="destructive" onClick={submit} loading={start.isPending} disabled={!canSubmit}>
Start return
</Button>
</>
}
>
{error && (
<Alert tone="destructive" className="mb-3" role="alert">
{error}
</Alert>
)}
<p className="mb-3 text-body-sm text-ink-2">
The parcel goes back to the sender&apos;s pickup point instead of being delivered. The rider is notified.
</p>
<Field label="Reason" required>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
aria-label="Return reason"
className={inputVariants()}
>
<option value="">Choose a reason</option>
{RTO_REASONS.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</Field>
<Field label={noteRequired ? 'Describe the reason' : 'Note (optional)'} required={noteRequired} className="mt-3">
<Textarea rows={3} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} aria-label="Return note" />
</Field>
</Modal>
);
}
const RESOLVE_COPY = {
cancel: {
title: 'Re-attempt delivery',
icon: RotateCcw,
body: 'The return is cancelled and the parcel goes back to delivery, where it was before the return started.',
confirm: 'Re-attempt delivery',
variant: 'default',
fail: 'The return was not cancelled.',
},
complete: {
title: 'Mark returned to sender',
icon: PackageCheck,
body: 'Confirms the parcel is back with the sender. This closes the return and the rider’s stop, and cannot be undone.',
confirm: 'Mark returned',
variant: 'destructive',
fail: 'The parcel was not marked returned.',
},
};
/** Close a return: `mode` is 'cancel' (re-attempt) or 'complete' (returned). */
export function ResolveRtoModal({ row, mode, onClose }) {
const cancel = useCancelRto();
const complete = useCompleteRto();
const mutation = mode === 'complete' ? complete : cancel;
const copy = RESOLVE_COPY[mode] || RESOLVE_COPY.cancel;
const [note, setNote] = useState('');
const [error, setError] = useState('');
useEffect(() => {
setNote('');
setError('');
}, [row, mode]);
const submit = () => {
setError('');
mutation.mutate(
{ consignmentId: row.consignmentid, note: note.trim() },
{
onSuccess: (res) => {
if (res?.success !== false) onClose();
else setError(res?.message || copy.fail);
},
onError: (err) => setError(errorText(err, copy.fail)),
}
);
};
return (
<Modal
open={Boolean(row)}
onOpenChange={(open) => !open && onClose()}
title={copy.title}
description={subtitleFor(row)}
icon={copy.icon}
busy={mutation.isPending}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={mutation.isPending}>
Back
</Button>
<Button variant={copy.variant} onClick={submit} loading={mutation.isPending}>
{copy.confirm}
</Button>
</>
}
>
{error && (
<Alert tone="destructive" className="mb-3" role="alert">
{error}
</Alert>
)}
<p className="mb-3 text-body-sm text-ink-2">{copy.body}</p>
{row?.returnreason && <p className="mb-3 text-caption text-ink-3">Return reason: {row.returnreason}</p>}
<Field label="Note (optional)">
<Textarea rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} aria-label="Resolution note" />
</Field>
</Modal>
);
}

View File

@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import {
Sparkles,
ShieldAlert,
CheckCircle2,
RefreshCw,
Zap
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { OpenToast } from '@/api/doormile/notify';
/**
* Proactive SLA Risk & Self-Healing Remediation Card.
* Detects aging, stalled, or at-risk parcels and presents a 1-click mitigation plan.
*/
export default function SlaRemediationCard({ finding, onRemediate }) {
const [remediated, setRemediated] = useState(false);
const [loading, setLoading] = useState(false);
if (!finding) return null;
const { title, why, severity = 'warning', count = 1, proposal } = finding;
// A proposal is only actionable when a caller supplies an executor. Without
// one there is no write path — the agent's mutating tools deliberately return
// proposals rather than performing the write themselves.
const canExecute = typeof onRemediate === 'function';
const handleApply = async () => {
// Guarded rather than merely disabled: a click that cannot act must never
// reach the success path.
//
// This previously fell through to `setTimeout(600)` and then reported
// "Remediation executed" with a green card — and no caller has ever passed
// onRemediate, so EVERY click took that branch. An operator was told a
// stalled rider had been messaged when nothing had been sent. A button that
// lies about acting is worse than no button, exactly as a wrong number is
// worse than no answer (assistant/CLAUDE.md §3).
if (!canExecute) return;
setLoading(true);
try {
const result = await onRemediate(finding);
setRemediated(true);
// OpenToast is (message, type). The branch called it ('success', msg), so
// every toast would have read just "success". The executor's own message
// is shown because it counts partial success ("Notified 6 … could not
// reach 2: …"), which a fixed string would hide.
OpenToast(result?.message || `Done: ${proposal.label}`, 'success');
} catch (err) {
OpenToast(err?.message || 'The action did not complete', 'error');
} finally {
setLoading(false);
}
};
const isCritical = severity === 'critical';
return (
<div
className={`rounded-xl border p-4 transition-all duration-200 ${
remediated
? 'bg-emerald-50/50 border-emerald-200'
: isCritical
? 'bg-rose-50/40 border-rose-200 hover:border-rose-300'
: 'bg-amber-50/40 border-amber-200 hover:border-amber-300'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div
className={`p-2 rounded-lg ${
remediated
? 'bg-emerald-100 text-emerald-700'
: isCritical
? 'bg-rose-100 text-rose-700'
: 'bg-amber-100 text-amber-700'
}`}
>
{remediated ? <CheckCircle2 className="h-4 w-4" /> : <ShieldAlert className="h-4 w-4" />}
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider font-mono text-ink">
{/* Not "Critical SLA Risk" / "Autonomous Finding": findings come
from cash and fleet rules too, and nothing here acts on its own. */}
{remediated ? 'Action sent' : isCritical ? 'Needs attention now' : 'Worth a look'}
</span>
<Badge variant="outline" className="text-[10px] h-4 py-0 font-mono">
{count} {count === 1 ? 'Order' : 'Orders'}
</Badge>
</div>
<h4 className="text-sm font-semibold text-ink mt-0.5">{title}</h4>
</div>
</div>
{!remediated && proposal && (
<Button
size="sm"
className={`text-xs gap-1.5 font-medium shadow-sm ${
isCritical
? 'bg-rose-600 hover:bg-rose-700 text-white'
: 'bg-amber-600 hover:bg-amber-700 text-white'
}`}
onClick={handleApply}
disabled={loading || !canExecute}
title={
canExecute
? proposal.blastRadius
: 'Review only — no execution path is wired for this action yet'
}
>
{loading ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<Zap className="h-3.5 w-3.5 fill-current" />
)}
{canExecute ? proposal.label : 'Review only'}
</Button>
)}
</div>
<p className="text-xs text-ink-2 mt-2 leading-relaxed">{why}</p>
{proposal && !remediated && (
<div className="mt-3 pt-2.5 border-t border-line/60 flex items-center justify-between text-xs text-ink-3">
<span className="flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5 text-accent-ink" />
Proposed Action: <strong className="text-ink">{proposal.label}</strong>
</span>
<span className="text-[11px] text-ink-4">{proposal.blastRadius}</span>
</div>
)}
</div>
);
}

View File

@@ -1,405 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ChevronDown, Send, Sparkles, Trash2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, IconButton, Spinner, StatusBadge, Surface } from '@/components/ds';
import { answerQuestion } from '@/lib/assistant/intents';
import { advanceFlow, detectFlow, executeFlow, startFlow } from '@/lib/assistant/flows';
import { getPageContext } from '@/lib/assistant/pageContext';
import { messageOf } from '@/api/doormile/notify';
/**
* Doormile AI — the operations copilot.
*
* A right-side slide-over rather than a page: it answers about live data while
* the operator stays on whatever screen raised the question.
*
* Two rules shape this file:
*
* - **Never put a React element in message state.** Messages are round-tripped
* through localStorage, and an element does not survive JSON — `$$typeof` is a
* Symbol and is dropped, so the rehydrated value crashes the next render.
* Everything in a message is plain data.
* - **Assistant turns are not bubbles.** Only the operator's own words get a
* bubble. That asymmetry is what keeps this reading as part of the console
* rather than a chatbot bolted onto it.
*/
const STORAGE_KEY = 'doormileAssistantThread';
const MAX_STORED = 40;
const loadThread = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const saveThread = (messages) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages.slice(-MAX_STORED)));
} catch {
/* A refused write (private mode, quota) costs the history, not the session. */
}
};
/** The endpoints an answer actually called — the verifiability contract. */
function SourceCalls({ calls }) {
const [open, setOpen] = useState(false);
if (!calls?.length) return null;
return (
<div className="mt-2">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="flex items-center gap-1 text-caption text-ink-4 transition-colors hover:text-ink-2"
>
<ChevronDown className={cn('h-3 w-3 transition-transform', open && 'rotate-180')} aria-hidden="true" />
{calls.length} source{calls.length === 1 ? '' : 's'}
</button>
{open && (
<div className="mt-1.5 space-y-1">
{calls.map((entry, index) => (
<div key={index} className="rounded-md bg-surface-sunken px-2 py-1.5">
<div className="flex items-center justify-between gap-2">
<code className="text-[11px] text-ink-2">{entry.target}</code>
<StatusBadge
status={entry.status === 'error' ? 'cancelled' : 'delivered'}
label={entry.status === 'error' ? 'partial' : 'ok'}
size="sm"
/>
</div>
{entry.stats && <p className="mt-0.5 text-[11px] text-ink-4">{entry.stats}</p>}
{entry.errorMessage && <p className="mt-0.5 text-[11px] text-warning">{entry.errorMessage}</p>}
</div>
))}
</div>
)}
</div>
);
}
function AssistantTurn({ message, onConfirm, confirming }) {
if (message.role === 'user') {
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-brand px-3.5 py-2 text-body-sm text-white">
{message.text}
</div>
</div>
);
}
return (
<div className="flex gap-2.5">
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-brand-tint text-brand">
<Sparkles className="h-3 w-3" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
{message.headline && <p className="text-body font-semibold text-ink-1">{message.headline}</p>}
{message.stats?.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{message.stats.map((stat) => (
<div key={stat.label} className="rounded-lg bg-surface-sunken px-2.5 py-1.5">
<p className="text-body-sm font-semibold tabular-nums text-ink-1">{stat.value}</p>
<p className="text-[11px] text-ink-3">{stat.label}</p>
</div>
))}
</div>
)}
{message.text && (
<p className="mt-1.5 whitespace-pre-line text-body-sm leading-relaxed text-ink-2">{message.text}</p>
)}
{/* An ordered trail — a parcel's scan history, most recent last. Plain
data like everything else in a message, so it survives the
localStorage round trip. */}
{message.list?.items?.length > 0 && (
<div className="mt-2.5">
<p className="text-overline uppercase text-ink-4">{message.list.title}</p>
<ol className="mt-1.5 space-y-1">
{message.list.items.map((item, index) => (
<li key={index} className="flex items-center justify-between gap-3 rounded-md bg-surface-sunken px-2.5 py-1.5">
<span className="truncate text-caption text-ink-2">{item.label}</span>
{item.meta && <span className="shrink-0 text-[11px] text-ink-4">{item.meta}</span>}
</li>
))}
</ol>
</div>
)}
{message.review && (
<Surface variant="outline" padding="sm" radius="lg" className="mt-2.5">
<p className="text-body-sm font-semibold text-ink-1">{message.review.title}</p>
<dl className="mt-2 space-y-1">
{message.review.lines.map(([label, value]) => (
<div key={label} className="flex justify-between gap-3">
<dt className="text-caption text-ink-3">{label}</dt>
<dd className="text-right text-caption font-medium text-ink-1">{value}</dd>
</div>
))}
</dl>
{message.review.preview?.length > 0 && (
<ul className="mt-2 space-y-0.5 border-t border-border pt-2">
{message.review.preview.map((line, index) => (
<li key={index} className="truncate text-[11px] text-ink-3">
{line}
</li>
))}
</ul>
)}
{message.done ? (
<p className="mt-2.5 text-caption text-success">Sent.</p>
) : (
<Button size="sm" className="mt-2.5 w-full" onClick={onConfirm} loading={confirming}>
Yes, create it
</Button>
)}
</Surface>
)}
<SourceCalls calls={message.sourceCalls} />
</div>
</div>
);
}
/** @param {any} props */
export function AssistantPanel({ open, onClose }) {
const location = useLocation();
const { label, suggestions } = useMemo(() => getPageContext(location.pathname), [location.pathname]);
const [messages, setMessages] = useState(loadThread);
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [flow, setFlow] = useState(null);
/* The previous turn, so a bare "what about yesterday?" can re-run it. */
const [context, setContext] = useState({});
const scrollRef = useRef(null);
const inputRef = useRef(null);
useEffect(() => saveThread(messages), [messages]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, busy]);
useEffect(() => {
if (!open) return undefined;
const onKey = (event) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
const push = useCallback((message) => setMessages((current) => [...current, message]), []);
const ask = useCallback(
async (rawText) => {
const text = String(rawText || '').trim();
if (!text || busy) return;
push({ role: 'user', text });
setInput('');
setBusy(true);
try {
/* A flow reply must never reach the router: the router matches text, and
a bare answer like a phone number matches no intent. */
if (flow) {
const { flow: next, review, error } = await advanceFlow(flow, text);
if (error) {
push({ role: 'assistant', text: error });
} else if (review) {
setFlow(null);
push({ role: 'assistant', headline: 'Ready to send', review });
} else {
setFlow(next);
push({ role: 'assistant', text: next.question });
}
return;
}
const kind = detectFlow(text);
if (kind) {
const started = startFlow(kind);
setFlow(started);
push({ role: 'assistant', headline: started.title, text: started.question });
return;
}
const result = await answerQuestion(text, context);
if (!result) {
push({
role: 'assistant',
text:
'I can’t answer that one yet. I cover orders, deliveries, riders, clients, customers, hubs, vehicles, tripsheets, exceptions and pricing — and I can create a customer or an order.\n\nFor anything about Doormile the company, doormile.com is where that lives.',
});
return;
}
setContext({ lastIntentId: result.intentId, lastParams: result.params });
push({
role: 'assistant',
headline: result.headline,
text: result.detail,
stats: result.stats,
list: result.list,
sourceCalls: result.sourceCalls,
});
} catch (err) {
push({ role: 'assistant', text: messageOf(err, 'Something went wrong reaching the API.') });
} finally {
setBusy(false);
}
},
[busy, flow, context, push]
);
const confirm = useCallback(
async (review, index) => {
setConfirming(true);
try {
const result = await executeFlow(review);
setMessages((current) =>
current.map((message, i) => (i === index ? { ...message, done: result.ok } : message))
);
push({ role: 'assistant', text: result.message });
} catch (err) {
push({ role: 'assistant', text: messageOf(err, 'The write failed — nothing was created.') });
} finally {
setConfirming(false);
}
},
[push]
);
const reset = () => {
setMessages([]);
setFlow(null);
setContext({});
};
if (!open) return null;
return (
<>
<div
className="fixed inset-0 z-50 bg-ink-1/20 backdrop-blur-[2px]"
onClick={onClose}
aria-hidden="true"
/>
<aside
role="dialog"
aria-label="Doormile AI"
className="fixed right-0 top-0 z-50 flex h-dvh w-full flex-col border-l border-border bg-surface shadow-xl sm:w-[26rem]"
>
<header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="flex items-center gap-2.5 min-w-0">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-brand-tint text-brand">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-body-sm font-semibold text-ink-1">Doormile AI</p>
<p className="truncate text-caption text-ink-3">Operations copilot · {label}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{messages.length > 0 && (
<IconButton label="Clear conversation" icon={Trash2} variant="ghost" size="sm" onClick={reset} />
)}
<IconButton label="Close" icon={X} variant="ghost" size="sm" onClick={onClose} />
</div>
</header>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
{messages.length === 0 ? (
<div className="space-y-3">
<p className="text-body-sm text-ink-2">
Ask about anything live in this console. Every answer comes from the same endpoints the pages call, and
shows which ones.
</p>
<div className="space-y-1.5">
{suggestions.slice(0, 6).map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => ask(suggestion)}
className="w-full rounded-lg border border-border bg-surface-subtle px-3 py-2 text-left text-body-sm
text-ink-2 transition-colors hover:border-brand/40 hover:bg-brand-tint/40"
>
{suggestion}
</button>
))}
</div>
</div>
) : (
messages.map((message, index) => (
<AssistantTurn
key={index}
message={message}
confirming={confirming}
onConfirm={() => confirm(message.review, index)}
/>
))
)}
{busy && (
<div className="flex items-center gap-2 text-caption text-ink-3">
<Spinner size="sm" /> Checking the live data…
</div>
)}
</div>
<form
onSubmit={(event) => {
event.preventDefault();
ask(input);
}}
className="border-t border-border p-3"
>
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
rows={1}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
/* Enter sends, Shift+Enter breaks the line — the convention an
operator typing quickly already expects. */
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
ask(input);
}
}}
placeholder={flow ? 'Type your answer…' : 'Ask about orders, riders, hubs…'}
className="max-h-32 min-h-[2.25rem] flex-1 resize-none rounded-lg border border-border bg-surface-subtle
px-3 py-2 text-body-sm text-ink-1 placeholder:text-ink-4 outline-none
focus:border-brand focus:bg-surface focus:ring-2 focus:ring-brand/15"
/>
<IconButton label="Send" icon={Send} type="submit" size="sm" disabled={!input.trim() || busy} />
</div>
</form>
</aside>
</>
);
}

View File

@@ -170,7 +170,7 @@ export function DataTable({
if (loading) {
return (
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
<Surface padding="none" radius="md" className={cn('overflow-hidden', className)}>
<SkeletonTable rows={initialPageSize && initialPageSize < 8 ? initialPageSize : 6} columns={columns.length} />
</Surface>
);
@@ -178,7 +178,7 @@ export function DataTable({
if (sortedRows.length === 0) {
return (
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
<Surface padding="none" radius="md" className={cn('overflow-hidden', className)}>
{emptyState || (
<EmptyState
icon={Inbox}
@@ -198,10 +198,24 @@ export function DataTable({
return (
<div className={cn('space-y-4', className)}>
<Surface padding="none" radius="xl" className="relative overflow-hidden">
{/* radius="md" (12px), down from "xl".
Surface's xl resolves to rounded-2xl — calc(var(--radius) + 8px) —
which with --radius at 0.75rem is a 20px corner. On a small chip that
reads as soft; on a full-width table it curves far enough in to cut
visibly across the first and last cells of the header row.
12px is `--radius` itself, and matches radiusCard in themes/dt/tokens
— the token meant for a large boxed region, which is what a table is.
A value picked off the scale rather than an arbitrary px, so it stays
in step if the base radius is ever retuned.
Set on all three branches (loading, empty, populated) so a table does
not change shape as its data arrives. */}
<Surface padding="none" radius="md" className="relative overflow-hidden">
<LoadingOverlay show={refreshing} />
<div className="overflow-x-auto">
<div className="no-scrollbar overflow-x-auto">
<table className="w-full border-collapse">
{caption && <caption className="sr-only">{caption}</caption>}

View File

@@ -24,8 +24,27 @@ export function DatePicker({
placeholder = 'Select date',
disabled = false,
align = 'end',
// Opt-in, so pages that pick ONE day (Orders, Deliveries) are unchanged.
// rangeSelect: the first calendar click sets the start and keeps the
// calendar open; the second sets the end and commits { from, to }. Clicking
// the end before the start is fine — the two are put in order.
rangeSelect = false,
// maxDate ('YYYY-MM-DD'): days after it are greyed out and can't be picked.
maxDate,
}) {
const [open, setOpen] = useState(false);
// rangeSelect only: the start day picked by the first click, and the day
// under the pointer, so the span between them previews before the 2nd click.
const [pendingFrom, setPendingFrom] = useState(null);
const [hoverDate, setHoverDate] = useState(null);
const handleOpenChange = (next) => {
setOpen(next);
if (!next) {
setPendingFrom(null);
setHoverDate(null);
}
};
const today = useMemo(() => dayjs().startOf('day'), []);
const todayStr = useMemo(() => today.format('YYYY-MM-DD'), [today]);
@@ -98,12 +117,41 @@ export function DatePicker({
return placeholder;
}, [parsed, placeholder]);
// Commit a date or preset
// Commit a date or preset.
//
// `finally`, because a consumer whose handler throws used to leave the
// calendar open on screen: setOpen(false) sat after the call and never ran,
// so clicking "This Month" looked like the picker had frozen. The error is
// deliberately still allowed to propagate — swallowing it would hide the
// consumer's bug — but the picker keeps its own invariant either way.
const commit = (nextVal) => {
if (onChange) {
onChange(nextVal);
try {
if (onChange) onChange(nextVal);
} finally {
setOpen(false);
setPendingFrom(null);
setHoverDate(null);
}
};
const isAfterMax = (dateStr) => Boolean(maxDate) && dateStr > maxDate;
const handleDayClick = (dateStr) => {
if (isAfterMax(dateStr)) return;
if (rangeSelect) {
if (!pendingFrom) {
setPendingFrom(dateStr);
return;
}
const [from, to] = pendingFrom <= dateStr ? [pendingFrom, dateStr] : [dateStr, pendingFrom];
commit({ from, to });
return;
}
if (typeof value === 'object' && !parsed.isSingle && value.to) {
commit({ from: dateStr, to: dateStr });
} else {
commit(dateStr);
}
setOpen(false);
};
// Day scrubbing (< and >)
@@ -140,9 +188,18 @@ export function DatePicker({
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// While a range is half-picked, highlight the start → hovered day instead of
// the committed value, so the operator sees what the second click will pick.
const pendingRange = useMemo(() => {
if (!pendingFrom) return null;
const end = hoverDate && !isAfterMax(hoverDate) ? hoverDate : pendingFrom;
return pendingFrom <= end ? { from: pendingFrom, to: end } : { from: end, to: pendingFrom };
}, [pendingFrom, hoverDate, maxDate]);
const isSelected = (day) => {
if (parsed.isAll) return false;
const s = day.format('YYYY-MM-DD');
if (pendingRange) return s >= pendingRange.from && s <= pendingRange.to;
if (parsed.isAll) return false;
if (parsed.from && parsed.to) {
return s >= parsed.from && s <= parsed.to;
}
@@ -150,13 +207,14 @@ export function DatePicker({
};
const isRangeEndpoint = (day) => {
if (parsed.isAll) return false;
const s = day.format('YYYY-MM-DD');
if (pendingRange) return s === pendingRange.from || s === pendingRange.to;
if (parsed.isAll) return false;
return s === parsed.from || s === parsed.to;
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<div className={cn('inline-flex items-center rounded-lg border border-border bg-card shadow-2xs transition-colors', className)}>
{showSteppers && (
<button
@@ -248,20 +306,18 @@ export function DatePicker({
const selected = isSelected(d);
const isTodayCell = dateStr === todayStr;
const endpoint = isRangeEndpoint(d);
const blocked = isAfterMax(dateStr);
return (
<button
key={dateStr}
type="button"
onClick={() => {
if (typeof value === 'object' && !parsed.isSingle && value.to) {
commit({ from: dateStr, to: dateStr });
} else {
commit(dateStr);
}
}}
disabled={blocked}
onClick={() => handleDayClick(dateStr)}
onMouseEnter={rangeSelect && pendingFrom ? () => setHoverDate(dateStr) : undefined}
className={cn(
'flex h-7 w-full items-center justify-center rounded-md text-xs font-medium transition-colors relative',
blocked && 'cursor-not-allowed opacity-30 hover:bg-transparent',
!inMonth && 'text-ink-4/40 hover:text-ink-2',
inMonth && !selected && 'text-ink-1 hover:bg-muted',
selected && !endpoint && 'bg-primary/15 text-primary rounded-none',
@@ -278,6 +334,14 @@ export function DatePicker({
})}
</div>
{rangeSelect && (
<p className="pt-2 text-center text-[11px] text-ink-3" aria-live="polite">
{pendingFrom
? `From ${dayjs(pendingFrom).format('DD MMM')} — now pick the end date`
: 'Pick a start date, then an end date'}
</p>
)}
{/* Quick Presets */}
<div className="mt-3 pt-2.5 border-t border-border/60 flex flex-wrap gap-1">
<button
@@ -365,6 +429,8 @@ DatePicker.propTypes = {
placeholder: PropTypes.string,
disabled: PropTypes.bool,
align: PropTypes.oneOf(['start', 'center', 'end']),
rangeSelect: PropTypes.bool,
maxDate: PropTypes.string,
};
export default DatePicker;

View File

@@ -0,0 +1,99 @@
import React from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { Input } from '@/components/ui/input';
/**
* A from/to date pair for the report pages.
*
* It exists because the same two `<Input type="date">` fields were copied into
* OrdersDetails, OrdersSummary and RidersSummary with no bounds on either, and
* that let through two ranges that can only ever return nothing:
*
* • INVERTED — from 30 Sep, to 1 Sep. Both inputs reported checkValidity()
* true, no message appeared anywhere, and the table showed zero rows. An
* operator reads that as "no orders in this period" when the filter is
* impossible. Same damage as a silently truncated count: an empty result
* presented as a finding.
*
* • FUTURE — January 2027. These reports filter on when an order was
* CREATED, so a future range is empty by definition.
*
* Bounds are enforced twice, because the two paths fail differently. `min` and
* `max` stop the browser's own picker offering an invalid day — that covers
* clicking, which is how it is actually used. The clamp in `commit` covers
* typing and pasting, which ignore those attributes entirely.
*
* When an edit would invert the range, the OTHER end moves to meet it rather
* than the edit being rejected. Picking a `from` after the current `to` means
* you have moved on to a later period; snapping `to` along with it is what you
* meant, and a rejected keystroke with no explanation is not.
*/
export function DateRangeFields({ value, onChange, max, label = 'to', className = '', size }) {
const today = dayjs().format('YYYY-MM-DD');
const ceiling = max === null ? undefined : max || today;
const from = value?.from || '';
const to = value?.to || '';
const clampToCeiling = (d) => (ceiling && d && d > ceiling ? ceiling : d);
const commit = (which, raw) => {
// An empty value is a cleared field, not an invalid one — pass it through
// and let the consumer decide what an open-ended range means.
if (!raw) {
onChange({ ...value, [which]: raw });
return;
}
const next = clampToCeiling(raw);
if (which === 'from') {
onChange({ from: next, to: to && next > to ? next : to });
} else {
onChange({ from: from && next < from ? next : from, to: next });
}
};
return (
<div className={`flex items-center gap-1.5 ${className}`.trim()}>
<Input
type="date"
size={size}
value={from}
// Bounded by the to-date as well as today, so the picker cannot offer
// a day that would invert the range.
max={to && (!ceiling || to < ceiling) ? to : ceiling}
onChange={(e) => commit('from', e.target.value)}
aria-label="From date"
className="w-36"
/>
<span className="text-xs text-ink-4">{label}</span>
<Input
type="date"
size={size}
value={to}
min={from || undefined}
max={ceiling}
onChange={(e) => commit('to', e.target.value)}
aria-label="To date"
className="w-36"
/>
</div>
);
}
DateRangeFields.propTypes = {
/** `{ from, to }` as YYYY-MM-DD strings. */
value: PropTypes.shape({ from: PropTypes.string, to: PropTypes.string }).isRequired,
/** Called with the whole next `{ from, to }`, already clamped. */
onChange: PropTypes.func.isRequired,
/**
* Latest selectable day. Defaults to today, because these reports filter on
* creation date. Pass `null` for a picker that may legitimately look ahead.
*/
max: PropTypes.string,
label: PropTypes.string,
className: PropTypes.string,
size: PropTypes.string
};
export default DateRangeFields;

View File

@@ -32,32 +32,46 @@ export function PageHeader({
}
: {};
if (!title && !Icon && !subtitle && !actions && !breadcrumb && !children) {
return null;
}
const hasLeading = Boolean(Icon || title || subtitle);
return (
<Wrapper {...motionProps} className={cn('space-y-4', className)}>
{breadcrumb}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<span className="grid place-items-center w-10 h-10 rounded-xl bg-brand text-white shadow-brand shrink-0">
<Icon className="w-5 h-5" aria-hidden="true" />
</span>
)}
<div className="min-w-0">
<h1
className={cn(
'font-heading text-ink-1 truncate',
size === 'display' ? 'text-display' : 'text-title-lg'
{(hasLeading || actions) && (
<div className={cn('flex flex-wrap items-center gap-4', hasLeading ? 'justify-between' : 'justify-end')}>
{hasLeading && (
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<span className="grid place-items-center w-10 h-10 rounded-xl bg-brand text-white shadow-brand shrink-0">
<Icon className="w-5 h-5" aria-hidden="true" />
</span>
)}
>
{title}
</h1>
{subtitle && <p className="text-body-sm text-ink-3 mt-1">{subtitle}</p>}
</div>
</div>
{(title || subtitle) && (
<div className="min-w-0">
{title && (
<h1
className={cn(
'font-heading text-ink-1 truncate',
size === 'display' ? 'text-display' : 'text-title-lg'
)}
>
{title}
</h1>
)}
{subtitle && <p className="text-body-sm text-ink-3 mt-1">{subtitle}</p>}
</div>
)}
</div>
)}
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
)}
{children}
</Wrapper>

View File

@@ -14,6 +14,28 @@ import { Badge } from '@/components/ui/badge';
* between tabs the same way the main nav pill does. `layoutGroupId` must be
* unique when two tab sets are on screen at once, otherwise the indicator
* animates between them.
*
* ── The strip scrolls itself ──
*
* A tab row is one non-wrapping line whose width is decided by its caller's
* data, and it is routinely wider than the space it is given: Deliveries has
* ten status tabs at roughly 1050px, and the assistant dock takes 30% of the
* viewport back (40% expanded) and now opens by default above 1024px. On a
* 1350px screen that leaves about 900px, so the last two tabs — Delivered and
* Cancelled — were simply unreachable. Not merely clipped: with nothing
* scrollable around them there was no gesture that could bring them back.
*
* Four of the six pages using this component had each hand-rolled the same
* wrapper to cope; the two that had not were broken. That ratio is the
* argument for putting it here. A component that renders a row of unknown
* length owns its own overflow — leaving it to every caller means it works
* until somebody adds a tab, and then it silently stops.
*
* The negative margin paired with equal padding is not cosmetic: `overflow-x`
* clips the focus ring on the first and last tab, and the pair gives the ring
* room without shifting the strip's visual position. It also makes the wrapper
* idempotent, so the four pages that still nest their own around it render
* identically.
*/
/** @param {any} props */
export function Tabs({
@@ -30,20 +52,70 @@ export function Tabs({
const fallbackId = React.useId();
const groupId = layoutGroupId || fallbackId;
const trackRef = React.useRef(null);
const activeRef = React.useRef(null);
// Keep the selected tab in view.
//
// Selection moves without a click — a deep link, a cleared filter, restored
// state — and landing on a page whose active tab sits off the right edge
// looks identical to landing on a page with no tab selected at all.
//
// Scrolls the TRACK's own scrollLeft rather than calling scrollIntoView,
// which walks every scrollable ancestor and would yank the whole page when
// the strip happens to sit below the fold.
React.useEffect(() => {
const track = trackRef.current;
const active = activeRef.current;
if (!track || !active) return;
const left = active.offsetLeft;
const right = left + active.offsetWidth;
const viewLeft = track.scrollLeft;
const viewRight = viewLeft + track.clientWidth;
if (left >= viewLeft && right <= viewRight) return;
track.scrollTo({
left: Math.max(0, left - (track.clientWidth - active.offsetWidth) / 2),
behavior: 'smooth',
});
}, [value]);
// The scroll track. `min-w-0` is what actually lets it shrink inside a flex
// parent — without it a flex item refuses to go below its content width and
// overflows the page instead of scrolling inside itself.
const track = (children) => (
<div
ref={trackRef}
// no-scrollbar: the strip scrolls, its bar is not drawn. A grey track
// under a row of pills is louder than the pills. The global
// ::-webkit-scrollbar:horizontal rule covers Chrome/Edge/Safari; this
// class is the Firefox fallback, and it is safe on a track that never
// scrolls vertically.
className="no-scrollbar -mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5"
>
{children}
</div>
);
if (variant === 'underline') {
return (
<div role="tablist" aria-label={ariaLabel} className={cn('flex items-center gap-1 border-b border-border', className)}>
return track(
// `w-max` lets the row grow past the track so there is something to
// scroll; `min-w-full` keeps the bottom border spanning the full width
// when the tabs do not fill it.
<div role="tablist" aria-label={ariaLabel} className={cn('flex w-max min-w-full items-center gap-1 border-b border-border', className)}>
{normalized.map((tab) => {
const active = tab.value === value;
return (
<button
key={tab.value}
ref={active ? activeRef : undefined}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange?.(tab.value)}
className={cn(
'relative inline-flex items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
'relative inline-flex shrink-0 items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
size === 'sm' ? 'text-body-sm' : 'text-body',
active ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
)}
@@ -67,12 +139,14 @@ export function Tabs({
);
}
return (
return track(
<div
role="tablist"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
// `w-max` so the pill rail sizes to its tabs and overflows the track
// rather than compressing them into each other.
'inline-flex w-max items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
className
)}
>
@@ -81,12 +155,16 @@ export function Tabs({
return (
<button
key={tab.value}
ref={active ? activeRef : undefined}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange?.(tab.value)}
className={cn(
'relative inline-flex items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
// shrink-0: without it the flex row squashes ten tabs into the
// available width instead of overflowing, and the track has
// nothing to scroll.
'relative inline-flex shrink-0 items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
size === 'sm' ? 'px-3 py-1 text-caption' : 'px-4 py-1.5 text-body-sm',
active ? 'text-white' : 'text-ink-3 hover:text-ink-1'
)}

View File

@@ -7,7 +7,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

View File

@@ -27,8 +27,10 @@ const BASE_STYLE = {
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
// No per-toast position: every toast renders in AppToaster's top-right stack.
// This used to force 'bottom-center', so design-system toasts and OpenToast
// ones appeared in two different corners of the same screen.
const withDefaults = (options = {}) => ({
position: /** @type {import('react-hot-toast').ToastPosition} */ ('bottom-center'),
...options,
style: { ...BASE_STYLE, ...(options.style || {}) },
});
@@ -51,7 +53,7 @@ export const toast = {
},
info(message, options) {
return hotToast(message, { duration: DURATIONS.info, ...withDefaults(options) });
return hotToast(message, { duration: DURATIONS.info, className: 'dm-toast-info', ...withDefaults(options) });
},
loading(message, options) {

View File

@@ -53,7 +53,7 @@ const AddressAutocomplete = ({
useEffect(() => {
if (value !== undefined && value !== inputValue) setInputValue(value || '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
@@ -84,7 +84,7 @@ const AddressAutocomplete = ({
}
setLoading(true);
fetchPredictions(inputValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue, biasKey]);
// Close the list on an outside click.

View File

@@ -59,7 +59,7 @@ inconsistent.
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
<PageShell> {/* gap={6} between regions */}
<PageHeader title="…" subtitle="…" live action={<Button …/>} />
<PageHeader title="…" live action={<Button …/>} />
<KpiGrid>…<StatCard/>…</KpiGrid> {/* fills the row, no dead gap */}
<DataCard
toolbar={<FilterToolbar>…selectors, date, search…</FilterToolbar>}

View File

@@ -1,4 +1,4 @@
/* eslint-disable react/prop-types */
import React, { useEffect, useRef } from 'react';
import { useDebounce } from 'use-debounce';

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { Toaster, toast as hotToast } from 'react-hot-toast';
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from 'lucide-react';
/**
* The one toast renderer for the console.
*
* Every toast — OpenToast (most of the app), the design system's `toast`
* helper, and direct react-hot-toast calls — renders here, top-right, as the
* same card: coloured accent, icon, a short title, the message, and a close
* button. Before this they appeared bottom-right or bottom-centre depending on
* which helper fired them, as bare white pills with no way to dismiss.
*
* react-hot-toast has no "warning" or "info" type, so OpenToast tags those
* with a className (`dm-toast-warning` / `dm-toast-info`) and this reads it.
*/
const VARIANTS = {
success: {
title: 'Done',
Icon: CheckCircle2,
accent: 'bg-emerald-500',
iconWrap: 'bg-emerald-50 text-emerald-600'
},
error: {
title: 'Something went wrong',
Icon: XCircle,
accent: 'bg-rose-500',
iconWrap: 'bg-rose-50 text-rose-600'
},
warning: {
title: 'Please check',
Icon: AlertTriangle,
accent: 'bg-amber-500',
iconWrap: 'bg-amber-50 text-amber-600'
},
info: {
title: 'Heads up',
Icon: Info,
accent: 'bg-sky-500',
iconWrap: 'bg-sky-50 text-sky-600'
},
loading: {
title: 'Working on it',
Icon: Loader2,
accent: 'bg-slate-400',
iconWrap: 'bg-slate-100 text-slate-500',
spin: true
}
};
export const variantOf = (t) => {
if (t.type === 'success') return 'success';
if (t.type === 'error') return 'error';
if (t.type === 'loading') return 'loading';
if (String(t.className || '').includes('dm-toast-warning')) return 'warning';
return 'info';
};
function ToastCard({ t }) {
const variant = VARIANTS[variantOf(t)];
const { Icon } = variant;
const urgent = t.type === 'error' || variantOf(t) === 'warning';
const message = typeof t.message === 'function' ? t.message(t) : t.message;
return (
<div
{...t.ariaProps}
role={urgent ? 'alert' : 'status'}
aria-live={urgent ? 'assertive' : 'polite'}
style={{
// Width is inline, not a Tailwind class: react-hot-toast measures each
// card once to stack them, and a card measured before its width class
// applied wrapped one word per line and pushed the next toast far off
// screen.
width: 360,
maxWidth: 'calc(100vw - 2rem)',
opacity: t.visible ? 1 : 0,
transform: t.visible ? 'translateX(0)' : 'translateX(16px)',
transition: 'opacity 180ms ease, transform 180ms ease'
}}
className="pointer-events-auto relative flex items-start gap-3 overflow-hidden rounded-xl border border-slate-200/90 bg-white py-3 pl-4 pr-3 shadow-[0_12px_32px_-8px_rgb(16_24_40/0.18),0_4px_10px_-4px_rgb(16_24_40/0.08)]"
>
<span className={`absolute inset-y-0 left-0 w-1 ${variant.accent}`} aria-hidden="true" />
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${variant.iconWrap}`} aria-hidden="true">
<Icon className={`h-4 w-4 ${variant.spin ? 'animate-spin' : ''}`} />
</span>
<div className="min-w-0 flex-1 pt-0.5">
<p className="text-[13px] font-semibold leading-5 text-slate-900">{variant.title}</p>
<div className="mt-0.5 text-[12.5px] leading-[1.45] text-slate-600 break-words">{message}</div>
</div>
{t.type !== 'loading' && (
<button
type="button"
onClick={() => hotToast.dismiss(t.id)}
className="-mr-1 mt-0.5 rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
aria-label="Dismiss notification"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
export default function AppToaster() {
return (
<Toaster
position="top-right"
gutter={10}
// Below the 56px header bar, so a toast never covers search or the
// account menu.
containerStyle={{ top: 68, right: 16 }}
toastOptions={{ duration: 3500 }}
>
{(t) => <ToastCard t={t} />}
</Toaster>
);
}

View File

@@ -1,5 +1,8 @@
import toast from 'react-hot-toast';
// Rendered by AppToaster (top-right card). react-hot-toast has no warning or
// info type, so those are tagged with a className the renderer reads — before,
// a warning and an info looked identical.
export const OpenToast = (message, color, time) => {
const duration = time || 2500;
if (color === 'error') {
@@ -8,5 +11,8 @@ export const OpenToast = (message, color, time) => {
if (color === 'success') {
return toast.success(message, { duration });
}
return toast(message, { duration });
if (color === 'warning') {
return toast(message, { duration, className: 'dm-toast-warning' });
}
return toast(message, { duration, className: 'dm-toast-info' });
};

View File

@@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-[9999] overflow-hidden rounded-lg bg-slate-900 px-2.5 py-1 text-xs font-medium text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />

View File

@@ -55,6 +55,36 @@ body {
height: 8px;
}
/* The four ::-webkit-scrollbar rules around this comment are DEAD in current
browsers. Measured, not assumed — Chrome 152, this app's own stylesheet
loaded, on an element that scrolls horizontally only:
inherits `* { scrollbar-width: thin }` -> 10px bar
scrollbar-width: auto -> 15px bar
scrollbar-width: none -> 0px
Neither the 8px sizing above nor a `::-webkit-scrollbar:horizontal
{ height: 0 }` had any effect. Once an element resolves the STANDARD
`scrollbar-width` property — which the `*` rule above hands every element
in the app — Chrome takes the standard path and ignores the legacy
pseudo-elements entirely. So the console's scrollbars are drawn by
`scrollbar-width: thin` + `scrollbar-color`, and the webkit block is inert.
Kept for older WebKit rather than deleted, but do not reach for it to
change anything: it will look like it should work and will not.
What this means for hiding the SIDEWAYS bar only: the standard property has
no per-axis form, and the webkit `:horizontal` selector that would provide
one is ignored. A bar can therefore only be hidden per-ELEMENT, with
`scrollbar-width: none` — the `.no-scrollbar` utility in index.css. It is
applied to the containers that scroll horizontally ONLY, where there is no
vertical bar for it to take away: every DataTable, every tab strip, the
profitability and batch-efficiency tables, and Dispatch's batch scroller,
strat row and compare timeline.
One container scrolls on both axes — the bulk-upload preview in
MultipleOrders — and keeps both bars deliberately. Hiding its horizontal
bar is not possible without hiding the vertical one the operator needs. */
*::-webkit-scrollbar-track {
background-color: transparent;
}

View File

@@ -2,10 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Bell, ChevronDown, LogOut, Menu, Search, Shield, User,
Activity, Bell, Bike, Bot, Car, ChevronDown, Coins, FileSpreadsheet,
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
ShieldAlert, Undo2, User, UserCheck, UserPlus, Warehouse,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { DOORMILE_WORDMARK_URL } from '@/assets/brand';
import { DOORMILE_MARK_URL } from '@/assets/brand';
import { Avatar } from '@/components/ds/Avatar';
import { Badge } from '@/components/ui/badge';
import {
@@ -14,46 +16,49 @@ import {
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useAuth } from '@/lib/AuthContext';
import { canOnboardClients } from '@/lib/clientOnboarding';
import { CLIENT_ONBOARDING_PATH, navGroupsFor } from '@/lib/consoleNav';
import { useExceptions } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import doormileMark from 'assets/images/doormile-mark.png';
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
import { useSkillRegistrySync } from '@/lib/assistant/skills/useSkillRegistrySync';
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
/**
* The console shell.
*
* Single 56px header bar, compact search with a keyboard shortcut, hairline tab
* indicator, mobile sheet — the design is unchanged. What changed is what it
* points at: the Doormile Express destinations rather than the hiring ones.
* indicator, mobile sheet.
*
* Seven destinations sit on the bar and the remaining eleven live in two
* grouped menus (Fleet Ops, Reports), built from the same DropdownMenu the
* account menu already uses. Eighteen flat tabs would not fit a 56px bar at any
* width, and a console header should recede rather than become the loudest
* thing on the page.
* Core destinations sit on the bar; the rest live in two grouped menus on it
* (Fleet Ops, Reports) plus a Settings section inside the account menu, which
* holds the two destinations that are configuration rather than operations
* (Pricing Matrix, Customers Directory).
*
* Said precisely because the previous wording — "three grouped menus (Fleet
* Ops, Reports, Settings)" — sent a reader looking for a third NAV_GROUPS
* entry that does not exist, and hid the fact that the Settings pair is
* reached by a different route from everything else. That difference is
* exactly what made them unreachable on mobile.
*/
const ROOT = '/doormile';
/* `doormile-logo.png` is a white wordmark — invisible on this bar's light
chrome as-is. Recolored to brand red via filter rather than swapping the
asset, same trick the source console's own TopNav uses on the identical
file, so the two headers render the mark in the same color from the same
source PNG. */
const LOGO_FILTER = {
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',
};
/** Destinations that sit directly on the header bar. */
const NAV = [
{ label: 'Dispatch', path: '/doormile/dispatch' },
{ label: 'Control X', path: '/doormile/control-x' },
{ label: 'Bookings', path: '/doormile/bookings' },
{ label: 'Orders', path: '/doormile/orders' },
{ label: 'Deliveries', path: '/doormile/deliveries' },
{ label: 'Milers', path: '/doormile/milers' },
{ label: 'Clients', path: '/doormile/tenants' },
// Staff manage every client's rate card here; a client login sees its own,
// read-only (the server scopes the list and refuses a client's changes).
{ label: 'Pricing', path: '/doormile/pricing' },
{ label: 'Customers', path: '/doormile/customers' },
];
/** Destinations grouped behind a menu, in the order the console lists them. */
@@ -61,28 +66,27 @@ const NAV_GROUPS = [
{
label: 'Fleet Ops',
items: [
{ label: 'Hubs', path: '/doormile/hubs' },
{ label: 'Vehicles', path: '/doormile/vehicles' },
{ label: 'Tripsheets', path: '/doormile/tripsheets' },
{ label: 'Exceptions', path: '/doormile/exceptions' },
{ label: 'Competitive Intel', path: '/doormile/competitive-intel' },
{ label: 'App Users', path: '/doormile/app-users' },
{ label: 'Hubs', path: '/doormile/hubs', icon: Warehouse },
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car },
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo },
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert },
{ label: 'Returns', path: '/doormile/returns', icon: Undo2 },
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity },
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck },
{ label: 'Agents', path: '/doormile/agents', icon: Bot },
],
},
{
label: 'Reports',
items: [
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary' },
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails' },
{ label: 'Riders Summary', path: '/doormile/reports/riderssummary' },
{ label: 'Profitability', path: '/doormile/reports/profitability' },
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary', icon: FileSpreadsheet },
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails', icon: FileText },
{ label: 'Milers Summary', path: '/doormile/reports/riderssummary', icon: Bike },
{ label: 'Profitability', path: '/doormile/reports/profitability', icon: Coins },
],
},
];
/** Every destination, flattened — what the mobile sheet renders. */
const ALL_DESTINATIONS = [...NAV, ...NAV_GROUPS.flatMap((group) => group.items)];
function GlobalSearch() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
@@ -224,8 +228,32 @@ export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { user, isClient, logout } = useAuth();
// One sync for the whole console, so the Exceptions banner and the chat's
// "what needs attention" briefing run on the same registry settings. Partner
// logins cannot read the registry (403) and run on code defaults.
useSkillRegistrySync(!isClient);
const [menuOpen, setMenuOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(() => {
try {
const saved = localStorage.getItem('doormileAssistantOpen');
if (saved !== null) return saved === 'true';
} catch {
/* ignore */
}
return typeof window !== 'undefined' ? window.innerWidth >= 1024 : true;
});
const toggleAssistant = (nextState) => {
setAssistantOpen((prev) => {
const next = typeof nextState === 'function' ? nextState(prev) : nextState;
try {
localStorage.setItem('doormileAssistantOpen', String(next));
} catch {
/* ignore */
}
return next;
});
};
const navItems = useMemo(() => {
if (isClient) {
@@ -234,12 +262,17 @@ export default function AdminLayout() {
return NAV;
}, [isClient]);
const navGroups = useMemo(() => {
if (isClient) {
return NAV_GROUPS.filter((group) => group.label !== 'Fleet Ops');
}
return NAV_GROUPS;
}, [isClient]);
const canOnboard = canOnboardClients(user, isClient);
const navGroups = useMemo(
() =>
navGroupsFor(NAV_GROUPS, {
isClient,
canOnboard,
onboardingItem: { label: 'Client Onboarding', path: CLIENT_ONBOARDING_PATH, icon: UserPlus },
}),
[isClient, canOnboard]
);
const allDestinations = useMemo(() => {
return [...navItems, ...navGroups.flatMap((group) => group.items)];
@@ -253,6 +286,9 @@ export default function AdminLayout() {
const groupIsActive = (group) => group.items.some((item) => isActive(item.path));
const displayName = user?.displayname || user?.authname || user?.name || 'Operator';
const isHome = location.pathname === '/doormile/home' || location.pathname === '/doormile' || location.pathname === '/';
const isAgentsPage = location.pathname === '/doormile/agents' || location.pathname.startsWith('/doormile/agents');
const hideAssistant = isHome || isAgentsPage;
return (
/* Transparent so the ambient canvas painted behind the app reads through.
@@ -265,22 +301,14 @@ export default function AdminLayout() {
<header data-app-header className="sticky top-0 z-50 border-b border-border bg-white/85 backdrop-blur-xl">
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
<Link
to="/doormile/dispatch"
aria-label="Doormile Express — Dispatch"
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
to="/doormile/home"
aria-label="Doormile Express — Home"
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 transition-transform active:scale-95"
>
{/* Full wordmark, not just the circular mark — matches the source
console's own TopNav, which renders this same logo at the
same relative scale inside its bar. */}
{/* Capped below `sm`: at its natural 209px the wordmark plus the
170px action cluster overflowed a 375px phone and scrolled the
whole document sideways. Scaling the mark is the one thing here
that can give — the controls beside it all have to stay. */}
<img
src={DOORMILE_WORDMARK_URL}
alt="Doormile Express"
style={LOGO_FILTER}
className="h-7 w-auto max-w-[120px] object-contain sm:max-w-none"
src={DOORMILE_MARK_URL}
alt="Doormile"
className="h-8 w-auto object-contain"
/>
</Link>
@@ -316,13 +344,14 @@ export default function AdminLayout() {
<button
type="button"
className={cn(
'relative flex items-center gap-1 whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium',
'transition-colors duration-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
'relative flex items-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium',
'transition-colors duration-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 cursor-pointer',
groupIsActive(group) ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
)}
>
{group.label}
<ChevronDown className="h-3 w-3" aria-hidden="true" />
{group.icon && <group.icon className="h-3.5 w-3.5 opacity-70" />}
<span>{group.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" aria-hidden="true" />
{groupIsActive(group) && (
<motion.span
layoutId="adminNavIndicator"
@@ -332,16 +361,43 @@ export default function AdminLayout() {
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
{group.items.map((item) => (
<DropdownMenuItem
key={item.path}
onClick={() => navigate(item.path)}
className={cn('cursor-pointer', isActive(item.path) && 'text-brand')}
>
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuContent align="start" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
{group.items.map((item) => {
const ItemIcon = item.icon;
const active = isActive(item.path);
return (
<DropdownMenuItem
key={item.path}
onClick={() => navigate(item.path)}
className={cn(
// items-center, not items-start: with the second
// line gone there is nothing to top-align to, and
// the icon would sit high against a single label.
'flex items-center gap-2.5 p-2 rounded-xl cursor-pointer transition-colors my-0.5',
active ? 'bg-brand-tint/80 text-brand' : 'hover:bg-slate-50 text-ink-1'
)}
>
{ItemIcon && (
<div
className={cn(
'w-7 h-7 rounded-lg flex items-center justify-center shrink-0',
active ? 'bg-brand text-white' : 'bg-slate-100 text-slate-600'
)}
>
<ItemIcon className="w-3.5 h-3.5" />
</div>
)}
{/* Label only. The descriptions under each item
("Sortation centers & facilities", "Linehaul &
middle-mile runs") were removed: this is a jump
menu for people who already know the console, so a
gloss on every row is read once and then skipped
past forever while still costing the height that
made the menu tall enough to cover the page. */}
<div className="min-w-0 flex-1 text-body-sm font-semibold">{item.label}</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
))}
@@ -350,22 +406,10 @@ export default function AdminLayout() {
<div className="ml-auto flex items-center gap-1.5">
<GlobalSearch />
{/* Doormile AI. A header control rather than a route: it answers
about live data while the operator stays on the screen that
raised the question. */}
<button
type="button"
onClick={() => setAssistantOpen((open) => !open)}
aria-label={assistantOpen ? 'Close Doormile AI' : 'Open Doormile AI'}
aria-expanded={assistantOpen}
className={cn(
'grid h-8 w-8 place-items-center rounded-lg transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
assistantOpen ? 'bg-brand-tint text-brand' : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<img className="h-9 w-9 max-w-none object-contain" src={doormileMark} alt="" aria-hidden="true" />
</button>
{/* MileTruth's control is no longer here. It is the floating
launcher at the bottom of the page (AILauncher, mounted beside
the panel below), so this row holds only the utilities that
belong in a header: search, alerts, the account menu. */}
{!isClient && <NotificationsMenu />}
@@ -382,27 +426,37 @@ export default function AdminLayout() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="font-normal">
<p className="text-body-sm font-semibold text-ink-1">{displayName}</p>
<p className="text-caption text-ink-3">{user?.email || user?.primaryemail}</p>
<Badge variant="soft" size="sm" className="mt-1.5">
{user?.role || 'Administrator'}
<DropdownMenuContent align="end" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
<DropdownMenuLabel className="font-normal px-2.5 py-2">
<p className="text-body-sm font-bold text-ink-1">{displayName}</p>
<p className="text-caption text-ink-3 truncate">{user?.email || user?.primaryemail || 'admin@doormile.com'}</p>
<Badge variant="soft" size="sm" className="mt-1.5 bg-rose-50 text-rose-700 font-semibold border-rose-200">
{user?.role || 'admin'}
</Badge>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer">
<User className="mr-2 h-4 w-4" /> Profile
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
<User className="mr-2.5 h-4 w-4 text-slate-500" /> Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" /> Change password
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
<Shield className="mr-2.5 h-4 w-4 text-slate-500" /> Change password
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => navigate('/doormile/settings')}
className={cn(
'cursor-pointer rounded-xl px-2.5 py-2 font-medium',
location.pathname.startsWith('/doormile/settings') && 'bg-brand-tint text-brand font-semibold'
)}
>
<Settings className="mr-2.5 h-4 w-4 text-slate-500" /> Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => logout()}
className="cursor-pointer text-destructive focus:text-destructive"
className="cursor-pointer text-destructive focus:text-destructive rounded-xl px-2.5 py-2 font-medium"
>
<LogOut className="mr-2 h-4 w-4" /> Sign out
<LogOut className="mr-2.5 h-4 w-4" /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -441,6 +495,20 @@ export default function AdminLayout() {
{item.label}
</Link>
))}
{/* Settings — the pair that lives in the account menu on desktop.
Without this block they existed on the bar's dropdown only, so
a phone could not reach Pricing or Customers at all. */}
<div className="my-2 h-px bg-border" />
<Link
to="/doormile/settings"
onClick={() => setMenuOpen(false)}
className={cn(
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
isActive('/doormile/settings') ? 'bg-brand-tint text-brand font-semibold' : 'text-ink-2 hover:bg-surface-sunken'
)}
>
Settings
</Link>
<div className="my-2 h-px bg-border" />
<Link
to="/doormile/profile"
@@ -456,13 +524,25 @@ export default function AdminLayout() {
{/* `dai-page` is the handshake with the assistant dock: while the panel
is open, DoormileAI.css reserves exactly its width of padding here so
the page slides open beside it instead of being covered. */}
<div className={cn("dai-page flex flex-col", !location.pathname.startsWith('/doormile/dispatch') && "px-4 md:flex-row lg:px-6")}>
<main className={cn("min-w-0 flex-1", !location.pathname.startsWith('/doormile/dispatch') && "py-6")}>
<div className={cn("dai-page flex flex-col flex-1", !location.pathname.startsWith('/doormile/control-x') && "px-4 md:flex-row lg:px-6")}>
<main className={cn(
"min-w-0 flex-1 flex flex-col",
!location.pathname.startsWith('/doormile/control-x') && location.pathname !== '/doormile/home' && location.pathname !== '/doormile' && "py-6"
)}>
<Outlet />
</main>
</div>
{assistantOpen && <AIPanel isOpen={assistantOpen} onClose={() => setAssistantOpen(false)} />}
{/* Both guarded by the SAME condition, deliberately adjacent: the home
page runs the assistant as the page itself, so neither the docked
panel nor its launcher belongs there. Splitting these two rules apart
is how you end up with a button that opens nothing. */}
{!hideAssistant && (
<>
<AILauncher isOpen={assistantOpen} onToggle={() => toggleAssistant((open) => !open)} />
<AIPanel isOpen={assistantOpen} onClose={() => toggleAssistant(false)} />
</>
)}
</div>
);
}

View File

@@ -21,10 +21,10 @@ export default function PageNotFound() {
const quickLinks = [
{
title: 'Live Dispatch',
title: 'Control X',
desc: 'Real-time fleet tracking & active runs',
icon: Radio,
path: '/doormile/dispatch',
path: '/doormile/control-x',
color: 'text-rose-600 bg-rose-50 border-rose-200/70 group-hover:bg-rose-100/70'
},
{
@@ -98,11 +98,11 @@ export default function PageNotFound() {
<div className="flex flex-wrap items-center justify-center gap-3 w-full max-w-md mb-9">
<button
type="button"
onClick={() => navigate(isAuthenticated ? '/doormile/dispatch' : '/login')}
onClick={() => navigate(isAuthenticated ? '/doormile/control-x' : '/login')}
className="flex-1 min-w-[160px] inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-medium text-sm text-white bg-rose-600 hover:bg-rose-700 active:scale-[0.98] shadow-sm shadow-rose-600/20 transition-all cursor-pointer"
>
<Home className="w-4 h-4" />
<span>{isAuthenticated ? 'Back to Dispatch' : 'Sign in to Console'}</span>
<span>{isAuthenticated ? 'Back to Control X' : 'Sign in to Console'}</span>
</button>
<button

View File

@@ -1,6 +1,9 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useHubs, useTenantLocations } from '@/lib/doormileHooks';
import { useAuth } from '@/lib/AuthContext';
// Shared with Control X's city filter. Two radii would mean the Orders page and
// the dispatch board disagreeing about which city an order is in.
import { haversineKm, ZONE_RADIUS_KM } from '@/lib/locationScope';
const ZONE_STORAGE_KEY = 'doormile_active_zone_id';
@@ -13,17 +16,6 @@ const ALL_ZONE = Object.freeze({
status: 'Active',
});
const haversineKm = (lat1, lon1, lat2, lon2) => {
if (![lat1, lon1, lat2, lon2].every((v) => Number.isFinite(Number(v)))) return undefined;
const R = 6371;
const toRad = (deg) => (Number(deg) * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const ZoneContext = createContext(null);
export function ZoneProvider({ children }) {
@@ -31,10 +23,12 @@ export function ZoneProvider({ children }) {
const tenantId = user?.tenantid || localStorage.getItem('tenantid') || '';
const isTenantUser = Boolean(tenantId && tenantId !== '0');
// Staff loads global hubs; Tenant loads tenant locations / kitchen hubs
const { data: rawHubs = [], isLoading: isHubsLoading, refetch: refetchHubs } = useHubs({
enabled: !isTenantUser,
});
// Staff see Doormile's hubs. A client sees ONLY its own locations (kitchens,
// depots). Doormile's hubs used to be appended for the client's city, and
// for every city whenever the session carried no city (any login made
// before the backend sent one), which is how a Coimbatore client ended up
// offered Hyderabad and Bangalore hubs.
const { data: rawHubs = [], isLoading: isHubsLoading, refetch: refetchHubs } = useHubs();
const { data: tenantLocations = [], isLoading: isLocationsLoading, refetch: refetchLocations } = useTenantLocations(
isTenantUser ? tenantId : null,
{ enabled: isTenantUser }
@@ -49,10 +43,21 @@ export function ZoneProvider({ children }) {
});
const activeHubs = useMemo(() => {
const doormileHubs = (rawHubs || []).filter(
(hub) => String(hub.status || 'Active').toLowerCase() === 'active'
);
if (isTenantUser) {
return (tenantLocations || []).map((loc) => ({
hubid: String(loc.tenantlocationid || loc.locationid || loc.id),
id: String(loc.tenantlocationid || loc.locationid || loc.id),
// A client location's id and a hub id are separate numberings, so the
// location's zone id is prefixed; `locationid` keeps the raw one for
// matching orders (matchesZone).
// A deactivated location is no longer a place the client ships from;
// it was listed (and labelled Active) regardless until now.
const own = (tenantLocations || [])
.filter((loc) => String(loc.status || 'Active').toLowerCase() === 'active')
.map((loc) => ({
hubid: `loc:${loc.tenantlocationid || loc.locationid || loc.id}`,
id: `loc:${loc.tenantlocationid || loc.locationid || loc.id}`,
locationid: String(loc.tenantlocationid || loc.locationid || loc.id),
hubname: loc.locationname || loc.name || `Kitchen Hub #${loc.tenantlocationid || loc.locationid}`,
city: loc.city || '',
address: loc.address || '',
@@ -63,11 +68,10 @@ export function ZoneProvider({ children }) {
isTenantLocation: true,
status: 'Active',
}));
return own;
}
return (rawHubs || []).filter(
(hub) => String(hub.status || 'Active').toLowerCase() === 'active'
);
return doormileHubs;
}, [isTenantUser, tenantLocations, rawHubs]);
const zones = useMemo(() => {
@@ -132,7 +136,16 @@ export function ZoneProvider({ children }) {
if (isTenantUser) {
return true;
}
if (item.applocationid != null && (String(item.applocationid) === targetHubId || String(item.applocationid) === String(currentHub.applocationid))) {
// A rider's applocationid is a CITY id; the selected zone is a HUB. Only
// compare city with city. This used to also test applocationid against
// the hub id — two unrelated number spaces — so with "Coimbatore
// Neptune Hub" (hubid 2) selected, every Hyderabad rider (city 2)
// matched and the miler lists showed riders from another city.
if (
item.applocationid != null &&
currentHub.applocationid != null &&
String(item.applocationid) === String(currentHub.applocationid)
) {
return true;
}
if (item.hubid != null && String(item.hubid) === targetHubId) {
@@ -147,21 +160,45 @@ export function ZoneProvider({ children }) {
// 1. Direct hub/location id match
//
// `servicinghubid` is queries.js's answer to "which hub OWNS this order",
// which is a different question from "where is it collected from" and the
// only one that stays answerable once the pickup is a customer's doorstep.
// It is consulted first, and only for staff sessions: a tenant's zones are
// its own locations, so a hub id compared against a tenantlocationid would
// match on a bare numeric collision and file the order under a zone that
// has nothing to do with it.
const itemHubId =
(!isTenantUser ? item.servicinghubid : null) ??
item.hubid ??
item.sourcehubid ??
item.applocationid ??
item.tenantlocationid ??
item.locationid;
if (itemHubId != null && String(itemHubId) === targetHubId) {
// The hub ids a booking can actually carry. `pickuphubid` and
// `nearesthubid` are the only hub foreign keys that exist on the
// pickupbookings row; `servicinghubid` is synthesised by queries.js and so
// reaches this function on Deliveries but never on Orders; `hubid`,
// `sourcehubid`, `applocationid` and `locationid` are not columns on a
// booking at all and are kept only for the other row shapes that pass
// through here.
//
// Until `pickuphubid` was added, every one of those was undefined on the
// Orders page, so this whole branch fell through and the 35km radius below
// was the only matcher left running — which means an order that reached the
// database without coordinates disappeared from every zone at once, with
// nowhere to see it and nothing to say it had been hidden.
//
// Each branch compares ONE id space against itself, and this is the whole
// reason the list is split.
//
// A staff zone is a hub, so only hub ids may be tested against it.
// `applocationid` is a city, `tenantlocationid` and `locationid` are a
// client's own sites — different numbering entirely, and a delivery row
// carries several of them at once (queries.js sets servicinghubid,
// applocationid and tenantlocationid on the same row). Booking DM-626241 is
// the live example: servicinghubid 5 (Bangalore Earth Hub) alongside
// tenantlocationid 3, which as a hub id is Hyderabad Mars Hub. Testing them
// all would file a Bangalore run under Hyderabad on a bare numeric
// collision.
//
// The old `??` chain hid this by stopping at the first non-null field, so
// the collision only fired when the earlier ones happened to be absent. It
// was a bug waiting on its inputs, not a safe design.
//
// What decides the id space is the selected ZONE, not who is signed in: a
// client can now pick one of their own locations (compare location ids)
// or a Doormile hub (compare hub ids).
const itemHubIds = currentHub.isTenantLocation
? [item.tenantlocationid, item.locationid]
: [item.servicinghubid, item.pickuphubid, item.nearesthubid, item.hubid, item.sourcehubid];
const targetId = currentHub.isTenantLocation ? String(currentHub.locationid) : targetHubId;
if (itemHubIds.some((id) => id != null && String(id) === targetId)) {
return true;
}
@@ -184,7 +221,7 @@ export function ZoneProvider({ children }) {
return true;
}
// 3. Coordinate distance match (within 35km radius of hub)
// 3. Coordinate distance match, within ZONE_RADIUS_KM of the hub
const hubLat = Number(currentHub.latitude);
const hubLon = Number(currentHub.longitude);
if (Number.isFinite(hubLat) && Number.isFinite(hubLon)) {
@@ -196,8 +233,8 @@ export function ZoneProvider({ children }) {
const pickDist = haversineKm(hubLat, hubLon, pickLat, pickLon);
const dropDist = haversineKm(hubLat, hubLon, dropLat, dropLon);
if (pickDist !== undefined && pickDist <= 35) return true;
if (dropDist !== undefined && dropDist <= 35) return true;
if (pickDist !== undefined && pickDist <= ZONE_RADIUS_KM) return true;
if (dropDist !== undefined && dropDist <= ZONE_RADIUS_KM) return true;
}
return false;
@@ -212,8 +249,13 @@ export function ZoneProvider({ children }) {
isAllZones,
isTenantUser,
tenantId,
isHubsLoading: isTenantUser ? isLocationsLoading : isHubsLoading,
refetchHubs: isTenantUser ? refetchLocations : refetchHubs,
isHubsLoading: isTenantUser ? isLocationsLoading || isHubsLoading : isHubsLoading,
refetchHubs: isTenantUser
? () => {
refetchLocations();
refetchHubs();
}
: refetchHubs,
matchesZone,
}),
[

294
src/lib/agentNetwork.js Normal file
View File

@@ -0,0 +1,294 @@
// ==============================|| The AI_engine agent swarm ||============================== //
//
// Nine agents — JARVIS plus eight specialised — living in the `AI_engine`
// repository, not in this one and not in `doormile_backend`. They subscribe to
// the same NATS JetStream the Go backend publishes to, reason about what they
// see, and raise alerts. Every autonomy gate is off, so none of them writes to
// the system.
//
// ─── Why this is a static table ──────────────────────────────────────────────
//
// `AI_engine` exposes no endpoint this console can call. There is a FastAPI
// gateway inside it with a `GET /agents/status`, but it is not deployed beside
// the console and nothing routes to it.
//
// So this file is a hand-maintained record, and it is deliberately shaped like
// the response such an endpoint would return — one flat object per agent, the
// same keys — so swapping it for a fetch is a change of source, not a rewrite
// of the page.
//
// **It can go stale.** Every `lastSeen` here was read from
// `AI_engine/logs/logiflow_2026-09-16.log`, the most recent run, which covers
// 16–20 Sep 2026. Treat the counts as of that date, not as live. The page says
// so on screen, because a status board that looks live and is not is worse than
// one that admits its age.
/** When the figures below were taken. Rendered on the page — see the note above. */
export const SNAPSHOT_AT = '2026-09-20T19:51:23+05:30';
export const SNAPSHOT_LABEL = 'Last run · 16–20 Sep 2026';
/**
* How an agent is woken.
*
* The split matters more than it looks: `nats` agents subscribe to the bus
* themselves and can act the moment an event lands, while `task` agents only
* run when JARVIS hands them something — and nothing in the Go backend asks
* JARVIS for anything, which is why five of the nine have never run at all.
*/
export const TRIGGER = {
nats: 'NATS event',
natsPull: 'NATS pull',
task: 'JARVIS task',
internal: 'In-process',
};
export const AGENTS = [
{
id: 'JARVIS',
name: 'JARVIS',
domain: 'orchestration',
status: 'running',
trigger: 'internal',
file: 'core/agent.py',
lines: null,
tests: null,
events: 0,
load: 8,
blurb: 'Master agent. Every other agent registers under it and receives work through it.',
detail:
'The orchestrator. It holds the registry of the eight specialised agents and routes tasks to them over an in-process message bus. It never touches NATS itself.',
subscribes: [],
publishes: 'Tasks to the five task-driven agents.',
api: 'No — it delegates.',
autonomy: 'not gated — routes only',
gated: false,
lastSeen:
'Started cleanly on 16 Sep at 15:23 and registered eight sub-agents. It has routed nothing since, because nothing in the backend asks it to.',
},
{
id: 'DISPATCH_AGENT',
name: 'Dispatch',
domain: 'dispatch',
status: 'active',
trigger: 'nats',
file: 'agents/dispatch_agent.py',
lines: 508,
tests: true,
events: 2,
load: 62,
blurb:
'Counts assignment failures per geo cell, checks rider liveness, raises an ops alert when a zone stops getting covered.',
detail:
'The only agent doing real work. It watches every assignment outcome, buckets failures by geographic cell, and decides whether a zone has genuinely lost coverage or is simply quiet. Escalation is rate-limited to one alert per zone per day.',
subscribes: ['booking.assigned', 'booking.assignment_failed'],
publishes: 'Ops alerts, customer delay notices, escalations.',
api: 'No — reads presence from Redis.',
autonomy: 'DISPATCH_AGENT_AUTONOMOUS = false',
gated: true,
lastSeen:
'Bound to stream ASSIGNMENTS on 16 Sep, and saw two assignment failures three seconds later. Both landed in zone unknown — the backend does not publish zone_id, which is exactly why a later commit started bucketing failures by geo cell instead.',
},
{
id: 'EXCEPTION_AGENT',
name: 'Exception',
domain: 'exception_handling',
status: 'degraded',
trigger: 'natsPull',
file: 'agents/exception_agent.py',
lines: 818,
tests: true,
events: 0,
load: 100,
blurb: 'Stall detection. Spots a rider who has stopped moving or a booking that has stopped progressing.',
detail:
'The largest agent. Two pull loops read rider pings and stall events, and a sweep walks active bookings looking for ones that have gone quiet. When it finds a stalled rider it publishes miler.stalled for the dispatch side to act on.',
subscribes: ['miler.location.updated', 'miler.stalled'],
publishes: 'miler.stalled',
api: 'Yes — api_post.',
autonomy: 'EXCEPTION_AGENT_AUTONOMOUS = false',
gated: true,
lastSeen:
'Lost NATS and never recovered. Both pull loops logged the same connection-closed error every two seconds for four days — 17,188 of the 18,146 lines in the log. The loop sleeps and retries a dead handle; it has no reconnect.',
},
{
id: 'EXPRESS_DISPATCH_AGENT',
name: 'Express Dispatch',
domain: 'express_dispatch',
status: 'idle',
trigger: 'nats',
file: 'agents/express_dispatch_agent.py',
lines: 391,
tests: true,
events: 0,
load: 0,
blurb: 'Handles dispatch requests raised from the console and express booking path.',
detail:
'The console-side counterpart to Dispatch. It waits on express.dispatch_req and works a booking the express path could not place, reading and writing through the Go API rather than assigning directly.',
subscribes: ['express.dispatch_req'],
publishes: 'Dispatch outcomes back onto the bus.',
api: 'Yes — api_get, api_post.',
autonomy: 'EXPRESS_AGENT_AUTONOMOUS = false',
gated: true,
lastSeen: 'Bound its consumer on 16 Sep and has received nothing. No express dispatch request has ever been published.',
},
{
id: 'ORDER_AGENT',
name: 'Order',
domain: 'order_management',
status: 'idle',
trigger: 'task',
file: 'agents/order_agent.py',
lines: 348,
tests: false,
events: 0,
load: 0,
blurb: 'Order intake, validation and categorisation.',
detail:
'Validates an incoming order, classifies it by zone and service type, and patches the result back. The most connected of the task agents — it reads, creates and patches through the Go API.',
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'Yes — get, post, patch.',
autonomy: 'no gate — never reachable',
gated: false,
lastSeen: 'Started on 16 Sep. Zero tasks received, because nothing sends tasks.',
},
{
id: 'ROUTE_OPTIMIZER',
name: 'Route Optimizer',
domain: 'route_optimization',
status: 'idle',
trigger: 'task',
file: 'agents/route_optimizer_agent.py',
lines: 465,
tests: false,
events: 0,
load: 0,
blurb: 'Builds delivery routes from zones and available hubs. Ten-minute cache.',
detail:
'Sequences waypoints into routes using zone membership and hub availability. SIMULATION: its zones and traffic patterns are hard-coded in _init_zones()/_init_traffic_patterns() and it makes no backend or routing-API call at all. Real stop ordering is done by internal/routing against the Valhalla-backed Route Optimization API, not here. The registry records it with status simulation.',
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',
autonomy: 'no gate — never reachable',
gated: false,
lastSeen: 'Started on 16 Sep. Zero tasks received.',
},
{
id: 'HUB_AGENT',
name: 'Hub',
domain: 'hub_management',
status: 'idle',
trigger: 'task',
file: 'agents/hub_agent.py',
lines: 460,
tests: false,
events: 0,
load: 0,
blurb: 'Hub operations — transit records, capacity, what is sitting where.',
detail:
'Tracks inventory at each hub and the transit records moving between them, and answers capacity questions about whether a hub can absorb more volume. SIMULATION: its hubs are 8 hard-coded entries in _init_hubs() (DL-HUB-01 and friends, with invented connected_hubs graphs) — not the hubs table. The registry records it with status simulation.',
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',
autonomy: 'no gate — never reachable',
gated: false,
lastSeen: 'Started on 16 Sep. Zero tasks received.',
},
{
id: 'FLEET_AGENT',
name: 'Fleet',
domain: 'fleet_management',
status: 'idle',
trigger: 'task',
file: 'agents/fleet_agent.py',
lines: 349,
tests: false,
events: 0,
load: 0,
blurb: 'Vehicles: status, capacity and live tracking.',
detail:
"Holds vehicle state and assignment, and answers which vehicle can carry a given load. It overlaps with the Go backend's own Vehicle model, which is the live source today. SIMULATION: its fleet is 19 vehicles hard-coded in _init_fleet() across Delhi, Mumbai, Bangalore, Hyderabad, Pune and Kolkata — not the vehicles table, and not the cities Doormile serves. The registry records it with status simulation for this reason.",
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',
autonomy: 'no gate — never reachable',
gated: false,
lastSeen: 'Started on 16 Sep. Zero tasks received.',
},
{
id: 'CUSTOMER_AGENT',
name: 'Customer',
domain: 'customer_communication',
status: 'idle',
trigger: 'task',
file: 'agents/customer_agent.py',
lines: 407,
tests: false,
events: 0,
load: 0,
blurb: 'Customer-facing notifications and tracking updates.',
detail:
'Composes and sends customer notifications across channels for booking, pickup and delivery events. Worth knowing before it is switched on: the backend has no SMS provider registered, so anything it sends by text goes to a log.',
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'Yes — api_get, api_post.',
autonomy: 'no gate — never reachable',
gated: false,
lastSeen: 'Started on 16 Sep. Zero tasks received.',
},
];
/** What the Go backend publishes, and the bus it publishes onto. */
export const SOURCE_SUBJECTS = ['booking.assigned', 'booking.assignment_failed', 'miler.location.updated'];
/**
* The last run, as the log tells it.
*
* `count` is how many times a line repeated. Two of these repeated 8,594 times
* each, which is the whole story of that run and the reason the repeat count is
* on screen rather than in a tooltip.
*/
export const RUN_LOG = [
{ at: '16 Sep 15:23:20', level: 'INFO', tone: 'info', count: null,
text: "DispatchAgent bound 'booking.assigned' on stream 'ASSIGNMENTS'" },
{ at: '16 Sep 15:23:20', level: 'INFO', tone: 'info', count: null,
text: '9 agents initialized (JARVIS + 8 specialized)' },
{ at: '16 Sep 15:23:23', level: 'INFO', tone: 'warn', count: 2,
text: '[DISPATCH] zone unknown — failure count 1/3, 2/3 (backend publishes no zone_id)' },
{ at: '20 Sep 19:51:23', level: 'ERROR', tone: 'error', count: 8594,
text: 'Stalled pull loop error: nats: connection closed' },
{ at: '20 Sep 19:51:23', level: 'ERROR', tone: 'error', count: 8594,
text: 'Location pull loop error: nats: connection closed' },
];
export const LOG_TOTAL_LINES = 18146;
export const LOG_ERROR_LINES = 17188;
/** Agents that subscribe to the bus themselves, in the order they appear. */
export const eventDrivenAgents = () => AGENTS.filter((a) => a.trigger === 'nats' || a.trigger === 'natsPull');
/** Agents that only run when JARVIS hands them work. */
export const taskDrivenAgents = () => AGENTS.filter((a) => a.trigger === 'task');
export const agentById = (id) => AGENTS.find((a) => a.id === id) || AGENTS[0];
/**
* The headline numbers.
*
* Derived rather than written down, so a change to the table above cannot leave
* a stat contradicting the cards underneath it — which is how a dashboard ends
* up saying "3 active" over three grey dots.
*/
export const networkStats = (agents = AGENTS) => {
const working = agents.filter((a) => a.status === 'active').length;
const gated = agents.filter((a) => a.gated).length;
return {
total: agents.length,
working,
degraded: agents.filter((a) => a.status === 'degraded').length,
idle: agents.filter((a) => a.status === 'idle').length,
events: agents.reduce((sum, a) => sum + (a.events || 0), 0),
gated,
autonomyOn: 0,
};
};

View File

@@ -6,21 +6,29 @@ Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `Doo
## 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.
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 **MileTruth** (or **Doormile AI**), subtitle **Operations Copilot**.
- **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.
- **Mounted in**: `src/layouts/AdminLayout.jsx` — rendered alongside the page content.
- **80% / 20% Split Layout**: By default on desktop (`window.innerWidth >= 1024`), the assistant is **open on initial load**, occupying 20% of the viewport (`--dai-dock-width: clamp(320px, 20vw, 460px)`), while the page content occupies the remaining 80%. Narrowed from 30vw on request — that width is what pushed the Deliveries tab strip past its container, so the 10vw goes back to the page.
- `clamp` rather than `max` because the **cap** is what makes a large monitor work: 20vw is 768px at 4K, a chat column wider than most documents. Three regimes — a 320px floor below ~1600px (20vw would be 256px on a 1280 laptop, too narrow for the composer and thread), 20vw between ~1600–2300px, and a 460px cap above that. At 1024px the floor wins, so the panel is 31% there, not 20%.
- At or below 900px the panel is a **full-width overlay with a scrim** and reserves no padding at all.
- `DOCK_NORMAL` in `AIPanel.jsx` must stay **byte-identical** to this value. The CSS applies before the effect that sets the property runs on first paint, so any difference between the two shows as the page jumping sideways on load.
- **Note:** this file is a duplicate of `src/components/assistant/CLAUDE.md`, and `src/lib/assistant/DoormileAI.css` is a dead copy of the stylesheet — nothing imports it. Only the `src/components/assistant/` copies are live.
- **Visual Separation**: An intentional **24px visible gutter** (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the page content/tables from the AI card so both surfaces are distinct.
- **Smooth Dismissal**: Closing via the header `×` button or navbar toggles `assistantOpen` off, smoothly expanding the page content to 100% full width. State is persisted in `localStorage ('doormileAssistantOpen')`.
- **`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).
- `AIPanel.js` — the portal, slide-over, focus/Escape handling, message state, history persistence, 4 clean action icons (History, Reset, Expand, Close), and the ask() flow.
- `AIWelcome.js` — greeting + suggestion pill chips (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.
- `AIComposer.js` — clean horizontal capsule pill container with textarea, mic, and `<LuSend />` button.
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
- `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
- `AIParts.js` — Spark / TypingIndicator / Metric / StatGrid / StateBlock.
- `pageContext.js` — route → context label + suggested questions.
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
- **`DoormileAI.css`** — the panel's stylesheet. Defines inset card geometry (`border-radius: 20px`, elevated shadow, floating margins off top/bottom/right edges on desktop).
- **`Home.jsx` Composer**: Prompt bar features a functional **Send** button (`Send` icon + "Send") replacing the previous Speak button. Top navigation bar features the Doormile red "D" mark.
### UI rules that are load-bearing, not cosmetic
@@ -29,7 +37,8 @@ An in-console Q&A assistant that answers operator questions about live data —
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
- **Each page offers ONLY its own suggestions.** `getPageContext` returns the route's context list and nothing else — no global set is appended. Asked for directly: on Orders the strip should be Orders questions, and likewise everywhere. This REVERSES the previous rule ("every page offers every suggestion"), which had already been walked back once from all twenty-one questions to a thirteen-question global set because Orders was offering "How many tenants do we have?" above the composer. Note it was barely observable before the change: every context declares at least four suggestions and the panel slices to four, so the globals mostly sat just past the cut — the behaviour was an accident of counting, not a rule. It is structural now. Dropping a question from a page's list does not remove it from the assistant: it stays typeable, stays a follow-up, and still leads on its own page.
- **A route missing from `ROUTES` falls back to `DEFAULT_CONTEXT`**, which is the generic operations set — and that is how a page ends up offering hub questions beside a table of bookings. `/doormile/bookings` did exactly that until it was given its own context. `/doormile/app-users` and `/doormile/competitive-intel` still fall back deliberately: the intent catalog has no questions about app users or competitor branches, and inventing chips that resolve to nothing would break the rule two lines down. Give a page its own context when real intents exist for it, not before.
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.

File diff suppressed because it is too large Load Diff

View File

@@ -189,6 +189,9 @@ export const executeCreateCustomer = async (payload) => {
return {
ok: false,
status,
// The server's own reason, unformatted, for callers that word the
// message themselves (the home page's plain-language toasts).
serverMessage,
message,
sourceCalls: [
{

View File

@@ -0,0 +1,92 @@
// ==============================|| Doormile AI — Autonomous Agent Factory ||============================== //
import { SkillRegistry } from '../skills/SkillRegistry';
import { wallClockNow } from './signals';
const SEVERITY_RANK = { critical: 0, warning: 1, watch: 2 };
export class AgentFactory {
/**
* Compiles an Agent configuration from a collection of skills.
*/
static createAgent(skills = SkillRegistry.getActiveSkills()) {
const activeSkills = Array.isArray(skills) ? skills : [];
// 1. Synthesize System Prompt
const instructions = activeSkills
.map((s) => `### ${s.name} (${s.category})\n${(s.instructions || '').trim()}`)
.join('\n\n');
const systemPrompt = `
You are the Doormile Autonomous Logistics Fleet Orchestrator.
You operate with the following active operational skill modules:
${instructions || 'Standard logistics routing and telemetry diagnostics.'}
Guiding Invariant:
Read-only queries execute instantly. Mutating actions (reassignments, pings, OTP enforcement) MUST return human-in-the-loop proposals with clear blast radius.
`.trim();
// 2. Aggregate Tools from active skills
const tools = activeSkills.flatMap((s) => s.tools || []);
const toolMap = new Map();
tools.forEach((t) => toolMap.set(t.name, t));
// 3. Telemetry Evaluation Engine
const evaluateTelemetry = (rows = [], now = wallClockNow()) => {
const allFindings = [];
for (const skill of activeSkills) {
if (typeof skill.evaluate === 'function') {
// Extract current threshold values
const rawThresholds = {};
if (skill.effectiveThresholds) {
Object.entries(skill.effectiveThresholds).forEach(([k, v]) => {
rawThresholds[k] = v.currentValue;
});
} else if (skill.thresholds) {
Object.entries(skill.thresholds).forEach(([k, v]) => {
rawThresholds[k] = v.value;
});
}
const findings = skill.evaluate(rows, now, rawThresholds);
if (Array.isArray(findings)) {
allFindings.push(...findings);
}
}
}
// Sort by severity (critical > warning > watch), then by affected row count descending
return allFindings.sort(
(a, b) =>
(SEVERITY_RANK[a.severity] ?? 99) - (SEVERITY_RANK[b.severity] ?? 99) ||
(b.count || 0) - (a.count || 0)
);
};
return {
name: 'Doormile Fleet Orchestrator',
version: '2.0.0',
activeSkillCount: activeSkills.length,
skills: activeSkills,
tools,
toolMap,
systemPrompt,
evaluateTelemetry,
executeSkillTool: async (toolName, args) => {
const tool = toolMap.get(toolName);
if (!tool || typeof tool.handler !== 'function') {
return { success: false, error: `Skill tool "${toolName}" not found or has no handler.` };
}
return tool.handler(args);
}
};
}
/**
* Helper to build the default active agent runtime based on current SkillRegistry state.
*/
static synthesizeDefaultAgent() {
return AgentFactory.createAgent(SkillRegistry.getActiveSkills());
}
}

View File

@@ -0,0 +1,254 @@
// ==============================|| Doormile AI — proposal executors ||============================== //
//
// The layer that turns a finding's proposal into a real API call.
//
// It exists as its own module because the skills deliberately have no API
// access at all — not one of them imports an endpoint, and their tool handlers
// only build proposal objects. That separation is worth keeping: a skill stays
// a pure function of rows and thresholds, which is why all eight are testable
// without a network. Execution is the part with consequences, so it lives in
// one place where the failure modes can be handled once.
//
// Two rules, both learned the hard way in assignActions.js:
//
// • Never report an action that did not happen. A card that says "rider
// notified" when the push returned 400 is worse than one that says nothing:
// a dispatcher who believes a rider was pinged does not follow up.
// • A partial success is a partial success. Notifying six riders out of eight
// is reported as six out of eight, with the two failures named.
import { getMilers, buildMilerLookup, notifyRider, batchAssignBookings } from '@/api/doormile';
/** A stable, human-facing reference for a row. */
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
/**
* Rider pushes key off milerprofileid; findings carry mileruserid.
*
* These are two different small integers on the same record, and using the
* wrong one fails silently in both directions — the notify 404s, or a different
* rider is paged. buildMilerLookup is the same bridge the Orders page uses;
* this must never grow a second one.
*/
const profileIdFor = (row, lookup) => lookup.byUserId.get(String(row?.userid))?.milerprofileid;
// ---- notifyRider ------------------------------------------------------------
const executeNotifyRider = async (scope, message) => {
const rows = (scope || []).filter((r) => r?.userid);
if (!rows.length) {
return { ok: false, message: 'None of these parcels has an assigned rider to notify.', sourceCalls: [] };
}
// One fetch for the whole batch. Per row would be an identical request each
// time, and the lookup is the same for all of them.
const milers = await getMilers();
const lookup = buildMilerLookup(milers);
const sourceCalls = [];
const notified = [];
const unreachable = [];
for (const row of rows) {
const profileId = profileIdFor(row, lookup);
// No profile id means no push is possible. Recorded rather than skipped,
// so the operator is not left assuming a phone buzzed.
if (!profileId) {
unreachable.push(`${ref(row)} (${row.ridername || 'rider'}: no device registered)`);
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(profileId, 'Doormile Ops', message);
notified.push(ref(row));
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'complete',
stats: `${ref(row)} → ${row.ridername || 'rider'}`
});
} catch (err) {
unreachable.push(`${ref(row)} (${err?.message || 'push failed'})`);
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'error',
errorMessage: err?.message || 'notification failed'
});
}
}
const parts = [];
if (notified.length) parts.push(`Notified ${notified.length} rider${notified.length === 1 ? '' : 's'}.`);
if (unreachable.length) parts.push(`Could not reach ${unreachable.length}: ${unreachable.join('; ')}.`);
return {
ok: notified.length > 0,
// Same contract as executeAssignMiler: say whether this was partial rather
// than leaving every caller to work it out from a different field.
partial: notified.length > 0 && unreachable.length > 0,
message: parts.join(' ') || 'Nothing was sent.',
notified,
unreachable,
sourceCalls
};
};
// ---- assignMiler ------------------------------------------------------------
//
// This WAS review-only, and the note explaining why is worth keeping because
// it names the actual constraint:
//
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. The admin route that
// could back it, POST /admin/bookings/:id/assign-miler, needs a chosen rider
// per booking, which a finding does not pick.
//
// The resolution was the backend decision that note called for:
// POST /admin/bookings/batch-assign now runs the same solver as the hub route
// (controllers/batchAssignService.go) under admin auth, so the console gets the
// solver that PICKS riders rather than one that needs them named.
//
// ⚠ This commits. There is no preview/reconcile step on either batch-assign
// route, so the proposal gate in the UI is the only thing between a finding and
// a real assignment. canExecuteProposal must stay the gate, and the card must
// keep requiring an explicit click — do not make this run automatically.
const executeAssignMiler = async (scope) => {
const rows = (scope || []).filter((r) => r?.bookingid);
if (!rows.length) {
return { ok: false, message: 'None of these findings carries a booking to assign.', sourceCalls: [] };
}
const bookingIds = [...new Set(rows.map((r) => Number(r.bookingid)))];
const target = 'POST /admin/bookings/batch-assign';
try {
const res = await batchAssignBookings(bookingIds);
// The backend reports per booking, and a partial success is a partial
// success — the same rule executeNotifyRider follows. Reporting "assigned"
// when four of nine landed sends a dispatcher away from five parcels that
// still have nobody.
const data = res?.data || res || {};
const assigned = Number(data.assigned ?? 0);
const skipped = Number(data.skipped ?? 0);
const sequenced = Number(data.riderssequenced ?? 0);
const reasons = (data.results || [])
.filter((r) => !r?.assigned && r?.reason)
.map((r) => `${r.bookingno || `#${r.bookingid}`} (${r.reason})`);
const parts = [];
if (assigned) parts.push(`Assigned ${assigned} order${assigned === 1 ? '' : 's'}.`);
if (skipped) parts.push(`Could not assign ${skipped}: ${reasons.join('; ') || 'no reason given'}.`);
// Stop order needs ROUTE_OPTIMIZER_URL configured. Saying nothing when it
// is zero would leave the operator assuming routes were ordered.
if (assigned && !sequenced) parts.push('Stops were not sequenced (route optimiser unavailable).');
else if (sequenced) parts.push(`Sequenced ${sequenced} rider${sequenced === 1 ? '' : 's'}.`);
return {
ok: assigned > 0,
// Declared, not inferred. reportActed used to detect a partial run by
// looking for an `unreachable` array, which only the notify executor
// returns — so a batch that assigned two of three was recorded as a
// clean success. The message said "partial", the telemetry said "ok",
// and the measurement the findings table exists for was wrong.
partial: assigned > 0 && skipped > 0,
message: parts.join(' ') || 'Nothing was assigned.',
sourceCalls: [{
name: 'batchAssignBookings',
target,
status: assigned > 0 ? 'complete' : 'error',
stats: `${assigned} assigned, ${skipped} skipped, ${sequenced} sequenced`,
...(assigned === 0 ? { errorMessage: reasons.join('; ') || 'no rider under capacity' } : {})
}]
};
} catch (err) {
return {
ok: false,
message: err?.message || 'The assignment did not complete.',
sourceCalls: [{
name: 'batchAssignBookings', target, status: 'error',
errorMessage: err?.message || 'assignment failed'
}]
};
}
};
// ---- registry ---------------------------------------------------------------
/**
* Keyed on the `tool` a finding's proposal actually carries.
*
* Deliberately NOT the skills' declared tool names (ping_stalled_rider,
* reassign_sla_critical_order, …). Those belong to a separate, currently unused
* layer whose handlers only build proposals; the UI renders `proposal.tool`,
* and that is what an operator's click has to resolve.
*
* Verbs with no entry here have no endpoint behind them yet. That is not an
* error — the card renders them disabled and says "Review only" rather than
* pretending.
*/
const EXECUTORS = {
notifyRider: (finding) =>
executeNotifyRider(
finding?.proposal?.scope,
finding?.severity === 'critical'
? 'Urgent: this delivery needs attention now. Please check the app.'
: 'Please check this delivery in the app when you can.'
),
// alert_low_battery_rider: the same endpoint notifyRider already uses, so
// this is a message, not a new capability. The registry seed names
// POST /admin/milers/:id/notify as its target and it was marked REVIEW ONLY
// only because nothing had been wired to it.
//
// The tool key is snake_case here, unlike notifyRider above, because
// RiderBatterySafetySkill.js:84 emits `alert_low_battery_rider` and this map
// is keyed on the verb a finding actually carries — not on a convention.
// Renaming the skill's verb to match would be the tidier fix and a wider
// change; it is not worth touching a shipped skill's output for.
alert_low_battery_rider: (finding) =>
executeNotifyRider(
finding?.proposal?.scope,
'Low battery: please charge now, or report to the nearest hub if you cannot.'
),
assignMiler: (finding) => executeAssignMiler(finding?.proposal?.scope),
// trigger_auto_dispatch: LateDispatchSkill proposes "auto-assign orders that
// have waited too long for dispatch", which is exactly what batch-assign
// does. seed.go marked it Target: "none yet" because no route picked riders
// for an admin login; /admin/bookings/batch-assign now does, so this needed
// no new capability — only the realisation that it is the same action under
// a different name.
//
// Deliberately NOT given its own executor: two code paths assigning riders
// would be a second definition of the same write, and the codebase already
// carries three (internal/assignment's AI path, AssignMilerToBooking, and
// the batch solver). One more would be the one that drifts.
trigger_auto_dispatch: (finding) => executeAssignMiler(finding?.proposal?.scope)
};
/** Whether a finding's proposal can actually be carried out. */
export const canExecuteProposal = (finding) => Boolean(EXECUTORS[finding?.proposal?.tool]);
/** Every verb that currently has a real executor behind it. */
export const executableTools = () => Object.keys(EXECUTORS);
/**
* Runs a finding's proposal.
*
* Throws when there is no executor rather than resolving quietly, so a caller
* cannot mistake "nothing happened" for success. The UI is expected to have
* checked canExecuteProposal first and disabled the control.
*/
export const executeProposal = async (finding) => {
const run = EXECUTORS[finding?.proposal?.tool];
if (!run) {
throw new Error(`No executor for "${finding?.proposal?.tool}" — this proposal is review-only.`);
}
return run(finding);
};

View File

@@ -0,0 +1,112 @@
// ==============================|| Doormile AI — the ops briefing ||============================== //
//
// Turns the findings from signals.js into the shape the assistant panel already
// renders: a headline, a detail body, and a metric. Pure — it takes rows and an
// instant and returns an object, so the whole briefing can be tested without a
// network, a clock, or React.
//
// This is the agent's "explain" step. Every line it produces is traceable to a
// rule in signals.js and to the rows that rule matched, because an operator who
// cannot see why the agent believes something will not act on it — and an
// unexplained instruction from a bot is exactly the thing CLAUDE.md §3 refuses
// to ship.
import { AgentFactory } from './AgentFactory';
import { ALL_CLEAR, wallClockNow } from './signals';
import { toAgentRows } from './normalise';
const SEVERITY_LABEL = {
critical: 'Needs attention now',
warning: 'Worth looking at',
watch: 'Keep an eye on'
};
/**
* A truncated scan is a floor, not a total.
*
* `/admin/bookings` caps pagesize, so a scan drains pages up to a budget and
* reports whether it ran out. Every count built on a truncated scan says "at
* least", because the alternative — stating a total that is quietly a subset —
* is the exact failure the assistant's verifiability contract exists to
* prevent. A missed breach is worse than an approximate one, so the briefing
* still runs; it just stops claiming completeness.
*/
export const countPrefix = (truncated) => (truncated ? 'at least ' : '');
/** One finding rendered as a block of lines. */
const renderFinding = (finding, truncated) => {
const lines = [];
lines.push(`${SEVERITY_LABEL[finding.severity]} — ${finding.title}`);
lines.push(finding.why);
if (finding.detail?.length) {
lines.push(...finding.detail.map((d) => ` • ${d}`));
const shown = finding.detail.length;
if (finding.count > shown) {
lines.push(` • …and ${countPrefix(truncated)}${finding.count - shown} more`);
}
}
if (finding.proposal) {
lines.push(` → ${finding.proposal.label} (${finding.proposal.blastRadius})`);
}
return lines.join('\n');
};
/**
* Builds the full briefing.
*
* `scan` is the result of the assistant's own booking scan — `{ rows,
* truncated }` — so the briefing inherits the same data the rest of the
* catalog answers from and cannot disagree with it.
*/
export const buildBriefing = (scan, now = wallClockNow()) => {
// Raw `/admin/bookings` rows in, agent rows out. Without this the rules read
// undefined on every field and the briefing reports an all-clear board.
const rows = toAgentRows(scan?.rows);
const truncated = Boolean(scan?.truncated);
// The SAME engine the Exceptions banner runs, deliberately.
//
// This used to call a private set of rules in signals.js that duplicated five
// of the skills and hardcoded their thresholds. The two drifted immediately —
// signals.js flagged a stalled rider at 25 minutes while DoorstepStallSkill
// used 20 — so the chat panel and the banner disagreed about the same parcel,
// and a threshold moved in Agent Studio changed one surface but not the other.
//
// Going through the registry means there is one definition of every rule, one
// place thresholds live, and the chat panel sees every enabled skill rather
// than the five somebody happened to reimplement here.
const findings = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(rows, now);
if (!findings.length) {
return {
headline: ALL_CLEAR,
detail: truncated
? 'Note: the booking scan hit its page budget, so this covers the most recent orders rather than every one.'
: `Checked ${rows.length} open and recent bookings.`,
findings,
metric: { value: 0, label: 'issues found' }
};
}
const critical = findings.filter((f) => f.severity === 'critical');
const affected = new Set(findings.flatMap((f) => f.rows.map((r) => r.bookingid))).size;
const headline = critical.length
? `${critical.length} thing${critical.length === 1 ? '' : 's'} need${critical.length === 1 ? 's' : ''} attention now, across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`
: `Nothing critical, but ${findings.length} thing${findings.length === 1 ? '' : 's'} worth looking at across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`;
const body = findings.map((f) => renderFinding(f, truncated)).join('\n\n');
const footer = truncated
? '\n\nThe booking scan hit its page budget, so these counts are a floor — there may be more.'
: '';
return {
headline,
detail: `${body}${footer}`,
findings,
metric: { value: findings.length, label: findings.length === 1 ? 'issue found' : 'issues found' }
};
};

View File

@@ -0,0 +1,150 @@
// ==============================|| Doormile AI — finding persistence ||============================== //
//
// Reports what the rule skills noticed to the backend, so it survives the
// render that produced it.
//
// The eight skills run here, in the operator's browser, on a 60-second React
// Query interval — and their output was discarded every cycle. Three questions
// had no answer at all:
//
// • Has this booking been flagged before, and how many times?
// • Which skills fire most, and which are ignored every single time?
// • Did the proposal an operator carried out actually clear the finding?
//
// The third is the one worth having. AgentOperationsBanner already re-runs its
// scan after executing a proposal precisely to see whether the finding
// disappears — that evidence existed for one render and then vanished.
//
// ─── Rules this module follows ───────────────────────────────────────────────
//
// • It is FIRE-AND-FORGET and never throws. Reporting is observability; the
// briefing must render identically whether or not the POST lands. A
// dispatcher looking at a stalled parcel does not care that telemetry is
// down, and must not be shown an error about it.
//
// • It sends the CLEARED set too. Only the console knows the full set it
// evaluated, so only it can tell the backend which fingerprints are gone.
// The backend cannot distinguish "resolved" from "the operator closed the
// tab".
//
// • It sends no personal data. Scope goes as booking ids only — no names,
// phones or addresses. The findings table is for measuring the skills, not
// a second copy of the bookings.
import { reportFindings, reportFindingActed } from '@/api/doormile';
/**
* The identity of a finding across polls.
*
* Deliberately only the skill and its scope's booking ids, sorted. NOT severity
* and NOT counts: both drift while the underlying problem is unchanged (an
* ageing SLA breach climbs from warning to critical), and including them would
* make every escalation look like a brand-new finding and reset the "how long
* has this been open" measurement — which is the single most useful thing the
* table records.
*
* Sorted because scope order is not stable across scans.
*/
export const fingerprintOf = (finding) => {
const skill = finding?.skillId || finding?.skill || 'unknown';
// The PROPOSED ACTION is part of the identity.
//
// One skill legitimately raises several findings over the same booking set —
// SlaGuardian emits both "notify the assigned riders" and "assign these
// orders" for the same three parcels. Keyed on skill + scope alone those
// collide, and because the fingerprint is the upsert key the second finding
// silently overwrites the first: one of the two disappears from the table and
// from every measurement built on it.
//
// Caught by running the real Exceptions page against a mock backend; the unit
// tests used a distinct skill per fingerprint and never produced a collision.
const tool = finding?.proposal?.tool || 'none';
const ids = (finding?.proposal?.scope || [])
.map((r) => r?.bookingid)
.filter((id) => id != null)
.map(Number)
.sort((a, b) => a - b);
return `${skill}:${tool}:${ids.join(',')}`;
};
const toItem = (finding) => ({
skillid: finding?.skillId || finding?.skill || 'unknown',
fingerprint: fingerprintOf(finding),
severity: finding?.severity || 'info',
title: String(finding?.title || finding?.headline || '').slice(0, 300),
proposaltool: finding?.proposal?.tool || '',
// Ids only. See the no-personal-data rule above.
bookingids: (finding?.proposal?.scope || [])
.map((r) => r?.bookingid)
.filter((id) => id != null)
.map(Number),
tenantid: Number(localStorage.getItem('tenantid')) || null
});
// Fingerprints seen on the previous scan, so this one can report what went
// away. Module-level rather than component state: the banner remounts on
// navigation and a remount must not look like every finding clearing at once.
let previous = new Set();
/**
* Report one scan's findings. Returns nothing and never rejects.
*
* Call AFTER the findings are rendered, not before — this must never sit in
* front of what the operator sees.
*/
export const reportScan = async (findings) => {
try {
const items = (findings || []).map(toItem).filter((i) => i.fingerprint && i.skillid);
const current = new Set(items.map((i) => i.fingerprint));
const cleared = [...previous].filter((fp) => !current.has(fp));
// Nothing open and nothing cleared is the common case on a quiet board;
// skip the round trip entirely.
if (!items.length && !cleared.length) {
previous = current;
return;
}
await reportFindings({ findings: items, cleared });
previous = current;
} catch {
// Silent by design. A failed report is not something an operator can act
// on, and the previous set is deliberately NOT updated — so the next scan
// retries the same clear set rather than losing it.
}
};
/**
* Record that an operator carried out a proposal, and how it went.
*
* `result` mirrors what the executors actually return: a partial success is a
* partial success. Flattening six-riders-notified-out-of-eight to "ok" would
* lose exactly the distinction actions.js preserves.
*/
export const reportActed = async (finding, executionResult) => {
try {
const fp = fingerprintOf(finding);
if (!fp) return;
let result = 'failed';
if (executionResult?.ok) {
// `partial` is declared by the executor. The fallback covers an executor
// that predates the field; without the declaration a batch-assign that
// skipped a booking reported as a clean success, because `unreachable`
// is a notify-only field.
const partial = typeof executionResult.partial === 'boolean'
? executionResult.partial
: (Array.isArray(executionResult?.unreachable) && executionResult.unreachable.length > 0);
result = partial ? 'partial' : 'ok';
}
await reportFindingActed(fp, result);
} catch {
// Same reasoning as reportScan: the action itself already happened and was
// reported to the operator. Losing its telemetry must not surface as a
// failure of the action.
}
};
/** Tests only — the module-level previous set would otherwise leak between them. */
export const __resetForTests = () => {
previous = new Set();
};

View File

@@ -0,0 +1,53 @@
// ==============================|| Doormile AI — scan row adapter ||============================== //
//
// The assistant's booking scan returns RAW rows straight off `/admin/bookings`
// — `status`, `createdat`, `assignedmileruserid`, `serviceoptions[0]` — not the
// normalised delivery rows the Deliveries page renders. The two shapes share
// almost no field names, so the agent's rules cannot read a scan row directly.
//
// This adapter is the seam. It exists as its own file because getting it wrong
// fails silently in the worst possible way: every rule reads `undefined`, every
// rule matches nothing, and the agent cheerfully reports "nothing needs
// attention" on a board that is on fire. That is precisely the failure mode
// CLAUDE.md §3 exists to prevent, and it is invisible without a test.
//
// The status derivation deliberately goes through the console's own
// `mapBookingStatusToDeliveryStatus` rather than a local copy — the same
// function the Deliveries page's table calls. That is the house rule (never
// hand-roll a second data path) and it is also what guarantees the agent and
// the screen can never disagree about what state a parcel is in.
import { mapBookingStatusToDeliveryStatus } from '@/api/doormile/queries';
/** The rider's arrival stamp, which the API has spelled several ways. */
const reachedAtOf = (b) => b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
/** Rider display name, from whichever join carried it. */
const riderNameOf = (b) =>
b.milername || b.ridername || b.assignedmilername || b.miler?.name || undefined;
/**
* One raw booking row → the shape signals.js reads.
*
* Every field is derived, never invented: a raw row that carries no service
* option yields `expecteddeliverytime: undefined`, and the SLA rules skip it
* rather than treating a missing promise as a kept or broken one.
*/
export const toAgentRow = (b) => ({
bookingid: b.bookingid ?? b.id,
orderid: b.bookingno || (b.bookingid ? `#${b.bookingid}` : '—'),
// Booking status, consignment status and reachedat resolved by the console's
// own mapper — including the Converted_To_Consignment handoff, where the
// lifecycle moves onto the consignment and the booking's status freezes.
orderstatus: mapBookingStatusToDeliveryStatus(b),
orderdate: b.createdat || b.orderdate || b.updatedat,
expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat,
reachedat: reachedAtOf(b),
userid: b.assignedmileruserid ?? b.mileruserid,
ridername: riderNameOf(b)
});
/** A whole scan's rows, adapted. Non-array input yields an empty list. */
export const toAgentRows = (rows) => (Array.isArray(rows) ? rows.map(toAgentRow) : []);

View File

@@ -0,0 +1,44 @@
// ==============================|| Doormile AI — shared agent primitives ||============================== //
//
// What is left after the rules moved out.
//
// This file used to hold its own copy of five detection rules — breached SLA,
// at-risk SLA, aging unassigned work, doorstep stalls, rider saturation — each
// with a hardcoded threshold in a local THRESHOLDS object. Every one of those
// rules already existed as a skill in skills/definitions/, with the same
// finding id and a threshold an operator can tune in Agent Studio.
//
// Two implementations of one rule is a bug with a delay fuse, and this one had
// already fired: signals.js flagged a doorstep stall at 25 minutes while
// DoorstepStallSkill used 20, so the chat panel and the Exceptions banner
// disagreed about the same rider, out of the box, before anyone touched a
// slider. Worse, the hardcoded copy could not see the registry at all — tuning
// a threshold moved the banner and left the chat panel where it was.
//
// The rules now live in exactly one place: skills/definitions/. Everything that
// needs findings goes through AgentFactory, which reads the registry, so there
// is one definition per rule and one place a threshold can be changed.
//
// What remains here is the small shared vocabulary that is genuinely common to
// every consumer and belongs to none of them.
import dayjs from 'dayjs';
/**
* "Now", in the same form every Doormile timestamp is stored in.
*
* NOT `new Date().toISOString()`. That returns UTC, and parseDoormileTimestamp
* deliberately strips any zone marker and reads the remaining digits as IST
* wall-clock — so an ISO string would hand the rules a clock running 5h30m
* slow, and every parcel less than five and a half hours overdue would look
* like it was still in the future. The agent would report an all-clear board
* through most of a working day.
*
* This was a live bug. It passed every unit test, because the tests all passed
* `now` explicitly and only the default was wrong.
*/
export const wallClockNow = () => dayjs().format('YYYY-MM-DD HH:mm:ss');
/** Shown when every enabled skill returns no findings. */
export const ALL_CLEAR =
'Nothing needs attention right now — no breached promises, no stalled riders, and no aging unassigned work.';

View File

@@ -191,10 +191,10 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// Sequential on purpose. These are writes against real dispatch records, and
// firing a burst of them concurrently makes a partial failure much harder to
// report accurately — which order did not land, and to whom.
// eslint-disable-next-line no-restricted-syntax
for (const t of targets) {
try {
// eslint-disable-next-line no-await-in-loop
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
assigned += 1;
assignedRiders.add(String(t.mileruserid));
@@ -218,12 +218,12 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// drops back should feel one buzz, not ten.
let notified = 0;
if (lookup) {
// eslint-disable-next-line no-restricted-syntax
for (const userid of assignedRiders) {
const rider = lookup.byUserId.get(userid);
if (rider?.milerprofileid) {
try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(rider.milerprofileid);
notified += 1;
} catch {

View File

@@ -124,18 +124,18 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
if (hasCoords(row)) {
located.push(row);
// eslint-disable-next-line no-continue
continue;
}
const key = cacheKey(row);
if (cache?.has(key)) {
located.push({ ...row, ...cache.get(key) });
// eslint-disable-next-line no-continue
continue;
}
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
// eslint-disable-next-line no-await-in-loop
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
const found = {
deliverylatitude: place?.geometry?.location?.lat?.(),
@@ -147,7 +147,7 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
// Only wait after a real request. A cache hit or a sheet coordinate costs
// nothing, which is what makes a re-run fast.
// eslint-disable-next-line no-await-in-loop
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
}

View File

@@ -128,15 +128,15 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (String(row.finalprice ?? '') !== '') {
out.push(row);
// eslint-disable-next-line no-continue
continue;
}
if (!match) {
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
// eslint-disable-next-line no-continue
continue;
}
// eslint-disable-next-line no-await-in-loop
const km = await calculateDrivingDistance(
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
@@ -144,7 +144,7 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (km == null) {
out.push({ ...row, priceError: 'could not measure the distance' });
// eslint-disable-next-line no-continue
continue;
}
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
@@ -209,7 +209,7 @@ export const executeCreateBulk = async (rows, shared) => {
const batch = batches[b];
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
try {
// eslint-disable-next-line no-await-in-loop
const res = await createExpressBookingBulk(batch);
// Three shapes, most-nested first. The live endpoint returns
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
@@ -236,7 +236,7 @@ export const executeCreateBulk = async (rows, shared) => {
status: 'error',
errorMessage: res.message || 'Rejected'
});
// eslint-disable-next-line no-continue
continue;
}

View File

@@ -103,7 +103,7 @@ export const advance = (flow) => {
const already = s.field === 'name' ? draft.firstname : draft[s.field];
if (already) {
step += 1;
// eslint-disable-next-line no-continue
continue;
}
return { flow: { ...flow, step }, ask: s.ask, done: false };

View File

@@ -37,18 +37,18 @@ export const advanceFlow = async (steps, flow) => {
const s = steps[step];
if (!applicable(s, draft) || draft[s.id] !== undefined) {
step += 1;
// eslint-disable-next-line no-continue
continue;
}
if (s.auto) {
// eslint-disable-next-line no-await-in-loop
const auto = await s.auto(draft);
if (auto?.patch) draft = { ...draft, ...auto.patch };
if (auto?.value !== undefined) {
draft = s.apply(draft, auto.value);
step += 1;
// eslint-disable-next-line no-continue
continue;
}
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
@@ -74,7 +74,14 @@ export const answerFlowStep = async (steps, flow, raw, option) => {
let value = raw;
if (s.resolve) {
const resolved = await s.resolve(raw);
// `option` as well as `raw`, so a step whose UI already resolved the
// answer can say so. The address step picks a place from a suggestion
// list that has already returned coordinates — re-geocoding the label it
// just handed us is a round trip that can also come back with a DIFFERENT
// place than the one the operator chose.
//
// Every existing resolve ignores the second argument, so this is additive.
const resolved = await s.resolve(raw, option);
if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
value = resolved.value;
}

View File

@@ -1,613 +0,0 @@
import dayjs from 'dayjs';
import {
assignMilerToBooking, createExpressBooking, createExpressBookingBulk, createTenantCustomer,
getAdminCustomers, getAdminPricing, getalltenants, getallriders, notifyMiler,
} from '@/api/doormile';
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
import { fetchBookingsForDay, scanBookings } from './scan';
import { bestNameMatch, bookingCharge, dayFromWords, formatRupees, isCancelled } from './vocab';
/**
* The assistant's five writes: a customer, a single order, a batch, a rider
* assignment, and a repeated day.
*
* **All five are conversations**, one question per turn — not forms. That is
* explicit product direction, and there is no create-form component here.
*
* **The write gate is non-negotiable.** A flow gathers, then shows exactly what
* will be sent, and the mutation fires only when the operator presses the
* button. `execute` is the only mutating function in this module and nothing
* calls it from a `match`. No intent can trigger a write.
*
* The panel intercepts a reply *before* the router sees it whenever a flow is
* open. That is load-bearing rather than a tidy-up: the router matches text, and
* a bare answer like `9876543210` matches no intent, so without the intercept
* every reply would be lost to "I can't answer that one yet".
*/
/** Customer creation writes to `/admin/tenantcustomers`.
*
* Settled by evidence: `POST /admin/customers` answers 405 Method Not Allowed —
* the route exists and POST is not among its methods. That resource grows a
* customer as a side effect of a booking, which is also why its records carry
* no address. The Customers page reads tenant customers for the same reason, so
* a customer created here appears there immediately.
*
* Creating one *via a booking* was rejected outright: "add a customer" must
* never silently dispatch a delivery.
*/
const CUSTOMER_STEPS = [
{ key: 'firstname', question: 'What is the customer’s first name?', required: true },
{ key: 'lastname', question: 'And their last name? (say "skip" if you don’t have it)' },
{
key: 'phone',
question: 'Their 10-digit mobile number?',
required: true,
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
},
{ key: 'email', question: 'An email address? (say "skip" if there isn’t one)' },
];
const ORDER_STEPS = [
{ key: 'tenant', question: 'Which client is this order for?', required: true },
{ key: 'pickupaddress', question: 'Where is it collected from?', required: true },
{ key: 'pickupcity', question: 'Which city is the pickup in?', required: true },
{ key: 'pickuppincode', question: 'And the pickup pincode?', required: true },
{ key: 'customer_name', question: 'Who is receiving it?', required: true },
{
key: 'customer_phone',
question: 'Their 10-digit mobile number?',
required: true,
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
},
{ key: 'deliveryaddress', question: 'What is the delivery address?', required: true },
{ key: 'deliverycity', question: 'Which city is the delivery in?', required: true },
{ key: 'deliverypincode', question: 'And the delivery pincode?', required: true },
{
key: 'finalprice',
question: 'What should this be charged at? (a number, or "skip" to send 0)',
validate: (value) =>
value === '' || !Number.isNaN(Number(value)) ? null : 'That is not a number — try again.',
},
{ key: 'notes', question: 'Anything the rider should know? (or "skip")' },
];
const BULK_STEPS = [
{ key: 'tenant', question: 'Which client are these orders for?', required: true },
{ key: 'pickupaddress', question: 'Where are they all collected from?', required: true },
{ key: 'pickupcity', question: 'Which city is that pickup in?', required: true },
{ key: 'pickuppincode', question: 'And its pincode?', required: true },
{
key: 'rows',
question:
'Now paste the recipients — one per line, as:\n\nname, phone, address, city, pincode\n\nPaste them all in one message.',
required: true,
},
];
/**
* Assign or reassign one order to a rider.
*
* The backend already assigns riders on its own (booking creation publishes an
* assignment-requested event that a worker picks up within minutes) — this
* flow is always an OVERRIDE of a decision the backend may have already made.
* That is why the second step re-states who currently holds the order rather
* than silently overwriting them: an operator who has not been told is far
* more likely to reassign a rider who was already correctly, better-informed,
* on their way.
*
* Unlike the create flows, both steps here need a live lookup (find the order,
* then find the rider) rather than a plain string — see `resolve` below.
*/
const ASSIGN_STEPS = [
{
key: 'orderRef',
question: 'Which order? (the order number, or the DM-… code)',
required: true,
resolve: async (value) => {
const scan = await scanBookings();
const needle = String(value).trim().toLowerCase().replace(/^#/, '');
const booking = scan.rows.find(
(row) =>
String(row.bookingid).toLowerCase() === needle ||
String(row.bookingno || '').toLowerCase() === needle ||
String(row.bookingno || '').toLowerCase().endsWith(needle)
);
if (!booking) return { error: `No order matches "${value}" — try the order number again.` };
return { value: booking };
},
},
{
key: 'riderRef',
/* Names who currently has it, if anyone — the whole point of asking rather
than just overwriting. */
question: (values) => {
const order = values.orderRef;
const label = order?.bookingno || `#${order?.bookingid}`;
return order?.assignedmileruserid
? `${label} is currently assigned. Who should it go to instead?`
: `Who should ${label} go to?`;
},
required: true,
resolve: async (value, values) => {
const riders = (await getallriders()) || [];
const rider = bestNameMatch(value, riders, (r) => r.displayname || r.authname);
if (!rider) return { error: `No rider matches "${value}" — try their name again.` };
if (String(rider.userid) === String(values.orderRef?.assignedmileruserid)) {
return { error: `${rider.displayname || rider.authname} already has this order — name someone else.` };
}
return { value: rider };
},
},
];
/**
* Repeat a previous day's dispatched orders as fresh bookings today.
*
* One question (which day), then a single automated pass — not a run of
* questions — because a booking already carries almost everything
* `createExpressBooking` needs, including both sets of coordinates. Only the
* recipient's name and phone are missing, and those come from the
* `appcustomerid` → `/admin/customers` join, the same one `fetchDeliveries`
* already does.
*
* Duplicate-safety is INVERTED here versus every other flow: near-identical
* orders are the *goal*. The only real risk is running the same day's repeat
* twice, so the guard fingerprints on `(appcustomerid, delivery address,
* pickup pincode)` against TODAY's own bookings, not against the source day.
*
* Prices are re-quoted at today's tariff — `getAdminPricing` + the same
* OSRM-distance-and-tariff formula `CreateOrder.jsx` already uses — never
* copied from the original booking, since a tariff can have changed since.
* Cancelled orders are never repeated, and the lookback is 7 days.
*/
const REPEAT_STEPS = [
{
key: 'sourceDay',
question: 'Which day should I repeat? (say "yesterday", or a date like 2026-08-20)',
required: true,
resolve: async (value) => {
const day = dayFromWords(value);
const today = dayjs().format('YYYY-MM-DD');
if (day > today) return { error: 'That is in the future — nothing to repeat yet.' };
if (day < dayjs().subtract(7, 'day').format('YYYY-MM-DD')) {
return { error: 'That is more than 7 days back — repeat only looks at the last week.' };
}
const [sourceScan, todayScan, customers] = await Promise.all([
fetchBookingsForDay(day),
fetchBookingsForDay(today),
getAdminCustomers().catch(() => []),
]);
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c]));
const fingerprint = (booking) =>
`${booking.appcustomerid}|${String(booking.deliveryaddress || '').toLowerCase().trim()}|${booking.pickuppincode || ''}`;
const alreadyRepeated = new Set(todayScan.rows.map(fingerprint));
const dispatched = sourceScan.rows.filter((b) => b.assignedmileruserid || b.consignmentid);
const candidates = [];
const skipped = [];
dispatched.forEach((booking) => {
/* Cancelled orders are never repeated — not even reported as skipped,
since there is nothing an operator would do about that reason. */
if (isCancelled(booking)) return;
if (alreadyRepeated.has(fingerprint(booking))) {
skipped.push({ booking, reason: 'already repeated today' });
return;
}
if (!booking.pickupaddress) {
skipped.push({ booking, reason: 'no pickup address recorded on the original order' });
return;
}
if (booking.tenantid == null) {
skipped.push({ booking, reason: 'no client recorded on the original order' });
return;
}
if (!Number.isFinite(Number(booking.deliverylatitude)) || !Number.isFinite(Number(booking.deliverylongitude))) {
skipped.push({ booking, reason: 'delivery address has no saved coordinates' });
return;
}
const customer = customerMap.get(booking.appcustomerid);
const phone = customer?.phone || customer?.contactno;
if (!phone) {
skipped.push({ booking, reason: 'no customer contact number on file' });
return;
}
candidates.push({ booking, customer_name: customer.firstname || customer.name || '', customer_phone: phone });
});
if (!candidates.length) {
return {
error: skipped.length
? `None of the ${skipped.length} dispatched order(s) from ${day} can be repeated — every one is missing something.`
: `No dispatched orders were found on ${day}.`,
};
}
/* Re-quoted per candidate, not fetched once for the day — a repeat can
span more than one client, and each client's own pricing row decides
the number (same reasoning as the bulk-create flow's per-tenant rate
lookup, just per-row here instead of per-file). */
const pricing = await getAdminPricing().catch(() => []);
const priced = await Promise.all(
candidates.map(async (candidate) => {
const rate = pricing.find((row) => String(row.tenantid) === String(candidate.booking.tenantid));
if (!rate) return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'no pricing configured for this client — used the previous price' };
try {
const km = await calculateDrivingDistance(
{ latitude: candidate.booking.pickuplatitude, longitude: candidate.booking.pickuplongitude },
{ latitude: candidate.booking.deliverylatitude, longitude: candidate.booking.deliverylongitude }
);
const price = calculateTotalCharge(km, Number(rate.baseprice) || 0, Number(rate.priceperkm) || 0, Number(rate.basedistance) || 0);
return { ...candidate, finalprice: Number(price.toFixed(2)) };
} catch {
return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'could not re-measure the route — used the previous price' };
}
})
);
return { value: { day, candidates: priced, skipped } };
},
},
];
const FLOWS = {
customer: { title: 'New customer', steps: CUSTOMER_STEPS },
order: { title: 'New order', steps: ORDER_STEPS },
bulk: { title: 'Bulk orders', steps: BULK_STEPS },
assign: { title: 'Assign a rider', steps: ASSIGN_STEPS },
repeat: { title: 'Repeat a run', steps: REPEAT_STEPS },
};
/* "assign"/"reassign" + rider word, or "assign order/it/this/DM-…", or "change
the rider" — matched ahead of the create-verb gate below since "assign" is
not one of those verbs. */
const ASSIGN_TRIGGER =
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it\b|this\b|that\b|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
/* "repeat yesterday('s run/orders)", "same orders as yesterday", "redo
yesterday" — also matched ahead of the create-verb gate, since "repeat" and
"redo" are not among those verbs either. */
const REPEAT_TRIGGER =
/\brepeat\s+(?:yesterday|today|the\s+run|last\s+\w+|orders?)\b|\bsame\s+orders?\s+as\s+(?:yesterday|last\s+\w+)\b|\bredo\s+(?:yesterday|the\s+run)\b/i;
/** Which flow, if any, a question is asking to start. */
export const detectFlow = (text) => {
const lower = String(text || '').toLowerCase();
if (ASSIGN_TRIGGER.test(lower)) return 'assign';
if (REPEAT_TRIGGER.test(lower)) return 'repeat';
if (!/\b(create|add|new|make|place|raise|book)\b/.test(lower)) return null;
if (/\bcustomer\b/.test(lower)) return 'customer';
if (/\b(bulk|multiple|many|batch of)\b/.test(lower) && /\border/.test(lower)) return 'bulk';
if (/\b(order|booking|delivery)\b/.test(lower)) return 'order';
return null;
};
export const startFlow = (kind) => {
const flow = FLOWS[kind];
if (!flow) return null;
const first = flow.steps[0];
const question = typeof first.question === 'function' ? first.question({}) : first.question;
return { kind, title: flow.title, stepIndex: 0, values: {}, question };
};
/**
* Applies one reply and returns the next state.
*
* Returns `{ flow }` while still gathering, or `{ flow, review }` once every
* step is answered — `review` is the exact payload that will be sent, which the
* operator confirms before anything is written.
*
* Async because a step's `resolve` (assign flow's order/rider lookup) may need
* a live API call; the create flows' steps have no `resolve` and pass through
* exactly as before.
*/
export const advanceFlow = async (flow, reply) => {
const steps = FLOWS[flow.kind].steps;
const step = steps[flow.stepIndex];
const raw = String(reply || '').trim();
const skipped = /^(skip|none|no|n\/a)$/i.test(raw);
const value = skipped ? '' : raw;
if (step.required && !value) {
return { flow, error: 'That one is required — please answer it.' };
}
if (value && step.validate) {
const problem = step.validate(value);
if (problem) return { flow, error: problem };
}
let resolved = value;
if (value && step.resolve) {
const outcome = await step.resolve(value, flow.values);
if (outcome.error) return { flow, error: outcome.error };
resolved = outcome.value;
}
const values = { ...flow.values, [step.key]: resolved };
const nextIndex = flow.stepIndex + 1;
if (nextIndex < steps.length) {
const nextStep = steps[nextIndex];
const question = typeof nextStep.question === 'function' ? nextStep.question(values) : nextStep.question;
return { flow: { ...flow, stepIndex: nextIndex, values, question } };
}
return { flow: { ...flow, stepIndex: nextIndex, values, question: null }, review: buildReview(flow.kind, values) };
};
/** A human-readable summary plus the payload that will actually be sent. */
const buildReview = (kind, values) => {
if (kind === 'repeat') {
const { day, candidates, skipped } = values.sourceDay;
return {
kind,
title: `Repeat ${candidates.length} order${candidates.length === 1 ? '' : 's'} from ${day}?`,
lines: [
['Source day', day],
['Will create', `${candidates.length} order${candidates.length === 1 ? '' : 's'}`],
skipped.length ? ['Skipping', `${skipped.length} — see below`] : null,
].filter(Boolean),
preview: [
...candidates
.slice(0, 5)
.map((c) => `${c.customer_name || 'Customer'} · ${c.booking.deliveryaddress} · ${formatRupees(c.finalprice)}`),
...skipped.slice(0, 3).map((s) => `Skipped ${s.booking.bookingno || `#${s.booking.bookingid}`} — ${s.reason}`),
],
values: { day, candidates, skipped },
};
}
if (kind === 'assign') {
const order = values.orderRef;
const rider = values.riderRef;
return {
kind,
title: 'Assign this order?',
lines: [
['Order', order.bookingno || `#${order.bookingid}`],
['To', rider.displayname || rider.authname],
['Phone', rider.phone],
].filter(([, v]) => v),
values: {
bookingid: order.bookingid,
bookingLabel: order.bookingno || `#${order.bookingid}`,
mileruserid: rider.userid,
milerprofileid: rider.milerprofileid,
riderName: rider.displayname || rider.authname,
},
};
}
if (kind === 'customer') {
return {
kind,
title: 'Create this customer?',
lines: [
['Name', [values.firstname, values.lastname].filter(Boolean).join(' ')],
['Phone', values.phone],
['Email', values.email],
].filter(([, v]) => v),
values,
};
}
if (kind === 'order') {
return {
kind,
title: 'Create this order?',
lines: [
['Client', values.tenant],
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
['Recipient', `${values.customer_name} · ${values.customer_phone}`],
['Drop', `${values.deliveryaddress}, ${values.deliverycity} ${values.deliverypincode}`],
['Charge', values.finalprice ? formatRupees(Number(values.finalprice)) : formatRupees(0)],
['Notes', values.notes],
].filter(([, v]) => v),
values,
};
}
const parsed = parseBulkRows(values.rows);
return {
kind,
title: `Create ${parsed.length} order${parsed.length === 1 ? '' : 's'}?`,
lines: [
['Client', values.tenant],
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
['Recipients', `${parsed.length} rows`],
],
preview: parsed.slice(0, 5).map((row) => `${row.customer_name} · ${row.customer_phone} · ${row.deliveryaddress}`),
values: { ...values, parsed },
};
};
/** `name, phone, address, city, pincode` per line. Blank lines are ignored. */
const parseBulkRows = (text) =>
String(text || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode] = line
.split(',')
.map((cell) => cell.trim());
return { customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode };
})
.filter((row) => row.customer_name && row.customer_phone);
const resolveTenantId = async (name) => {
const tenants = (await getalltenants()) || [];
const tenant = bestNameMatch(name, tenants, (t) => t.tenantname);
return tenant?.tenantid ?? null;
};
/**
* The only mutating function in this module. Called from the review card's
* button and from nowhere else.
*/
export const executeFlow = async (review) => {
const { kind, values } = review;
if (kind === 'repeat') {
const bookings = values.candidates.map(({ booking, customer_name, customer_phone, finalprice }) => ({
tenantid: booking.tenantid,
pickupaddress: booking.pickupaddress,
pickupcity: booking.pickupcity || '',
pickuppincode: booking.pickuppincode || '',
pickuplatitude: booking.pickuplatitude,
pickuplongitude: booking.pickuplongitude,
customer_name,
customer_phone,
deliveryaddress: booking.deliveryaddress,
deliverycity: booking.deliverycity || '',
deliverypincode: booking.deliverypincode || '',
deliverylatitude: booking.deliverylatitude,
deliverylongitude: booking.deliverylongitude,
service_option: 'Normal',
finalprice,
notes: booking.notes || '',
parcels: booking.parcels?.length
? booking.parcels.map((p) => ({
itemcategory: p.itemcategory || 'General',
itemdescription: p.itemdescription || 'Order',
declaredvalue: p.declaredvalue || 0,
}))
: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: finalprice }],
}));
/* Repeats are scoped to one calendar day, so this should never approach
the bulk endpoint's 200-per-call cap — refused rather than silently
truncated on the rare day that does. */
if (bookings.length > 200) {
return { ok: false, message: `That is ${bookings.length} orders; the bulk endpoint takes 200 at a time.` };
}
const result = await createExpressBookingBulk(bookings);
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
/* Two response shapes have been seen from this endpoint in practice — read
defensively rather than assume one. A shape this doesn't recognise just
means the reassignment pass below finds nothing to do; the orders
themselves are still created either way. */
const created = result?.results || result?.data?.results || [];
/* Straight back to whoever had it, one order at a time — sequential on
purpose, since these are real writes against dispatch records and a
burst of concurrent calls makes a partial failure much harder to
attribute to the right order. */
let reassigned = 0;
for (let i = 0; i < values.candidates.length; i += 1) {
const riderId = values.candidates[i].booking.assignedmileruserid;
const createdId = created[i]?.bookingid ?? created[i]?.id;
if (riderId && createdId) {
try {
await assignMilerToBooking(createdId, { mileruserid: Number(riderId) });
reassigned += 1;
} catch {
/* The create already succeeded — a failed reassignment leaves that
order pending rather than undoing it. */
}
}
}
const parts = [
`${bookings.length} order${bookings.length === 1 ? '' : 's'} created from ${values.day}.`,
reassigned ? `${reassigned} went straight back to their previous rider.` : null,
values.skipped.length ? `${values.skipped.length} from the original day could not be repeated.` : null,
].filter(Boolean);
return { ok: true, message: parts.join(' ') };
}
if (kind === 'assign') {
const result = await assignMilerToBooking(values.bookingid, { mileruserid: Number(values.mileruserid) });
if (result?.success === false) return { ok: false, message: result.message || 'The order was not assigned.' };
/* The assignment already landed at this point — a failed push is reported,
not treated as the write having failed. */
let notified = false;
if (values.milerprofileid) {
try {
await notifyMiler(values.milerprofileid, 'DoormileXpress', 'A new order has been assigned to you.');
notified = true;
} catch {
/* swallowed — reported via the message below instead */
}
}
const tail = notified
? ' They have been notified.'
: values.milerprofileid
? ' The notification failed — tell them directly.'
: ' This rider has no profile id, so no notification could be sent.';
return { ok: true, message: `${values.bookingLabel} is now with ${values.riderName}.${tail}` };
}
if (kind === 'customer') {
const result = await createTenantCustomer({
firstname: values.firstname,
lastname: values.lastname || '',
phone: values.phone,
email: values.email || '',
});
if (result?.success === false) return { ok: false, message: result.message || 'The customer was not created.' };
return {
ok: true,
message: `${values.firstname} ${values.lastname || ''}`.trim() + ' was added. They appear on the Customers page now.',
};
}
const tenantid = await resolveTenantId(values.tenant);
if (!tenantid) {
return { ok: false, message: `No client matches "${values.tenant}" — nothing was created.` };
}
if (kind === 'order') {
const price = Number(values.finalprice) || 0;
const result = await createExpressBooking({
tenantid,
pickupaddress: values.pickupaddress,
pickupcity: values.pickupcity,
pickuppincode: values.pickuppincode,
customer_name: values.customer_name,
customer_phone: values.customer_phone,
deliveryaddress: values.deliveryaddress,
deliverycity: values.deliverycity,
deliverypincode: values.deliverypincode,
service_option: 'Normal',
finalprice: price,
notes: values.notes || '',
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: price }],
});
if (result?.success === false) return { ok: false, message: result.message || 'The order was not created.' };
return { ok: true, message: 'Order created. It is on the Orders page under Pending.' };
}
const bookings = (values.parsed || []).map((row) => ({
tenantid,
pickupaddress: values.pickupaddress,
pickupcity: values.pickupcity,
pickuppincode: values.pickuppincode,
customer_name: row.customer_name,
customer_phone: row.customer_phone,
deliveryaddress: row.deliveryaddress || '',
deliverycity: row.deliverycity || '',
deliverypincode: row.deliverypincode || '',
service_option: 'Normal',
finalprice: 0,
notes: '',
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: 0 }],
}));
/* The endpoint takes at most 200 per call — refuse rather than silently
truncating the operator's list. */
if (bookings.length > 200) {
return { ok: false, message: `That is ${bookings.length} rows; this endpoint takes 200 at a time. Split the list.` };
}
const result = await createExpressBookingBulk(bookings);
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
return { ok: true, message: `${bookings.length} orders created. They are on the Orders page under Pending.` };
};

View File

@@ -22,6 +22,7 @@ import {
getTenantLocations
} from 'pages/api/doormileApi';
import { getalltenants, getallridersummary } from 'pages/api/api';
import { buildBriefing } from '@/lib/assistant/agent/briefing';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
import { STATUS } from 'themes/dt/tokens';
@@ -386,7 +387,7 @@ const fetchBookingsInRange = async (start, end) => {
let lastPageFetched = 1;
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
// eslint-disable-next-line no-await-in-loop
const next = await getBookingsPageCached(page);
lastPageFetched = page;
if (!next.rows.length) {
@@ -745,6 +746,48 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
const MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [
{
// The ops briefing: every monitoring skill enabled in the agent registry,
// run over the same booking scan the rest of the catalog answers from —
// the SAME engine as the Exceptions banner (AgentFactory), so the chat and
// the banner can never disagree about a parcel. Ported from
// feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3).
//
// FIRST on purpose: later intents match bare "orders"/"riders", which
// appear in most phrasings of a sweep.
//
// Narrower than the branch on purpose. The branch also claimed
// "late/overdue/delayed orders", which would have stolen the existing
// delayed-orders answer (a count on the Orders taxonomy) and replaced it
// with a briefing. Only an explicit request for a sweep lands here.
id: 'opsBriefing',
label: 'What needs attention right now — e.g. "what needs attention", "anything going wrong", "ops check"',
match: (text) => {
const t = String(text).toLowerCase();
const asksForSweep =
/\b(what|anything|any)\b.{0,24}\b(needs?\s+(my\s+)?attention|going\s+wrong|at\s+risk|urgent|on\s+fire)\b/.test(t) ||
/\b(ops|operations|status|health|daily)\s+(check|sweep|briefing|brief)\b/.test(t) ||
/\bwhat\s+should\s+i\s+(do|look\s+at)\b/.test(t);
return asksForSweep ? {} : null;
},
run: async () => {
const scan = await scanBookings();
const briefing = buildBriefing(scan);
return {
headline: briefing.headline,
detail: briefing.detail,
metric: briefing.metric,
sourceCalls: [
scanCall(
scan,
briefing.findings.length
? `${briefing.findings.length} finding${briefing.findings.length === 1 ? '' : 's'} over ${scan.rows.length} rows`
: `no issues over ${scan.rows.length} rows`
)
]
};
}
},
{
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
// contains "orders", so createBulkOrders and createOrder would otherwise
@@ -2090,6 +2133,30 @@ const INTENTS = [
const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i]));
// Which intent the ordered catalog reaches for a question, WITHOUT running it.
//
// Exported for the page-suggestion test. The catalog is ordered and the first
// match wins, so "something matches this chip" and "the right thing matches
// this chip" are different questions — ROADMAP.md B1/B2 are two shipped cases
// where they had different answers.
//
// Covers the deterministic pass only. answerQuestion tries semantic routing
// and the multi-part splitter ahead of this, both of which are additive: they
// can route a question the catalog would miss, never away from one it hits.
export const resolveIntentId = (text) => {
const normalized = correctTypos(text);
for (const intent of INTENTS) {
let params = null;
try {
params = intent.match(normalized);
} catch {
params = null;
}
if (params) return intent.id;
}
return null;
};
export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label);
// Clean, directly-askable example phrasings — for "recommended question"
@@ -2126,7 +2193,7 @@ const matchAndRun = async (text) => {
for (const intent of INTENTS) {
const params = intent.match(text);
if (!params) continue;
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params };
}
@@ -2194,7 +2261,7 @@ const answerMultiPart = async (text) => {
if (segments.length < 2) return null;
const results = [];
for (const segment of segments) {
// eslint-disable-next-line no-await-in-loop
const r = await matchAndRun(segment);
if (r) results.push(r);
}
@@ -2243,7 +2310,7 @@ export async function answerQuestion(text, context = {}) {
// WHAT. Embeddings are good at the former and unreliable at the latter.
const params = intent?.match(normalized);
if (intent && params) {
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params, routing: routed };
}

View File

@@ -127,26 +127,60 @@ export const ORDER_STEPS = [
},
{
id: 'deliveryaddress',
type: 'text',
ask: 'Where is it being delivered? Give the full address.',
// `address` renders a real search field with live suggestions instead of
// leaving the operator to type a full address into the chat box and hope
// the geocoder finds it. Typing still works — the resolve below falls back
// to geocoding free text — so this step degrades to its old behaviour
// wherever the field is not rendered.
type: 'address',
ask: 'Where is it being delivered? Start typing the area, street or pincode.',
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
// an address that can't be located is refused here rather than becoming a
// booking nothing can dispatch.
resolve: async (t) => {
const place = await geocodeAddress(String(t).trim()).catch(() => null);
resolve: async (t, option) => {
// A place picked from the suggestion list is already resolved, with the
// coordinates the operator actually chose. Geocoding its label again
// can return a different place entirely.
const place = option?.place || (await geocodeAddress(String(t).trim()).catch(() => null));
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
(place.address_components || []).forEach((c) => {
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
});
return {
value: {
...parts,
deliverylatitude: place.geometry?.location?.lat?.(),
deliverylongitude: place.geometry?.location?.lng?.()
}
};
// geocodingService.standardizePlace returns a FLAT place — formatted_address,
// city, postcode, latitude, longitude. This used to read Google's raw
// shape instead (`address_components`, `geometry.location.lat()`), and
// neither exists on what geocodeAddress actually returns: the forEach
// never ran and both coordinates came back undefined. Every order created
// through this flow was therefore missing its delivery coordinates — the
// exact thing the comment above says the optimiser routes on — and the
// pincode step below fired every time because nothing ever set it.
//
// The Google shape is still read as a fallback so a caller that hands in
// a raw Places result keeps working.
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined);
let lat = num(place.latitude);
let lng = num(place.longitude);
let city = place.city || '';
let pincode = place.postcode || '';
if (lat === undefined && place.geometry?.location) {
const loc = place.geometry.location;
lat = num(typeof loc.lat === 'function' ? loc.lat() : loc.lat);
lng = num(typeof loc.lng === 'function' ? loc.lng() : loc.lng);
}
if (!city || !pincode) {
(place.address_components || []).forEach((c) => {
if (!city && (c.types || []).includes('locality')) city = c.long_name;
if (!pincode && (c.types || []).includes('postal_code')) pincode = c.long_name;
});
}
const value = { deliveryaddress: place.formatted_address || place.name || String(t).trim() };
if (city) value.deliverycity = city;
if (pincode) value.deliverypincode = pincode;
if (lat !== undefined && lng !== undefined) {
value.deliverylatitude = lat;
value.deliverylongitude = lng;
}
return { value };
},
apply: (d, v) => ({ ...d, ...v })
},

View File

@@ -1,103 +0,0 @@
/**
* What the assistant knows about where the operator is standing.
*
* Every page offers every question. The assistant answers about orders, riders,
* hubs and the rest regardless of which screen is open, so hiding a question
* because you happen to be on Dispatch made it look narrower than it is. The
* page decides the ORDER — its own questions lead — not the membership.
*
* Every suggestion here must actually resolve against the intent catalog. A
* chip that comes back "I can't answer that yet" is worse than no chip.
*/
const CATALOG = [
'How many orders today?',
'How many orders are cancelled?',
'Morning batch orders',
'Revenue this week',
'How many riders are active?',
'How many clients do we have?',
'How many hubs?',
'How many vehicles?',
'Any open exceptions?',
'How many tripsheets are dispatched?',
'How many customers?',
'How many pricing rules?',
'Track consignment DM-CN-1',
'Assign a rider to an order',
"Repeat yesterday's run",
'Compare orders today vs yesterday',
'Status of hub Chennai',
'Find vehicle TN01AB1234',
'How many competitor branches are tracked?',
'How many carrier pricing entries do we have?',
];
const BY_ROUTE = [
[
/^\/doormile\/dispatch/,
'Dispatch',
['Morning batch orders', 'How many riders are active?', "Repeat yesterday's run", 'How many orders today?'],
],
[
/^\/doormile\/orders/,
'Orders',
['How many orders today?', 'Assign a rider to an order', 'How many orders are cancelled?', 'Revenue this week'],
],
[/^\/doormile\/deliveries/, 'Deliveries', ['How many orders are delivered?', 'Track consignment DM-CN-1', 'Morning batch orders']],
[/^\/doormile\/riders/, 'Riders', ['How many riders are active?', 'Where is Murali?']],
[/^\/doormile\/tenants/, 'Clients', ['How many clients do we have?', 'How many pricing rules?']],
[/^\/doormile\/customers/, 'Customers', ['How many customers?', 'Create a customer']],
[/^\/doormile\/pricing/, 'Pricing', ['How many pricing rules?', 'Revenue this week']],
[/^\/doormile\/hubs/, 'Hubs', ['How many hubs?', 'Status of hub Chennai', 'How many vehicles?']],
[/^\/doormile\/vehicles/, 'Vehicles', ['How many vehicles?', 'Find vehicle TN01AB1234', 'How many hubs?']],
[/^\/doormile\/tripsheets/, 'Tripsheets', ['How many tripsheets are dispatched?', 'How many consignments?']],
[/^\/doormile\/exceptions/, 'Exceptions', ['Any open exceptions?', 'How many consignments?']],
[
/^\/doormile\/competitive-intel/,
'Competitive Intel',
['How many competitor branches are tracked?', 'How many carrier pricing entries do we have?'],
],
[/^\/doormile\/reports/, 'Reports', ['Revenue this week', 'Compare orders today vs yesterday', 'How many orders today?']],
];
/** `{ label, suggestions }` for a route — its own questions first, then the rest. */
export const getPageContext = (pathname) => {
const hit = BY_ROUTE.find(([pattern]) => pattern.test(pathname || ''));
const label = hit ? hit[1] : 'Console';
const leading = hit ? hit[2] : [];
/* Deduplicated, leading questions first — one flat list means one place a
question can live. */
const suggestions = [...new Set([...leading, ...CATALOG])];
return { label, suggestions };
};
export const CHIP_LABELS = {
'How many orders today?': 'Orders today',
'How many orders are cancelled?': 'Cancelled',
'Morning batch orders': 'Morning batch',
'Revenue this week': 'Revenue',
'How many riders are active?': 'Active riders',
'How many clients do we have?': 'Clients',
'How many hubs?': 'Hubs',
'How many vehicles?': 'Vehicles',
'Any open exceptions?': 'Exceptions',
'How many tripsheets are dispatched?': 'Tripsheets',
'How many customers?': 'Customers',
'How many pricing rules?': 'Pricing rules',
'How many orders are delivered?': 'Delivered',
'Where is Murali?': 'Locate Murali',
'Create a customer': 'New customer',
'How many consignments?': 'Consignments',
'Track consignment DM-CN-1': 'Track DM-CN-1',
'Assign a rider to an order': 'Assign rider',
'Repeat yesterday\'s run': 'Repeat run',
'Compare orders today vs yesterday': 'Compare orders',
'Status of hub Chennai': 'Chennai hub',
'Find vehicle TN01AB1234': 'Find vehicle',
'How many competitor branches are tracked?': 'Competitor branches',
'How many carrier pricing entries do we have?': 'Carrier pricing'
};
export const ORDER_CREATED = '__orderCreated';
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';

View File

@@ -220,7 +220,7 @@ export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
if (shouldStop?.()) break;
const row = candidates[i];
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
// eslint-disable-next-line no-await-in-loop
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
priced.push(out);
}

View File

@@ -0,0 +1,123 @@
// ==============================|| Doormile AI — Skill Registry ||============================== //
//
// The console's eight monitoring skills: their RULES live in code
// (./definitions), their SETTINGS — enabled, thresholds — live in the backend
// agent registry (/admin/ai/skills, edited in Settings → Skills & Tools). This
// class joins the two.
//
// On the feat/agentic-ops-layer branch these settings were kept in the
// browser's localStorage, so a threshold tuned on one machine changed nothing
// on any other, and two operators could look at the same board through
// different rules. Ported onto main in Phase 3 of docs/agent-platform-plan.md,
// the settings come from the registry instead, and nothing is stored locally.
//
// Until the registry answers (first load, a network error, a partner login
// that is not allowed to read it) every skill runs on its code defaults, and
// `getSource()` says so, so the banner can tell the operator which settings it
// is running on rather than implying they are the tuned ones.
import { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
import { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
import { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
import { HighValueCodSkill } from './definitions/HighValueCodSkill';
import { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
import { HubCongestionSkill } from './definitions/HubCongestionSkill';
import { LateDispatchSkill } from './definitions/LateDispatchSkill';
import { CashExposureSkill } from './definitions/CashExposureSkill';
export const DEFAULT_SKILLS = [
SlaGuardianSkill,
DoorstepStallSkill,
FleetBalancerSkill,
HighValueCodSkill,
RiderBatterySafetySkill,
HubCongestionSkill,
LateDispatchSkill,
CashExposureSkill
];
/** A stored value outside the definition's range falls back to its default. */
const inRange = (config, v) =>
typeof v === 'number' && Number.isFinite(v) && v >= config.min && v <= config.max;
class SkillRegistryClass {
constructor() {
this.skills = new Map();
this.listeners = new Set();
this.config = {}; // { [skillId]: { enabled: boolean, thresholds: { [key]: number } } }
this.source = 'defaults';
DEFAULT_SKILLS.forEach((skill) => this.skills.set(skill.id, skill));
}
/**
* Adopt the registry's settings. `rows` is the /admin/ai/skills response;
* rows for skills this console does not implement are ignored.
*/
applyRegistryConfig(rows) {
const next = {};
(Array.isArray(rows) ? rows : []).forEach((row) => {
if (!row || !this.skills.has(row.skillid)) return;
next[row.skillid] = {
enabled: Boolean(row.enabled),
thresholds: row.thresholds && typeof row.thresholds === 'object' ? row.thresholds : {}
};
});
this.config = next;
this.source = 'registry';
this.notify();
}
/** Forget the registry's settings and run on code defaults. */
clearRegistryConfig() {
if (this.source === 'defaults' && !Object.keys(this.config).length) return;
this.config = {};
this.source = 'defaults';
this.notify();
}
/** 'registry' when running on the tuned settings, 'defaults' otherwise. */
getSource() {
return this.source;
}
getAllSkills() {
return Array.from(this.skills.values()).map((skill) => {
const cfg = this.config[skill.id];
const enabled = cfg ? cfg.enabled : (skill.defaultEnabled ?? true);
const effectiveThresholds = {};
Object.entries(skill.thresholds || {}).forEach(([key, config]) => {
const stored = cfg?.thresholds?.[key];
effectiveThresholds[key] = { ...config, currentValue: inRange(config, stored) ? stored : config.value };
});
return { ...skill, enabled, effectiveThresholds };
});
}
getActiveSkills() {
return this.getAllSkills().filter((s) => s.enabled);
}
getSkill(skillId) {
return this.getAllSkills().find((s) => s.id === skillId);
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
const all = this.getAllSkills();
this.listeners.forEach((fn) => {
try {
fn(all);
} catch {
// One listener failing must not stop the others hearing the change.
}
});
}
}
export const SkillRegistry = new SkillRegistryClass();

View File

@@ -0,0 +1,161 @@
// ==============================|| Skill: Cash Exposure Agent ||============================== //
//
// Monitors the total live COD (Cash on Delivery) cash a single rider is carrying
// across ALL their active orders. When total exposure exceeds the safe limit,
// proposes a mandatory cash handoff stop at the nearest hub.
//
// This is distinct from HighValueCodSkill, which monitors per-order value.
// This skill monitors per-rider TOTAL cash accumulation across their full run.
const ACTIVE_COD_STATUSES = new Set(['accepted', 'picked', 'active', 'arrived']);
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const CashExposureSkill = {
id: 'skill_cash_exposure',
name: 'Cash Exposure Agent',
description: 'Monitors total live COD cash per rider across all active orders. Flags when a rider carries unsafe cash levels and proposes hub handoff.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no COD amount per order: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no COD amount per order: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
maxCashPerRider: {
label: 'Max Safe Cash Per Rider',
description: 'Maximum total COD cash (₹) a single rider should carry simultaneously across all active orders.',
value: 10000,
min: 2000,
max: 50000,
step: 1000,
unit: '₹'
},
warningCashPercent: {
label: 'Warning Threshold (%)',
description: 'Percentage of max cash limit at which to issue a warning (before it becomes critical).',
value: 75,
min: 50,
max: 95,
step: 5,
unit: '%'
}
},
instructions: `
- A rider carrying excessive COD cash is a major financial risk if the phone dies, the rider is delayed, or there is a dispute.
- Flag riders above the cash threshold for a mandatory hub cash handoff before continuing deliveries.
- Critical: stop new COD orders being assigned to flagged riders until handoff is confirmed.
`,
tools: [
{
name: 'enforce_cash_handoff',
description: 'Proposes a mandatory cash handoff stop at the nearest hub for an overexposed rider.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Rider user ID carrying excessive cash' },
totalCash: { type: 'number', description: 'Total COD cash the rider currently holds (₹)' },
hubId: { type: 'string', description: 'Nearest hub for handoff' }
},
required: ['milerId', 'totalCash']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'cash_handoff',
milerId: args.milerId,
totalCash: args.totalCash,
hubId: args.hubId || 'nearest',
blastRadius: `Reroutes rider to nearest hub for cash handoff of ₹${args.totalCash?.toLocaleString('en-IN')}. Pauses new COD order assignments to this rider until confirmed.`
}
})
}
],
evaluate: (rows = [], _now, currentThresholds = {}) => {
const maxCash = currentThresholds.maxCashPerRider ?? 10000;
const warningPct = (currentThresholds.warningCashPercent ?? 75) / 100;
const warningLimit = maxCash * warningPct;
const findings = [];
// Group active COD orders by rider
const byRider = {};
rows
.filter((r) =>
ACTIVE_COD_STATUSES.has(String(r?.orderstatus || '').toLowerCase()) &&
r?.paymenttype &&
String(r.paymenttype).toLowerCase().includes('cod') &&
r?.userid
)
.forEach((r) => {
const uid = r.userid;
if (!byRider[uid]) {
byRider[uid] = { riderId: uid, riderName: r.ridername || r.miler_name || `Rider ${uid}`, orders: [] };
}
byRider[uid].orders.push(r);
});
const criticalRiders = [];
const warningRiders = [];
Object.values(byRider).forEach((riderData) => {
const total = riderData.orders.reduce((sum, r) => {
const amt = parseFloat(r.cod_amount || r.orderamount || r.totalamount || 0);
return sum + (isNaN(amt) ? 0 : amt);
}, 0);
riderData.totalCash = total;
if (total >= maxCash) {
criticalRiders.push(riderData);
} else if (total >= warningLimit) {
warningRiders.push(riderData);
}
});
criticalRiders.sort((a, b) => b.totalCash - a.totalCash);
warningRiders.sort((a, b) => b.totalCash - a.totalCash);
if (criticalRiders.length) {
findings.push({
id: 'cash_exposure_critical',
skillId: 'skill_cash_exposure',
severity: 'critical',
title: `${criticalRiders.length} rider${criticalRiders.length === 1 ? '' : 's'} carrying ≥₹${maxCash.toLocaleString('en-IN')} in COD cash`,
why: `Total COD cash per rider exceeds the safe limit of ₹${maxCash.toLocaleString('en-IN')}. Immediate hub handoff required.`,
count: criticalRiders.length,
rows: criticalRiders.flatMap((rd) => rd.orders),
detail: criticalRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} active COD order${rd.orders.length === 1 ? '' : 's'}`
),
proposal: {
label: 'Enforce cash handoff at hub',
tool: 'enforce_cash_handoff',
scope: criticalRiders.flatMap((rd) => rd.orders),
blastRadius: 'Reroutes affected riders to nearest hub for mandatory cash handoff. New COD assignments paused.'
}
});
}
if (warningRiders.length) {
findings.push({
id: 'cash_exposure_warning',
skillId: 'skill_cash_exposure',
severity: 'warning',
title: `${warningRiders.length} rider${warningRiders.length === 1 ? '' : 's'} approaching cash limit (>${Math.round(warningPct * 100)}% of ₹${maxCash.toLocaleString('en-IN')})`,
why: `Total COD cash is approaching the safe threshold. Plan a hub handoff stop for these riders.`,
count: warningRiders.length,
rows: warningRiders.flatMap((rd) => rd.orders),
detail: warningRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} orders`
)
});
}
return findings;
}
};

View File

@@ -0,0 +1,90 @@
// ==============================|| Skill: Doorstep Stall Rescuer ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const DoorstepStallSkill = {
id: 'skill_doorstep_stall',
name: 'Doorstep Stall Rescuer',
description: 'Flags riders who stamped arrival at consignee doorstep but have been stalled with no progress.',
category: 'rider_operations',
icon: 'MapPinOff',
defaultEnabled: true,
thresholds: {
arrivedStalledMin: {
label: 'Doorstep Stall Timeout',
description: 'Minutes after arriving at customer location before flagging as stalled.',
value: 20,
min: 10,
max: 60,
step: 5,
unit: 'min'
}
},
instructions: `
- When a rider is stalled at a delivery location, suggest verifying customer contact or initiating automated phone verification.
`,
tools: [
{
name: 'ping_stalled_rider',
description: 'Sends an in-app ping to check if the rider needs help with gate access or customer contact.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Miler ID to reach' },
message: { type: 'string', description: 'Optional prompt text' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'notify_miler',
milerId: args.milerId,
blastRadius: `Pings rider #${args.milerId} with doorstep assistance prompt.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const stallLimit = currentThresholds.arrivedStalledMin ?? 20;
const stalled = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'arrived' && r.reachedat)
.map((r) => ({ row: r, waitingMin: minutesBetween(r.reachedat, now) }))
.filter((m) => m.waitingMin !== null && m.waitingMin >= stallLimit)
.sort((a, b) => b.waitingMin - a.waitingMin);
if (!stalled.length) return [];
return [
{
id: 'stalled_at_door',
skillId: 'skill_doorstep_stall',
severity: 'critical',
title: `${stalled.length} rider${stalled.length === 1 ? '' : 's'} stalled at door for > ${stallLimit}m`,
why: 'Rider app reported arrival but delivery confirmation has not completed. Possible gate, address, or absent consignee issue.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — ${m.row.ridername || 'Rider'} waiting ${m.waitingMin} min`),
proposal: {
label: 'Message waiting riders & support',
tool: 'notifyRider',
scope: stalled.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an assistance check-in to stalled delivery riders.'
}
}
];
}
};

View File

@@ -0,0 +1,87 @@
// ==============================|| Skill: Fleet Load Balancer ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
export const FleetBalancerSkill = {
id: 'skill_fleet_balancer',
name: 'Fleet Load Balancer',
description: 'Prevents rider overload by flagging riders at maximum active parcel capacity while queue work waits.',
category: 'fleet_optimization',
icon: 'Scale',
defaultEnabled: true,
thresholds: {
riderActiveCap: {
label: 'Rider Active Capacity Cap',
description: 'Maximum concurrent active orders a single rider should carry simultaneously.',
value: 3,
min: 1,
max: 6,
step: 1,
unit: 'orders'
}
},
instructions: `
- Keep rider concurrent workload at or below the specified active cap to prevent cumulative delivery delays.
- Proactively redistribute un-picked orders from overloaded riders to available nearby fleet.
`,
tools: [
{
name: 'balance_rider_load',
description: 'Reallocates queued unpicked orders from a saturated rider to an available rider.',
parameters: {
type: 'object',
properties: {
fromMilerId: { type: 'number', description: 'Overloaded miler ID' },
toMilerId: { type: 'number', description: 'Available miler ID' }
},
required: ['fromMilerId', 'toMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'rebalance_workload',
fromMilerId: args.fromMilerId,
toMilerId: args.toMilerId,
blastRadius: `Transfers queued parcels from Rider #${args.fromMilerId} to Rider #${args.toMilerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const activeCap = currentThresholds.riderActiveCap ?? 3;
const safeRows = Array.isArray(rows) ? rows : [];
const unassignedCount = safeRows.filter((r) => String(r?.orderstatus || '').toLowerCase() === 'pending').length;
const byRider = new Map();
safeRows.filter((r) => isOpen(r) && r.userid).forEach((r) => {
const key = String(r.userid);
if (!byRider.has(key)) byRider.set(key, { riderId: r.userid, name: r.ridername || `Rider ${r.userid}`, rows: [] });
byRider.get(key).rows.push(r);
});
const saturated = [...byRider.values()]
.filter((entry) => entry.rows.length >= activeCap)
.sort((a, b) => b.rows.length - a.rows.length);
if (!saturated.length || unassignedCount === 0) return [];
return [
{
id: 'rider_saturation',
skillId: 'skill_fleet_balancer',
severity: 'watch',
title: `${saturated.length} rider${saturated.length === 1 ? ' is' : 's are'} at capacity (≥${activeCap} jobs) while orders queue`,
why: `Riders carrying ${activeCap}+ in-flight jobs are capped and will not receive newly queued orders.`,
count: saturated.length,
rows: saturated.flatMap((entry) => entry.rows),
detail: saturated.slice(0, 8).map((entry) => `${entry.name} — ${entry.rows.length} concurrent orders`)
}
];
}
};

View File

@@ -0,0 +1,93 @@
// ==============================|| Skill: High-Value COD Escort ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HighValueCodSkill = {
id: 'skill_high_value_cod',
name: 'High-Value Cash Guardian',
description: 'Audits large Cash-on-Delivery consignments to prevent loss, fraud, or unverified deliveries.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no payment amount or payment mode: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no payment amount or payment mode: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
codRiskThresholdAmount: {
label: 'High-Value COD Threshold (₹)',
description: 'Cash-on-Delivery amount above which an order is classified as high-risk cash asset.',
value: 3000,
min: 1000,
max: 20000,
step: 500,
unit: '₹'
}
},
instructions: `
- High-value COD consignments must require OTP verification at the doorstep and verified senior rider assignment.
`,
tools: [
{
name: 'enforce_otp_verification',
description: 'Flags an order as strictly requiring consignee OTP delivery confirmation before handover.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Target booking ID' }
},
required: ['bookingId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'require_otp',
bookingId: args.bookingId,
blastRadius: `Enables mandatory OTP confirmation on mobile app for parcel #${args.bookingId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minAmount = currentThresholds.codRiskThresholdAmount ?? 3000;
const safeRows = Array.isArray(rows) ? rows : [];
const highValueCod = safeRows.filter((r) => {
if (!isOpen(r)) return false;
const isCod = String(r.paymenttype || r.payment_mode || '').toLowerCase().includes('cod') ||
String(r.paymentstatus || '').toLowerCase() === 'pending';
const amount = Number(r.orderamount || r.totalamount || r.price || 0);
return isCod && amount >= minAmount;
});
if (!highValueCod.length) return [];
return [
{
id: 'high_value_cod_alert',
skillId: 'skill_high_value_cod',
severity: 'watch',
title: `${highValueCod.length} high-value COD consignment${highValueCod.length === 1 ? '' : 's'} (≥ ₹${minAmount.toLocaleString()}) active`,
why: `Large cash collection in transit requires mandatory OTP handover verification.`,
count: highValueCod.length,
rows: highValueCod,
detail: highValueCod.slice(0, 8).map((r) => {
const amt = Number(r.orderamount || r.totalamount || r.price || 0);
return `${ref(r)} — ₹${amt.toLocaleString()} (${r.orderstatus || 'active'})`;
}),
proposal: {
label: 'Verify OTP requirements',
tool: 'enforce_otp_verification',
scope: highValueCod,
blastRadius: 'Sets strict OTP delivery guard on high-value cash deliveries.'
}
}
];
}
};

View File

@@ -0,0 +1,105 @@
// ==============================|| Skill: Hub Congestion Agent ||============================== //
//
// Detects parcels that have been sitting at a hub for too long without
// being picked up by a rider. These represent blocked capacity at the hub
// and direct SLA risk for the customer. This is critical for hub operations
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const HUB_STATUSES = new Set(['pending', 'accepted']); // at hub, not yet picked
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HubCongestionSkill = {
id: 'skill_hub_congestion',
name: 'Hub Congestion Agent',
description: 'Detects parcels sitting at a hub without rider pickup beyond the dwell threshold. Dispatches nearest idle rider.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
hubDwellMinutes: {
label: 'Hub Dwell Threshold',
description: 'Minutes a parcel can sit at hub without a rider pickup before raising a congestion alert.',
value: 45,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- Parcels sitting at a hub beyond the dwell threshold block capacity for the next batch.
- Identify the nearest idle rider in the same zone and dispatch immediately.
- Prefer riders with the fewest active orders to avoid overloading.
`,
tools: [
{
name: 'dispatch_hub_idle_parcels',
description: 'Proposes dispatching a nearby idle rider to collect stalled hub parcels.',
parameters: {
type: 'object',
properties: {
hubId: { type: 'string', description: 'Hub identifier where parcels are stalled' },
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs to dispatch' },
milerId: { type: 'number', description: 'Target idle rider user ID' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'dispatch_hub_parcels',
hubId: args.hubId || 'unknown',
bookingIds: args.bookingIds,
milerId: args.milerId,
blastRadius: `Dispatches rider to collect ${args.bookingIds?.length || 'stalled'} parcel(s) from hub. Updates parcel status to In-Transit.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const dwellLimit = currentThresholds.hubDwellMinutes ?? 45;
const findings = [];
const stalled = rows
.filter((r) => HUB_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, dwellMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.dwellMin !== null && m.dwellMin >= dwellLimit)
.sort((a, b) => b.dwellMin - a.dwellMin);
if (stalled.length) {
findings.push({
id: 'hub_parcel_stalled',
skillId: 'skill_hub_congestion',
severity: 'critical',
title: `${stalled.length} parcel${stalled.length === 1 ? '' : 's'} stalled at hub for >${dwellLimit}m`,
why: 'Parcel has been at the hub beyond the acceptable dwell time with no rider assigned for pickup.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — at hub for ${m.dwellMin} min`),
proposal: {
label: 'Dispatch idle rider to hub',
tool: 'dispatch_hub_idle_parcels',
scope: stalled.map((m) => m.row),
blastRadius: 'Assigns nearest idle zone rider to collect and dispatch all stalled parcels.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,133 @@
// ==============================|| Skill: Late Dispatch Agent ||============================== //
//
// Flags orders that have been accepted/created but not yet dispatched to a rider
// within the acceptable dispatch window during business hours.
// Late dispatch at creation cascades into late delivery at the door.
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const PENDING_DISPATCH_STATUSES = new Set(['pending']);
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const LateDispatchSkill = {
id: 'skill_late_dispatch',
name: 'Late Dispatch Agent',
description: 'Flags orders accepted but sitting in queue without dispatch for too long. Triggers auto-assignment to prevent cascading delays.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
lateDispatchMinutes: {
label: 'Dispatch Deadline',
description: 'Minutes after order creation before flagging as late dispatch.',
value: 30,
min: 10,
max: 90,
step: 5,
unit: 'min'
},
criticalDispatchMinutes: {
label: 'Critical Dispatch Deadline',
description: 'Minutes after order creation before escalating to critical alert.',
value: 60,
min: 30,
max: 180,
step: 10,
unit: 'min'
}
},
instructions: `
- Orders waiting for dispatch beyond the threshold accumulate delay that can't be recovered at the doorstep.
- Auto-assign to the nearest available rider immediately.
- Prioritize orders with the earliest SLA deadlines when multiple are pending.
`,
tools: [
{
name: 'trigger_auto_dispatch',
description: 'Proposes auto-assigning late-pending orders to available riders in zone.',
parameters: {
type: 'object',
properties: {
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs requiring immediate dispatch' },
reason: { type: 'string', description: 'Dispatch urgency reason' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'auto_dispatch',
bookingIds: args.bookingIds,
reason: args.reason || 'Late dispatch threshold exceeded',
blastRadius: `Auto-assigns ${args.bookingIds?.length || 'late'} order(s) to nearest available zone riders. Rider notifications will be sent.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const lateThreshold = currentThresholds.lateDispatchMinutes ?? 30;
const criticalThreshold = currentThresholds.criticalDispatchMinutes ?? 60;
const findings = [];
const allPending = rows
.filter((r) => PENDING_DISPATCH_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, waitMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.waitMin !== null && m.waitMin >= lateThreshold)
.sort((a, b) => b.waitMin - a.waitMin);
const critical = allPending.filter((m) => m.waitMin >= criticalThreshold);
const warning = allPending.filter((m) => m.waitMin >= lateThreshold && m.waitMin < criticalThreshold);
if (critical.length) {
findings.push({
id: 'late_dispatch_critical',
skillId: 'skill_late_dispatch',
severity: 'critical',
title: `${critical.length} order${critical.length === 1 ? '' : 's'} critically late for dispatch (>${criticalThreshold}m)`,
why: `Order has been sitting in queue for over ${criticalThreshold} minutes with no rider assigned. Delivery SLA is at severe risk.`,
count: critical.length,
rows: critical.map((m) => m.row),
detail: critical.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min for dispatch`),
proposal: {
label: 'Auto-dispatch to available riders',
tool: 'trigger_auto_dispatch',
scope: critical.map((m) => m.row),
blastRadius: 'Immediately assigns nearest available riders. Overrides manual queue priority.'
}
});
}
if (warning.length) {
findings.push({
id: 'late_dispatch_warning',
skillId: 'skill_late_dispatch',
severity: 'warning',
title: `${warning.length} order${warning.length === 1 ? '' : 's'} approaching dispatch deadline (>${lateThreshold}m)`,
why: `Order has been waiting ${lateThreshold}+ minutes for rider assignment. Act now to prevent SLA breach.`,
count: warning.length,
rows: warning.map((m) => m.row),
detail: warning.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min`),
proposal: {
label: 'Dispatch now',
tool: 'trigger_auto_dispatch',
scope: warning.map((m) => m.row),
blastRadius: 'Assigns available riders to pending orders based on zone proximity.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,91 @@
// ==============================|| Skill: Rider Battery & Device Safety ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const RiderBatterySafetySkill = {
id: 'skill_rider_battery_safety',
name: 'Rider Device & SOS Safety',
description: 'Monitors rider device telemetry to prevent unreachability due to low battery during active deliveries.',
category: 'rider_safety',
icon: 'BatteryWarning',
// OFF by default (2026-09-29): the rows this rule reads carry no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows',
defaultEnabled: false,
thresholds: {
criticalBatteryPercent: {
label: 'Critical Battery Level (%)',
description: 'Threshold below which rider device is considered at risk of shutdown.',
value: 15,
min: 5,
max: 30,
step: 5,
unit: '%'
}
},
instructions: `
- When a rider has critical battery while carrying in-transit orders, advise charging or offloading remaining pending pickups.
`,
tools: [
{
name: 'alert_low_battery_rider',
description: 'Notifies rider to connect portable charger or report to nearest hub.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Target miler user ID' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'battery_alert',
milerId: args.milerId,
blastRadius: `Sends device power warning notification to Rider #${args.milerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minBattery = currentThresholds.criticalBatteryPercent ?? 15;
const safeRows = Array.isArray(rows) ? rows : [];
const atRisk = safeRows.filter((r) => {
if (!isOpen(r) || !r.userid) return false;
const battery = Number(r.battery_percentage || r.battery_level || r.battery || 100);
return battery > 0 && battery <= minBattery;
});
if (!atRisk.length) return [];
return [
{
id: 'rider_critical_battery',
skillId: 'skill_rider_battery_safety',
severity: 'warning',
title: `${atRisk.length} active delivery under low rider device battery (≤ ${minBattery}%)`,
why: `Rider phone is nearing shutdown, risking lost GPS telemetry and customer communication failure.`,
count: atRisk.length,
rows: atRisk,
detail: atRisk.slice(0, 8).map((r) => {
const bat = r.battery_percentage || r.battery_level || r.battery || 'Low';
return `${ref(r)} — ${r.ridername || 'Rider'} (${bat}% battery)`;
}),
proposal: {
label: 'Send power alert',
tool: 'alert_low_battery_rider',
scope: atRisk,
blastRadius: 'Sends charging prompt to rider.'
}
}
];
}
};

View File

@@ -0,0 +1,157 @@
// ==============================|| Skill: SLA Breach Guardian ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const IN_FLIGHT_STATUSES = new Set(['picked', 'active']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const SlaGuardianSkill = {
id: 'skill_sla_guardian',
name: 'SLA Breach Guardian',
description: 'Monitors promised customer delivery ETAs and flags both breached and imminent SLA violations.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
slaRiskWindowMin: {
label: 'At-Risk Warning Window',
description: 'Minutes before promised delivery window to flag an un-picked parcel as at-risk.',
value: 45,
min: 15,
max: 90,
step: 5,
unit: 'min'
},
unassignedAgingMin: {
label: 'Unassigned Aging Threshold',
description: 'Minutes an order can remain pending with no assigned rider before raising an alarm.',
value: 60,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- When orders exceed promised delivery ETA or are within the risk window without being in flight, prioritize immediate reassignment.
- Select nearby available riders with high historical on-time fulfillment rates.
`,
tools: [
{
name: 'reassign_sla_critical_order',
description: 'Proposes transferring an SLA-at-risk order to an optimal nearby rider.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Booking ID to reassign' },
targetMilerId: { type: 'number', description: 'Target miler user ID' },
reason: { type: 'string', description: 'Justification' }
},
required: ['bookingId', 'targetMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'reassign_miler',
bookingId: args.bookingId,
milerId: args.targetMilerId,
reason: args.reason || 'SLA risk mitigation',
blastRadius: `Reassigns parcel #${args.bookingId} and updates customer ETA tracking.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const riskWindow = currentThresholds.slaRiskWindowMin ?? 45;
const agingWindow = currentThresholds.unassignedAgingMin ?? 60;
const findings = [];
// 1. Breached SLA
const breached = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime)
.map((r) => ({ row: r, overdueMin: minutesBetween(r.expecteddeliverytime, now) }))
.filter((m) => m.overdueMin !== null && m.overdueMin > 0)
.sort((a, b) => b.overdueMin - a.overdueMin);
if (breached.length) {
findings.push({
id: 'sla_breached',
skillId: 'skill_sla_guardian',
severity: 'critical',
title: `${breached.length} ${breached.length === 1 ? 'parcel is' : 'parcels are'} past promised delivery time`,
why: 'Estimated delivery time on service option has elapsed and parcel is not yet delivered.',
count: breached.length,
rows: breached.map((m) => m.row),
detail: breached.slice(0, 8).map((m) => `${ref(m.row)} — ${m.overdueMin} min overdue, status: ${m.row.orderstatus}`),
proposal: {
label: 'Notify assigned riders',
tool: 'notifyRider',
scope: breached.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an urgent priority notification to the assigned rider.'
}
});
}
// 2. At-Risk SLA
const atRisk = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime && !IN_FLIGHT_STATUSES.has(String(r.orderstatus).toLowerCase()))
.map((r) => ({ row: r, dueInMin: minutesBetween(now, r.expecteddeliverytime) }))
.filter((m) => m.dueInMin !== null && m.dueInMin > 0 && m.dueInMin <= riskWindow)
.sort((a, b) => a.dueInMin - b.dueInMin);
if (atRisk.length) {
findings.push({
id: 'sla_at_risk',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${atRisk.length} parcel${atRisk.length === 1 ? '' : 's'} due within ${riskWindow}m and not yet moving`,
why: 'Inside the delivery promise window but parcel pickup has not begun.',
count: atRisk.length,
rows: atRisk.map((m) => m.row),
detail: atRisk.slice(0, 8).map((m) => `${ref(m.row)} — due in ${m.dueInMin} min, status: ${m.row.orderstatus}`)
});
}
// 3. Unassigned Aging
const aging = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'pending')
.map((r) => ({ row: r, ageMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.ageMin !== null && m.ageMin >= agingWindow)
.sort((a, b) => b.ageMin - a.ageMin);
if (aging.length) {
findings.push({
id: 'unassigned_aging',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${aging.length} booking${aging.length === 1 ? '' : 's'} unassigned for > ${agingWindow}m`,
why: 'Order has been sitting in queue with no assigned miler.',
count: aging.length,
rows: aging.map((m) => m.row),
detail: aging.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.ageMin} min`),
proposal: {
label: 'Auto-assign available rider',
tool: 'assignMiler',
scope: aging.map((m) => m.row),
blastRadius: 'Initiates dispatch matching across idle zone riders.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,10 @@
export { SkillRegistry, DEFAULT_SKILLS } from './SkillRegistry';
export { useSkillRegistrySync } from './useSkillRegistrySync';
export { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
export { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
export { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
export { HighValueCodSkill } from './definitions/HighValueCodSkill';
export { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
export { HubCongestionSkill } from './definitions/HubCongestionSkill';
export { LateDispatchSkill } from './definitions/LateDispatchSkill';
export { CashExposureSkill } from './definitions/CashExposureSkill';

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useAiSkills } from '@/lib/doormileHooks';
import { SkillRegistry } from './SkillRegistry';
/**
* Keeps the in-memory SkillRegistry in step with the backend agent registry.
*
* Mounted once, in the console shell, so the Exceptions banner and the chat's
* "what needs attention" briefing both run on the same, tuned settings. A
* change saved in Settings → Skills & Tools invalidates the registry query,
* this refetches, and every subscriber re-evaluates.
*
* `enabled` is false for partner-tenant logins: the registry is Doormile-staff
* only (the backend answers 403), so they run on code defaults, which the
* banner states.
*/
export function useSkillRegistrySync(enabled = true) {
const { data, isError } = useAiSkills({ enabled, retry: false });
useEffect(() => {
if (data) SkillRegistry.applyRegistryConfig(data);
}, [data]);
useEffect(() => {
if (isError || !enabled) SkillRegistry.clearRegistryConfig();
}, [isError, enabled]);
}

View File

@@ -0,0 +1,168 @@
// ==============================|| MileTruth — greetings & "what can you do?" ||============================== //
//
// "good afternoon" used to reach the intent router, match nothing, and get
// "I could not answer that from live data" — the first thing a new user typed
// was answered with a failure. This module answers it instead: a greeting back,
// then what the assistant can actually do, with examples that run when clicked.
//
// Deliberately narrow. A greeting only counts when it is the WHOLE message, or
// the opening of one ("hi, how many orders today?") — then the greeting is
// stripped and the question is answered normally. Nothing here touches data.
//
// Keep CAPABILITIES honest: list only what the home page really does. Every
// example is sent as a real question when clicked, so a wrong one fails in
// front of the user.
const GREETING_RE =
/^\s*(?:(good\s*(?:morning|afternoon|evening|night|day))|(hi+|hello+|hey+|hiya|howdy|yo|namaste|vanakkam|greetings|gm|ge|ga))\b[\s,!.]*(?:(?:there|team|all|everyone|miletruth|mile\s*truth|ai|bot|assistant|buddy|friend|sir|madam)\b[\s,!.]*)?/i;
// ---- "What can you do?" -------------------------------------------------------
//
// Recognised by MEANING, not by an exact sentence. The first version listed
// fixed phrasings, and "what are things you can do ?" — an ordinary way to
// ask — wasn't one of them, so it got "Sorry, I didn't understand". People
// word this question a hundred ways; the rules below look for its shape:
// a question about YOU (the assistant) and what it does / offers / knows.
//
// Two guards keep real questions out:
// • DOMAIN words (orders, riders, hubs, revenue …) mean it's an operations
// question — "what can you tell me about orders today" goes to the router.
// • Long messages (> 14 words) are never treated as small talk.
const DOMAIN_RE =
/\b(?:orders?|bookings?|deliver(?:y|ies)|riders?|milers?|drivers?|hubs?|vehicles?|fleet|revenue|sales|profit|pricing|price|consignments?|parcels?|exceptions?|tripsheets?|customers?|clients?|tenants?|dispatch|batch|pincode|today|yesterday|week|month|dm-[a-z0-9-]+|\d{4,})\b/i;
// Exact short commands that mean "show me the menu".
const MENU_RE =
/^(?:help|help\s+me|menu|options|features|capabilities|commands|guide|start|get\s+started|how\s+are\s+you(?:\s+doing)?|what'?s\s+up|sup|what\s+now|now\s+what|anything\s+else|what\s+else)$/;
const CAPABILITY_PATTERNS = [
// what (are the / all / kind of) things can you do · what do you do · what else can u help with
/\b(?:what|wat|wht|which)\b.*\b(?:you|u|ya)\b.*\b(?:do|does|help|offer|handle|support|manage|provide|know|able)\b/,
// what can I (ask / do / use) · what should I ask · what questions can I ask
/\b(?:what|wat|which|how)\b.*\b(?:can|could|should|do)\b.*\b(?:i|we)\b.*\b(?:ask|do|use|say|type|try)\b/,
// can you help me · can you do anything · are you able to help
/^(?:can|could|will|would|are)\s+(?:you|u)\b.*\b(?:help|do|assist|able)\b/,
// your features / abilities / skills / functions / commands
/\b(?:your|ur)\b.*\b(?:features?|capabilit(?:y|ies)|abilit(?:y|ies)|skills?|functions?|commands?|uses?|job|purpose|role)\b/,
// list / show / tell me (all) your features · show me what you can do
/\b(?:list|show|tell|explain|give)\b.*\b(?:features?|capabilit(?:y|ies)|abilit(?:y|ies)|skills?|functions?|commands?|what\s+(?:you|u)\s+(?:can|do))\b/,
// who / what are you · what is this (ai / bot / tool / thing / page) · what is miletruth
/\bwho\b.*\b(?:are|r)\b.*\b(?:you|u)\b/,
/\bwhat\b.*\b(?:is|are|'s)\b.*\b(?:you|u|this|miletruth|mile\s*truth)\b/,
// how does this work · how do I use this / you · how to use · how to start
/\bhow\b.*\b(?:does|do|to|can)\b.*\b(?:work|use|start|begin|get\s+started)\b/,
// why should I use you · what are you for · what are you good at
/\b(?:good\s+at|for\s+what|what\s+for|used\s+for)\b/
];
const normalize = (text) =>
String(text || '')
.toLowerCase()
.replace(/[’`]/g, "'")
.replace(/[^a-z0-9' -]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
export const isCapabilitiesQuestion = (text) => {
const t = normalize(text);
if (!t) return false;
if (MENU_RE.test(t)) return true;
if (t.split(' ').length > 14) return false;
if (DOMAIN_RE.test(t)) return false;
return CAPABILITY_PATTERNS.some((re) => re.test(t));
};
// Short courtesies with no question in them — answered with a short reply, not
// the full capability list.
const THANKS_RE = /^\s*(?:thanks?|thank\s+you|thx|ty|ok(?:ay)?|cool|great|nice|super|awesome|got\s+it)\b[\s!.]*(?:(?:so\s+much|a\s+lot|miletruth|mile\s*truth)\b[\s!.]*)?$/i;
const clockGreeting = (now) => {
const h = now.getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
};
/**
* Splits a leading greeting off a message.
* Returns { greeting, rest } — greeting is the phrase to answer with (null
* when there wasn't one), rest is whatever followed it, trimmed.
*/
export const splitGreeting = (text, now = new Date()) => {
const raw = String(text || '');
const m = raw.match(GREETING_RE);
if (!m) return { greeting: null, rest: raw.trim() };
// Mirror the greeting the user chose ("good afternoon" → "Good afternoon",
// "gm" → "Good morning"); answer a plain hi/hello by the clock.
const ABBREVIATIONS = { gm: 'morning', ga: 'afternoon', ge: 'evening' };
const part = m[1]
? m[1].replace(/^good\s*/i, '').toLowerCase()
: ABBREVIATIONS[String(m[2] || '').toLowerCase()];
const greeting = part ? `Good ${part}` : clockGreeting(now);
return { greeting, rest: raw.slice(m[0].length).trim() };
};
export const isThanks = (text) => THANKS_RE.test(String(text || ''));
/** What the home page does, grouped, each with examples that run on click. */
export const CAPABILITIES = [
{
title: 'Create & book',
description: 'I collect the details step by step, and nothing is saved until you press confirm.',
examples: ['Create a new order', 'Create a new customer', 'Assign a rider to an order', "Repeat yesterday's orders"]
},
{
title: 'Orders & deliveries',
description: 'Live counts, trends and status from the order board.',
examples: ['How many orders today?', 'Which orders are delayed?', 'Orders today vs yesterday']
},
{
title: 'Riders & fleet',
description: 'Who is working, which vehicles are free, and how each hub is doing.',
examples: ['How many riders are active?', 'How many vehicles are available?', 'Current hub status']
},
{
title: 'Money & reports',
description: 'Revenue, consignments and exceptions that need attention.',
examples: ['Total revenue today', 'How many consignments do we have?', 'Show open exceptions']
}
];
/**
* Answers a greeting / capability / thanks message, or returns null when the
* message is something else (the caller then routes it normally, using
* `rest` so the greeting doesn't confuse the router).
*
* Returns { kind, title, summary, capabilities?, rest }.
*/
export const answerSmallTalk = (text, { name = '', now = new Date() } = {}) => {
const { greeting, rest } = splitGreeting(text, now);
const who = String(name || '').trim().split(/\s+/)[0];
const hello = greeting ? `${greeting}${who ? `, ${who}` : ''}!` : '';
const intro =
'I’m MileTruth, your Doormile operations assistant. I read live data from the console to answer questions, and I can create orders and customers for you — always showing you exactly what will be saved before anything is sent.';
if (!rest || isCapabilitiesQuestion(rest)) {
return {
kind: greeting && !rest ? 'greeting' : 'capabilities',
title: hello ? `${hello} Here’s what I can do for you.` : 'Here’s what I can do for you.',
summary: `${intro} Click any example below to try it, or just type your question.`,
capabilities: CAPABILITIES,
rest: ''
};
}
if (isThanks(rest)) {
return {
kind: 'thanks',
title: 'You’re welcome! Anything else I can help with?',
summary: 'Ask about orders, riders, hubs or revenue — or say “what can you do?” to see everything.',
rest: ''
};
}
// A greeting in front of a real question: let the question through.
return greeting ? { kind: 'passthrough', greeting: hello, rest } : null;
};

170
src/lib/clientOnboarding.js Normal file
View File

@@ -0,0 +1,170 @@
/**
* Who may onboard a new client (create a tenant and its console login).
*
* The server is the real gate — POST /admin/clients/onboard answers 403 to
* anyone outside CLIENT_ONBOARDING_OWNERS (default admin@doormile.com), and
* only for Doormile staff with roleid 1. This list only decides whether the
* console SHOWS the page and its links, so keep it matching the server's.
*/
const DEFAULT_OWNERS = 'admin@doormile.com';
export const CLIENT_ONBOARDING_OWNERS = String(import.meta.env?.VITE_CLIENT_ONBOARDING_OWNERS || DEFAULT_OWNERS)
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
/**
* @param {any} user the signed-in console user (AuthContext)
* @param {boolean} isClient true for a partner-tenant login
*/
export function canOnboardClients(user, isClient) {
if (!user || isClient) return false;
const email = String(user.email || '').trim().toLowerCase();
const role = String(user.role || '').toLowerCase();
return CLIENT_ONBOARDING_OWNERS.includes(email) && (role === 'admin' || String(user.roleid) === '1');
}
/** A random password an operator can hand over: 14 chars, no look-alikes. */
export function generateClientPassword(length = 14) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#%&*';
const bytes = new Uint32Array(length);
globalThis.crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < length; i += 1) out += chars[bytes[i] % chars.length];
// Guarantee a digit and a letter, so a generated password is never all one class.
if (!/\d/.test(out)) out = `${out.slice(0, -1)}7`;
if (!/[a-z]/i.test(out)) out = `k${out.slice(1)}`;
return out;
}
/** Client-side mirror of the server's validation, for inline errors. */
/**
* What a client ships. The SAME eight values the backend prices against
* (constants.DeliveryCategories) — a tenant whose category is not a pricing
* category cannot be priced, so this must not drift from that list.
*/
export const DELIVERY_CATEGORIES = [
'General', 'Documents', 'Electronics', 'Clothing',
'Fragile', 'Medical', 'Automotive', 'Food'
];
/**
* Whether a return journey makes sense for what this client ships.
*
* Food is the exception: a meal that comes back is waste, not inventory.
* Mirrors constants.ReverseLogisticsAllowed, which ENFORCES this in
* InitiateConsignmentRTO — this copy only decides what the form shows, and
* hiding a control is a courtesy, not a rule.
*
* Unknown or empty allows returns, matching the server: the field is new, and
* new information must not withdraw a capability existing clients already use.
*/
export const reverseLogisticsAllowed = (category) => category !== 'Food';
export function validateOnboarding(form) {
const errors = {};
const phone = String(form.phone || '').replace(/\D/g, '');
if (String(form.companyname || '').trim().length < 2) errors.companyname = 'Enter the company name';
if (String(form.contactname || '').trim().length < 2) errors.contactname = "Enter the contact person's name";
if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(String(form.email || '').trim())) errors.email = 'Enter a valid email address';
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = 'Enter a 10-digit mobile number';
if (!form.applocationid) errors.applocationid = "Choose the client's operating city";
// Required, matching the server. Not defaulted to General: the category sets
// the client's pricing AND whether their parcels can be returned, so
// guessing it decides both on the operator's behalf without asking.
if (!DELIVERY_CATEGORIES.includes(String(form.deliverycategory || ''))) {
errors.deliverycategory = 'Choose what this client delivers';
}
const pw = String(form.password || '');
if (pw.length < 8) errors.password = 'At least 8 characters';
else if (pw.length > 72) errors.password = 'At most 72 characters';
else if (pw.toLowerCase() === String(form.email || '').trim().toLowerCase() || pw === phone) {
errors.password = 'Must not be the email or the phone number';
}
if (form.confirm !== pw) errors.confirm = 'The passwords do not match';
Object.assign(errors, validateAddress(form));
return errors;
}
/**
* The client's main address. Mirrors the server: an address, a 6-digit
* pincode, and map coordinates — which only come from picking a suggestion,
* so typed text alone is refused.
*/
export function validateAddress(form) {
const errors = {};
if (String(form.address || '').trim().length < 5) errors.address = "Enter the client's address";
else if (String(form.address).trim().length > 300) errors.address = 'At most 300 characters';
else if (!hasMapLocation(form)) errors.address = 'Pick the address from the suggestions so it has a map location';
if (!/^[1-9]\d{5}$/.test(String(form.pincode || '').replace(/\s/g, ''))) errors.pincode = 'Enter a 6-digit pincode';
return errors;
}
/** Real coordinates inside India (zero means nothing was picked). */
export function hasMapLocation(form) {
const lat = Number(form.latitude);
const lng = Number(form.longitude);
return Number.isFinite(lat) && Number.isFinite(lng) && lat >= 6 && lat <= 37.5 && lng >= 68 && lng <= 97.5;
}
/**
* The address fields from a place picked in AddressAutocomplete (the same
* shapes the order form reads: our own fields first, Google-style
* address_components as a fallback).
*/
export function addressFromPlace(place) {
if (!place) return {};
const parts = { city: '', state: '', pincode: '' };
(place.address_components || []).forEach((c) => {
(c.types || []).forEach((type) => {
if (type === 'locality') parts.city = c.long_name;
if (type === 'administrative_area_level_1') parts.state = c.long_name;
if (type === 'postal_code') parts.pincode = c.long_name;
});
});
const g = place.geometry?.location;
const read = (v) => (typeof v === 'function' ? v() : v);
const lat = place.latitude != null ? place.latitude : read(g?.lat);
const lng = place.longitude != null ? place.longitude : read(g?.lng);
const out = {
address: place.formatted_address || place.name || '',
city: place.city || parts.city,
state: place.state || parts.state,
pincode: String(place.postcode || parts.pincode || '').replace(/\s/g, ''),
};
if (lat != null && lng != null) {
out.latitude = Number(lat);
out.longitude = Number(lng);
}
return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== '' && v != null));
}
/** The address part of a form, as the API takes it. */
export function addressPayload(form) {
return {
address: String(form.address || '').trim(),
city: String(form.city || '').trim(),
state: String(form.state || '').trim(),
pincode: String(form.pincode || '').replace(/\s/g, ''),
latitude: Number(form.latitude),
longitude: Number(form.longitude),
};
}
/**
* Why onboarding cannot run, from the failed GET /admin/clients/cities. The
* page shows this instead of an empty city list: the likeliest cause is a
* console pointed at an API that does not have the onboarding endpoints yet.
*/
export function onboardingUnavailableMessage(err) {
const status = err?.response?.status;
if (status === 404) {
return 'This API server does not have client onboarding yet. The backend changes that add it need to be deployed first.';
}
if (status === 403) {
return 'The server refused this login. Client onboarding is only for the admin@doormile.com account, signed in as Doormile staff.';
}
if (status === 401) return 'Your session has expired. Sign in again.';
if (!err?.response) return 'Could not reach the API server. Check your connection and try again.';
return err.response.data?.message || `The server answered ${status}. Try again shortly.`;
}

37
src/lib/consoleNav.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* Which header-menu groups a login sees.
*
* Kept apart from AdminLayout so the rule can be tested without rendering the
* whole shell.
*/
/**
* The Fleet Ops pages a client (tenant) login may open. The server scopes each
* of these to the client: hubs to its own city, exceptions and returns to its
* own parcels. Everything else under Fleet Ops is internal or would show other
* clients' data, and the server refuses a client token on it.
*/
export const CLIENT_FLEET_OPS_PATHS = ['/doormile/hubs', '/doormile/exceptions', '/doormile/returns'];
export const CLIENT_ONBOARDING_PATH = '/doormile/clients/onboard';
/**
* @param {Array<{label: string, items: Array<{path: string}>}>} groups the full menu
* @param {{isClient: boolean, canOnboard: boolean, onboardingItem: object}} who
*/
export function navGroupsFor(groups, { isClient, canOnboard, onboardingItem }) {
if (isClient) {
// Never Agents or Client Onboarding: those are Doormile-only.
return groups.map((group) =>
group.label === 'Fleet Ops'
? { ...group, items: group.items.filter((item) => CLIENT_FLEET_OPS_PATHS.includes(item.path)) }
: group
);
}
if (!canOnboard) return groups;
// Only the onboarding owner login sees this entry; the server refuses
// everyone else regardless.
return groups.map((group) =>
group.label === 'Fleet Ops' ? { ...group, items: [...group.items, onboardingItem] } : group
);
}

73
src/lib/dateRange.js Normal file
View File

@@ -0,0 +1,73 @@
import dayjs from 'dayjs';
/**
* The DatePicker's value, as a `{ from, to }` range.
*
* `components/ds/DatePicker` emits three different shapes and a page has to
* handle all three: `'YYYY-MM-DD'` for a single day (grid click, Today,
* Yesterday, the prev/next steppers), `{ from, to }` for the range presets
* (Last 7 Days, This Month), and the string `'all'` for no filter.
*
* Coercing everything through `dayjs(value).format(...)` looks like a
* normalisation and is not: `dayjs({ from, to })` and `dayjs('all')` are both
* Invalid Date, so the range presets and All Dates silently became the literal
* string "Invalid Date" and every row was filtered out against it.
*
* An empty bound means unbounded on that side, which is what the API layer's
* `inRange` already expects.
*/
export const toDateRange = (value) => {
if (!value || value === 'all') return { from: '', to: '' };
if (typeof value === 'string') return { from: value, to: value };
if (typeof value !== 'object') return { from: '', to: '' };
return { from: value.from || '', to: value.to || '' };
};
/**
* Keep the rows whose `createdat` falls inside the DatePicker's current value.
*
* Lives here rather than inline on each page because Orders and Bookings read
* the same `/admin/bookings` rows through the same picker, and a page that
* wrote its own comparison would eventually disagree with the other about what
* "today" contains — the kind of difference nobody notices until two screens
* show different counts for the same day.
*
* A row whose timestamp will not parse is dropped from a date-scoped view. That
* is the same call `fetchBookingsInRange` makes: a date filter cannot honestly
* claim a row it could not place in time. Nothing is dropped when the value is
* 'all', which is the case that has to keep showing everything.
*/
export const filterByCreatedAt = (rows, value, parse) => {
if (!value || value === 'all') return rows || [];
const { from, to } = toDateRange(value);
if (!from && !to) return rows || [];
return (rows || []).filter((row) => {
const at = parse(row?.createdat);
if (!at?.isValid?.()) return false;
const day = at.format('YYYY-MM-DD');
if (from && day < from) return false;
if (to && day > to) return false;
return true;
});
};
/**
* DatePicker value → the { from, to } range the report pages query with.
*
* The shared DatePicker hands back one day ('YYYY-MM-DD') or a { from, to }
* range (its presets, or rangeSelect). Reports always need a range, never
* past today — they count orders by when they were CREATED, so a future day is
* empty by definition — and never inverted.
*/
export const toReportRange = (value) => {
const today = dayjs().format('YYYY-MM-DD');
const clamp = (d) => (d && d > today ? today : d);
if (typeof value === 'string' && value !== 'all') return { from: clamp(value), to: clamp(value) };
if (value && typeof value === 'object') {
const from = clamp(value.from || value.to || today);
const to = clamp(value.to || value.from || today);
return from <= to ? { from, to } : { from: to, to: from };
}
return { from: today, to: today };
};

View File

@@ -11,6 +11,15 @@
* module-local; the logic is unchanged.
*/
/** The rider key a refused order is filed under. Dispatch's rider map skips
* this key explicitly, which is what keeps it out of the miler count. */
export const UNASSIGNED_RIDER_ID = 'unassigned';
// The id an order is known by, whichever spelling the service used. Shared so
// that an order the board is showing can always be found again in the list it
// came from — the refusal lists carry no `orderid` of their own.
const orderKey = (o) => o?.orderid || o?.bookingno || o?.bookingid || o?.order_id || o?.booking_id || o?.id || null;
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
// the Reconcile tab UI and the reconcile-API payload.
export const extractRiders = (previewData) => {
@@ -24,6 +33,10 @@ export const extractRiders = (previewData) => {
const seenOrderIds = new Set();
const push = (riderId, riderName, orders) => {
if (riderId == null) return;
// `withUnassignedZones` files refused orders under a synthetic rider so the
// board can draw them. This list becomes the reconcile and commit payload,
// and a bucket is not a miler — sending it would post `userid: NaN`.
if (String(riderId) === UNASSIGNED_RIDER_ID) return;
const key = String(riderId);
if (!map.has(key)) {
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
@@ -103,13 +116,43 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
}
// 2) Move within zones[].riders[].orders[]
// A reply can carry refusals with no zones at all (the empty-input shape has
// no `zones` key). Give it an empty one so the search below still runs —
// otherwise the board would offer a rider picker for an order this function
// could never find, and report success without moving anything.
if (!Array.isArray(next.zones) && Array.isArray(next.unassigned_orders) && next.unassigned_orders.length) {
next.zones = [];
}
if (Array.isArray(next.zones)) {
let movedOrder = null;
let homeZoneIdx = -1;
// The order may have no rider at all. The optimiser files the ones it
// refused in `unassigned_orders[]`, and picking a miler for one of those
// from the board is exactly how an operator recovers a failed run — so
// that list has to be searched too, and the order taken out of it, or the
// "N unassigned" banner would never fall as they work through them.
const takeFromRefused = (list, zi) => {
if (!Array.isArray(list)) return false;
// An id-less row must not match an id-less lookup — that would move an
// arbitrary order instead of the one the operator picked.
const ui = list.findIndex((o) => {
const k = orderKey(o);
return k != null && String(k) === String(orderId);
});
if (ui === -1) return false;
movedOrder = list[ui];
list.splice(ui, 1);
if (zi != null) homeZoneIdx = zi;
return true;
};
for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) {
const zone = next.zones[zi];
if (!Array.isArray(zone.riders)) continue;
if (!Array.isArray(zone.riders)) {
takeFromRefused(zone.unassigned_orders, zi);
continue;
}
for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) {
const r = zone.riders[ri];
if (!Array.isArray(r.orders)) continue;
@@ -128,11 +171,18 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
}
}
}
if (!movedOrder) takeFromRefused(zone.unassigned_orders, zi);
}
// A refusal the service reported at the top level rather than per zone.
if (!movedOrder) takeFromRefused(next.unassigned_orders, null);
if (movedOrder) {
const updated = {
...movedOrder,
// A refused order carries no `orderid` of its own — it was never in the
// rider tree. Stamp it now so every reader downstream, including the
// commit payload, keys it the same way the board did.
orderid: movedOrder.orderid || orderKey(movedOrder) || orderId,
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
@@ -151,8 +201,19 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
break;
}
}
if (!placed && homeZoneIdx >= 0) {
next.zones[homeZoneIdx].riders.push({
if (!placed) {
// Land it in the zone it came from. A zone that only ever held
// refusals has no `riders` array at all, and an order reported at the
// top level has no home zone — neither may end with the order being
// dropped on the floor, which is what returning here without placing
// it would do.
if (homeZoneIdx < 0) {
next.zones.push({ zone_name: movedOrder.zone_name || 'Unzoned', riders: [] });
homeZoneIdx = next.zones.length - 1;
}
const home = next.zones[homeZoneIdx];
home.riders = home.riders || [];
home.riders.push({
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
@@ -272,3 +333,168 @@ export function computeDeliveryAmounts(list) {
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
});
}
/**
* The orders the optimiser could NOT place, and why.
*
* These were being dropped on the floor. `normalizePreviewData` walks
* `zones[].riders[].orders[]` and `details[]`; `extractRiders` returns early
* on a null rider id. An order the optimiser refused appears in neither — it
* sits in `zones[].unassigned_orders[]`, which nothing in this file read.
*
* The result was the failure this was written for: the optimiser answers
*
* { code: 200, status: true,
* zones: [{ active_riders_count: 0, riders: [],
* unassigned_orders: [{ …the order…,
* unassigned_reason: "No riders found (check partner online
* status)." }] }] }
*
* — a complete refusal, carrying its own explanation — and the operator got a
* green "Orders optimised" toast over an empty board. The reason was in the
* payload the whole time.
*
* Reads the top level too: the service puts them under each zone today, but
* the empty-input response shape (`details: {}`, no zones) shows the envelope
* is not fixed, and an order that reaches neither place is an order nobody
* ever hears about again.
*/
export const extractUnassigned = (previewData) => {
if (!previewData) return [];
const out = [];
const seen = new Set();
const take = (orders, zoneName) => {
(Array.isArray(orders) ? orders : []).forEach((o, idx) => {
const id = String(orderKey(o) || `unassigned-${idx}`);
if (seen.has(id)) return;
seen.add(id);
out.push({
...o,
orderid: id,
zone_name: o?.zone_name || zoneName || null,
// One phrase, whichever spelling the service used. Never invented: an
// order with no stated reason says so, because "no reason given" is
// itself worth seeing when it happens to every row.
unassigned_reason: o?.unassigned_reason || o?.reason || o?.message || 'No reason given'
});
});
};
(Array.isArray(previewData.zones) ? previewData.zones : []).forEach((z) =>
take(z?.unassigned_orders, z?.zone_name)
);
take(previewData.unassigned_orders, null);
return out;
};
/**
* What actually happened, in numbers — for the toast and the banner.
*
* `code: 200` is not success here. The optimiser returns 200 whether it
* placed every order or none, so the caller has to count. Reporting "Orders
* optimised" off the HTTP status is what turned a total failure into a green
* tick.
*/
export const assignmentSummary = (previewData) => {
const riders = extractRiders(previewData);
const unassigned = extractUnassigned(previewData);
const assigned = riders.reduce((n, r) => n + (r.orders?.length || 0), 0);
// Distinct reasons, most common first — one line explains a whole failed
// run, and in practice every row carries the same reason.
const counts = new Map();
unassigned.forEach((o) => counts.set(o.unassigned_reason, (counts.get(o.unassigned_reason) || 0) + 1));
const reasons = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([reason, count]) => ({ reason, count }));
return {
assigned,
unassigned: unassigned.length,
total: assigned + unassigned.length,
riders: riders.length,
reasons,
// True when the optimiser placed nothing at all but was handed work.
failed: assigned === 0 && unassigned.length > 0
};
};
/**
* Put the refused orders back on the board.
*
* The board renders `zones[].riders[].orders[]` and nothing else, so a reply
* whose orders are all in `unassigned_orders[]` produced an empty screen —
* "No orders in Afternoon Batch", zero zones, zero pins — over a response
* that named the zone, counted the orders and gave the reason. The operator
* was shown less than the optimiser said.
*
* Each refused order is filed under its zone as an `unassigned` rider bucket.
* That is not a new shape: the live-data path in Dispatch already synthesises
* exactly this for a row with no rider, and both rider-map loops there skip
* the `unassigned` key by name — so folding these in makes the zone, its
* order count and its pins appear without ever inventing a miler.
*
* Returns the input untouched when there is nothing to fold, and is
* idempotent: re-folding replaces the bucket rather than appending a second.
*/
export const withUnassignedZones = (previewData) => {
if (!previewData) return previewData;
const rows = extractUnassigned(previewData);
if (!rows.length) return previewData;
const byZone = new Map();
rows.forEach((o) => {
const name = o.zone_name || 'Unzoned';
if (!byZone.has(name)) byZone.set(name, []);
byZone.get(name).push(o);
});
const zones = (Array.isArray(previewData.zones) ? previewData.zones : []).map((z) => ({ ...z }));
byZone.forEach((orders, name) => {
let zone = zones.find((z) => (z.zone_name || 'Unzoned') === name);
if (!zone) {
zone = { zone_name: name, riders: [] };
zones.push(zone);
}
const bucket = {
rider_id: UNASSIGNED_RIDER_ID,
rider_name: 'Unassigned',
// step/trip only exist so the order cards and step chips have something
// to number by; they carry no routing meaning for an unplaced order.
orders: orders.map((o, idx) => ({
...o,
step: o.step || idx + 1,
trip_number: o.trip_number || 1
}))
};
zone.riders = [
...(zone.riders || []).filter((r) => String(r.rider_id ?? r.userid) !== UNASSIGNED_RIDER_ID),
bucket
];
});
return { ...previewData, zones };
};
/**
* Does an edit change a route the solver planned?
*
* Reconcile re-sequences a rider's stops after the operator rearranges them,
* and Assign Orders stays blocked until it succeeds. That is right for an
* edit inside the solver's plan, and a dead end for an order the solver
* REFUSED: such an order was never sequenced, and `reconcile-steps` is the
* same external service whose rider pool came back empty — a miler it has
* never heard of will not appear in its response, the dirty flag never
* clears, and the order can never be dispatched at all.
*
* Narrow on purpose. False only when the order was in the refusal list AND
* the miler chosen has no other stops. Dropping a refused order into an
* existing route does change that route's sequence, and still reconciles.
*/
export const editNeedsReconcile = (before, after, { orderId, newRiderId }) => {
const wasRefused = extractUnassigned(before).some((o) => String(o.orderid) === String(orderId));
if (!wasRefused) return true;
const stops = extractRiders(after).find((r) => String(r.rider_id) === String(newRiderId))?.orders?.length ?? 0;
return stops > 1;
};

View File

@@ -49,6 +49,11 @@ export const calculateDrivingRoute = async (origin, destination) => {
const data = await response.json();
if (data.routes && data.routes.length > 0) {
const route = data.routes[0];
// `distance` stays whole km: it is what pricing and bulk upload bill on,
// and changing its precision would change invoices. `meters` is the
// unrounded road length, for display only — a 120 m drop rounds to
// 0 km here and must not be shown as "0 km" or floored to "1 km".
const meters = Math.max(0, Math.round(route.distance));
const distanceKm = Math.round(route.distance / 1000);
const durationMin = Number.isFinite(route.duration) ? Math.round(route.duration / 60) : null;
// OSRM geojson coordinates are [lng, lat] -> convert to Leaflet [lat, lng]
@@ -56,6 +61,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
lastRouteDurationMin = durationMin;
return {
distance: Math.max(0, distanceKm),
meters,
minutes: durationMin,
polyline: polyline.length > 0 ? polyline : [[lat1, lon1], [lat2, lon2]],
resolved: true
@@ -81,6 +87,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
lastRouteDurationMin = null;
return {
distance: aerialDistance,
meters: Math.round(R * c * 1.3 * 1000),
minutes: null,
polyline: [[lat1, lon1], [lat2, lon2]],
resolved: true
@@ -95,6 +102,30 @@ export const calculateDrivingDistance = async (origin, destination) => {
return res.distance;
};
/**
* Formats a road length for display: metres under 1 km (to the nearest 10 m),
* km with one decimal above it. Returns '—' when the length is unknown.
* @param {number} meters
*/
export const formatRouteDistance = (meters) => {
if (meters === null || meters === undefined) return '—';
const m = Number(meters);
if (!Number.isFinite(m) || m < 0) return '—';
const tens = Math.round(m / 10) * 10;
if (tens < 1000) return `${tens} m`;
return `${Number((m / 1000).toFixed(1))} km`;
};
/**
* Formats a drive time for display. OSRM reports seconds; a sub-minute leg
* rounds to 0 and must read "< 1 min", not fall through to a default.
* @param {number|null} minutes
*/
export const formatRouteDuration = (minutes) => {
if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return '—';
return Number(minutes) < 1 ? '< 1 min' : `${minutes} mins`;
};
/**
* Calculates total charge based on distance and pricing tier
*/

Some files were not shown because too many files have changed in this diff Show More