Compare commits
36 Commits
b1747480df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5faf0e1deb | |||
| 3d07ea8170 | |||
| ff1b795292 | |||
| 0d5edf94bf | |||
| 8dca169b83 | |||
| 21775fe6f9 | |||
| 7aca6a6a48 | |||
| 6e73b59ca9 | |||
| 806f4a2b3f | |||
| d33fff01d3 | |||
| ede1731fa5 | |||
| 53199127ba | |||
| 92e8da2842 | |||
| 05a4cbdf8a | |||
| 5bf0104b61 | |||
| 3bde09839c | |||
| 912f3cbcd4 | |||
| d288197c35 | |||
| 981f0566a6 | |||
| f86e4b853b | |||
| 3e7aaf1e25 | |||
| d875900cb0 | |||
| beff5a1cbe | |||
| 9c50de1142 | |||
| cbce47f949 | |||
| 5eb4a6466b | |||
| 7544a07066 | |||
| c6dfa3cb8f | |||
| b9b0380927 | |||
| 6585a8da00 | |||
| 16278d3893 | |||
| 1bfbdb59ef | |||
| 7992208361 | |||
| 3e2e9cad3a | |||
| a044afc614 | |||
| 8e87947c5f |
@@ -228,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 |
|
||||
|
||||
777
docs/agent-platform-phase7-plan.md
Normal file
777
docs/agent-platform-phase7-plan.md
Normal 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
724
docs/agent-platform-plan.md
Normal 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.
|
||||
300
docs/reverse-logistics-plan.md
Normal file
300
docs/reverse-logistics-plan.md
Normal 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.
|
||||
|
||||
@@ -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
1357
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
BIN
public/miletruth-logo-dark.png
Normal file
BIN
public/miletruth-logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/miletruth-logo-red.png
Normal file
BIN
public/miletruth-logo-red.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/miletruth-logo.png
Normal file
BIN
public/miletruth-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
70
src/App.jsx
70
src/App.jsx
@@ -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,6 +14,13 @@ 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'));
|
||||
|
||||
@@ -26,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'));
|
||||
@@ -51,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">
|
||||
@@ -81,13 +91,33 @@ const AuthenticatedApp = () => {
|
||||
now" without a filter being set first. */}
|
||||
<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/home" replace />} />
|
||||
|
||||
<Route path="home" element={<Home />} />
|
||||
<Route path="dispatch" element={<Dispatch />} />
|
||||
<Route path="dispatch/preview" element={<DispatchPreview />} />
|
||||
{/* 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". */}
|
||||
@@ -100,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 />} />
|
||||
@@ -118,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 />} />
|
||||
@@ -130,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>
|
||||
|
||||
@@ -148,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -15,3 +15,6 @@ 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';
|
||||
|
||||
|
||||
@@ -36,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.
|
||||
|
||||
|
||||
@@ -925,50 +925,14 @@ 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
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -1025,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;
|
||||
}
|
||||
|
||||
88
src/components/assistant/DoormileAI/AIAddressStep.jsx
Normal file
88
src/components/assistant/DoormileAI/AIAddressStep.jsx
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
78
src/components/assistant/DoormileAI/AILauncher.jsx
Normal file
78
src/components/assistant/DoormileAI/AILauncher.jsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { AiOutlineMore as MoreOutlined } from 'react-icons/ai';
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuHistory,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
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';
|
||||
@@ -36,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';
|
||||
@@ -402,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;
|
||||
@@ -1430,6 +1432,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
side="bottom"
|
||||
label={view === 'history' ? 'Back to conversation' : 'Conversation history'}
|
||||
tooltip={view === 'history' ? 'Back to conversation' : 'History'}
|
||||
aria-pressed={view === 'history'}
|
||||
@@ -1439,6 +1442,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
side="bottom"
|
||||
label="New conversation"
|
||||
tooltip="New conversation"
|
||||
icon={<LuRotateCcw />}
|
||||
@@ -1447,6 +1451,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<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}
|
||||
@@ -1456,6 +1461,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
side="bottom"
|
||||
label="Close assistant"
|
||||
tooltip="Close assistant"
|
||||
icon={<LuX />}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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="MileTruth">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="dai-root dai-trigger"
|
||||
data-active={isOpen}
|
||||
aria-label="Open MileTruth"
|
||||
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;
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
212
src/components/doormile/AgentOperationsBanner.jsx
Normal file
212
src/components/doormile/AgentOperationsBanner.jsx
Normal 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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
89
src/components/doormile/ConsignmentTimeline.jsx
Normal file
89
src/components/doormile/ConsignmentTimeline.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
196
src/components/doormile/RtoDialogs.jsx
Normal file
196
src/components/doormile/RtoDialogs.jsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
137
src/components/doormile/SlaRemediationCard.jsx
Normal file
137
src/components/doormile/SlaRemediationCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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="MileTruth"
|
||||
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">MileTruth</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
99
src/components/ds/DateRangeFields.jsx
Normal file
99
src/components/ds/DateRangeFields.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -85,7 +85,15 @@ export function Tabs({
|
||||
// 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} className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
|
||||
119
src/components/third-party/AppToaster.jsx
vendored
Normal file
119
src/components/third-party/AppToaster.jsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/components/third-party/OpenToast.js
vendored
8
src/components/third-party/OpenToast.js
vendored
@@ -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' });
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,12 +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 {
|
||||
Activity, Bell, Bike, Building2, Car, ChevronDown, Coins, FileSpreadsheet,
|
||||
FileText, IndianRupee, ListTodo, LogOut, Menu, Search, Settings, Shield,
|
||||
ShieldAlert, User, UserCheck, Users, Warehouse,
|
||||
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_MARK_URL, 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 {
|
||||
@@ -16,10 +16,13 @@ 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.
|
||||
@@ -47,12 +50,15 @@ const LOGO_FILTER = {
|
||||
|
||||
/** 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' },
|
||||
];
|
||||
|
||||
/** Destinations grouped behind a menu, in the order the console lists them. */
|
||||
@@ -60,46 +66,27 @@ const NAV_GROUPS = [
|
||||
{
|
||||
label: 'Fleet Ops',
|
||||
items: [
|
||||
{ label: 'Hubs', path: '/doormile/hubs', icon: Warehouse, desc: 'Sortation centers & facilities' },
|
||||
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car, desc: 'Fleet register & EV telematics' },
|
||||
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo, desc: 'Linehaul & middle-mile runs' },
|
||||
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert, desc: 'Live alerts & delivery blockers' },
|
||||
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity, desc: 'Benchmarking & market rates' },
|
||||
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck, desc: 'System operators & roles' },
|
||||
{ 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', icon: FileSpreadsheet, desc: 'Daily volume & status aggregations' },
|
||||
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails', icon: FileText, desc: 'Granular leg-by-leg breakdown' },
|
||||
{ label: 'Milers Summary', path: '/doormile/reports/riderssummary', icon: Bike, desc: 'Fleet KPIs & on-duty shifts' },
|
||||
{ label: 'Profitability', path: '/doormile/reports/profitability', icon: Coins, desc: 'Gross margin & variable costs' },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* Configuration destinations, kept off the operations bar.
|
||||
*
|
||||
* Declared here rather than written inline in the account dropdown because
|
||||
* two surfaces need them: that dropdown on desktop, and the mobile sheet.
|
||||
* When they lived only in the dropdown's JSX, moving them off the bar quietly
|
||||
* removed them from the phone entirely — the sheet builds its list from NAV
|
||||
* plus NAV_GROUPS, and these were in neither. Both pages stayed routed and
|
||||
* reachable by typed URL, which is the kind of gap nobody reports and nobody
|
||||
* finds.
|
||||
*
|
||||
* (This replaces an ALL_DESTINATIONS constant that flattened NAV and
|
||||
* NAV_GROUPS. Its comment claimed it was "what the mobile sheet renders"; the
|
||||
* sheet had moved to a role-filtered local of the same name in lower case, so
|
||||
* the constant had no readers at all and would have reintroduced the same
|
||||
* omission if anyone had trusted it.)
|
||||
*/
|
||||
const SETTINGS_NAV = [
|
||||
{ label: 'Pricing Matrix', path: '/doormile/pricing', icon: IndianRupee },
|
||||
{ label: 'Customers Directory', path: '/doormile/customers', icon: Users },
|
||||
];
|
||||
|
||||
function GlobalSearch() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -241,6 +228,10 @@ 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(() => {
|
||||
try {
|
||||
@@ -271,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)];
|
||||
@@ -291,6 +287,8 @@ export default function AdminLayout() {
|
||||
|
||||
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.
|
||||
@@ -372,28 +370,31 @@ export default function AdminLayout() {
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className={cn(
|
||||
'flex items-start gap-2.5 p-2 rounded-xl cursor-pointer transition-colors my-0.5',
|
||||
// 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 mt-0.5',
|
||||
'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>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-body-sm font-semibold">{item.label}</div>
|
||||
{item.desc && (
|
||||
<div className="text-[11px] text-ink-3 leading-tight mt-0.5 line-clamp-1">
|
||||
{item.desc}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -405,32 +406,10 @@ export default function AdminLayout() {
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<GlobalSearch />
|
||||
|
||||
{/* MileTruth. A header control rather than a route: shown on all pages except the home page */}
|
||||
{!isHome && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAssistant((open) => !open)}
|
||||
aria-label={assistantOpen ? 'Close MileTruth' : 'Open MileTruth'}
|
||||
aria-expanded={assistantOpen}
|
||||
className={cn(
|
||||
'grid h-8 w-8 place-items-center rounded-lg transition-colors cursor-pointer',
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{/* 20px inside a 32px button.
|
||||
Was `h-9 w-9 max-w-none` — a 36px image in a 32px button,
|
||||
so the mark overflowed its own control by 2px on every
|
||||
side. Two visible consequences: it stood proud of the
|
||||
notification bell and avatar beside it, breaking the row's
|
||||
alignment, and it covered the button's own area completely,
|
||||
so the `hover:bg-surface-sunken` tint had nowhere to show —
|
||||
the control looked dead on hover while its neighbours lit
|
||||
up. Sized to sit inside the button, like the Bell's 16px
|
||||
icon does. */}
|
||||
<img className="h-5 w-5 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 />}
|
||||
|
||||
@@ -463,18 +442,15 @@ export default function AdminLayout() {
|
||||
<Shield className="mr-2.5 h-4 w-4 text-slate-500" /> Change password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
<div className="px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
||||
<Settings className="w-3 h-3 text-slate-400" /> Settings
|
||||
</div>
|
||||
{SETTINGS_NAV.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className="cursor-pointer rounded-xl px-2.5 py-2 font-medium"
|
||||
>
|
||||
<item.icon className="mr-2.5 h-4 w-4 text-slate-500" /> {item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<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()}
|
||||
@@ -523,22 +499,16 @@ export default function AdminLayout() {
|
||||
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" />
|
||||
<div className="px-3 pb-1 text-[10px] font-bold uppercase tracking-wider text-ink-3">
|
||||
<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
|
||||
</div>
|
||||
{SETTINGS_NAV.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
|
||||
isActive(item.path) ? 'bg-brand-tint text-brand' : 'text-ink-2 hover:bg-surface-sunken'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</Link>
|
||||
<div className="my-2 h-px bg-border" />
|
||||
<Link
|
||||
to="/doormile/profile"
|
||||
@@ -554,16 +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 flex-1", !location.pathname.startsWith('/doormile/dispatch') && "px-4 md:flex-row lg:px-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/dispatch') && location.pathname !== '/doormile/home' && location.pathname !== '/doormile' && "py-6"
|
||||
!location.pathname.startsWith('/doormile/control-x') && location.pathname !== '/doormile/home' && location.pathname !== '/doormile' && "py-6"
|
||||
)}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{!isHome && <AIPanel isOpen={assistantOpen} onClose={() => toggleAssistant(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
294
src/lib/agentNetwork.js
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -37,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
@@ -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: [
|
||||
{
|
||||
|
||||
92
src/lib/assistant/agent/AgentFactory.js
Normal file
92
src/lib/assistant/agent/AgentFactory.js
Normal 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());
|
||||
}
|
||||
}
|
||||
254
src/lib/assistant/agent/actions.js
Normal file
254
src/lib/assistant/agent/actions.js
Normal 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);
|
||||
};
|
||||
112
src/lib/assistant/agent/briefing.js
Normal file
112
src/lib/assistant/agent/briefing.js
Normal 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' }
|
||||
};
|
||||
};
|
||||
150
src/lib/assistant/agent/findingReport.js
Normal file
150
src/lib/assistant/agent/findingReport.js
Normal 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();
|
||||
};
|
||||
53
src/lib/assistant/agent/normalise.js
Normal file
53
src/lib/assistant/agent/normalise.js
Normal 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) : []);
|
||||
44
src/lib/assistant/agent/signals.js
Normal file
44
src/lib/assistant/agent/signals.js
Normal 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.';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.` };
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
123
src/lib/assistant/skills/SkillRegistry.js
Normal file
123
src/lib/assistant/skills/SkillRegistry.js
Normal 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();
|
||||
161
src/lib/assistant/skills/definitions/CashExposureSkill.js
Normal file
161
src/lib/assistant/skills/definitions/CashExposureSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
90
src/lib/assistant/skills/definitions/DoorstepStallSkill.js
Normal file
90
src/lib/assistant/skills/definitions/DoorstepStallSkill.js
Normal 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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
87
src/lib/assistant/skills/definitions/FleetBalancerSkill.js
Normal file
87
src/lib/assistant/skills/definitions/FleetBalancerSkill.js
Normal 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`)
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
93
src/lib/assistant/skills/definitions/HighValueCodSkill.js
Normal file
93
src/lib/assistant/skills/definitions/HighValueCodSkill.js
Normal 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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
105
src/lib/assistant/skills/definitions/HubCongestionSkill.js
Normal file
105
src/lib/assistant/skills/definitions/HubCongestionSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
133
src/lib/assistant/skills/definitions/LateDispatchSkill.js
Normal file
133
src/lib/assistant/skills/definitions/LateDispatchSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
157
src/lib/assistant/skills/definitions/SlaGuardianSkill.js
Normal file
157
src/lib/assistant/skills/definitions/SlaGuardianSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
10
src/lib/assistant/skills/index.js
Normal file
10
src/lib/assistant/skills/index.js
Normal 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';
|
||||
27
src/lib/assistant/skills/useSkillRegistrySync.js
Normal file
27
src/lib/assistant/skills/useSkillRegistrySync.js
Normal 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]);
|
||||
}
|
||||
168
src/lib/assistant/smalltalk.js
Normal file
168
src/lib/assistant/smalltalk.js
Normal 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
170
src/lib/clientOnboarding.js
Normal 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
37
src/lib/consoleNav.js
Normal 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
73
src/lib/dateRange.js
Normal 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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -67,6 +67,14 @@ export const KEYS = {
|
||||
carrierPricing: (page, limit) => ['doormile', 'carrier-pricing', page, limit],
|
||||
|
||||
appLocations: ['doormile', 'app-locations'],
|
||||
|
||||
// AI agent registry. One prefix, so a write invalidates agents, skills,
|
||||
// tools and the audit together — a skill toggle changes an agent's counts.
|
||||
aiRegistry: ['doormile', 'ai'],
|
||||
aiAgents: ['doormile', 'ai', 'agents'],
|
||||
aiSkills: ['doormile', 'ai', 'skills'],
|
||||
aiTools: ['doormile', 'ai', 'tools'],
|
||||
aiAudit: ['doormile', 'ai', 'audit'],
|
||||
};
|
||||
|
||||
/* ── Shared helpers ───────────────────────────────────────────────────────── */
|
||||
@@ -100,6 +108,9 @@ const BOOKING_KEYS = [
|
||||
['doormile', 'bookings'],
|
||||
['doormile', 'deliveries'],
|
||||
['doormile', 'delivery-counts'],
|
||||
// A parcel's timeline (ConsignmentTimeline), so a status change made from
|
||||
// Deliveries shows in an open order drawer at once.
|
||||
['doormile', 'returns', 'history'],
|
||||
];
|
||||
|
||||
/* ── Dashboard, reports, profile ──────────────────────────────────────────── */
|
||||
@@ -179,6 +190,37 @@ export const useTenant = (id, options) =>
|
||||
export const useCreateTenant = () =>
|
||||
useDoormileMutation({ mutationFn: api.createAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client created' });
|
||||
|
||||
export const useOnboardedClients = (options) =>
|
||||
useQuery({ queryKey: [...KEYS.tenants, 'onboarded'], queryFn: api.getOnboardedClients, staleTime: 30_000, ...options });
|
||||
|
||||
export const useOnboardingCities = (options) =>
|
||||
useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options });
|
||||
|
||||
export const useUpdateOnboardedClient = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ authid, data }) => api.updateOnboardedClient(authid, data),
|
||||
invalidates: [KEYS.tenants],
|
||||
successMessage: 'Client updated',
|
||||
});
|
||||
|
||||
export const useDeleteOnboardedClient = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: (authid) => api.deleteOnboardedClient(authid),
|
||||
invalidates: [KEYS.tenants],
|
||||
successMessage: 'Client login removed',
|
||||
});
|
||||
|
||||
// No toasts: the page shows its own success panel and inline errors.
|
||||
export const useOnboardClient = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.onboardClient,
|
||||
onSuccess: (result) => {
|
||||
if (result?.success !== false) queryClient.invalidateQueries({ queryKey: KEYS.tenants });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTenant = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ id, data }) => api.updateAdminTenant(id, data),
|
||||
@@ -324,6 +366,8 @@ export const useDeleteVehicle = () =>
|
||||
export const useMilers = (options) =>
|
||||
useQuery({ queryKey: KEYS.milers, queryFn: api.getallriders, staleTime: 30_000, ...options });
|
||||
|
||||
export const useRiders = useMilers;
|
||||
|
||||
export const useMiler = (id, options) =>
|
||||
useQuery({ queryKey: KEYS.miler(id), queryFn: () => api.getMiler(id), enabled: !!id, ...options });
|
||||
|
||||
@@ -373,7 +417,14 @@ export const useBlockMiler = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ id, data }) => api.blockMiler(id, data),
|
||||
invalidates: [KEYS.milers],
|
||||
successMessage: 'Rider updated',
|
||||
successMessage: 'Rider blocked',
|
||||
});
|
||||
|
||||
export const useUnblockMiler = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: (id) => api.unblockMiler(id),
|
||||
invalidates: [KEYS.milers],
|
||||
successMessage: 'Rider unblocked',
|
||||
});
|
||||
|
||||
export const useAssignMilerVehicle = () =>
|
||||
@@ -500,6 +551,62 @@ export const useCancelDelivery = () =>
|
||||
successMessage: 'Delivery cancelled',
|
||||
});
|
||||
|
||||
/* ── Reverse logistics (RTO) ──────────────────────────────────────────────── */
|
||||
|
||||
// Exceptions too: starting a return resolves the parcel's Undeliverable /
|
||||
// Receiver_Refused exception on the server, and the list must show that at
|
||||
// once rather than on its next poll.
|
||||
const RETURN_KEYS = [...BOOKING_KEYS, ['doormile', 'returns'], KEYS.exceptions];
|
||||
|
||||
export const useReturns = (params = {}, options) =>
|
||||
useQuery({
|
||||
queryKey: ['doormile', 'returns', params],
|
||||
queryFn: () => api.getReturns(params),
|
||||
placeholderData: (previous) => previous,
|
||||
staleTime: 15_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
/* Both live under the returns key, so every return action above refreshes them. */
|
||||
export const useReturnsSummary = (params = {}, options) =>
|
||||
useQuery({
|
||||
queryKey: ['doormile', 'returns', 'summary', params],
|
||||
queryFn: () => api.getReturnsSummary(params),
|
||||
placeholderData: (previous) => previous,
|
||||
staleTime: 60_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
export const useConsignmentHistory = (consignmentId, options) =>
|
||||
useQuery({
|
||||
queryKey: ['doormile', 'returns', 'history', String(consignmentId)],
|
||||
queryFn: () => api.getConsignmentHistory(consignmentId),
|
||||
enabled: !!consignmentId,
|
||||
staleTime: 15_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
export const useInitiateRto = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ consignmentId, reason, note }) => api.initiateRto(consignmentId, { reason, note }),
|
||||
invalidates: RETURN_KEYS,
|
||||
successMessage: 'Return to sender started',
|
||||
});
|
||||
|
||||
export const useCancelRto = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ consignmentId, note }) => api.cancelRto(consignmentId, note),
|
||||
invalidates: RETURN_KEYS,
|
||||
successMessage: 'Return cancelled — delivery will be re-attempted',
|
||||
});
|
||||
|
||||
export const useCompleteRto = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ consignmentId, note }) => api.completeRto(consignmentId, note),
|
||||
invalidates: RETURN_KEYS,
|
||||
successMessage: 'Marked returned to sender',
|
||||
});
|
||||
|
||||
/* ── Consignments ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export const useConsignments = (options) =>
|
||||
@@ -790,3 +897,69 @@ export const useOrderPercentages = (startdate, enddate, options) =>
|
||||
staleTime: 30_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
/* ── AI agent registry (Settings → Skills & Tools) ────────────────────────── */
|
||||
|
||||
export const useAiAgents = (options) =>
|
||||
useQuery({ queryKey: KEYS.aiAgents, queryFn: api.getAiAgents, staleTime: 30_000, ...options });
|
||||
|
||||
export const useAiSkills = (options) =>
|
||||
useQuery({ queryKey: KEYS.aiSkills, queryFn: api.getAiSkills, staleTime: 30_000, ...options });
|
||||
|
||||
export const useAiTools = (options) =>
|
||||
useQuery({ queryKey: KEYS.aiTools, queryFn: api.getAiTools, staleTime: 5 * 60_000, ...options });
|
||||
|
||||
export const useUpdateAiSkill = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ id, patch }) => api.updateAiSkill(id, patch),
|
||||
invalidates: [KEYS.aiRegistry],
|
||||
successMessage: 'Skill updated',
|
||||
});
|
||||
|
||||
export const useCreateAiSkill = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: (skill) => api.createAiSkill(skill),
|
||||
invalidates: [KEYS.aiRegistry],
|
||||
successMessage: 'Skill registered',
|
||||
});
|
||||
|
||||
export const useUpdateAiAgent = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ id, patch }) => api.updateAiAgent(id, patch),
|
||||
invalidates: [KEYS.aiRegistry],
|
||||
successMessage: 'Agent updated',
|
||||
});
|
||||
|
||||
// Read-only, and it changes as agents work: refetched every minute while open.
|
||||
// The banner's source of truth; re-checked every 30 s so it turns green on its
|
||||
// own once AI_engine starts reading the registry.
|
||||
export const useAiStatus = (options) =>
|
||||
useQuery({
|
||||
queryKey: [...KEYS.aiRegistry, 'status'],
|
||||
queryFn: api.getAiStatus,
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
...options,
|
||||
});
|
||||
|
||||
export const useAiInsights = (days = 7, options) =>
|
||||
useQuery({
|
||||
queryKey: [...KEYS.aiRegistry, 'insights', days],
|
||||
queryFn: () => api.getAiInsights(days),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
// A playground run. No toasts and no invalidation: it changes nothing, and the
|
||||
// Test tab shows its own result or error inline.
|
||||
export const useRunAiPlayground = () => useMutation({ mutationFn: api.runAiPlayground });
|
||||
|
||||
export const useAiDecisions = (params = {}, options) =>
|
||||
useQuery({
|
||||
queryKey: [...KEYS.aiRegistry, 'decisions', params],
|
||||
queryFn: () => api.getAiDecisions(params),
|
||||
staleTime: 30_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
106
src/lib/locationScope.js
Normal file
106
src/lib/locationScope.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// ==============================|| Which city a row belongs to ||============================== //
|
||||
//
|
||||
// Control X carries a hub/city picker in its header, keyed on `applocationid`.
|
||||
// It rebuilt the query key and refetched, and then filtered nothing: the id was
|
||||
// destructured out of the key in `fetchDeliveries` and never read, exactly as
|
||||
// startdate/enddate once were. The chip said "Coimbatore" while the map drew a
|
||||
// Bangalore rider on a Bangalore road, which is worse than having no picker —
|
||||
// an operator reads a labelled screen as scoped.
|
||||
//
|
||||
// `/admin/bookings` takes only pageno/pagesize, so this is a client-side pass,
|
||||
// the same shape the date range uses.
|
||||
|
||||
/** How far from a hub a stop can be and still belong to that hub's city. */
|
||||
export const ZONE_RADIUS_KM = 35;
|
||||
|
||||
export 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));
|
||||
};
|
||||
|
||||
/** A coordinate pair is only usable if it is real. 0,0 is the Gulf of Guinea,
|
||||
* and it is what this system writes when nothing geocoded — both for a hub
|
||||
* (`Coimbatore Test Hub`) and for an order saved without a pin. Treating it as
|
||||
* a place puts orders in the sea; treating it as absent is the truth. */
|
||||
const usable = (lat, lon) =>
|
||||
Number.isFinite(Number(lat)) &&
|
||||
Number.isFinite(Number(lon)) &&
|
||||
!(Number(lat) === 0 && Number(lon) === 0);
|
||||
|
||||
const hasCoords = (h) => usable(h?.latitude, h?.longitude);
|
||||
|
||||
/** Every hub id a row might name. */
|
||||
const NAMED_HUB_KEYS = ['servicinghubid', 'pickuphubid', 'nearesthubid', 'hubid', 'sourcehubid'];
|
||||
|
||||
/**
|
||||
* A predicate for "this row belongs to the selected city", or `null` for
|
||||
* "everything" — which is what the caller wants when no city is selected.
|
||||
*
|
||||
* Returns null rather than a match-all function so the caller can skip the pass
|
||||
* entirely, and so "All locations" is one branch rather than a filter that
|
||||
* happens to be true.
|
||||
*
|
||||
* An unrecognised `applocationId`, or one with no hubs, also returns null. A
|
||||
* picker pointing at a city we cannot resolve must not empty the board: a blank
|
||||
* screen reads as "no work today", and that is the more expensive mistake.
|
||||
*/
|
||||
export const makeLocationFilter = (hubs, applocationId) => {
|
||||
const id = Number(applocationId);
|
||||
if (!Number.isFinite(id) || id === 0) return null;
|
||||
|
||||
const all = hubs || [];
|
||||
const here = all.filter((h) => Number(h?.applocationid) === id);
|
||||
if (!here.length) return null;
|
||||
|
||||
const hubIds = new Set(here.map((h) => String(h.hubid)));
|
||||
// Every known hub, so a row naming one somewhere ELSE counts as located —
|
||||
// and is therefore excluded here rather than treated as unplaceable.
|
||||
const knownHubIds = new Set(all.map((h) => String(h?.hubid)));
|
||||
const centres = here.filter(hasCoords);
|
||||
|
||||
return (row) => {
|
||||
if (!row) return false;
|
||||
|
||||
const named = NAMED_HUB_KEYS.map((k) => row[k]).filter((v) => v != null).map(String);
|
||||
|
||||
// 1. The hub the row itself names, when that hub is one of ours.
|
||||
if (named.some((v) => hubIds.has(v))) return true;
|
||||
|
||||
// 2. Either end of the journey inside the city's footprint. A run that
|
||||
// collects here and delivers elsewhere is still this city's work, and so
|
||||
// is one that merely arrives — so either end qualifying is enough.
|
||||
const ends = [
|
||||
[row.pickuplatitude ?? row.latitude, row.pickuplongitude ?? row.longitude],
|
||||
[row.deliverylatitude ?? row.droplat, row.deliverylongitude ?? row.droplon],
|
||||
];
|
||||
let located = false;
|
||||
for (const [lat, lon] of ends) {
|
||||
if (!usable(lat, lon)) continue;
|
||||
for (const hub of centres) {
|
||||
const d = haversineKm(hub.latitude, hub.longitude, lat, lon);
|
||||
if (d === undefined) continue;
|
||||
located = true;
|
||||
if (d <= ZONE_RADIUS_KM) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. It names a hub we know, and that hub is not in this city.
|
||||
if (named.some((v) => knownHubIds.has(v))) return false;
|
||||
|
||||
// 4. Its coordinates put it somewhere, and somewhere else. Drop it — that
|
||||
// is the whole point of the picker.
|
||||
if (located) return false;
|
||||
|
||||
// 5. Not locatable at all: no hub we recognise, no usable coordinates. Keep
|
||||
// it. A row that cannot be placed has to stay visible under every city
|
||||
// rather than disappear from all of them with nothing to say it was
|
||||
// hidden — the exact failure this area keeps producing.
|
||||
return true;
|
||||
};
|
||||
};
|
||||
@@ -435,6 +435,12 @@ export const classifyBooking = (booking, anchors = []) => {
|
||||
const servicingHubId =
|
||||
b.sourcehubid ??
|
||||
b.hubid ??
|
||||
// The column the backend actually writes when the collection point is a
|
||||
// base (`createExpressBooking` sets it from the request, and the hub
|
||||
// console reads it back). It outranks the anchor match below because it is
|
||||
// what the order RECORDS, where the anchor is only what its coordinates
|
||||
// resolve to — and those two can disagree once a hub is moved or retired.
|
||||
b.pickuphubid ??
|
||||
(originAnchor?.kind === 'hub' ? originAnchor.hubid : null) ??
|
||||
originAnchor?.hubid ??
|
||||
(destAnchor?.kind === 'hub' ? destAnchor.hubid : null) ??
|
||||
|
||||
92
src/lib/orderPricing.js
Normal file
92
src/lib/orderPricing.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The delivery charge shown and sent (as `finalprice`) when ops create orders —
|
||||
* the single order form and bulk upload. The backend stores the figure as
|
||||
* given, so this IS the price the client is billed.
|
||||
*
|
||||
* What it replaces, in both pages:
|
||||
* - A rate card value of 0 was overwritten by a made-up default
|
||||
* (`Number(x) || 40`, `|| 10`, `|| 2`): a card set to "₹15 per km, no base
|
||||
* fee, no free km" was charged ₹40 plus ₹15 for each km after the first 2.
|
||||
* - A client without a rate card was priced off ANOTHER client's card
|
||||
* (`list[0]`), or a hard-coded ₹40 + ₹10/km after 2 km.
|
||||
* - The first card found for the client was used even if Inactive, and the
|
||||
* Pricing page's "priority" was ignored.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Doormile's standard rate, for a client with no rate card: ₹15 per km, at
|
||||
* least 1 km billed. Without the minimum a trip under 1 km (or a distance the
|
||||
* router rounded to 0) priced at ₹0, and the backend treats a 0 `finalprice` as
|
||||
* "not given" and re-prices the booking from another client's card.
|
||||
*/
|
||||
export const STANDARD_RATE = Object.freeze({
|
||||
baseprice: 0,
|
||||
basedistance: 0,
|
||||
priceperkm: 15,
|
||||
minimumkm: 1,
|
||||
isStandard: true,
|
||||
});
|
||||
|
||||
/** A rate value as a number; an empty or invalid value is 0, never a default. */
|
||||
const amount = (value) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
};
|
||||
|
||||
const isActive = (rule) => !rule?.status || String(rule.status).toLowerCase() === 'active';
|
||||
|
||||
/** Inside the card's effectivefrom / effectiveto window (either may be unset). */
|
||||
const inEffect = (rule, now = Date.now()) => {
|
||||
const from = rule?.effectivefrom ? Date.parse(rule.effectivefrom) : NaN;
|
||||
const to = rule?.effectiveto ? Date.parse(rule.effectiveto) : NaN;
|
||||
return (Number.isNaN(from) || from <= now) && (Number.isNaN(to) || to >= now);
|
||||
};
|
||||
|
||||
/** The Pricing page's priority: lower wins; unset is 1, and 0 stays 0. */
|
||||
const priorityOf = (rule) => {
|
||||
const n = Number(rule?.priority);
|
||||
return rule?.priority == null || rule.priority === '' || !Number.isFinite(n) ? 1 : n;
|
||||
};
|
||||
|
||||
/**
|
||||
* The rate card for a client: its Active rules, the lowest priority number
|
||||
* first (the Pricing page's "lower wins"), newest on a tie. A Doormile-wide
|
||||
* card (no client) applies next; failing both, the standard rate. Never
|
||||
* another client's card.
|
||||
*/
|
||||
export function pickRateCard(pricingList, tenantId, now = Date.now()) {
|
||||
const rules = (Array.isArray(pricingList) ? pricingList : []).filter((r) => isActive(r) && inEffect(r, now));
|
||||
const byPriority = (a, b) => priorityOf(a) - priorityOf(b) || amount(b.pricingid) - amount(a.pricingid);
|
||||
|
||||
if (tenantId != null && String(tenantId) !== '' && String(tenantId) !== '0') {
|
||||
const own = rules.filter((r) => String(r.tenantid) === String(tenantId)).sort(byPriority);
|
||||
if (own.length) return own[0];
|
||||
}
|
||||
const doormileWide = rules.filter((r) => r.isdefault || !r.tenantid || String(r.tenantid) === '0').sort(byPriority);
|
||||
return doormileWide[0] || STANDARD_RATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The charge for a trip of `distanceKm` on `rate`: the base price covers the
|
||||
* first `basedistance` km, every km after that is `priceperkm`. Rounded to
|
||||
* paise.
|
||||
*/
|
||||
export function chargeForDistance(distanceKm, rate = STANDARD_RATE) {
|
||||
const km = Math.max(amount(distanceKm), amount(rate?.minimumkm));
|
||||
const base = amount(rate?.baseprice);
|
||||
const freeKm = amount(rate?.basedistance);
|
||||
const perKm = amount(rate?.priceperkm);
|
||||
const charge = km <= freeKm ? base : base + (km - freeKm) * perKm;
|
||||
return Math.round(charge * 100) / 100;
|
||||
}
|
||||
|
||||
/** "₹15 / km" style labels for the rate card panel. */
|
||||
export function describeRate(rate) {
|
||||
const base = amount(rate?.baseprice);
|
||||
const freeKm = amount(rate?.basedistance);
|
||||
return {
|
||||
base: base > 0 ? `₹${base}${freeKm > 0 ? ` (first ${freeKm} km)` : ''}` : 'No base fee',
|
||||
perKm: `₹${amount(rate?.priceperkm)} / km${amount(rate?.minimumkm) > 0 ? ` (min ${amount(rate.minimumkm)} km)` : ''}`,
|
||||
source: rate?.isStandard ? 'Standard rate (no client rate card)' : 'Client rate card',
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user