initial commit
This commit is contained in:
285
src/components/assistant/CLAUDE.md
Normal file
285
src/components/assistant/CLAUDE.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# CLAUDE.md — `src/pages/nearle/assistant/`
|
||||
|
||||
Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `DoormileAI/`). Read this before touching either.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
An in-console Q&A assistant that answers operator questions about live data — "how many orders today", "morning batch orders", "how many riders are active" — by calling the same API functions every other page in this console already uses. Product name is **Doormile AI**, subtitle **Operations Copilot**. Not a standalone page: it lives as a **right-side slide-over** opened from a header icon.
|
||||
|
||||
- **Mounted in**: `src/layout/MainLayout/AppTopNav.js` — a single `<DoormileAITrigger />`. There is **no route and no sidebar entry** for this feature — don't add one back. If you're tempted to give it a full page, re-read §2 first; that was tried and deliberately reverted.
|
||||
- **`intents.js`** — all data logic: the intent catalog, keyword/phrase matching, and the API calls that produce answers. The UI never fetches.
|
||||
- **`DoormileAI/`** — all UI:
|
||||
- `index.js` — the trigger button; owns open/closed state and returns focus to itself on close.
|
||||
- `AIPanel.js` — the portal, scrim, slide-over, focus/Escape handling, message state, history persistence, and the ask() flow.
|
||||
- `AIWelcome.js` — greeting + suggestion cards (empty-thread state only).
|
||||
- `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
|
||||
- `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline.
|
||||
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
|
||||
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
|
||||
- `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
|
||||
- `pageContext.js` — route → context label + suggested questions.
|
||||
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
|
||||
|
||||
### UI rules that are load-bearing, not cosmetic
|
||||
|
||||
- **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot.
|
||||
- **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message.
|
||||
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
|
||||
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
|
||||
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
|
||||
- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
|
||||
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
|
||||
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why this is deterministic, not LLM-based
|
||||
|
||||
This was a deliberate, explicit product decision (not a technical limitation worked around silently): **this app has zero backend of its own** — confirmed exhaustively (no `server/`, no Firebase Cloud Functions, no `firebase-admin`/`firebase-functions` dependency, `Dockerfile` just serves a static CRA build via nginx). An LLM call needs an API key held server-side; there is nowhere in this repo's infrastructure to put one without shipping it to the browser.
|
||||
|
||||
Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no.
|
||||
|
||||
### RAG — rejected for the data, later built for the ROUTING
|
||||
|
||||
RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.**
|
||||
|
||||
What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced:
|
||||
|
||||
```
|
||||
question → embed → Chroma → intentId + confidence → the SAME run() → live API call
|
||||
```
|
||||
|
||||
Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase.
|
||||
|
||||
Three rules that must hold:
|
||||
|
||||
- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today.
|
||||
- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values.
|
||||
- **Write intents need high confidence.** A semantic near-miss must never open a create form.
|
||||
|
||||
---
|
||||
|
||||
## 3. The intent pattern (`intents.js`)
|
||||
|
||||
Each entry in `INTENTS` is:
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'someIntent',
|
||||
label: 'Human-readable description — e.g. "example phrasing"',
|
||||
match: (text) => params | null, // does this intent apply? extract params or refuse
|
||||
run: async (params) => ({ headline, detail, sourceCalls }) | null // real answer, or "couldn't resolve"
|
||||
}
|
||||
```
|
||||
|
||||
- `answerQuestion(text)` walks `INTENTS` **in order** and returns the first intent whose `match` recognises the text **and** whose `run` resolves to a non-null result. `run` returning `null` means "the pattern matched but couldn't be resolved" (e.g. no tenant name in the question actually matched a real tenant) — the loop falls through to the next intent rather than answering with a guess.
|
||||
- `sourceCalls` feeds `<ChatToolCalls>` behind the per-message "Sources" disclosure in `AIMessage.js` — every answer can still show which API was queried and what came back, so an operator can verify it wasn't invented. It is collapsed by default for visual quiet; **do not remove it**, that disclosure is the verifiability contract.
|
||||
- `metric` and `stats` (optional) drive the panel's headline number and breakdown grid. `stats` comes from `statusStats()`, which tallies the *same* `mapBookingStatusToDeliveryStatus` classification as the prose `headline`, so the sentence and the cards can never disagree.
|
||||
- Every `run` calls a real function from `pages/api/api.js` / `pages/api/doormileApi.js`. **Never fabricate a number** — if no existing function covers a question, either add a new intent that calls a real endpoint, or leave the question unanswered (falls through to the "I can't answer that one yet" state in `AIPanel.js`). A wrong number from this bot is worse than no answer.
|
||||
|
||||
### Coverage grows with the console, not ahead of it
|
||||
|
||||
The catalog covers orders/bookings, riders, tenants, and the fleet/ops resources (hubs, vehicles, tripsheets, exceptions, app users, customers, pricing, consignments, partners, competitor branches, carrier pricing). When a new admin resource gets its own page in this console, add a matching intent here too — and **always call the exact same `getX()` function that page's own table already calls** (e.g. `hubStatus` calls `getHubs()`, the same function `hubs.js` uses). Never write a bespoke fetch for the bot. This is what keeps the bot's numbers live and in agreement with what the corresponding page shows — the whole point of not hand-rolling a separate data path.
|
||||
|
||||
### Composite questions — `orderQuery`
|
||||
|
||||
`orderQuery` (ordered 3rd, ahead of `riderCounts`) is the one intent that composes filters: status x batch x tenant x rider, plus rankings ("top 5 tenants by orders"). Everything else in the catalog answers exactly one dimension and discards the rest of the sentence.
|
||||
|
||||
**It claims a question only when two or more of status/batch/tenant/rider are present, or a ranking is asked for.** A date is deliberately NOT counted as a dimension — every intent already handles dates, and counting it re-routed four working questions ("how many cancelled orders today") away from the intents that answer them better. If you widen this matcher, re-run the routing probe first; over-claiming here silently changes answers across the whole catalog.
|
||||
|
||||
`run` returns `null` when a named tenant or rider doesn't resolve, so an unrecognised name falls through rather than having its filter silently dropped — which is the exact bug this intent exists to fix.
|
||||
|
||||
Entity names resolve through `bestNameMatch`, which is bidirectional (the question may name a shorter or longer form than the record) and prefers the longest match, so "Acme" can't beat "Acme Foods" when both exist.
|
||||
|
||||
### Beyond single-question matching
|
||||
|
||||
A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, all still deterministic (no LLM):
|
||||
|
||||
- **Typo tolerance** — `correctTypos()` runs once before matching, correcting misspelled domain keywords (length ≥5, Levenshtein distance ≤1/≤2) against a fixed `KEYWORD_VOCAB`. It never touches order IDs, tenant names, or short words — only known keywords get "corrected," so it can't invent a wrong one.
|
||||
- **Richer dates** — `explicitDateFromWords` (DD/MM/YYYY, ISO), `weekdayFromWords` (most recent past occurrence of a named day), and `rangeFromWords` (this/last week, this/last month, explicit "from X to Y") feed `dayFromWords`/`rangeFromWords`. Still a fixed vocabulary, not a date-parsing library — an unrecognised phrase falls back to today, never a guessed date.
|
||||
- **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue".
|
||||
- **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched.
|
||||
- **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up.
|
||||
- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale.
|
||||
- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all.
|
||||
- **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent.
|
||||
|
||||
### Ordering and cross-domain guards — read before adding an intent
|
||||
|
||||
A real bug shipped here once: `statusBreakdown` matched the word "active" (a valid order status), so "how many riders are active today" was swallowed by the order-status intent and called `getBookings` instead of `getallridersummary` — because `statusBreakdown` sat earlier in `INTENTS` than `riderCounts` and its `run` never returns `null` (it always finds *some* count, even 0), so it never yielded.
|
||||
|
||||
The fix, and the rule going forward:
|
||||
|
||||
1. **Domain-specific intents (rider, tenant) are ordered near the top**, ahead of the generic order/status/date intents, so an unambiguous keyword like "rider" always wins first-match.
|
||||
2. **Generic intents explicitly refuse to match on another domain's keyword**, via helper guards like `mentionsRiders(text)` at the top of their `match`. This is deliberately redundant with (1) — if someone reorders `INTENTS` later without noticing the significance, the guards still hold.
|
||||
|
||||
If you add a new intent whose trigger words could plausibly appear in an unrelated intent's question (status words, "for", generic nouns), do both: place it appropriately in the order, and add a guard to anything downstream it could shadow — don't rely on ordering alone.
|
||||
|
||||
### Date/batch/status vocabulary — reuse, don't reinvent
|
||||
|
||||
- **Batch bucketing** (`morning`/`afternoon`/`evening`) comes from `src/utils/batchBucket.js`, extracted from `Dispatch.js`/`deliveries.js`'s canonical model (see `dispatch/CLAUDE.md` §1). Bucketing on anything other than `orderdate` (a booking's `createdat`) will disagree with what those two pages show — don't reintroduce `expecteddeliverytime`/`assigntime` bucketing here, they were both tried and rejected for the same reasons documented there.
|
||||
- **Order status classification comes from `utils/orderStatusGroups.js`**, which is the SAME match set the Orders page's tabs count with (`orders.js` imports `statusesInGroup` for its `ORDERS_STATUS_TABS`). Use `groupForBookingStatus` / `isInGroup` / `statusesInGroup`; don't grow a third definition.
|
||||
- It is deliberately **not** `mapBookingStatusToDeliveryStatus` (api.js), which is the *Deliveries* page's rider-centric taxonomy and keeps `miler_assigned` on `pending`. The two exist on purpose — Orders tracks the operator's action, Deliveries tracks the rider's. Don't merge them; that was tried and reverted per explicit product direction.
|
||||
- The assistant answers order-status questions with the ORDERS taxonomy because that is the screen an operator compares its answers against. A live bug came from the mismatch: the Orders page showed 19 Assigned while the bot said 0.
|
||||
- **Date words** are a fixed, small vocabulary — not a real date-parsing library. Don't guess at "the 5th" style phrasing; an unrecognised date phrase falls back to today rather than to a wrong date.
|
||||
- **State questions vs flow questions — do not default a state question to today.** `mentionsAnyDate(text)` distinguishes "the question named a date" from "we defaulted to one":
|
||||
- *State* ("how many orders are assigned / cancelled") describes the queue **right now** and must be unscoped, because the Orders page's tabs apply no date filter either. Scoping it to orders *created today* is what made the bot answer 0 against a page showing 19.
|
||||
- *Flow* ("how many orders today", revenue, batches) genuinely needs a period and keeps the today default.
|
||||
When a state question is answered unscoped, say so in the detail — the answer must never leave the operator guessing which window it covered.
|
||||
- `GET /admin/bookings` has no server-side date/status/tenant filter, so every intent fetches and filters client-side. It does **not** fetch a single page: `pagesize` is capped at 1000 server-side, so a lone `getBookings(1, 1000)` silently under-reports the moment an account passes 1000 lifetime bookings. Use `fetchBookingsInRange(start, end)` / `fetchBookingsForDay(day)` / `scanBookings()`, which drain pages via `getBookingsPage` up to `MAX_PAGES` and return `{ rows, truncated, scanned, pagesFetched, total }`.
|
||||
- **`truncated` is not optional to handle.** If you write a new intent, run its count through `countPhrase(scan, n)` ("At least 42"), append `truncationNote(scan)` to the detail, and build its audit entry with `scanCall(scan, ...)` — which reports `status: 'error'` when capped so the tool-call strip can't show a green "complete" beside a partial number. An intent that reads `scan.rows` and ignores `scan.truncated` reintroduces exactly the bug this replaced.
|
||||
- **Revenue excludes cancelled orders and is labelled "estimated"** — `revenueOf(rows)` sums every `serviceoptions[].estimatedprice` on non-cancelled rows. It is a quote, not a settled amount; don't relabel it "revenue" flat.
|
||||
- **"Assigned" does not go through the coarse bucket.** `mapBookingStatusToDeliveryStatus` collapses `miler_assigned` into `pending` alongside `pending_pickup` (orders with no rider at all), so `rawStatusFromWords` matches the backend enum directly. Any other question naming a raw enum should do the same rather than being forced into a delivery-status bucket.
|
||||
|
||||
---
|
||||
|
||||
### Customer creation writes to `/admin/tenantcustomers`
|
||||
|
||||
Settled by evidence, not by reading the docs:
|
||||
|
||||
```
|
||||
POST /admin/customers → 405 Method Not Allowed (confirmed live)
|
||||
```
|
||||
|
||||
405 is unambiguous — the route exists and POST is not among its methods. `express-console-api.md` lists `/admin/customers` as GET + PATCH only and the server agrees. It was pointed there briefly on explicit instruction; the live 405 settled it. **Don't try it again.**
|
||||
|
||||
**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.)
|
||||
|
||||
**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately.
|
||||
|
||||
Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change.
|
||||
|
||||
The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks.
|
||||
|
||||
Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery.
|
||||
|
||||
The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js`
|
||||
|
||||
Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable.
|
||||
|
||||
**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`.
|
||||
|
||||
### The panel drives the conversation, not the router
|
||||
|
||||
`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router.
|
||||
|
||||
Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer.
|
||||
|
||||
### One engine, three flows
|
||||
|
||||
The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker.
|
||||
|
||||
Step entries carry:
|
||||
|
||||
| key | meaning |
|
||||
|---|---|
|
||||
| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. |
|
||||
| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. |
|
||||
| `type: 'text'` | answered through the composer. |
|
||||
| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). |
|
||||
| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. |
|
||||
| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. |
|
||||
| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. |
|
||||
| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. |
|
||||
|
||||
### Two silent-NaN traps that were live
|
||||
|
||||
- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value.
|
||||
- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was.
|
||||
|
||||
`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate.
|
||||
|
||||
### Bulk — a conversation, then one long pass
|
||||
|
||||
Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient.
|
||||
|
||||
**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked.
|
||||
|
||||
**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept.
|
||||
|
||||
**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column.
|
||||
|
||||
### One row pipeline, two inputs
|
||||
|
||||
A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else.
|
||||
|
||||
**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column.
|
||||
|
||||
`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row.
|
||||
|
||||
**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional:
|
||||
- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely.
|
||||
- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197.
|
||||
- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup.
|
||||
|
||||
**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose.
|
||||
|
||||
**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded.
|
||||
|
||||
Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated.
|
||||
|
||||
### Repeat Runs — `repeatRuns.js` / `repeatFlow.js`
|
||||
|
||||
"Same orders as yesterday." One question (which day), then a pass, then the usual gate.
|
||||
|
||||
**It is the cheapest write here, and the reason is structural:** a booking already carries 15 of the 17 fields `buildOrderPayload` needs — including BOTH SETS OF COORDINATES. Only `customer_name` and `customer_phone` are missing, and they come from the `appcustomerid` → `/admin/customers` join. **So a repeat needs no geocoding at all** — the ~1 lookup/second Nominatim throttle that dominates the bulk-file flow simply doesn't apply.
|
||||
|
||||
**A booking is a snapshot, not a template.** The drift check is phase one, not polish. All three of its main rules came from one live page of 36 bookings, not from imagination:
|
||||
|
||||
| Trap | Seen on |
|
||||
|---|---|
|
||||
| `pickupaddress` absent entirely | booking 57 — has the pincode and coordinates, no address key |
|
||||
| `tenantid` is null | every `Customer_App` booking (24–27). `Number(null)` → tenant `0` |
|
||||
| pickup pincode no longer served | CityGate refuses at the middleware, before the handler |
|
||||
|
||||
Plus: the customer record can be deleted, and a booking can lack delivery coordinates. `driftReason` returns a **reason, never a boolean** — an operator dropping a row deserves to know which field went stale.
|
||||
|
||||
**Duplicate safety is INVERTED here.** Everywhere else near-identical orders are an error (`wasAlreadySubmitted`); a repeat deliberately creates them, so that guard would misfire every time. The question that matters is *has this run already been repeated today?* — answered by fingerprinting today's own bookings on `(phone + delivery address + pickup pincode)` and setting aside anything already present. Without it, a double-click books every customer twice, because the bulk endpoint has no idempotency key.
|
||||
|
||||
**Prices are re-quoted at today's tariff, never copied.** `finalprice` is deliberately left blank so `priceBulkRows` fills it exactly as an unpriced bulk row. Yesterday's number is kept as `previousPrice` purely so a tariff change is *visible* rather than discovered on an invoice. Pricing runs **per row** because a day's run can span tenants, and a tenant's own pricing row decides the number.
|
||||
|
||||
**`__pickup` travels on the ROW, not the shared draft** — which is why `executeCreateBulk` now prefers `r.__pickup ?? shared.__pickup`. A bulk file shares one kitchen; a repeated day does not, and collapsing them would silently re-address half the orders.
|
||||
|
||||
Cancelled orders are never repeated. Lookback is 7 days — beyond that it stops being "the usual round".
|
||||
|
||||
### Assigning a rider — `assignActions.js` / `assignFlow.js`
|
||||
|
||||
The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run.
|
||||
|
||||
**Two endpoints, and they are not interchangeable.** One order → `POST /admin/bookings/:id/assign-miler`. Many orders → `POST /hub/bookings/batch-assign`, which is **the only call that sequences stops** (doormile-flow.md §4): it sends each affected rider's whole active set to the route optimiser and writes step order, per-leg distance and ETA. Assigning ten orders with ten single calls leaves every route unsequenced.
|
||||
|
||||
**⚠ Two different rider IDs on adjacent endpoints.** `assign-miler` takes a **`mileruserid`**; `/admin/milers/:id/notify` keys off a **`milerprofileid`**. Getting it wrong fails silently in both directions — the assign 404s, or the rider is never told. `buildMilerLookup` is the bridge and orders.js already uses it for exactly this; don't grow a second lookup. The assertions cover this specifically because it is invisible in review: both are small integers on the same record.
|
||||
|
||||
**The backend already assigns riders.** Creation publishes `booking.assignment_requested`; a worker picks a rider within 10km on proximity and retries 5× over 10 minutes (§3). Everything here is an **override**, which is why the flow re-reads the booking's current assignee and asks before replacing them. Silently overwriting throws away a better-informed choice and strands a rider who has already been told the job is theirs.
|
||||
|
||||
**The holder lookup happens inside the booking step's `resolve`, not after it.** `advanceFlow` evaluates the keep/replace step's `when` the instant the booking is applied — a lookup landing one tick later means the step is skipped and an already-assigned order is silently reassigned. That was a live bug caught by the assertions.
|
||||
|
||||
Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed.
|
||||
|
||||
Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 30 for `repeatRuns`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's deliberately out of scope right now
|
||||
|
||||
- **Deleting or cancelling anything.** Creates and rider assignment are built (§3.5); destructive writes are not. Cancelling an order has downstream effects a confirm button doesn't cover. Note that *replacing* an already-assigned rider IS reachable — but only behind an explicit keep-or-replace question naming the current holder, never as a silent overwrite.
|
||||
- **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives.
|
||||
- **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built.
|
||||
- **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started.
|
||||
- **Automated tests for the intent matcher.** The matcher is pure functions (`match`/`run` per intent) and would be straightforward to unit-test, but the project's stated convention is "no tests of consequence, lint is the only gate" (root `CLAUDE.md`). Adding a test framework here is a scope decision for the user, not something to introduce silently.
|
||||
|
||||
---
|
||||
|
||||
## 5. Don'ts
|
||||
|
||||
- Don't re-add a route/sidebar entry for this feature — it's a header slide-over, not a page.
|
||||
- Don't let a new intent's `match` fire without considering what other intents' trigger words it might contain (see §3's ordering rule).
|
||||
- Don't bucket batches or classify statuses with page-local logic — reuse `utils/batchBucket.js` and `mapBookingStatusToDeliveryStatus`.
|
||||
- Don't answer with a number that didn't come from `sourceCalls`-tracked real data — including anything rendered into a `metric` or `stats` card.
|
||||
- Don't surface a raw API error string to the operator. Errors log to `console.error` and render as the polished error state; the toast that used to leak `err.response.data.message` is gone.
|
||||
@@ -45,24 +45,32 @@
|
||||
}
|
||||
|
||||
.dai-root {
|
||||
--dai-accent: #C8102E;
|
||||
/* Brand accent — resolves to the app's accent token, which is black
|
||||
(root CLAUDE.md §6.2: "the brand is black", one accent only). Send
|
||||
button, active states and the trigger all read from this single
|
||||
variable, so switching the assistant to Doormile red is a one-line
|
||||
change here rather than a hunt through the components. */
|
||||
--dai-accent: var(--color-accent, #0f172a);
|
||||
--dai-accent-contrast: #ffffff;
|
||||
--dai-accent-ring: rgba(200, 16, 46, 0.16);
|
||||
/* Focus ring, tinted with the accent rather than a flat grey wash. */
|
||||
--dai-accent-ring: color-mix(in srgb, var(--dai-accent) 14%, transparent);
|
||||
|
||||
--dai-ai-from: #C8102E;
|
||||
--dai-ai-to: #E11D48;
|
||||
--dai-ai-glow: rgba(200, 16, 46, 0.15);
|
||||
/* AI identity accent — used ONLY on the spark/orb marks so the assistant
|
||||
reads as an AI surface without turning the panel into a purple product. */
|
||||
--dai-ai-from: #6366f1;
|
||||
--dai-ai-to: #8b5cf6;
|
||||
--dai-ai-glow: rgba(99, 102, 241, 0.18);
|
||||
|
||||
--dai-text: #0f172a;
|
||||
--dai-text-secondary: #475569;
|
||||
--dai-text-secondary: #64748b;
|
||||
--dai-text-muted: #94a3b8;
|
||||
--dai-surface: #ffffff;
|
||||
--dai-surface-alt: #f8fafc;
|
||||
--dai-surface-hover: #f1f5f9;
|
||||
--dai-border: #e2e8f0;
|
||||
--dai-border-strong: #cbd5e1;
|
||||
--dai-border: rgba(15, 23, 42, 0.08);
|
||||
--dai-border-strong: rgba(15, 23, 42, 0.14);
|
||||
--dai-scrim: rgba(15, 23, 42, 0.08);
|
||||
--dai-shadow: -6px 0 24px rgba(15, 23, 42, 0.06);
|
||||
--dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
|
||||
--dai-live: #10b981;
|
||||
|
||||
--dai-panel-width: 428px;
|
||||
@@ -109,22 +117,16 @@
|
||||
No radius and no drop shadow: both are what make a surface read as floating
|
||||
ABOVE the page. A soft shadow is kept only as a left-edge falloff so the
|
||||
seam has depth without the panel detaching. */
|
||||
/* Two classes, not one: the element is a shadcn SheetContent, which carries
|
||||
`inset-y-0 h-full` from Tailwind. Those utilities are emitted after this
|
||||
stylesheet, so a single `.dai-panel` ties on specificity and loses — the
|
||||
dock then starts at y=0 and covers the top nav it is supposed to sit under.
|
||||
`.dai-root.dai-panel` out-specifies them; `height: auto` hands the box back
|
||||
to top/bottom so it cannot run past the bottom of the window. */
|
||||
.dai-root.dai-panel {
|
||||
.dai-panel {
|
||||
position: fixed;
|
||||
/* Measured from the app header by AIPanel — see the [data-app-header] hook. */
|
||||
/* Set from JS by AIPanel — Astryx does not publish a header-height token,
|
||||
despite --appshell-header-height looking like one. */
|
||||
top: var(--dai-dock-top, 57px);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
z-index: 1200;
|
||||
width: var(--dai-dock-width);
|
||||
display: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -138,20 +140,13 @@
|
||||
transform var(--dai-duration) var(--dai-ease),
|
||||
visibility 0s linear var(--dai-duration);
|
||||
}
|
||||
|
||||
.dai-panel[data-state='open'],
|
||||
.dai-panel[data-open='true'] {
|
||||
display: flex !important;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform var(--dai-duration) var(--dai-ease),
|
||||
visibility 0s;
|
||||
}
|
||||
.dai-panel[data-state='closed'],
|
||||
.dai-panel[data-open='false'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---- The page makes room -------------------------------------------------
|
||||
`.dai-docked` is set on <body> while the panel is open. Padding rather than
|
||||
@@ -162,10 +157,10 @@
|
||||
The transition sits on the container unconditionally so the page slides back
|
||||
when the panel closes too — a rule that only exists while `.dai-docked` is
|
||||
applied cannot animate its own removal. */
|
||||
.dai-page {
|
||||
.astryx-layout-content {
|
||||
transition: padding-right var(--dai-duration) var(--dai-ease);
|
||||
}
|
||||
body.dai-docked .dai-page {
|
||||
body.dai-docked .astryx-layout-content {
|
||||
padding-right: var(--dai-dock-width);
|
||||
}
|
||||
|
||||
@@ -174,7 +169,7 @@ body.dai-docked .dai-page {
|
||||
used to be everywhere — a full-width overlay with a scrim — and the page
|
||||
stops reserving a strip it cannot afford. */
|
||||
@media (max-width: 900px) {
|
||||
.dai-root.dai-panel {
|
||||
.dai-panel {
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 1301;
|
||||
@@ -187,7 +182,7 @@ body.dai-docked .dai-page {
|
||||
.dai-scrim[data-open='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
body.dai-docked .dai-page {
|
||||
body.dai-docked .astryx-layout-content {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
@@ -199,59 +194,67 @@ body.dai-docked .dai-page {
|
||||
-------------------------------------------------------------------------- */
|
||||
.dai-root .dai-header {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px 16px 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 14px 12px 12px 14px;
|
||||
border-bottom: 1px solid var(--dai-border);
|
||||
}
|
||||
.dai-root .dai-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dai-text);
|
||||
}
|
||||
.dai-root .dai-subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: var(--dai-text-muted);
|
||||
}
|
||||
/* The AI mark — sleek rounded tinted container */
|
||||
/* The AI mark. A soft gradient orb — not a robot face. */
|
||||
.dai-root .dai-spark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 9px;
|
||||
background: #fff1f2;
|
||||
border: 1px solid #fecdd3;
|
||||
border-radius: 999px;
|
||||
background: var(--dai-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* The mark's own canvas is only 66.8% content — a third of every edge is
|
||||
transparent padding, measured off the PNG's alpha bounding box (x and y both
|
||||
170..853 of 1024, i.e. perfectly centred). Drawing it at 100% therefore
|
||||
rendered a D two-thirds the size the box implied, which is exactly why it
|
||||
read as too small. 150% cancels that padding so the D fills its box edge to
|
||||
edge; the overflow:hidden above clips only transparent pixels. */
|
||||
.dai-root .dai-spark img {
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.dai-root .dai-spark[data-size='sm'] {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.dai-root .dai-spark[data-size='md'] {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.dai-root .dai-spark[data-size='lg'] {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
/* Live indicator — crisp pill */
|
||||
/* Live indicator — subtle, not a large pill. */
|
||||
.dai-root .dai-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-weight: 550;
|
||||
color: #047857;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dai-root .dai-live-dot {
|
||||
@@ -275,10 +278,9 @@ body.dai-docked .dai-page {
|
||||
/* Page-context strip — "Orders · Today · All locations" */
|
||||
.dai-root .dai-context {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
padding: 7px 14px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--dai-text-secondary);
|
||||
color: var(--dai-text-muted);
|
||||
background: var(--dai-surface-alt);
|
||||
border-bottom: 1px solid var(--dai-border);
|
||||
white-space: nowrap;
|
||||
@@ -367,31 +369,34 @@ body.dai-docked .dai-page {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 6px 14px;
|
||||
padding: 5px 10px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
font-weight: 550;
|
||||
font-weight: 500;
|
||||
color: var(--dai-text);
|
||||
background: var(--dai-surface);
|
||||
border: 1px solid var(--dai-border);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
/* 14px, pinned — NOT 999px.
|
||||
|
||||
On a one-line chip a fully round radius already resolves to about 14px, so
|
||||
these look identical. The difference shows on a chip whose question wraps
|
||||
to two lines: 999px would resolve to half of ~46px and the chip stops
|
||||
reading as a chip and starts reading as a card, so one long suggestion
|
||||
would look like a different component from the nineteen beside it. */
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
transform 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
transform 140ms ease;
|
||||
}
|
||||
.dai-suggestion:hover {
|
||||
background: #fff1f2;
|
||||
border-color: #fecdd3;
|
||||
color: #C8102E;
|
||||
background: var(--dai-surface-alt);
|
||||
border-color: var(--dai-border-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(200, 16, 46, 0.1);
|
||||
}
|
||||
.dai-suggestion:active {
|
||||
transform: translateY(0);
|
||||
@@ -406,7 +411,7 @@ body.dai-docked .dai-page {
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
.dai-suggestion:hover .dai-suggestion-icon {
|
||||
color: #C8102E;
|
||||
color: var(--dai-text-secondary);
|
||||
}
|
||||
.dai-root .dai-suggestion-text {
|
||||
min-width: 0;
|
||||
@@ -497,32 +502,22 @@ body.dai-docked .dai-page {
|
||||
}
|
||||
|
||||
.dai-root .dai-msg-user {
|
||||
max-width: 84%;
|
||||
max-width: 82%;
|
||||
margin-left: auto;
|
||||
padding: 10px 15px;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #C8102E 0%, #A00C24 100%);
|
||||
box-shadow: 0 2px 8px rgba(200, 16, 46, 0.16);
|
||||
color: var(--dai-accent-contrast);
|
||||
background: var(--dai-accent);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Assistant — sleek rounded response card */
|
||||
.dai-root .dai-msg-row {
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--dai-border);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03);
|
||||
max-width: 95%;
|
||||
width: fit-content;
|
||||
}
|
||||
/* Assistant — no bubble. Text sits on the panel surface. */
|
||||
.dai-root .dai-msg-ai {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
line-height: 1.5;
|
||||
color: var(--dai-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -534,8 +529,8 @@ body.dai-docked .dai-page {
|
||||
}
|
||||
.dai-root .dai-msg-name {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
color: var(--dai-text-secondary);
|
||||
}
|
||||
.dai-root .dai-msg-footer {
|
||||
font-size: 11px;
|
||||
@@ -700,23 +695,24 @@ body.dai-docked .dai-page {
|
||||
-------------------------------------------------------------------------- */
|
||||
.dai-root .dai-composer-wrap {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid var(--dai-border);
|
||||
background: var(--dai-surface);
|
||||
}
|
||||
.dai-root .dai-composer {
|
||||
border: 1.5px solid var(--dai-border);
|
||||
border-radius: 16px;
|
||||
background: var(--dai-surface-alt);
|
||||
border: 1px solid var(--dai-border-strong);
|
||||
/* 14px, the same corner the suggestion chips use. At 5px the input was the
|
||||
one sharp-cornered thing in a panel of rounded surfaces, and it sat
|
||||
directly beneath the chips where the mismatch was most visible. */
|
||||
border-radius: 14px;
|
||||
background: var(--dai-surface);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
padding: 10px 12px 8px 14px;
|
||||
padding: 10px 10px 8px 14px;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
background-color 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
.dai-root .dai-composer[data-focused='true'] {
|
||||
background: var(--dai-surface);
|
||||
border-color: var(--dai-accent);
|
||||
box-shadow: 0 0 0 3px var(--dai-accent-ring);
|
||||
}
|
||||
@@ -815,27 +811,24 @@ body.dai-docked .dai-page {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--dai-accent);
|
||||
color: var(--dai-accent-contrast);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(200, 16, 46, 0.25);
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
opacity 140ms ease,
|
||||
transform 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
transform 140ms ease;
|
||||
}
|
||||
.dai-root .dai-send:hover:not(:disabled) {
|
||||
opacity: 0.92;
|
||||
transform: scale(1.05);
|
||||
opacity: 0.86;
|
||||
}
|
||||
.dai-root .dai-send:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.dai-root .dai-send:focus-visible {
|
||||
outline: 2px solid var(--dai-accent);
|
||||
@@ -846,7 +839,6 @@ body.dai-docked .dai-page {
|
||||
.dai-root .dai-send:disabled {
|
||||
background: var(--dai-surface-hover);
|
||||
color: var(--dai-text-muted);
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
.dai-root .dai-composer textarea {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { LuArrowUp } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { ChatDictationButton, useChatDictation } from './shim';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat';
|
||||
|
||||
// ==============================|| Doormile AI — composer ||============================== //
|
||||
//
|
||||
@@ -136,7 +136,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
disabled={!canSend}
|
||||
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
<LuArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from './shim';
|
||||
import { HStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { Selector } from './shim';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
|
||||
// ==============================|| Doormile AI — one dropdown step ||============================== //
|
||||
//
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { memo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { AiOutlineCopy as CopyOutlined } from 'react-icons/ai';
|
||||
import { LuChevronDown } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { IconButton } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { ChatToolCalls } from './shim';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { ChatToolCalls } from '@astryxdesign/core/Chat';
|
||||
|
||||
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
|
||||
import AIFlowStep from './AIFlowStep';
|
||||
@@ -51,7 +51,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
|
||||
variant="ghost"
|
||||
label="Copy answer"
|
||||
tooltip="Copy answer"
|
||||
icon={<Copy />}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => onCopy(message)}
|
||||
/>
|
||||
</HStack>
|
||||
@@ -96,7 +96,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
|
||||
<span>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
</span>
|
||||
<ChevronDown size={11} aria-hidden="true" />
|
||||
<LuChevronDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import '../DoormileAI.css';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { X as CloseOutlined, MoreHorizontal as MoreOutlined } from 'lucide-react';
|
||||
import { ChevronDown as LuChevronDown, PanelRightOpen as LuPanelRightOpen, PanelRightClose as LuPanelRightClose, History as LuHistory, Trash2 as LuTrash2, MessageSquare as LuMessageSquare } from 'lucide-react';
|
||||
import { AiOutlineClose as CloseOutlined, AiOutlineMore as MoreOutlined } from 'react-icons/ai';
|
||||
import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose, LuHistory, LuTrash2, LuMessageSquare } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { IconButton } from './shim';
|
||||
import { DropdownMenu } from './shim';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
const OpenToast = ({message, tone}) => toast({description: message, variant: tone === 'error' ? 'destructive' : 'default'});
|
||||
const STATUS = { error: 'error', success: 'success', warning: 'warning', info: 'info', muted: 'muted' };
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import { STATUS } from 'themes/dt/tokens';
|
||||
import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '@/lib/assistant/intents';
|
||||
import { executeCreateCustomer, buildCustomerPayload } from '@/lib/assistant/actions';
|
||||
import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions';
|
||||
@@ -228,17 +226,8 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsMounted(true);
|
||||
// One frame between mounting and showing, so the slide-in has a
|
||||
// from-state to animate out of. rAF is the right clock for that, but a
|
||||
// hidden or heavily throttled document never runs the callback at all —
|
||||
// and a panel that refuses to open is worse than one that opens without
|
||||
// its animation, so a timer backs it up.
|
||||
const raf = requestAnimationFrame(() => setIsShown(true));
|
||||
const fallback = setTimeout(() => setIsShown(true), 50);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(fallback);
|
||||
};
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
setIsShown(false);
|
||||
const timer = setTimeout(() => setIsMounted(false), ANIMATION_MS);
|
||||
@@ -275,14 +264,15 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
}, [isWide]);
|
||||
|
||||
// ---- anchor the dock to the real header height ----
|
||||
// The dock starts where the top nav ends. Measured rather than assumed, and
|
||||
// re-measured on resize, so the seam stays closed if the header ever changes
|
||||
// height — a hard-coded number leaves a sliver of page visible above the
|
||||
// panel the moment the bar grows.
|
||||
// The dock starts where the top nav ends. `--appshell-header-height` looks
|
||||
// like the right token for that and Dispatch.css already reads it, but
|
||||
// Astryx does not actually publish it — verified in the browser, where it
|
||||
// resolves to nothing and the fallback wins. The header is 57px, not the 64
|
||||
// a fallback would guess, so trusting it left a 7px sliver of page visible
|
||||
// above the panel.
|
||||
//
|
||||
// AdminLayout marks its bar `data-app-header`; that attribute exists purely
|
||||
// as this hook. The selector used to be `.astryx-layout-header`, a class this
|
||||
// app does not render at all, so the measurement silently never ran.
|
||||
// Measured instead, and re-measured on resize, so the seam stays closed if
|
||||
// the header ever changes height.
|
||||
useEffect(() => {
|
||||
if (!isMounted) return undefined;
|
||||
const header = document.querySelector('[data-app-header]');
|
||||
@@ -1394,25 +1384,22 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
// shrink and scroll. See the [data-compact] rule in DoormileAI.css.
|
||||
const isCompactStrip = hasThread;
|
||||
|
||||
if (!isMounted || !isShown) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="dai-scrim"
|
||||
data-open={isShown}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<HStack className="dai-root dai-scrim" data-open={isShown} padding={0} gap={0} onClick={onClose} aria-hidden="true" />
|
||||
|
||||
<VStack
|
||||
as="aside"
|
||||
ref={panelRef}
|
||||
className="dai-root dai-panel"
|
||||
data-open={isShown}
|
||||
data-state={isShown ? 'open' : 'closed'}
|
||||
gap={0}
|
||||
padding={0}
|
||||
/* `complementary`, not `dialog`. A dialog promises modality — focus is
|
||||
trapped, the rest of the page is inert, and screen readers announce
|
||||
it as blocking. None of that is true any more: the page beside it
|
||||
stays live and operable, so announcing it as a dialog would be a
|
||||
lie to exactly the users who cannot see that it is docked. */
|
||||
role="complementary"
|
||||
aria-label="Doormile AI — Operations Copilot"
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { AlertTriangle, Info, Sparkles } from 'lucide-react';
|
||||
import { AiOutlineWarning as WarningOutlined, AiOutlineInfoCircle as InfoCircleOutlined } from 'react-icons/ai';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
|
||||
// ==============================|| Doormile AI — shared primitives ||============================== //
|
||||
//
|
||||
// Small presentational pieces shared by the panel's states. Kept in one file
|
||||
// because each is a handful of lines and they are only ever used together.
|
||||
|
||||
// The AI mark — a soft gradient orb, deliberately not a robot face.
|
||||
// The Doormile D. One component behind the assistant's identity, so the
|
||||
// header, every reply, the welcome screen and the thinking state can't drift
|
||||
// apart — they all render this. The mark is RGBA with a transparent ground, so
|
||||
// it sits on the panel surface rather than needing a coloured chip behind it.
|
||||
export const Spark = ({ size = 'sm' }) => (
|
||||
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
|
||||
<Sparkles className="text-brand w-4 h-4" />
|
||||
<img src={doormileMark} alt="" />
|
||||
</HStack>
|
||||
);
|
||||
|
||||
@@ -87,8 +99,8 @@ StatGrid.propTypes = {
|
||||
// silently dropped), and the rehydrated "element" would then crash the render
|
||||
// on the next panel open.
|
||||
const STATE_ICONS = {
|
||||
warning: <AlertTriangle />,
|
||||
info: <Info />
|
||||
warning: <WarningOutlined />,
|
||||
info: <InfoCircleOutlined />
|
||||
};
|
||||
|
||||
// A full list, rendered readably. Replaces the "…and 4 more" truncation that
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from './shim';
|
||||
import { HStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { TextArea } from './shim';
|
||||
import { FileInput } from './shim';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
|
||||
import { parseBulkRows, BULK_MAX } from '@/lib/assistant/bulkOrderActions';
|
||||
import { parseBulkFile, templateCsv, downloadCsv } from '@/lib/assistant/bulkFile';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import { CHIP_LABELS } from './pageContext';
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
import AIPanel from './AIPanel';
|
||||
import '../DoormileAI.css';
|
||||
|
||||
@@ -20,17 +23,19 @@ const DoormileAITrigger = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="dai-root dai-trigger inline-flex items-center justify-center rounded-md p-2 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
data-active={isOpen}
|
||||
aria-label="Open Doormile AI"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
<Bot className="dai-trigger-mark text-slate-700" aria-hidden="true" />
|
||||
</button>
|
||||
<Tooltip content="Doormile AI">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="dai-root dai-trigger"
|
||||
data-active={isOpen}
|
||||
aria-label="Open Doormile AI"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
<img className="dai-trigger-mark" src={doormileMark} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<AIPanel isOpen={isOpen} onClose={close} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
Clock3 as LuClock3,
|
||||
Package as LuPackage,
|
||||
Bike as LuBike,
|
||||
Truck as LuTruck,
|
||||
Building2 as LuBuilding2,
|
||||
CircleDot as LuCircleDot,
|
||||
Banknote as LuBanknote,
|
||||
Users as LuUsers,
|
||||
Layers as LuLayers,
|
||||
TimerOff as LuTimerOff,
|
||||
UserPlus as LuUserPlus,
|
||||
PackagePlus as LuPackagePlus,
|
||||
ListPlus as LuListPlus,
|
||||
Repeat as LuRepeat
|
||||
} from 'lucide-react';
|
||||
LuClock3,
|
||||
LuPackage,
|
||||
LuBike,
|
||||
LuTruck,
|
||||
LuBuilding2,
|
||||
LuCircleDot,
|
||||
LuBanknote,
|
||||
LuUsers,
|
||||
LuLayers,
|
||||
LuTimerOff,
|
||||
LuUserPlus,
|
||||
LuPackagePlus,
|
||||
LuListPlus,
|
||||
LuRepeat
|
||||
} from 'react-icons/lu';
|
||||
|
||||
// ==============================|| Doormile AI — page context ||============================== //
|
||||
//
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import React from 'react';
|
||||
import { DropdownMenu as KrowDropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
|
||||
export const HStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, vAlign, justify, wrap, style, ...props }) => {
|
||||
return (
|
||||
<Component
|
||||
className={`flex flex-row ${
|
||||
vAlign === 'start' ? 'items-start' : vAlign === 'end' ? 'items-end' : vAlign === 'center' ? 'items-center' : 'items-stretch'
|
||||
} ${
|
||||
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
|
||||
} ${wrap === 'wrap' ? 'flex-wrap' : ''} ${className}`}
|
||||
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const VStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, align, justify, style, ...props }) => {
|
||||
return (
|
||||
<Component
|
||||
className={`flex flex-col ${
|
||||
align === 'start' ? 'items-start' : align === 'end' ? 'items-end' : align === 'center' ? 'items-center' : 'items-stretch'
|
||||
} ${
|
||||
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
|
||||
} ${className}`}
|
||||
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const Text = ({ as: Component = 'span', children, className = '', style, ...props }) => {
|
||||
return (
|
||||
<Component className={`text-sm ${className}`} style={style} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconButton = ({ children, className = '', icon, onClick, tooltip, label, size, variant, isIconOnly, ...props }) => {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors ${
|
||||
size === 'sm' ? 'p-1.5' : 'p-2'
|
||||
} ${isIconOnly ? 'aspect-square' : ''} ${className}`}
|
||||
onClick={onClick}
|
||||
title={tooltip || label}
|
||||
aria-label={label}
|
||||
{...props}
|
||||
>
|
||||
{icon || children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({ button, items }) => (
|
||||
<KrowDropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton {...button} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{items?.map((it, i) => (
|
||||
<DropdownMenuItem key={i} onClick={it.onClick}>
|
||||
{it.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</KrowDropdownMenu>
|
||||
);
|
||||
|
||||
export const Button = ({ children, onClick, className = '' }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const Selector = ({ value, onChange, options = [], placeholder }) => (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{placeholder || 'Select...'}
|
||||
</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.value || o} value={o.value || o}>
|
||||
{o.label || o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
export const TextArea = ({ value, onChange, placeholder, rows = 3, className = '' }) => (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={`block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FileInput = ({ onChange, accept, className = '' }) => (
|
||||
<input
|
||||
type="file"
|
||||
onChange={onChange}
|
||||
accept={accept}
|
||||
className={`block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChatToolCalls = ({ calls }) => (
|
||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||
{calls?.map((c, i) => (
|
||||
<div key={i} className="mb-1">
|
||||
<strong>{c.tool || 'Tool'}</strong>
|
||||
<pre className="mt-1 overflow-x-auto text-[10px]">{JSON.stringify(c.args, null, 2)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const useChatDictation = () => {
|
||||
return { isDictating: false, startDictation: () => {}, stopDictation: () => {}, toggleDictation: () => {} };
|
||||
};
|
||||
|
||||
export const ChatDictationButton = ({ isDictating, onToggle }) => (
|
||||
<button type="button" onClick={onToggle} className="p-2 text-gray-500 hover:text-blue-500" title="Dictation unavailable">
|
||||
🎤
|
||||
</button>
|
||||
);
|
||||
306
src/components/assistant/RAG_PLAN.md
Normal file
306
src/components/assistant/RAG_PLAN.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Doormile AI — RAG implementation plan
|
||||
|
||||
Retrieval-augmented routing and document Q&A, backed by a local ChromaDB.
|
||||
|
||||
**Status:** plan only. Nothing here is built.
|
||||
**Prerequisite decision:** where the sidecar runs (see §11).
|
||||
|
||||
---
|
||||
|
||||
## 1. What this changes, and what it deliberately does not
|
||||
|
||||
### The problem being solved
|
||||
|
||||
The assistant routes questions with **34 hand-written regex matchers**. That has a hard vocabulary ceiling, and we hit it repeatedly:
|
||||
|
||||
| Phrase | What went wrong |
|
||||
|---|---|
|
||||
| "cancellation rate" | `\bcancel(led)?\b` doesn't match "cancellation" — the `\b` fails on the following `l` |
|
||||
| "8494948494" (a bare reply) | matched no intent at all; the reply was dropped |
|
||||
| "orders per day this week" | `weekOrders` answered with one number and silently dropped "per day" |
|
||||
| "delivered orders for Acme" | tenant discarded until `orderQuery` was built |
|
||||
|
||||
Every one of those was a regex fix. The next ten will be too. **RAG removes the ceiling** — a new phrasing works because it's *semantically near* an example, not because someone wrote a pattern for it.
|
||||
|
||||
### What RAG must NOT do here
|
||||
|
||||
`assistant/CLAUDE.md` §2 records RAG being considered and rejected, and that reasoning stands **for the data**:
|
||||
|
||||
> "this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions."
|
||||
|
||||
A vector store is a snapshot. "How many orders today" changes every minute. Answering it from embeddings means answering from whenever we last indexed.
|
||||
|
||||
**So: RAG selects the question. The existing API layer still produces every number.**
|
||||
|
||||
```
|
||||
question ──► embed ──► Chroma ──► top-k intent examples ──► intentId + slots + confidence
|
||||
│
|
||||
▼
|
||||
the SAME deterministic run() executes
|
||||
│
|
||||
▼
|
||||
real API call ──► real number
|
||||
```
|
||||
|
||||
The "never fabricate a number" guarantee survives untouched. Nothing in the vector store ever becomes a figure the operator reads.
|
||||
|
||||
### Why this is not blocked by the §2 decision
|
||||
|
||||
§2's blocker was: *an LLM call needs an API key, and a static CRA build has nowhere to put one.*
|
||||
|
||||
This plan uses a **local embedding model running in Node** (`@xenova/transformers`). No key, no network, no per-call cost. The blocker doesn't apply. If we later want a hosted embedding model or a generation step, §2 applies again and needs its own decision.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
docker-compose.yml
|
||||
├── chroma chromadb/chroma:latest :8000 persistent volume ./.chroma
|
||||
└── ai-sidecar node:20 :8787 services/ai
|
||||
|
||||
services/ai/
|
||||
├── package.json own deps — NOT added to the CRA package.json
|
||||
├── index.js Express app: /health, /route, /ask, /reindex
|
||||
├── embed.js MiniLM via @xenova/transformers, cached in-process
|
||||
├── collections.js Chroma client, collection get-or-create
|
||||
├── seed/
|
||||
│ ├── intents.js builds intent_examples from the phrasing catalog
|
||||
│ ├── docs.js chunks the markdown docs
|
||||
│ └── phrasings.json ~20 example phrasings per intent (hand-written)
|
||||
├── eval.js routing accuracy vs the regex baseline
|
||||
└── README.md how to run it locally
|
||||
```
|
||||
|
||||
**The CRA app gains no new dependencies.** The sidecar is a separate package with its own `package.json`, so `react-scripts`, the webpack config and the `resolutions` block in the root `package.json` are untouched (root `CLAUDE.md` §4.3).
|
||||
|
||||
### Ports and env
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chroma | `http://localhost:8000` |
|
||||
| Sidecar | `http://localhost:8787` |
|
||||
| CRA reads | `REACT_APP_AI_URL` (absent → RAG disabled, regex only) |
|
||||
|
||||
`REACT_APP_AI_URL` being unset must be a supported state, not a broken one — that is what keeps the app deployable exactly as it is today.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
### Collection `intent_examples`
|
||||
|
||||
One vector per example phrasing. ~34 intents × ~20 phrasings ≈ **700 vectors**. Trivially small; Chroma handles it in memory.
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'statusBreakdown::07',
|
||||
document: 'how many orders got cancelled this week',
|
||||
metadata: {
|
||||
intentId: 'statusBreakdown',
|
||||
domain: 'orders', // orders | riders | tenants | hubs | vehicles | ops | write
|
||||
isWrite: false, // write intents need a higher bar — see §6
|
||||
slotsHint: 'status,range' // documentation only; slots still parsed deterministically
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Collection `console_docs`
|
||||
|
||||
Chunked markdown for genuine document Q&A — "what is CityGate", "why does dispatch reconcile before commit".
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'express-console-api.md::conventions::2',
|
||||
document: '<chunk text>',
|
||||
metadata: {
|
||||
source: 'express-console-api.md',
|
||||
heading: 'Conventions across every endpoint',
|
||||
updatedAt: '2026-08-19'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Chunking:** split on markdown headings, then hard-wrap at ~800 characters with ~100 characters of overlap. Heading path is prepended to each chunk so a chunk carries its own context.
|
||||
|
||||
**Sources to index:** `express-console-api.md`, root `CLAUDE.md`, `src/pages/api/CLAUDE.md`, `src/pages/nearle/assistant/CLAUDE.md`, `src/pages/nearle/dispatch/CLAUDE.md`, `src/pages/nearle/orders/CLAUDE.md`, `ROADMAP.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. The routing contract
|
||||
|
||||
### `POST /route`
|
||||
|
||||
```jsonc
|
||||
// request
|
||||
{ "text": "how many orders got cancelled this week" }
|
||||
|
||||
// response
|
||||
{
|
||||
"intentId": "statusBreakdown",
|
||||
"confidence": "high", // high | medium | low
|
||||
"score": 0.91, // cosine similarity of top-1
|
||||
"margin": 0.19, // top-1 minus top-2 — the honest signal
|
||||
"alternatives": [
|
||||
{ "intentId": "orderRate", "score": 0.72 }
|
||||
],
|
||||
"matchedExample": "how many orders got cancelled this week"
|
||||
}
|
||||
```
|
||||
|
||||
### How confidence is derived — and why margin, not score
|
||||
|
||||
Cosine similarity is **not** a probability of correctness. 0.87 does not mean "87% likely right". What actually carries information is the **margin** between the best and second-best match:
|
||||
|
||||
| Condition | Confidence | Bot behaviour |
|
||||
|---|---|---|
|
||||
| `score ≥ 0.75` and `margin ≥ 0.10` | **high** | route and answer |
|
||||
| `score ≥ 0.60` and `margin ≥ 0.05` | **medium** | route, and name the interpretation in the answer |
|
||||
| otherwise | **low** | **don't guess** — offer the top 2–3 as buttons |
|
||||
|
||||
Thresholds are starting values, tuned in phase 7 against the eval corpus.
|
||||
|
||||
**What the operator sees:** *"Matched: orders by status · high confidence"* — never *"87% sure the answer is 42."* The answer's correctness comes from deterministic execution; the score only describes how sure we are which question was asked. Conflating the two would be exactly the kind of false precision this bot has avoided all along.
|
||||
|
||||
### `POST /ask` (docs)
|
||||
|
||||
```jsonc
|
||||
{ "text": "what is CityGate" }
|
||||
→ { "chunks": [ { "text": "...", "source": "express-console-api.md",
|
||||
"heading": "Conventions", "score": 0.88 } ] }
|
||||
```
|
||||
|
||||
**No generation step.** It returns the source passages with attribution and the panel renders them. Summarising them into new prose would require an LLM — which is §2's blocked decision — and would also let a paraphrase drift from what the doc says.
|
||||
|
||||
---
|
||||
|
||||
## 5. Embedding model
|
||||
|
||||
**`Xenova/all-MiniLM-L6-v2`** via `@xenova/transformers`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Size | ~25 MB, downloaded once, cached on disk |
|
||||
| Dimensions | 384 |
|
||||
| Runs | in-process in Node — no key, no network, no per-call cost |
|
||||
| Speed | ~5 ms per short query on CPU |
|
||||
|
||||
Good enough for short operator phrases, which is the whole workload. Upgrade path if the eval shows it's not: `text-embedding-3-small`. That needs a key, which re-opens §2 — do not do it silently.
|
||||
|
||||
**Important:** queries and documents must be embedded with the *same* model and the same normalisation. A model change means a full re-seed; `seed.js` writes the model name into collection metadata so a mismatch is detectable rather than silently wrong.
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration with the bot
|
||||
|
||||
`answerQuestion(text, context)` gains a routing step **in front of** the existing matcher:
|
||||
|
||||
```js
|
||||
// 1. try semantic routing, but never let it break the bot
|
||||
const routed = await routeViaRag(text).catch(() => null);
|
||||
|
||||
// 2. high/medium confidence → run that intent's own run()
|
||||
if (routed && routed.confidence !== 'low') {
|
||||
const intent = INTENTS_BY_ID[routed.intentId];
|
||||
const params = intent?.match(text) ?? deriveSlots(text, routed);
|
||||
const result = params && (await intent.run(params));
|
||||
if (result) return { ...result, routing: routed };
|
||||
}
|
||||
|
||||
// 3. fall back to the 34 regex intents, exactly as today
|
||||
return matchAndRun(text);
|
||||
```
|
||||
|
||||
### Non-negotiables
|
||||
|
||||
**The regex matcher stays.** It is the fallback when the sidecar is down, `REACT_APP_AI_URL` is unset, or confidence is low. Today's behaviour is the floor — RAG can only improve it, never remove it.
|
||||
|
||||
**Slots stay deterministic.** `rangeFromWords`, `statusFromWords`, `orderStatusGroups`, entity resolution all keep their jobs. Embeddings are good at *"what kind of question is this"* and bad at *"the 14th"*. RAG picks the intent; parsing extracts the values.
|
||||
|
||||
**Write intents need a higher bar.** `createCustomer` and `createOrder` must require **high** confidence AND an explicit verb match. A semantic near-miss must never open a write form. The `isWrite` flag in metadata exists for exactly this check.
|
||||
|
||||
**Timeout.** 400 ms budget on `/route`; past that, fall through to regex. The bot must never feel slower because a container is cold.
|
||||
|
||||
---
|
||||
|
||||
## 7. Evaluation — how we prove it is better
|
||||
|
||||
The existing **40-phrase routing corpus** (`scratchpad/route_probe.mjs`) becomes the regression baseline. But it is not a fair test: those phrases were written *for* the regex matcher and it scores 40/40 on them.
|
||||
|
||||
The real test is a **held-out set of ~60 phrasings neither implementation was tuned against**, written by someone who hasn't read the matchers. Operator language, not developer language: *"anything stuck?"*, *"what's late"*, *"how'd Kumar do"*.
|
||||
|
||||
`eval.js` reports:
|
||||
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| Routing accuracy | correct `intentId` — the headline number |
|
||||
| Coverage | % answered at all (regex's weakness: silent no-match) |
|
||||
| False routes | wrong intent answered confidently — **the number that matters most** |
|
||||
| Write safety | zero write intents triggered by non-write phrasings |
|
||||
| p50 / p95 latency | must stay under the 400 ms budget |
|
||||
|
||||
**Ship criterion:** RAG beats regex on accuracy *and* coverage on the held-out set, with **zero** false write routes. A false route is worse than a no-match — it's the same class of failure as the "19 assigned vs 0" bug.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phases
|
||||
|
||||
| # | Deliverable | Acceptance | Effort |
|
||||
|---|---|---|---|
|
||||
| 0 | **Sidecar hosting decision** (§11) | answered | — |
|
||||
| 1 | `docker-compose.yml`, Chroma up, Express `/health` | `curl :8787/health` → ok, Chroma reachable | 0.5 d |
|
||||
| 2 | `embed.js`, model cached, round-trip verified | same text → identical vector twice | 0.5 d |
|
||||
| 3 | `phrasings.json` — 20 per intent × 34 | seeded, count verified | 1.5 d |
|
||||
| 4 | `POST /route` with confidence + margin | returns correct intent for 20 hand checks | 0.5 d |
|
||||
| 5 | Bot integration + fallback + 400 ms timeout | **kill the sidecar mid-session → bot still answers** | 0.5 d |
|
||||
| 6 | `console_docs` + `POST /ask` + panel rendering | "what is CityGate" returns the right passage | 1 d |
|
||||
| 7 | `eval.js` + held-out set + threshold tuning | report produced, thresholds fixed from data | 1 d |
|
||||
|
||||
**Total ≈ 5.5 days.** Phases 1–5 (≈3.5 d) deliver the whole routing win; 6–7 add docs and proof.
|
||||
|
||||
---
|
||||
|
||||
## 9. Operations
|
||||
|
||||
- **`npm run seed:ai`** rebuilds both collections from scratch. Idempotent.
|
||||
- **Docs drift silently.** Re-seed on any change to an indexed markdown file — a CI step, or a pre-commit hook. A stale doc answer is worse than none, because it looks authoritative.
|
||||
- **Chroma persistence** is a bind-mounted `./.chroma` volume. Add to `.gitignore`.
|
||||
- **Model cache** likewise (`.cache/transformers`).
|
||||
- **Nothing in the sidecar touches the Doormile API.** It only routes text. All data access stays in the browser through the existing typed functions, which keeps the tenant scoping and the bearer token exactly where they are today.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| **First backend in the project** | Dev-only to start. Deploying it is a separate decision with real ops cost. |
|
||||
| Sidecar down → bot dead | Regex fallback + timeout. Tested explicitly in phase 5. |
|
||||
| Semantic near-miss opens a write form | `isWrite` requires high confidence **and** verb match. Zero-tolerance metric in eval. |
|
||||
| Local model too weak on short phrases | Measured in phase 7. Upgrade path exists but re-opens §2. |
|
||||
| Docs go stale | Re-seed in CI. |
|
||||
| Phrasing catalog becomes a second matcher to maintain | It is data, not code — and unlike regexes, near-misses still work. |
|
||||
| Scope creep into generation | Explicitly out (§12). |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open decisions — needed before phase 1
|
||||
|
||||
1. **Where does the sidecar run?**
|
||||
- **(a) Dev-only** — runs on operator machines / a dev box. Zero ops. RAG is an enhancement that's simply absent in production.
|
||||
- **(b) Deployed alongside nginx** — the project gains a backend: hosting, monitoring, a deploy pipeline, an internal network hop. Bigger commitment than the vector DB itself.
|
||||
|
||||
*Recommendation: (a) first.* Prove routing accuracy with real operator language before taking on ops.
|
||||
|
||||
2. **Does `/ask` (docs) ship to operators, or is it internal?** The indexed docs contain engineering notes, including known backend bugs.
|
||||
|
||||
3. **Who writes the held-out eval set?** It has to be someone who hasn't read the matchers, or the test is worthless.
|
||||
|
||||
---
|
||||
|
||||
## 12. Explicitly out of scope
|
||||
|
||||
- **Any generation step.** `/ask` returns source passages with attribution, never paraphrase. Generation needs a hosted model and a key — §2's blocked decision.
|
||||
- **Embedding operational data.** No bookings, riders or customers in the vector store. Numbers come from the API, always.
|
||||
- **Replacing the regex matcher.** It becomes the fallback, permanently.
|
||||
- **Semantic slot extraction.** Dates, statuses and entities stay deterministic.
|
||||
390
src/components/assistant/ROADMAP.md
Normal file
390
src/components/assistant/ROADMAP.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# Doormile Bot — v3 development plan
|
||||
|
||||
Analysis of the shipped v2 (`BotPanel.js` + `intents.js`, 9 intents) and the staged plan to take it to an advanced operator assistant.
|
||||
|
||||
Authority: `src/pages/nearle/assistant/CLAUDE.md` constrains this work — no route/sidebar (§5), no LLM without a key-location decision (§2), no write action without a propose→confirm gate (§4). This plan does not override any of those; where it touches one, it says so explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Analysis of v2
|
||||
|
||||
### Architecture today
|
||||
|
||||
```
|
||||
answerQuestion(text)
|
||||
└─ for each of 9 INTENTS, in array order
|
||||
├─ intent.match(text) → params | null
|
||||
└─ intent.run(params) → { headline, detail, sourceCalls } | null
|
||||
first match that resolves wins; otherwise "I can't answer that yet"
|
||||
```
|
||||
|
||||
One question maps to exactly one intent. Every data intent bulk-fetches `getBookings(1, 1000)` and filters client-side.
|
||||
|
||||
### Defects, ranked by damage
|
||||
|
||||
#### Tier A — the bot states wrong numbers confidently
|
||||
|
||||
**A1. The 1000-row ceiling is real and undetectable.**
|
||||
`BULK_PAGESIZE = 1000` is not a chosen page size, it is the API's hard cap (`express-console-api.md` → Conventions: "Default 500, cap 1000"). Worse, `getBookings` returns `response.data.data` and discards the envelope's `total`, so no caller can even detect truncation. Past 1000 lifetime bookings every count intent under-reports with no warning, while `sourceCalls` displays `status: 'complete'` beside it. This directly violates the stated promise in CLAUDE.md §3 ("a wrong number from this bot is worse than no answer").
|
||||
|
||||
**A2. Range words are silently downgraded to "today".**
|
||||
Only `revenueTotal` and `weekOrders` call `rangeFromWords`. The other seven use `dayFromWords`, which ignores "this week" and returns today. "How many delivered orders this week" is answered by `statusBreakdown` as *today's* count. The headline says "today", so it is disclosed — but the operator asked something else and got an answer to a different question.
|
||||
|
||||
**A3. `revenueTotal` is mislabelled and includes cancelled orders.**
|
||||
It sums `serviceoptions[0].estimatedprice` over every row in range with no status filter. Cancelled bookings inflate it, only the first service option is counted, and the figure is an *estimate* presented as "Total revenue".
|
||||
|
||||
**A4. "assigned" resolves to the wrong bucket.**
|
||||
`statusFromWords` maps `assigned → 'accepted'`, but `mapBookingStatusToDeliveryStatus` maps the backend's `miler_assigned → 'pending'`. "How many assigned orders today" therefore counts `pickup_scheduled` + `converted_to_consignment` and excludes the orders the operator means.
|
||||
|
||||
#### Tier B — the bot answers a different question
|
||||
|
||||
**B1. `riderCounts` swallows every sentence containing "rider".**
|
||||
`match: (text) => (mentionsRiders(text) ? {} : null)` and its `run` never returns null. "Which rider has order #4821", "how many orders did rider Suresh deliver" and "rider performance this week" all return the same fleet availability summary. This is the mirror image of the bug CLAUDE.md §3 documents as fixed — the guard stopped `statusBreakdown` stealing rider questions, but nothing stops `riderCounts` stealing everything else.
|
||||
|
||||
**B2. `orderLookup`'s fall-through answers a different question entirely.**
|
||||
It searches only page 1. An order not in the most recent 1000 returns `null`, the loop continues, and `totalOrders` answers "142 orders created today" to the question "status of order #9931". Fall-through is right when an intent *mismatched*; it is wrong when the intent matched and the lookup failed.
|
||||
|
||||
**B3. No composite filters.** "Delivered orders for Acme this week" is answered by `statusBreakdown` alone — tenant and range discarded.
|
||||
|
||||
**B4. `resolveTenant` is one-directional substring matching.** The question must contain the tenant's full name. "Orders for acme foods" against tenant "Acme Foods Pvt Ltd" fails, falls through, and `totalOrders` answers with the all-tenant count.
|
||||
|
||||
**B5. `orderIdFromWords` treats any bare 4+ digit run as an order id** — years, pincodes, quantities.
|
||||
|
||||
#### Tier C — architecture
|
||||
|
||||
**C1. No TanStack Query.** CLAUDE.md §7 makes it the rule for all reads. `answerQuestion` calls the API functions raw. Clicking the five suggestion chips issues five separate 1000-row fetches; `tenantCount` issues two sequentially.
|
||||
|
||||
**C2. `sourceCalls` are hand-written after the fact** with `status: 'complete'` hardcoded. They cannot represent a failed or partial call and will drift from the code the moment a `run` is edited. `ChatToolCalls` already supports `'pending' | 'running' | 'complete' | 'error'` plus `duration`, `errorMessage` and `resultDetail` — none used.
|
||||
|
||||
**C3. The two aggregation endpoints are unused.** `GET /admin/reports` (`from`/`to`/`tenantid`/`locationid`/`hubid`, with `by_location`/`by_hub`/`by_tenant`/`by_rider` blocks per `getReports`'s comment) and `GET /admin/dashboard` ("counts + today's numbers") are server-computed, uncapped, and already wrapped in `doormileApi.js`. They are the correct source for every counting question and the answer to A1.
|
||||
|
||||
**C4. No abort or timeout.** A 1000-row fetch cannot be cancelled; the composer is simply disabled.
|
||||
|
||||
#### Tier D — UX
|
||||
|
||||
- **D1.** Suggestion chips are gated on `messages.length === 0`, so they vanish permanently after the first question. No reset control.
|
||||
- **D2.** History dies when the popover closes (accepted in CLAUDE.md §1, but a liability once answers get expensive).
|
||||
- **D3.** Answers are two strings. `detail` truncates at 10 ids with "…and N more" and there is no way to see the rest, and no way to jump to the matching rows on the Orders page.
|
||||
- **D4.** Unanswered questions are dropped — no signal on what to build next.
|
||||
- **D5.** Fixed `PANEL_WIDTH = 400` / `MESSAGES_HEIGHT = 420` raw px.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Target architecture
|
||||
|
||||
Replace one-question-one-intent with a three-stage pipeline:
|
||||
|
||||
```
|
||||
parse(text) → Query pure, no I/O, fully unit-testable
|
||||
resolve(Query) → Dataset cached, paginated, audited, abortable
|
||||
render(Query, Dataset) → Answer headline + blocks + real sourceCalls
|
||||
```
|
||||
|
||||
`Query` is a slot bag, not an intent id:
|
||||
|
||||
```js
|
||||
{
|
||||
subject : 'orders' | 'riders' | 'tenants' | 'revenue' | 'order',
|
||||
metric : 'count' | 'sum' | 'breakdown' | 'lookup' | 'top',
|
||||
filters : { range: {start, end, label}, batch, status, tenantId, hubId, riderId, orderId },
|
||||
groupBy : 'status' | 'batch' | 'tenant' | 'rider' | 'hour' | null,
|
||||
limit : number
|
||||
}
|
||||
```
|
||||
|
||||
Filters compose. "Delivered orders for Acme this week" fills three slots and runs one query instead of picking one of three intents.
|
||||
|
||||
Proposed file layout inside `src/pages/nearle/assistant/`:
|
||||
|
||||
```
|
||||
parse/
|
||||
vocab.js date/range, batch, status, metric, groupBy vocabularies
|
||||
entities.js tenant/rider/hub resolution + fuzzy scoring
|
||||
parseQuery.js text → Query (+ confidence, + unresolved slots)
|
||||
resolve/
|
||||
source.js cached, paginated booking source; reports/dashboard source
|
||||
aggregate.js count / sum / breakdown / top over a normalised row set
|
||||
render/
|
||||
answer.js Query + Dataset → { headline, blocks[], sourceCalls[] }
|
||||
intents.js thin compatibility shim → parseQuery + resolve + render
|
||||
BotPanel.js richer rendering, chips, reset, stop, persistence
|
||||
```
|
||||
|
||||
`intents.js` keeps its exported surface (`answerQuestion`, `EXAMPLE_QUESTIONS`) so `BotPanel.js` and `AppTopNav.js` are unaffected during the swap.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Phased plan
|
||||
|
||||
### Phase 0 — Truth foundation (blocking; ship before any new capability)
|
||||
|
||||
Nothing else matters while the numbers can be wrong.
|
||||
|
||||
| # | Task | Files | Acceptance |
|
||||
|---|---|---|---|
|
||||
| 0.1 | Expose the envelope `total`/`page` from `getBookings` — return `{ rows, total, page }` or add `getBookingsPage`. Keep the existing signature working for `fetchDeliveries`' four callers. | `pages/api/doormileApi.js` | A caller can detect that more rows exist than were returned. |
|
||||
| 0.2 | Build a paginated booking source that drains pages until the oldest row predates the requested range, with a hard page budget. Emits a `truncated` flag when the budget is hit. | `resolve/source.js` | "How many orders today" is correct on an account with >1000 lifetime bookings. |
|
||||
| 0.3 | Route counting/aggregate questions through `getReports(from, to, tenantid, locationid, hubid)` first; fall back to the paginated booking scan only when reports can't answer the shape. | `resolve/source.js` | Counts for a date range come from one server call, not a 1000-row scan. |
|
||||
| 0.4 | Never present a truncated result as complete — if `truncated`, headline reads "at least N" and the tool call carries `status: 'error'` or an `errorMessage`. | `render/answer.js` | Truncation is visible in the answer, not just the console. |
|
||||
| 0.5 | Fix A3: exclude `cancelled` from revenue, sum all `serviceoptions`, relabel as "estimated". | `render/answer.js` | "Total revenue today" excludes cancelled and says "estimated". |
|
||||
| 0.6 | Fix A4: align status synonyms with `BOOKING_STATUS_TO_DELIVERY_STATUS`. "assigned" → the bucket `miler_assigned` actually lands in. | `parse/vocab.js` | "Assigned orders today" matches what the Orders page shows for the same filter. |
|
||||
| 0.7 | Fix B2: when an intent matched but its lookup failed, answer "I couldn't find order X" — do not fall through to a broader intent. | `resolve/` + `render/` | Asking for a nonexistent order never returns a global count. |
|
||||
|
||||
**Risk:** 0.3 depends on the `/admin/reports` response shape, which is documented only in `jupiter2doormile.md` and is not in `express-console-api.md`. Confirm live before building on it; if the shape doesn't carry what's needed, 0.2 alone still fixes A1 at higher cost.
|
||||
|
||||
### Phase 1 — Slot parser (the capability multiplier)
|
||||
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 1.1 | `parseQuery(text) → Query` with independent slot extraction; unrecognised slots stay empty rather than defaulting. | Unit tests over a fixed corpus (Part 5). |
|
||||
| 1.2 | Real relative-date vocabulary: today, yesterday, this/last week, last N days, this month, explicit `DD MMM` and `YYYY-MM-DD`. Anything unparsed → *ask*, don't assume today. | "Delivered orders this week" returns the week, not today. |
|
||||
| 1.3 | Entity resolution with bidirectional + fuzzy matching and an ambiguity path: 0 matches → say so; 1 → use it; 2+ → ask which. | "Orders for acme foods" resolves to "Acme Foods Pvt Ltd". |
|
||||
| 1.4 | Confidence scoring replaces first-match-wins. Below threshold → clarifying question listing what was understood. | "Rider Suresh's orders today" no longer returns a fleet summary (fixes B1). |
|
||||
| 1.5 | Conversation context: carry the last `Query` forward; a follow-up mutates only the slots it names. Reset on explicit "start over" and on an entity switch. | "and yesterday?" / "just for Acme" work as follow-ups. |
|
||||
| 1.6 | Guard B5: a bare number is an order id only with an order-ish trigger word nearby and no date/quantity reading. | "Orders in 2026" is not treated as an id lookup. |
|
||||
|
||||
Coverage after this phase is the product of the slots, not a list of nine — subject × range × status × batch × tenant × rider all compose.
|
||||
|
||||
### Phase 2 — Answers that are objects, not sentences
|
||||
|
||||
| # | Task | Notes |
|
||||
|---|---|---|
|
||||
| 2.1 | `Answer.blocks[]` — typed blocks (`stat`, `table`, `breakdown`, `link`) rendered by `BotPanel`. | Replaces the two-string shape. |
|
||||
| 2.2 | Result table for row-returning answers: `Table` + `StatusBadge` cells instead of "…and N more". | Reuse `components/nearle_components/StatusBadge`. |
|
||||
| 2.3 | Deep link — every answer carries the filter state that produced it, with a button that navigates to the Orders/Deliveries page pre-filtered. | The single biggest usability jump: answer → action. |
|
||||
| 2.4 | Breakdown answers via `groupBy` (by status / batch / tenant / rider / hour). | Feeds off `/admin/reports` `by_*` blocks where available. |
|
||||
| 2.5 | New subjects using already-exported functions: `getBookingTrack` (where is order X), `getMilerActivity` / `getMilerSummary` (what has rider X done), `getConsignments`, `getTripsheets`, `getHubs`, `getVehicles`. | No new endpoints needed. |
|
||||
| 2.6 | Real `sourceCalls`: emitted by the fetch layer, streaming `pending → running → complete/error`, with `duration` and `errorMessage`. | `ChatToolCalls` already supports all four states. |
|
||||
|
||||
### Phase 3 — Panel UX
|
||||
|
||||
| # | Task |
|
||||
|---|---|
|
||||
| 3.1 | Keep suggestion chips available after the first message (collapse into a `ChatComposerDrawer` or a header affordance), plus a "New chat" reset. |
|
||||
| 3.2 | Persist thread + last `Query` to `sessionStorage` so closing the popover doesn't lose it. |
|
||||
| 3.3 | `onStop` / `isStopShown` on `ChatComposer` wired to an `AbortController` through the fetch layer. |
|
||||
| 3.4 | `ChatSystemMessage` for context resets, day dividers and truncation notices. |
|
||||
| 3.5 | `ChatLayoutScrollButton` + `useChatNewMessages` for long threads. |
|
||||
| 3.6 | `useTriggerMenu` + `ChatComposerTokenElement`: `@tenant` / `@rider` / `/` commands so an operator *picks* a real entity instead of relying on fuzzy matching. Directly de-risks 1.3. |
|
||||
| 3.7 | Replace raw `PANEL_WIDTH`/`MESSAGES_HEIGHT` px with tokens; keyboard/focus check inside the `Popover` (Escape currently closes the panel mid-typing). |
|
||||
| 3.8 | Log unanswered questions locally (capped ring buffer) and surface them — this is the backlog for the next intent round. |
|
||||
| 3.9 | i18n the bot's strings into `utils/locales/en.json` like the rest of the app. |
|
||||
|
||||
### Phase 4 — Actions, confirm-gated (needs sign-off)
|
||||
|
||||
CLAUDE.md §4 rules this out today and specifies the shape it must take if built: propose → operator confirms → execute. Plan accordingly:
|
||||
|
||||
1. Parse produces an `Action` (never executed at parse time).
|
||||
2. Render shows exactly what will be submitted — target rows, field values, the endpoint — as a `ChatSystemMessage` with explicit confirm/cancel.
|
||||
3. Execute only on confirm, through the same api.js functions the pages use, honouring the dispatch reconcile rule (root CLAUDE.md §4) and the notify-rider-after-mutation rule (§9).
|
||||
4. Post-action, refetch the related queries and show the new state.
|
||||
|
||||
Safe first candidates: `notifyMiler` (broadcast to a rider), `cancelBooking` (single, with confirm). Deliberately last: order creation — CityGate pincode validation and delivery-slot windows live elsewhere and must not be bypassed.
|
||||
|
||||
### Phase 5 — LLM as parser only (BLOCKED on a decision)
|
||||
|
||||
CLAUDE.md §2 records that this was raised and rejected because there is nowhere to hold a key — no backend, static nginx build. That reasoning still stands, so this phase is blocked, not dismissed. If the key question is ever answered, the correct shape is narrow:
|
||||
|
||||
- The model does **slot extraction only** — text in, a validated `Query` JSON out via tool-use / structured output. It never produces a number, a row, or a sentence the operator reads as fact.
|
||||
- Deterministic code still executes every fetch and every calculation.
|
||||
- Slots that don't resolve against real tenants/hubs/riders are rejected and the regex parser runs as fallback.
|
||||
|
||||
This preserves the "no fabricated numbers" guarantee exactly, while removing the vocabulary ceiling. Note it also makes Phase 1 the fallback path rather than dead work.
|
||||
|
||||
### Phase 6 — Proactive
|
||||
|
||||
Once the query layer is trustworthy: watch for conditions rather than waiting to be asked — "14 morning-batch orders unassigned, 30 minutes to cutoff", "rider X offline mid-route". Surfaces as a badge on the bot icon and a `ChatSystemMessage`. Ties into the existing FCM path.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Decisions needed
|
||||
|
||||
1. **`/admin/reports` response shape** — confirm live. Blocks task 0.3, which is the cheap fix for the 1000-row problem.
|
||||
2. **Write actions** — in scope for this round, or stays read-only? Blocks Phase 4 entirely.
|
||||
3. **LLM key location** — unchanged from CLAUDE.md §2? Blocks Phase 5.
|
||||
4. **History persistence** — `sessionStorage` (dies with the tab) or Redux + `localStorage` (survives)? Affects 3.2.
|
||||
5. **Tenant-scoped logins** — should the bot say "across your tenant" when the token carries a tenantid, rather than implying global figures?
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Regression corpus
|
||||
|
||||
Build this as a fixture the parser is tested against; every row is a question the bot must either answer correctly or explicitly decline.
|
||||
|
||||
| Question | Must produce |
|
||||
|---|---|
|
||||
| how many orders today | count, orders, today |
|
||||
| how many orders this week | count, orders, 7-day range (currently → today) |
|
||||
| how many delivered orders this week | count + status + range (currently drops range) |
|
||||
| delivered orders for Acme this week | count + status + tenant + range (currently drops two) |
|
||||
| morning batch orders yesterday | count + batch + day |
|
||||
| how many riders are active | rider availability |
|
||||
| which rider has order #4821 | order lookup → rider (currently fleet summary) |
|
||||
| how many orders did rider Suresh deliver today | rider activity (currently fleet summary) |
|
||||
| status of order #9931 (nonexistent) | "couldn't find it" (currently a global count) |
|
||||
| orders in 2026 | not an id lookup |
|
||||
| total revenue today | estimated, cancelled excluded |
|
||||
| how many assigned orders today | must agree with the Orders page |
|
||||
| and yesterday? (follow-up) | previous query, day shifted |
|
||||
| how many tenants | tenant count |
|
||||
| where is order DM-BK-123 | tracking |
|
||||
| top 5 tenants by orders this week | breakdown + limit |
|
||||
| unassigned orders right now | pending count |
|
||||
| how many orders (account with >1000 bookings) | correct, or explicitly "at least N" |
|
||||
|
||||
---
|
||||
|
||||
# Part 6 — Capability levels
|
||||
|
||||
A different cut from the phases above: not *how* to build it, but *what the bot
|
||||
could do*, ordered by how much has to exist underneath.
|
||||
|
||||
Baseline: `doormileApi.js` exports **96 functions, 37 of them reads**. The bot
|
||||
calls **13** — all list endpoints. Every level below L6 is built from functions
|
||||
that already exist and are already used by some page in this console.
|
||||
|
||||
---
|
||||
|
||||
## L0 — Foundation (not a feature; blocks everything)
|
||||
|
||||
Every count the bot gives is capped at `getBookings(1, 1000)` — page 1, at the
|
||||
API's hard cap — and `getBookings` discards the envelope's `total`, so
|
||||
truncation is undetectable. Fix pagination, surface `total`, route counts
|
||||
through `GET /admin/reports`, and say "at least N" when truncated.
|
||||
|
||||
Until this lands, every level below inherits a silent wrong-number risk.
|
||||
|
||||
---
|
||||
|
||||
## L1 — Counts and lists — **SHIPPED**
|
||||
|
||||
25 intents over 13 API functions. Single-dimension questions, plus typo
|
||||
tolerance, date vocabulary, comparisons, multi-part, and date follow-ups.
|
||||
|
||||
**Ceiling:** one filter at a time. "Delivered orders for Acme this week"
|
||||
answers only the status.
|
||||
|
||||
---
|
||||
|
||||
## L2 — Composable queries
|
||||
|
||||
Slot filling replaces first-match-wins: `subject x range x status x batch x
|
||||
tenant x rider x hub` all compose into one query.
|
||||
|
||||
- "Delivered orders for Acme this week"
|
||||
- "Pending morning-batch orders at Coimbatore hub yesterday"
|
||||
- "Cancelled orders for Acme vs Beta last month"
|
||||
|
||||
**New endpoints needed:** none. Coverage becomes the product of the slots
|
||||
rather than a list of 25.
|
||||
|
||||
---
|
||||
|
||||
## L3 — Entity intelligence
|
||||
|
||||
Deep answers about *one* thing, using the detail endpoints the bot has never
|
||||
touched.
|
||||
|
||||
| Subject | Functions available now | Unlocks |
|
||||
|---|---|---|
|
||||
| Order | `getBooking`, `getBookingTrack` | "Where is DM-BK-123", full status timeline, assigned rider, ETA |
|
||||
| Parcel | `getConsignment`, `getConsignmentLogs`, `trackConsignment` | Scan history, exception trail |
|
||||
| Rider | `getMiler`, `getMilerActivity`, `getMilerLogs`, `getMilerSummary` | "What has Suresh done today" — assigned/completed/rejected/cancelled, riderkms, last ping, live position |
|
||||
| Hub / vehicle | `getHub`, `getVehicle` | Per-site detail, assigned fleet |
|
||||
| Tenant | `getAdminTenant`, `getTenantLocations`, `getTenantCustomers` | Sites, customers, contact |
|
||||
| Exception | `getException` | Why a delivery failed |
|
||||
| Pricing | `quotePricing`, `simulatePricing` | "What would a 5kg parcel from 641001 to 600001 cost?" — a real calculation, not a lookup |
|
||||
|
||||
**Warning:** `riderLookup` today reads `found.status`, `found.phonenumber`,
|
||||
`found.vehicletype`. The confirmed-live miler shape (documented in `api.js`)
|
||||
has none of those — it carries `availabilitystatus`, `phone`,
|
||||
`defaultvehicletype`. Fix against the real shape before extending this level.
|
||||
|
||||
---
|
||||
|
||||
## L4 — Analytics, ranking, anomaly
|
||||
|
||||
Built on `getReports` (`by_tenant` / `by_hub` / `by_rider` blocks),
|
||||
`getLocationsSummary`, `getMilerSummary`.
|
||||
|
||||
- "Top 5 tenants by orders this week"
|
||||
- "Which hub is busiest / which needs attention"
|
||||
- "Which riders have the most cancellations"
|
||||
- "Cancellation rate this week vs last"
|
||||
- "Orders per hour today"
|
||||
|
||||
**"Which orders are delayed" is computable today** — `serviceoptions[0].
|
||||
estimateddeliveryat` exists on a booking, so "past estimated delivery and not
|
||||
yet delivered" is a real filter, not a guess. This is probably the single
|
||||
highest-value question on the list and nothing currently answers it.
|
||||
|
||||
---
|
||||
|
||||
## L5 — Navigation and UI control
|
||||
|
||||
The assistant stops being a read-only oracle and starts driving the console.
|
||||
|
||||
- Every answer carries the filter state that produced it → "Open in Orders"
|
||||
- "Show me cancelled orders" navigates and applies the filter, instead of
|
||||
returning a count
|
||||
- "Open order DM-BK-123" routes to the record
|
||||
|
||||
**Dependency:** the target pages must accept filter state from the URL. Check
|
||||
what `orders.js` supports before committing to this.
|
||||
|
||||
---
|
||||
|
||||
## L6 — Write actions, confirm-gated
|
||||
|
||||
Out of scope per `CLAUDE.md` §4 until signed off, and that doc already fixes
|
||||
the required shape: propose -> show the exact payload and affected rows ->
|
||||
operator confirms -> execute -> refetch. Never straight from match to mutation.
|
||||
|
||||
Tiered by blast radius:
|
||||
|
||||
| Tier | Functions | Risk |
|
||||
|---|---|---|
|
||||
| T1 | `notifyMiler` | Sends a message. Reversible by sending another. |
|
||||
| T2 | `assignMilerToBooking`, `assignVehicleToBooking`, `updateBookingStatus`, `cancelBooking`, `updateExceptionStatus`, `blockMiler` | Single record, real consequence |
|
||||
| T3 | `batchAssignBookings`, `bulkCancelBookings` | Many records at once |
|
||||
| T4 | `createTripsheet`, `addTripsheetItem`, `dispatchTripsheet`, `arriveTripsheet` | Multi-step workflow with ordering rules |
|
||||
| T5 | `createExpressBooking`, `createExpressBookingBulk` | Last. CityGate pincode validation and delivery-slot windows live elsewhere and must not be bypassed. |
|
||||
|
||||
Must honour the dispatch reconcile rule (root `CLAUDE.md` §4) and the
|
||||
notify-rider-after-mutation rule (§9) inside the executor, so the assistant
|
||||
can't become a backdoor around either.
|
||||
|
||||
---
|
||||
|
||||
## L7 — Proactive / watch
|
||||
|
||||
Stops waiting to be asked. A watch loop evaluates threshold conditions and
|
||||
pushes into the thread plus a badge on the trigger.
|
||||
|
||||
- "14 morning-batch orders unassigned, 30 minutes to cutoff"
|
||||
- "Rider offline mid-route"
|
||||
- "Hub with zero active riders"
|
||||
|
||||
**Dependency:** the notification panel in `AppTopNav` is currently static
|
||||
scaffolding, not wired to a real alert stream.
|
||||
|
||||
---
|
||||
|
||||
## L8 — LLM as parser only — BLOCKED
|
||||
|
||||
`CLAUDE.md` §2 records the decision: no backend, nowhere to hold a key. If
|
||||
that ever changes, the model does **slot extraction only** — text in, a
|
||||
validated Query out. It never produces a number, a row, or a sentence read as
|
||||
fact. Deterministic code still executes every fetch and every calculation, and
|
||||
the L2 parser becomes the fallback.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. **L0** — one day, removes the wrong-number risk
|
||||
2. **L4's delay detection** — highest value per unit of work, no new endpoints
|
||||
3. **L2** — multiplies coverage, deletes code
|
||||
4. **L3** — the 24 unused read functions
|
||||
5. **L5** — makes answers actionable
|
||||
6. **L6/L7** — only after sign-off
|
||||
203
src/components/assistant/actions.js
Normal file
203
src/components/assistant/actions.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createTenantCustomer } from 'pages/api/doormileApi';
|
||||
|
||||
// ==============================|| Doormile AI — write actions ||============================== //
|
||||
//
|
||||
// The FIRST write capability in the assistant. Read the rules before adding
|
||||
// another one.
|
||||
//
|
||||
// The contract (assistant/CLAUDE.md §4): an intent never mutates. It returns a
|
||||
// PROPOSAL — the exact payload it would submit — and nothing reaches the API
|
||||
// until the operator presses Create in the panel. Parsing, validation and
|
||||
// execution are separated here so the proposal can be built, shown and
|
||||
// discarded without any possibility of a request going out.
|
||||
//
|
||||
// Customer creation was chosen as the first write deliberately: it needs two
|
||||
// required fields where an order needs fourteen, it has no CityGate pincode
|
||||
// gate, no geocoding and no delivery slot, and a wrong record is an edit
|
||||
// rather than a rider dispatched to the wrong address.
|
||||
|
||||
// "create a customer", "add new client" — an explicit verb + noun. Deliberately
|
||||
// narrow: nothing here should fire on a question that merely mentions customers.
|
||||
export const CREATE_CUSTOMER_TRIGGER = /\b(?:create|add|register|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:customer|client)\b/i;
|
||||
|
||||
// Words that are part of the instruction rather than the person's name.
|
||||
const FILLER =
|
||||
/\b(?:create|add|register|new|a|an|the|customer|client|named|called|with|phone|number|mobile|contact|no|email|id|please)\b/gi;
|
||||
|
||||
// Pulls what it can out of free text. Anything it can't find stays undefined
|
||||
// and is asked for — never guessed.
|
||||
export const parseCustomerDraft = (text) => {
|
||||
const raw = String(text || '');
|
||||
|
||||
const email = (raw.match(/[\w.+-]+@[\w-]+\.[\w.]{2,}/) || [])[0];
|
||||
|
||||
// Exactly ten digits, standalone. A longer run is not a phone number and
|
||||
// must not be silently truncated into one.
|
||||
const phone = (raw.match(/(?<!\d)(\d{10})(?!\d)/) || [])[1];
|
||||
|
||||
// Whatever is left after removing the instruction, the phone and the email
|
||||
// is the person's name.
|
||||
let nameArea = raw;
|
||||
if (email) nameArea = nameArea.replace(email, ' ');
|
||||
if (phone) nameArea = nameArea.replace(phone, ' ');
|
||||
const nameWords = nameArea
|
||||
.replace(FILLER, ' ')
|
||||
.replace(/[^A-Za-z .'-]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 1);
|
||||
|
||||
return {
|
||||
firstname: nameWords[0],
|
||||
lastname: nameWords.slice(1).join(' ') || undefined,
|
||||
phone,
|
||||
email
|
||||
};
|
||||
};
|
||||
|
||||
// Mirrors createCustomer.js's own checks — a name, and a phone of exactly ten
|
||||
// digits. If the form would refuse it, the assistant refuses it too, rather
|
||||
// than letting the server decide.
|
||||
//
|
||||
// No tenant check: a customer record carries no tenantid, and the documented
|
||||
// POST body doesn't take one. The page's own `isStaffLogin && !tid` guard
|
||||
// exists because its dropdown sends a speculative tenantid; the assistant
|
||||
// doesn't send one at all.
|
||||
export const validateCustomerDraft = (draft) => {
|
||||
const missing = [];
|
||||
if (!draft.firstname) missing.push('the customer’s name');
|
||||
if (!draft.phone || !/^\d{10}$/.test(String(draft.phone))) missing.push('a 10-digit mobile number');
|
||||
return { ok: missing.length === 0, missing };
|
||||
};
|
||||
|
||||
// The exact body that will be POSTed.
|
||||
//
|
||||
// Documented body for POST /admin/tenantcustomers is
|
||||
// { firstname, lastname, phone, email } — and a customer record carries NO
|
||||
// tenantid, which is why the tenant field was removed. A real
|
||||
// GET /admin/customers response does carry address, doorno, landmark, suburb,
|
||||
// city, state, postcode, latitude and longitude, so those are sent on a
|
||||
// best-effort basis: empty strings are dropped rather than sent as noise, and
|
||||
// if the server ignores the rest nothing breaks.
|
||||
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
||||
|
||||
export const buildCustomerPayload = (draft) =>
|
||||
clean({
|
||||
firstname: draft.firstname,
|
||||
lastname: draft.lastname || '',
|
||||
phone: draft.phone,
|
||||
email: draft.email || '',
|
||||
address: draft.address,
|
||||
doorno: draft.doorno,
|
||||
landmark: draft.landmark,
|
||||
suburb: draft.suburb,
|
||||
city: draft.city,
|
||||
state: draft.state,
|
||||
postcode: draft.postcode,
|
||||
latitude: draft.latitude,
|
||||
longitude: draft.longitude
|
||||
});
|
||||
|
||||
// The ONLY function in the assistant that mutates anything. Called exclusively
|
||||
// from the panel's confirm handler — never from an intent's run().
|
||||
//
|
||||
// ---- Which endpoint, and why -----------------------------------------------
|
||||
//
|
||||
// Writes to POST /admin/tenantcustomers. This is now settled by evidence, not
|
||||
// by reading the docs:
|
||||
//
|
||||
// POST /admin/customers → 405 Method Not Allowed (confirmed live)
|
||||
//
|
||||
// 405 is the unambiguous answer: the route exists, and POST is not among its
|
||||
// methods. express-console-api.md lists /admin/customers as GET + PATCH only,
|
||||
// and the server agrees. It was pointed there briefly on explicit instruction;
|
||||
// the live 405 settled it.
|
||||
//
|
||||
// The consequence, which the assistant states in its success message rather
|
||||
// than leaving the operator to discover: a customer created here does NOT
|
||||
// appear on the Customers page, because that page reads GET /admin/customers.
|
||||
// On that resource a customer comes into existence as a side effect of a
|
||||
// booking — POST /admin/expressbooking documents `customer_phone` as "creates a
|
||||
// Guest customer if unknown". A B2C customer is, by design, someone who has
|
||||
// ordered.
|
||||
//
|
||||
// To make created customers visible on that page, one of these has to happen:
|
||||
// • the Customers page reads /admin/tenantcustomers (tried once, reverted —
|
||||
// it changes what that page means, and its edit dialog would then PATCH a
|
||||
// different store by id), or
|
||||
// • the backend adds POST /admin/customers.
|
||||
//
|
||||
// The payload keeps the address fields. The documented body is
|
||||
// { firstname, lastname, phone, email }; the rest are sent best-effort and
|
||||
// ignored if unsupported.
|
||||
export const executeCreateCustomer = async (payload) => {
|
||||
const started = Date.now();
|
||||
const call = {
|
||||
name: 'createTenantCustomer',
|
||||
target: 'POST /admin/tenantcustomers',
|
||||
stats: Object.keys(payload).join(', ')
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await createTenantCustomer(payload);
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
|
||||
// doormileApi mutations return the full envelope, so a `success: false`
|
||||
// arrives as a resolved promise, not a rejection.
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server rejected the customer.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message || 'Rejected' }]
|
||||
};
|
||||
}
|
||||
|
||||
const created = res?.data || res;
|
||||
return {
|
||||
ok: true,
|
||||
id: created?.appcustomerid ?? created?.customerid ?? created?.id,
|
||||
created,
|
||||
message: 'Customer created.',
|
||||
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created id ${created?.appcustomerid ?? created?.id ?? '—'}` }]
|
||||
};
|
||||
} catch (err) {
|
||||
// An HTTP failure used to throw straight past this function, and the panel
|
||||
// printed a generic "could not be created" with the status thrown away —
|
||||
// which is the one detail needed to tell "the route does not exist" apart
|
||||
// from "the body was wrong". Report the status, the server's own message,
|
||||
// and name the endpoint.
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
// doormileAxios rejects with the response BODY, not the axios error, so
|
||||
// `err.response` is undefined here — the status arrives as `err.httpStatus`.
|
||||
const status = err.httpStatus ?? err.response?.status;
|
||||
const serverMessage = err.message || err.error;
|
||||
|
||||
let message;
|
||||
if (status === 405) {
|
||||
// 405 is "the route exists but not this method" — a different fact from
|
||||
// 404, and worth stating precisely so nobody re-tries the same call.
|
||||
message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.';
|
||||
} else if (status === 404) {
|
||||
message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.';
|
||||
} else if (status === 400 || status === 422) {
|
||||
message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
} else if (status) {
|
||||
message = `POST /admin/tenantcustomers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
} else {
|
||||
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
message,
|
||||
sourceCalls: [
|
||||
{
|
||||
...call,
|
||||
duration,
|
||||
status: 'error',
|
||||
errorMessage: `${status || 'network'}${serverMessage ? ` · ${serverMessage}` : ''}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
252
src/components/assistant/assignActions.js
Normal file
252
src/components/assistant/assignActions.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import { getMilers, assignMilerToBooking } from 'pages/api/doormileApi';
|
||||
import { buildMilerLookup, notifyRider } from 'pages/api/api';
|
||||
|
||||
// ==============================|| Doormile AI — assigning a rider ||============================== //
|
||||
//
|
||||
// Fourth write capability. Two endpoints, and which one is correct depends
|
||||
// entirely on how many orders are being assigned:
|
||||
//
|
||||
// ONE order → POST /admin/bookings/:id/assign-miler
|
||||
// MANY orders → POST /hub/bookings/batch-assign
|
||||
//
|
||||
// These are NOT interchangeable. doormile-flow.md §4: batch-assign is "the only
|
||||
// place stops get ordered" — after assigning it sends each affected rider's
|
||||
// whole active set to the route optimiser and writes step, per-leg distance and
|
||||
// ETA back onto `bookingassignments`. Assigning ten orders with ten single
|
||||
// calls leaves every route unsequenced and riders choosing their own order.
|
||||
//
|
||||
// ---- The two rider IDs -----------------------------------------------------
|
||||
//
|
||||
// `assign-miler` takes a **mileruserid** in its body. `/admin/milers/:id/notify`
|
||||
// keys off a **milerprofileid**. Different identity spaces on adjacent
|
||||
// endpoints — doormile-flow.md calls this out by name. Getting it wrong fails
|
||||
// quietly in both directions: the assign 404s, or the rider is never told.
|
||||
// `buildMilerLookup` is the bridge, and it is the SAME one orders.js already
|
||||
// uses for exactly this translation. Don't grow a second lookup here.
|
||||
//
|
||||
// ---- Assignment is normally automatic --------------------------------------
|
||||
//
|
||||
// Creating a booking publishes `booking.assignment_requested`; a worker finds a
|
||||
// rider within 10km via Redis GEO, scores them, and commits — retrying 5 times,
|
||||
// 2 minutes apart (doormile-flow.md §3). So everything here is an OVERRIDE of a
|
||||
// decision the backend is already making, which is why the flow re-reads the
|
||||
// booking's current assignee before offering to change it.
|
||||
|
||||
export const ASSIGN_TRIGGER =
|
||||
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it|this|that|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
|
||||
|
||||
// Riders, plus the id bridge, in one call.
|
||||
export const loadRiders = async () => {
|
||||
const milers = (await getMilers()) || [];
|
||||
return { milers, lookup: buildMilerLookup(milers) };
|
||||
};
|
||||
|
||||
const riderName = (m) => m.displayname || m.authname || m.name || `Rider #${m.userid}`;
|
||||
|
||||
// Available riders first — an operator picking by hand wants the ones who can
|
||||
// actually take it at the top. Beyond that, alphabetical: any other ordering
|
||||
// (nearest, least loaded) would need position data this list doesn't carry, and
|
||||
// a proximity label nobody can stand behind is worse than none.
|
||||
const isAvailable = (m) => /avail|active|online|free/i.test(String(m.availabilitystatus || ''));
|
||||
|
||||
export const riderOptions = (milers) =>
|
||||
[...(milers || [])]
|
||||
.sort((a, b) => {
|
||||
const byAvail = Number(isAvailable(b)) - Number(isAvailable(a));
|
||||
return byAvail || riderName(a).localeCompare(riderName(b));
|
||||
})
|
||||
.map((m) => ({
|
||||
value: String(m.userid),
|
||||
label: [riderName(m), m.phone, m.defaultvehicletype, m.availabilitystatus || 'availability unknown'].filter(Boolean).join(' · '),
|
||||
record: m
|
||||
}));
|
||||
|
||||
// Who currently holds this booking, resolved to a person rather than an id.
|
||||
export const currentAssignee = (booking, lookup) =>
|
||||
booking?.assignedmileruserid ? lookup?.byUserId?.get(String(booking.assignedmileruserid)) || null : null;
|
||||
|
||||
export const describeRider = (m) => (m ? [riderName(m), m.phone].filter(Boolean).join(' · ') : null);
|
||||
|
||||
// ---- one order --------------------------------------------------------------
|
||||
export const executeAssign = async (booking, rider) => {
|
||||
const started = Date.now();
|
||||
const bookingLabel = booking?.bookingno || `#${booking?.bookingid}`;
|
||||
const call = {
|
||||
name: 'assignMilerToBooking',
|
||||
target: `POST /admin/bookings/${booking?.bookingid}/assign-miler`,
|
||||
stats: `${bookingLabel} → ${riderName(rider)}`
|
||||
};
|
||||
|
||||
try {
|
||||
// mileruserid, NOT milerprofileid. See the note at the top of this file.
|
||||
const res = await assignMilerToBooking(booking.bookingid, { mileruserid: Number(rider.userid) });
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server refused the assignment.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
|
||||
};
|
||||
}
|
||||
|
||||
const sourceCalls = [{ ...call, duration, status: 'complete' }];
|
||||
|
||||
// CLAUDE.md §9: any mutation that affects a rider is followed by a push.
|
||||
// It is deliberately NOT allowed to fail the assignment — the order IS
|
||||
// assigned at this point, and reporting otherwise would be a lie.
|
||||
// Whether the push ACTUALLY went out, not whether it could have been
|
||||
// attempted. This used to be reported as `Boolean(rider.milerprofileid)`
|
||||
// — i.e. "this rider has an id, so assume they were told" — which is a
|
||||
// different claim entirely. Caught live: notify returned 400 and the
|
||||
// assistant still said "The rider has been notified."
|
||||
//
|
||||
// The assignment itself is unaffected; it had already landed. But a
|
||||
// dispatcher who believes a rider was pinged does not follow up, and this
|
||||
// bot's whole contract is that it never states something it has not
|
||||
// confirmed.
|
||||
let notified = false;
|
||||
|
||||
if (rider.milerprofileid) {
|
||||
try {
|
||||
await notifyRider(rider.milerprofileid);
|
||||
notified = true;
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
|
||||
status: 'complete',
|
||||
stats: 'rider notified'
|
||||
});
|
||||
} catch (err) {
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
|
||||
status: 'error',
|
||||
errorMessage: err.message || 'notification failed'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No profile id means no push is possible — say so rather than letting
|
||||
// the operator assume the rider's phone buzzed.
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: '/admin/milers/:id/notify',
|
||||
status: 'error',
|
||||
errorMessage: 'this rider has no milerprofileid, so no notification could be sent'
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, rider, bookingLabel, notified, sourceCalls };
|
||||
} catch (err) {
|
||||
// doormileAxios rejects with the response BODY; the status rides on
|
||||
// err.httpStatus.
|
||||
const status = err.httpStatus;
|
||||
const message = status
|
||||
? `POST /admin/bookings/${booking?.bookingid}/assign-miler returned ${status}${
|
||||
err.message ? ` — ${err.message}` : ''
|
||||
}. Nothing changed.`
|
||||
: `${err.message || 'The request failed'} — nothing changed.`;
|
||||
return {
|
||||
ok: false,
|
||||
message,
|
||||
sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ---- repeat a run, keeping each order with the rider who ran it last --------
|
||||
//
|
||||
// A repeated run is the SAME drops to the SAME doors. The rider who did them
|
||||
// yesterday already knows the buzzer, the gate code and which side of the
|
||||
// building to park on, so re-deriving an assignment from scratch throws away
|
||||
// the one piece of routing knowledge the previous day produced.
|
||||
//
|
||||
// Deliberately built on /admin/bookings/:id/assign-miler, one call per order,
|
||||
// rather than the hub batch endpoint: batch-assign lets the BACKEND choose
|
||||
// riders, which is the opposite of the intent here, and it is refused to every
|
||||
// non-hub login anyway (403, confirmed live).
|
||||
//
|
||||
// The trade-off this accepts: assigning individually does not sequence a
|
||||
// rider's stops. Yesterday's run was already sequenced for these same drops,
|
||||
// so the ordering is not arbitrary — but it is not recomputed either, and the
|
||||
// caller states that rather than implying a fresh optimisation.
|
||||
export const executeRepeatAssign = async (createdPairs, rows) => {
|
||||
const started = Date.now();
|
||||
|
||||
// Only rows whose source order actually had a rider. A blank one is not a
|
||||
// failure — yesterday's copy was never assigned either.
|
||||
const targets = (createdPairs || [])
|
||||
.map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null }))
|
||||
.filter((t) => t.mileruserid != null);
|
||||
|
||||
if (!targets.length) {
|
||||
return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] };
|
||||
}
|
||||
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
const assignedRiders = new Set();
|
||||
const failures = [];
|
||||
let assigned = 0;
|
||||
|
||||
// Sequential on purpose. These are writes against real dispatch records, and
|
||||
// firing a burst of them concurrently makes a partial failure much harder to
|
||||
// report accurately — which order did not land, and to whom.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const t of targets) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
|
||||
assigned += 1;
|
||||
assignedRiders.add(String(t.mileruserid));
|
||||
} catch (err) {
|
||||
failures.push({ bookingid: t.bookingid, reason: err.message || `HTTP ${err.httpStatus || '?'}` });
|
||||
}
|
||||
}
|
||||
|
||||
const sourceCalls = [
|
||||
{
|
||||
name: 'assignMilerToBooking',
|
||||
target: 'POST /admin/bookings/:id/assign-miler',
|
||||
duration: `${Date.now() - started}ms`,
|
||||
status: failures.length ? 'error' : 'complete',
|
||||
stats: `${assigned} of ${targets.length} re-assigned to yesterday's rider`,
|
||||
errorMessage: failures.length ? `${failures.length} could not be assigned` : undefined
|
||||
}
|
||||
];
|
||||
|
||||
// One push per rider, not per order — a rider getting ten of yesterday's
|
||||
// drops back should feel one buzz, not ten.
|
||||
let notified = 0;
|
||||
if (lookup) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const userid of assignedRiders) {
|
||||
const rider = lookup.byUserId.get(userid);
|
||||
if (rider?.milerprofileid) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await notifyRider(rider.milerprofileid);
|
||||
notified += 1;
|
||||
} catch {
|
||||
// Notification failure never fails the assignment — the order IS
|
||||
// assigned by this point. It is reported, not swallowed.
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: 'POST /admin/milers/:id/notify',
|
||||
status: notified === assignedRiders.size ? 'complete' : 'error',
|
||||
stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: assigned > 0,
|
||||
assigned,
|
||||
skipped: (createdPairs || []).length - targets.length,
|
||||
failures,
|
||||
notified,
|
||||
riders: assignedRiders.size,
|
||||
sourceCalls
|
||||
};
|
||||
};
|
||||
200
src/components/assistant/assignFlow.js
Normal file
200
src/components/assistant/assignFlow.js
Normal file
@@ -0,0 +1,200 @@
|
||||
import { scanBookings } from './intents';
|
||||
import { getStatusMeta } from 'themes/dt/status';
|
||||
import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational assign ||============================== //
|
||||
//
|
||||
// Reached two ways, and the difference is only what the draft is seeded with:
|
||||
//
|
||||
// • straight after creating an order — the panel seeds `booking`, so the
|
||||
// first question is already about a known order
|
||||
// • "assign a rider to DM-BK-…" — the operator names it, and the first step
|
||||
// resolves that reference against the real booking list
|
||||
//
|
||||
// ---- Why there is a "keep or replace" step ---------------------------------
|
||||
//
|
||||
// The backend assigns riders BY ITSELF within seconds of creation and keeps
|
||||
// retrying for ten minutes (doormile-flow.md §3). By the time an operator
|
||||
// answers a dropdown, a rider may already hold the order — one the backend
|
||||
// chose on proximity, which is information this list does not have.
|
||||
//
|
||||
// So the flow re-reads the booking's current assignee and, if there is one,
|
||||
// asks before replacing them. Silently overwriting would throw away a better
|
||||
// decision and strand a rider who has already been told the job is theirs.
|
||||
|
||||
export const ASSIGN_STEPS = [
|
||||
{
|
||||
id: 'bookingno',
|
||||
// A SELECT, not a text field.
|
||||
//
|
||||
// This used to ask "Give me its number — for example DM-BK-0D915D43-33705"
|
||||
// and wait for it to be typed. Two problems with that. An operator does not
|
||||
// know the number by heart, so the question sent them to another screen to
|
||||
// go and read one. And it was the fallback reached whenever a create did
|
||||
// not hand back a booking id — so the moment the API response shape was
|
||||
// anything other than expected, "assign the order I just made" turned into
|
||||
// "recite a 20-character reference".
|
||||
//
|
||||
// A list removes the failure mode rather than patching it: unassigned
|
||||
// orders first, because those are the ones anyone is here to assign.
|
||||
//
|
||||
// `resolve` below is kept, so typing a number still works — the engine runs
|
||||
// it for a picked option too, and it already matches on bookingid.
|
||||
type: 'select',
|
||||
ask: 'Which order should I assign?',
|
||||
// The list is built from a live scan, so it can come back empty — an API
|
||||
// failure, or genuinely no bookings. `resolve` below still accepts a typed
|
||||
// number, so say that rather than leaving Cancel as the only way out.
|
||||
emptyHint: 'I couldn’t load the order list. Type the order number instead — for example DM-BK-0D915D43-33705.',
|
||||
// Seeded by the panel when this follows a create, so it is skipped there —
|
||||
// that path goes straight to the rider list.
|
||||
when: (d) => !d.booking,
|
||||
options: async () => {
|
||||
const scan = await scanBookings();
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
|
||||
return [...(scan.rows || [])]
|
||||
.map((b) => ({ b, holder: lookup ? currentAssignee(b, lookup) : null }))
|
||||
// Unassigned first; the API already returns newest-first, and that
|
||||
// order is preserved within each group by a stable sort.
|
||||
.sort((x, y) => Number(Boolean(x.holder)) - Number(Boolean(y.holder)))
|
||||
// A dropdown is for picking, not for browsing. Past this many the
|
||||
// operator is better served by naming the order.
|
||||
.slice(0, 30)
|
||||
.map(({ b, holder }) => ({
|
||||
value: String(b.bookingid),
|
||||
label: [
|
||||
b.bookingno || `#${b.bookingid}`,
|
||||
// A raw booking carries `status`; `orderstatus` is the mapped
|
||||
// field the LIST pages add. Reading the wrong one made every
|
||||
// option in this dropdown say "Unknown" — seen live.
|
||||
getStatusMeta(b.status ?? b.orderstatus).label,
|
||||
holder ? `held by ${describeRider(holder)}` : 'unassigned'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}));
|
||||
},
|
||||
resolve: async (raw) => {
|
||||
const needle = String(raw || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!needle) return { error: 'I need an order number.' };
|
||||
|
||||
const scan = await scanBookings();
|
||||
|
||||
// EXACT matches win across the whole list before any fuzzy one is
|
||||
// considered. The old version tested all three conditions per row inside
|
||||
// a single find(), so row ORDER decided the winner: a row whose
|
||||
// bookingno merely CONTAINED the needle could match before the row whose
|
||||
// bookingid actually equalled it.
|
||||
//
|
||||
// That is not theoretical. Picking from the dropdown sends a bare
|
||||
// numeric bookingid as the answer, and every bookingno ends in a digit
|
||||
// run — so "32143" substring-matched DM-BK-81DFAF19-32143 while some
|
||||
// other booking genuinely had id 32143. The assignment then went to a
|
||||
// different order than the one on screen, which reads as "it said it
|
||||
// assigned but nothing updated".
|
||||
const exact = scan.rows.find(
|
||||
(b) => String(b.bookingid) === needle || String(b.bookingno || '').toLowerCase() === needle
|
||||
);
|
||||
|
||||
// Substring is a convenience for someone typing part of a reference, so
|
||||
// it needs enough characters to identify one order. Below this it is
|
||||
// guesswork — "1" would match most of the list.
|
||||
const MIN_FUZZY = 4;
|
||||
const found =
|
||||
exact ||
|
||||
(needle.length >= MIN_FUZZY
|
||||
? scan.rows.find((b) => String(b.bookingno || '').toLowerCase().includes(needle))
|
||||
: null);
|
||||
if (!found) {
|
||||
return {
|
||||
error: scan.truncated
|
||||
? `I couldn't find ${raw} in the most recent ${scan.scanned.toLocaleString(
|
||||
'en-IN'
|
||||
)} bookings. It may be further back than I can scan.`
|
||||
: `I couldn't find an order matching ${raw}. Check the number and try again.`
|
||||
};
|
||||
}
|
||||
// Resolve the current holder HERE, not afterwards. advanceFlow evaluates
|
||||
// keepOrReplace's `when` the instant this step is applied — a lookup that
|
||||
// lands even one tick later means the step is skipped and an
|
||||
// already-assigned order is silently reassigned.
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
return { value: { booking: found, currentRider: lookup ? currentAssignee(found, lookup) : null } };
|
||||
},
|
||||
apply: (d, v) => ({ ...d, booking: v.booking, __currentRider: v.currentRider })
|
||||
},
|
||||
{
|
||||
id: 'keepOrReplace',
|
||||
type: 'select',
|
||||
// The question text is rewritten by the panel to name the current holder;
|
||||
// this is the fallback if that lookup came back empty.
|
||||
ask: 'This order already has a rider. Keep them, or assign someone else?',
|
||||
when: (d) => Boolean(d.__currentRider),
|
||||
options: async (d) => [
|
||||
{ value: 'keep', label: `Keep ${describeRider(d.__currentRider) || 'the current rider'}` },
|
||||
{ value: 'replace', label: 'Assign someone else' }
|
||||
],
|
||||
apply: (d, v) => ({ ...d, keepOrReplace: v, __keep: v === 'keep' })
|
||||
},
|
||||
{
|
||||
id: 'mileruserid',
|
||||
type: 'select',
|
||||
ask: 'Which rider should take it?',
|
||||
when: (d) => !d.__keep,
|
||||
options: async () => {
|
||||
const { milers } = await loadRiders();
|
||||
return riderOptions(milers);
|
||||
},
|
||||
// Resolved rather than taken straight from the clicked option.
|
||||
//
|
||||
// `apply` used to read `option?.record`, which is only populated when a
|
||||
// button was CLICKED. Answer this step by typing — a rider's name, or an
|
||||
// id — and option is undefined, so __rider was undefined, and executeAssign
|
||||
// then threw on `rider.userid` inside its try/catch. The operator saw a
|
||||
// failed assignment with "Cannot read properties of undefined" instead of
|
||||
// an answer.
|
||||
//
|
||||
// Resolving here means both paths produce a real miler record, and a name
|
||||
// that matches nobody gets a sentence rather than a crash.
|
||||
resolve: async (raw) => {
|
||||
const needle = String(raw || '').trim();
|
||||
if (!needle) return { error: 'I need a rider.' };
|
||||
|
||||
const { milers, lookup } = await loadRiders();
|
||||
const rider =
|
||||
lookup?.byUserId?.get(needle) ||
|
||||
lookup?.byName?.get(needle.toLowerCase()) ||
|
||||
(milers || []).find((m) => String(m.userid) === needle) ||
|
||||
(milers || []).find((m) => (m.displayname || m.authname || '').toLowerCase() === needle.toLowerCase());
|
||||
|
||||
if (!rider) return { error: `I couldn't find a rider matching ${raw}. Pick one from the list.` };
|
||||
// Both ids travel: assign-miler needs `userid`, the push needs
|
||||
// `milerprofileid`, and they are different fields on the same record.
|
||||
return { value: { mileruserid: String(rider.userid), rider } };
|
||||
},
|
||||
apply: (d, v) => ({ ...d, mileruserid: v.mileruserid, __rider: v.rider })
|
||||
}
|
||||
];
|
||||
|
||||
// `booking` is optional — present when this follows a create.
|
||||
export const startAssignFlow = async (booking) => {
|
||||
const draft = booking ? { booking } : {};
|
||||
if (booking) {
|
||||
// Resolve who holds it right now, so the keep/replace step knows whether to
|
||||
// ask at all. A failure here degrades to "nobody assigned yet", which is
|
||||
// the safe direction: the operator is asked to choose rather than being
|
||||
// told something untrue about the current rider.
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
const held = lookup ? currentAssignee(booking, lookup) : null;
|
||||
if (held) draft.__currentRider = held;
|
||||
}
|
||||
return startFlow(ASSIGN_STEPS, 'assignRider', draft);
|
||||
};
|
||||
|
||||
export const advanceAssign = (flow) => advanceFlow(ASSIGN_STEPS, flow);
|
||||
|
||||
export const answerAssignStep = (flow, raw, option) => answerFlowStep(ASSIGN_STEPS, flow, raw, option);
|
||||
223
src/components/assistant/bulkFile.js
Normal file
223
src/components/assistant/bulkFile.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import Papa from 'papaparse';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from 'utils/bulkOrderColumns';
|
||||
|
||||
// ==============================|| Doormile AI — bulk order file upload ||============================== //
|
||||
//
|
||||
// Turns a CSV / XLS / XLSX into the SAME row array `parseBulkRows` produces from
|
||||
// a paste, so everything downstream — geocoding, validation, review, the chunked
|
||||
// submit, the per-row outcome report — is untouched by where the rows came from.
|
||||
//
|
||||
// Both parsers are already dependencies (`papaparse`, `xlsx`) and both are the
|
||||
// ones multipleOrders.js uses, as is the column map. A sheet that uploads on
|
||||
// that page uploads here.
|
||||
//
|
||||
// Three reporting rules, all of them about not lying by omission:
|
||||
//
|
||||
// • An unparseable row becomes a REPORTED error with its line number, never a
|
||||
// silently skipped line. A bulk import that quietly drops row 14 is worse
|
||||
// than one that refuses outright.
|
||||
// • Columns that were not recognised are NAMED. An operator whose price
|
||||
// column is titled something unexpected has to be told it was ignored, or
|
||||
// they'll submit 200 orders priced from a column nothing ever read.
|
||||
// • Rows duplicated inside the file are flagged BEFORE submit. The bulk
|
||||
// endpoint has no idempotency key, so a duplicate that gets through is a
|
||||
// second real rider dispatched to the same door.
|
||||
|
||||
const CSV_EXT = /\.csv$/i;
|
||||
const EXCEL_EXT = /\.xlsx?$/i;
|
||||
|
||||
const digits = (v) => String(v ?? '').replace(/\D/g, '');
|
||||
const text = (v) => String(v ?? '').trim();
|
||||
|
||||
// A sheet cell can be a number, a date, or padded text — normalise to the same
|
||||
// shapes parseBulkRows yields so validateBulkRow behaves identically.
|
||||
const shapeRow = (raw, line) => {
|
||||
const { row, ignored } = mapSheetRow(raw);
|
||||
|
||||
const lat = Number(row.deliverylatitude);
|
||||
const lng = Number(row.deliverylongitude);
|
||||
const hasCoords = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0;
|
||||
|
||||
return {
|
||||
ignored,
|
||||
row: {
|
||||
line,
|
||||
customer_name: text(row.customer_name),
|
||||
// A 10-digit Indian mobile arrives as 9812345678, 09812345678, +91
|
||||
// 98123 45678, or — from Excel — 9812345678 as a float. Strip to digits
|
||||
// and drop a leading country/trunk prefix the same way the single-order
|
||||
// flow does.
|
||||
customer_phone: digits(row.customer_phone).replace(/^(?:0|91)(?=\d{10}$)/, ''),
|
||||
deliveryaddress: text(row.deliveryaddress),
|
||||
deliverypincode: digits(row.deliverypincode),
|
||||
deliverycity: text(row.deliverycity),
|
||||
// Blank is meaningful: it means "quote this row from the tenant's pricing
|
||||
// row and the routed distance", the same as the single-order flow. It is
|
||||
// NOT zero.
|
||||
finalprice: text(row.finalprice),
|
||||
itemdescription: text(row.itemdescription) || 'Order',
|
||||
itemcategory: text(row.itemcategory) || 'General',
|
||||
quantity: Math.max(1, Number(row.quantity) || 1),
|
||||
weight: text(row.weight),
|
||||
// Coordinates from the sheet let the geocode pass skip this row entirely,
|
||||
// which on a 200-row file is the difference between minutes and seconds.
|
||||
...(hasCoords ? { deliverylatitude: lat, deliverylongitude: lng, resolvedAddress: text(row.deliveryaddress) } : {})
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Structural check only — enough to know the row is worth geocoding. The full
|
||||
// gate is validateBulkRow, applied after coordinates exist.
|
||||
const structuralError = (row) => {
|
||||
if (!row.customer_name) return 'No receiver name';
|
||||
if (!row.deliveryaddress) return 'No delivery address';
|
||||
if (!row.customer_phone) return 'No phone number';
|
||||
if (row.finalprice !== '' && Number.isNaN(Number(row.finalprice))) return `Price "${row.finalprice}" is not a number`;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mapSheetRecords = (records, headers, sheetName) => {
|
||||
const rows = [];
|
||||
const errors = [];
|
||||
const ignoredColumns = new Set();
|
||||
|
||||
records.forEach((raw, i) => {
|
||||
// +2: the header row is line 1, so the first data row is line 2 — the line
|
||||
// number an operator sees in their own spreadsheet.
|
||||
const line = i + 2;
|
||||
const { row, ignored } = shapeRow(raw, line);
|
||||
ignored.forEach((c) => ignoredColumns.add(c));
|
||||
|
||||
// A trailing blank row is an artefact of the file, not an operator error.
|
||||
if (!row.customer_name && !row.deliveryaddress && !row.customer_phone) return;
|
||||
|
||||
const error = structuralError(row);
|
||||
if (error) errors.push({ line, text: row.customer_name || row.deliveryaddress || `Row ${line}`, reason: error });
|
||||
else rows.push(row);
|
||||
});
|
||||
|
||||
const normalised = headers.map(normalizeHeader);
|
||||
const missingRequired = requiredSheetColumns().filter((c) => !normalised.includes(normalizeHeader(c)));
|
||||
|
||||
return {
|
||||
rows,
|
||||
errors,
|
||||
sheetName,
|
||||
ignoredColumns: [...ignoredColumns],
|
||||
// Reported, not enforced: the page only warns about these too, and a
|
||||
// hand-built sheet using plain headers ("name", "phone") legitimately has
|
||||
// none of the tenant's official titles while still being complete.
|
||||
missingRequired,
|
||||
recognisedColumns: headers.filter((h) => rowFieldForHeader(h)).map((h) => String(h).trim()),
|
||||
duplicates: findDuplicateRows(rows)
|
||||
};
|
||||
};
|
||||
|
||||
// Same recipient at the same address twice in one file. Reported, never removed
|
||||
// automatically — two parcels to one door is a legitimate order, and deciding
|
||||
// which is which is the operator's call, not the parser's.
|
||||
export const findDuplicateRows = (rows) => {
|
||||
const seen = new Map();
|
||||
const dupes = [];
|
||||
rows.forEach((r) => {
|
||||
const key = `${r.customer_phone}|${normalizeHeader(r.deliveryaddress)}`;
|
||||
if (seen.has(key)) dupes.push({ line: r.line, firstLine: seen.get(key), customer_name: r.customer_name });
|
||||
else seen.set(key, r.line);
|
||||
});
|
||||
return dupes;
|
||||
};
|
||||
|
||||
export const parseBulkFile = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
reject(new Error('No file selected.'));
|
||||
return;
|
||||
}
|
||||
const isCsv = CSV_EXT.test(file.name);
|
||||
const isExcel = EXCEL_EXT.test(file.name);
|
||||
if (!isCsv && !isExcel) {
|
||||
reject(new Error(`“${file.name}” isn’t a spreadsheet. Upload a .csv, .xls or .xlsx file.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCsv) {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
dynamicTyping: false,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (!results.data?.length) {
|
||||
reject(new Error('That CSV has a header row but no data rows.'));
|
||||
return;
|
||||
}
|
||||
resolve(mapSheetRecords(results.data, results.meta.fields || [], file.name));
|
||||
},
|
||||
error: (err) => reject(new Error(`Couldn’t read that CSV — ${err.message}`))
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Couldn’t read that file.'));
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const workbook = XLSX.read(e.target.result, { type: 'binary' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
// Only the first sheet is read, and the name is reported back so an
|
||||
// operator whose data sits on "Sheet2" can see which one was used.
|
||||
const records = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '', raw: false });
|
||||
if (!records?.length) {
|
||||
reject(new Error(`Sheet “${sheetName}” is empty.`));
|
||||
return;
|
||||
}
|
||||
resolve(mapSheetRecords(records, Object.keys(records[0]), `${file.name} · ${sheetName}`));
|
||||
} catch (err) {
|
||||
reject(new Error(`Couldn’t read that spreadsheet — ${err.message}`));
|
||||
}
|
||||
};
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
|
||||
// ---- downloads --------------------------------------------------------------
|
||||
|
||||
// Hands the operator a file built from data they already supplied — a Blob
|
||||
// assembled in the page, not a fetch and not an upload.
|
||||
export const downloadCsv = (filename, csv) => {
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const toCsv = (headers, rows) =>
|
||||
[headers, ...rows]
|
||||
.map((r) => r.map((c) => (/[",\n]/.test(String(c ?? '')) ? `"${String(c).replace(/"/g, '""')}"` : String(c ?? ''))).join(','))
|
||||
.join('\r\n');
|
||||
|
||||
// A blank sheet with the exact headers this parser reads, so operators stop
|
||||
// guessing at column titles.
|
||||
export const templateCsv = () =>
|
||||
toCsv(TEMPLATE_HEADERS, [['Ravi Kumar', '9812345678', '12 Trichy Rd, Coimbatore', '641018', 'Coimbatore', 'Documents', '1', '']]);
|
||||
|
||||
// The rows that did NOT go through, in the same column shape, so they can be
|
||||
// fixed and re-uploaded. This is what makes a partial success recoverable
|
||||
// without re-submitting the rows that already landed.
|
||||
export const failedRowsCsv = (failed) =>
|
||||
toCsv(
|
||||
[...TEMPLATE_HEADERS, 'Reason'],
|
||||
failed.map((r) => [
|
||||
r.customer_name || '',
|
||||
r.customer_phone || '',
|
||||
r.deliveryaddress || '',
|
||||
r.deliverypincode || '',
|
||||
r.deliverycity || '',
|
||||
r.itemdescription || '',
|
||||
r.quantity ?? 1,
|
||||
r.finalprice ?? '',
|
||||
r.error || r.reason || 'Rejected'
|
||||
])
|
||||
);
|
||||
193
src/components/assistant/bulkFlow.js
Normal file
193
src/components/assistant/bulkFlow.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational bulk create ||============================== //
|
||||
//
|
||||
// Same conversation shape as orderFlow.js — one question per turn, a dropdown
|
||||
// wherever the page uses one — but the rows come from a sheet instead of being
|
||||
// dictated one field at a time. The form version was replaced on explicit
|
||||
// direction: "don't show it as the form way, it should be like chatting".
|
||||
//
|
||||
// The steps deliberately mirror the single-order flow's opening, because they
|
||||
// ARE the same questions: which tenant, which pickup location, which service.
|
||||
// Only the last step differs — a whole file instead of one recipient.
|
||||
//
|
||||
// What is NOT a step: locating and pricing the rows. Those are a long-running
|
||||
// pass over the whole file (~1 lookup/second), so the panel runs them after the
|
||||
// last answer and reports progress into the conversation. Making them a "step"
|
||||
// would mean a question nobody is being asked.
|
||||
|
||||
const isStaffLogin = () => {
|
||||
const t = localStorage.getItem('tenantid');
|
||||
return !t || t === '0';
|
||||
};
|
||||
|
||||
export const BULK_STEPS = [
|
||||
{
|
||||
id: 'tenantid',
|
||||
type: 'select',
|
||||
ask: 'Which tenant are these orders for?',
|
||||
when: () => isStaffLogin(),
|
||||
options: async () => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
},
|
||||
apply: (d, v) => ({ ...d, tenantid: v })
|
||||
},
|
||||
{
|
||||
id: 'pickuplocationid',
|
||||
type: 'select',
|
||||
// One pickup location for the whole file — the same shape the bulk page
|
||||
// uses, and what makes a single batch dispatchable.
|
||||
ask: 'Which business location are they all picked up from?',
|
||||
options: async (d) => {
|
||||
const tid = d.tenantid || localStorage.getItem('tenantid');
|
||||
const locations = (await getTenantLocations(tid)) || [];
|
||||
return locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
label: `${l.locationname || l.address || 'Location'}${
|
||||
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
|
||||
}`,
|
||||
record: l
|
||||
}));
|
||||
},
|
||||
// Refused here rather than after submitting: CityGate runs server-side
|
||||
// before the handler, and would reject every row in the file with an
|
||||
// opaque middleware error.
|
||||
validate: (v, option) =>
|
||||
cityGateFor(option?.record?.pincode)
|
||||
? null
|
||||
: `That location’s pincode (${
|
||||
option?.record?.pincode || 'unknown'
|
||||
}) is outside the cities Doormile serves, so every row would be refused. Pick another location.`,
|
||||
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
|
||||
},
|
||||
{
|
||||
id: 'service_option',
|
||||
type: 'select',
|
||||
ask: 'Which service level for all of them?',
|
||||
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
|
||||
apply: (d, v) => ({ ...d, service_option: v })
|
||||
},
|
||||
{
|
||||
id: 'rows',
|
||||
type: 'rows',
|
||||
ask: 'Now the orders themselves — upload a sheet, or paste the rows.',
|
||||
// The whole parse result is stored, not just the rows: the ignored columns
|
||||
// and in-file duplicates have to be reportable, and a count of rows alone
|
||||
// can't say what was quietly not read.
|
||||
validate: (parsed) =>
|
||||
parsed?.rows?.length
|
||||
? null
|
||||
: 'I couldn’t read any complete rows out of that. Every row needs at least a name, a phone and an address.',
|
||||
apply: (d, parsed) => ({ ...d, rows: parsed.rows, __parse: parsed })
|
||||
}
|
||||
];
|
||||
|
||||
export const startBulkFlow = () => {
|
||||
// Same seeding rule as the single-order flow: a client login skips the tenant
|
||||
// question, so the id has to be in the draft or the payload sends NaN.
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
return startFlow(BULK_STEPS, 'createBulk', tid && tid !== '0' ? { tenantid: tid } : {});
|
||||
};
|
||||
|
||||
export const advanceBulk = (flow) => advanceFlow(BULK_STEPS, flow);
|
||||
export const answerBulkStep = (flow, raw, option) => answerFlowStep(BULK_STEPS, flow, raw, option);
|
||||
|
||||
// ---- the long pass: locate, then price --------------------------------------
|
||||
//
|
||||
// Extracted from the old form so the conversation can run it and narrate it.
|
||||
// Two economies keep a large file practical, and both are load-bearing:
|
||||
//
|
||||
// • a sheet carrying latitude/longitude columns skips the lookup entirely
|
||||
// • results are cached by address, so a re-run after fixing a few rows does
|
||||
// not re-look-up the ones that were already fine
|
||||
//
|
||||
// `shouldStop` is read through a function, never a captured boolean — as state
|
||||
// it was evaluated once at call time and Stop did nothing for 200 rows.
|
||||
export const GEOCODE_INTERVAL_MS = 1100;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
export const cacheKey = (row) => `${String(row.deliveryaddress || '').toLowerCase()}|${row.deliverypincode || ''}`;
|
||||
export const hasCoords = (row) => Number.isFinite(Number(row.deliverylatitude)) && Number.isFinite(Number(row.deliverylongitude));
|
||||
|
||||
export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgress, shouldStop } = {}) => {
|
||||
const located = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
if (shouldStop?.()) break;
|
||||
const row = rows[i];
|
||||
|
||||
if (hasCoords(row)) {
|
||||
located.push(row);
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
const key = cacheKey(row);
|
||||
if (cache?.has(key)) {
|
||||
located.push({ ...row, ...cache.get(key) });
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
|
||||
const found = {
|
||||
deliverylatitude: place?.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place?.geometry?.location?.lng?.(),
|
||||
resolvedAddress: place?.formatted_address
|
||||
};
|
||||
cache?.set(key, found);
|
||||
located.push({ ...row, ...found });
|
||||
|
||||
// Only wait after a real request. A cache hit or a sheet coordinate costs
|
||||
// nothing, which is what makes a re-run fast.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Rows never reached because Stop was pressed keep no coordinates, so they
|
||||
// report as unsendable instead of vanishing from the count.
|
||||
if (located.length < rows.length) located.push(...rows.slice(located.length));
|
||||
|
||||
// Only a row that is located AND unpriced needs a routing call. Pricing an
|
||||
// unlocatable row spends an OSRM request just to fail, and re-pricing a row
|
||||
// that carried its own price would overwrite the operator's number.
|
||||
const needPricing = located.filter((r) => hasCoords(r) && String(r.finalprice ?? '') === '');
|
||||
|
||||
// Stop deliberately does NOT gate this phase. It exists to stop the ~1/second
|
||||
// ADDRESS lookups; pricing is unthrottled and bounded by what was already
|
||||
// located. Gating it here meant a Stop mid-lookup left every located row
|
||||
// unpriced and therefore unsendable — throwing away exactly the work the
|
||||
// operator is told is kept.
|
||||
const priced = needPricing.length
|
||||
? await priceBulkRows(needPricing, pickup, tenantid, { onProgress: (p) => onProgress?.({ ...p, phase: 'price' }) })
|
||||
: [];
|
||||
const pricedByLine = new Map(priced.map((r) => [r.line, r]));
|
||||
|
||||
const checked = located.map((r) => {
|
||||
const merged = pricedByLine.get(r.line) || r;
|
||||
return {
|
||||
...merged,
|
||||
// The pricing reason is more specific than "Price must be a number", so it
|
||||
// wins when both apply.
|
||||
error: merged.priceError ? `Couldn’t price it — ${merged.priceError}` : validateBulkRow(merged)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: checked,
|
||||
valid: checked.filter((r) => !r.error),
|
||||
invalid: checked.filter((r) => r.error)
|
||||
};
|
||||
};
|
||||
|
||||
// How many rows still need a network lookup — the only honest basis for an ETA.
|
||||
export const lookupsNeeded = (rows, cache) => rows.filter((r) => !hasCoords(r) && !cache?.has(cacheKey(r))).length;
|
||||
|
||||
export const batchCount = (n) => Math.ceil(n / BULK_MAX);
|
||||
299
src/components/assistant/bulkOrderActions.js
Normal file
299
src/components/assistant/bulkOrderActions.js
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createExpressBookingBulk, getAdminPricing } from 'pages/api/doormileApi';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from 'utils/distance';
|
||||
import { buildOrderPayload } from './orderActions';
|
||||
|
||||
// ==============================|| Doormile AI — bulk order creation ||============================== //
|
||||
//
|
||||
// Third write capability, and the highest-blast-radius one: a single press can
|
||||
// dispatch dozens of riders. Everything here is built around making that
|
||||
// visible BEFORE it happens and legible AFTER.
|
||||
//
|
||||
// Shared with the single-order path on purpose:
|
||||
// • buildOrderPayload — so a bulk row and a single order are byte-identical
|
||||
// on the wire, including the pickuplocationid workaround (that field 500s
|
||||
// server-side; raw pickup fields are sent instead).
|
||||
// • validateOrderDraft — the same gate, applied per row.
|
||||
|
||||
// Server cap, documented in express-console-api.md. Exceeding it is a hard
|
||||
// error rather than a silent truncation, so rows are chunked instead.
|
||||
export const BULK_MAX = 200;
|
||||
|
||||
export const CREATE_BULK_TRIGGER =
|
||||
/\b(?:create|add|place|book|new|bulk|multiple)\s+(?:multiple|many|several|bulk|\d+)\s*(?:orders|bookings|deliveries)\b|\bbulk\s+(?:order|booking|upload)\b|\bmultiple\s+orders\b/i;
|
||||
|
||||
// ---- Parsing a pasted list --------------------------------------------------
|
||||
//
|
||||
// Operators paste from a spreadsheet, so accept the shapes that actually
|
||||
// arrive: comma, tab or pipe separated, one row per line, with an optional
|
||||
// header row.
|
||||
//
|
||||
// name, phone, address, pincode, city, price, description
|
||||
//
|
||||
// Anything unparseable becomes a REPORTED row error rather than a silently
|
||||
// dropped line — a bulk import that quietly skips row 14 is worse than one
|
||||
// that refuses.
|
||||
const SPLIT = /\t|\||,(?![^(]*\))/;
|
||||
|
||||
const HEADER_HINT = /name|phone|mobile|address|pincode|city|price|amount|item|description/i;
|
||||
|
||||
export const parseBulkRows = (text) => {
|
||||
const lines = String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!lines.length) return { rows: [], errors: [] };
|
||||
|
||||
// Drop a header row only when it looks like one AND carries no phone number.
|
||||
const first = lines[0];
|
||||
const looksLikeHeader = HEADER_HINT.test(first) && !/\d{10}/.test(first);
|
||||
const body = looksLikeHeader ? lines.slice(1) : lines;
|
||||
|
||||
const rows = [];
|
||||
const errors = [];
|
||||
|
||||
body.forEach((line, i) => {
|
||||
const parts = line.split(SPLIT).map((p) => p.trim());
|
||||
const lineNo = (looksLikeHeader ? 2 : 1) + i;
|
||||
if (parts.length < 4) {
|
||||
errors.push({ line: lineNo, text: line, reason: 'Needs at least name, phone, address and pincode' });
|
||||
return;
|
||||
}
|
||||
const [customer_name, customer_phone, deliveryaddress, deliverypincode, deliverycity, finalprice, itemdescription] = parts;
|
||||
rows.push({
|
||||
line: lineNo,
|
||||
customer_name,
|
||||
customer_phone: String(customer_phone || '').replace(/\D/g, ''),
|
||||
deliveryaddress,
|
||||
deliverypincode: String(deliverypincode || '').replace(/\D/g, ''),
|
||||
deliverycity: deliverycity || '',
|
||||
finalprice: finalprice || '',
|
||||
itemdescription: itemdescription || 'Order',
|
||||
itemcategory: 'General',
|
||||
quantity: 1
|
||||
});
|
||||
});
|
||||
|
||||
return { rows, errors };
|
||||
};
|
||||
|
||||
// Per-row validation. Deliberately NOT validateOrderDraft: a pasted row has no
|
||||
// coordinates (there is no address search on a paste), and the single-order
|
||||
// gate requires them. Bulk rows are geocoded by the caller before submit, and
|
||||
// rows that fail to geocode are reported, not sent.
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
|
||||
export const validateBulkRow = (row) => {
|
||||
if (!row.customer_name) return 'Missing customer name';
|
||||
if (!PHONE_RE.test(row.customer_phone)) return 'Phone must be exactly 10 digits';
|
||||
if (!row.deliveryaddress) return 'Missing delivery address';
|
||||
if (!row.deliverypincode) return 'Missing delivery pincode';
|
||||
// Coordinates are checked BEFORE the price, because an unlocatable address is
|
||||
// the root cause and a blank price is its symptom — the row was never priced
|
||||
// precisely because there was nothing to route. Reporting "price must be a
|
||||
// number" here sent the operator to fix the wrong column.
|
||||
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
|
||||
return 'Address could not be located — the order could not be routed';
|
||||
}
|
||||
if (row.finalprice === '' || Number.isNaN(Number(row.finalprice))) return 'Price must be a number';
|
||||
return null;
|
||||
};
|
||||
|
||||
// ---- per-row pricing --------------------------------------------------------
|
||||
//
|
||||
// A blank price column means "quote it", exactly as the single-order flow does —
|
||||
// not zero. The tenant's pricing row is fetched ONCE for the whole file (fetching
|
||||
// it per row would be 200 identical requests), then each unpriced row costs one
|
||||
// OSRM call for its routed distance.
|
||||
//
|
||||
// A row that can't be priced keeps its blank price and carries the reason. It
|
||||
// then fails validateBulkRow and is reported, rather than being submitted at a
|
||||
// number nobody chose.
|
||||
export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, shouldStop } = {}) => {
|
||||
const pricing = (await getAdminPricing()) || [];
|
||||
// tenantid is numeric on the pricing row and a string from localStorage — a
|
||||
// strict comparison here silently priced every order at zero once before.
|
||||
const match = pricing.find((p) => String(p.tenantid) === String(tenantid));
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const row = rows[i];
|
||||
// A stop leaves the remaining rows exactly as they were — unpriced and
|
||||
// therefore invalid — instead of half-pricing the file.
|
||||
if (shouldStop?.()) {
|
||||
out.push(...rows.slice(i));
|
||||
break;
|
||||
}
|
||||
onProgress?.({ done: i, total: rows.length, current: row.customer_name || row.deliveryaddress });
|
||||
|
||||
if (String(row.finalprice ?? '') !== '') {
|
||||
out.push(row);
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
if (!match) {
|
||||
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const km = await calculateDrivingDistance(
|
||||
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
|
||||
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
|
||||
).catch(() => null);
|
||||
|
||||
if (km == null) {
|
||||
out.push({ ...row, priceError: 'could not measure the distance' });
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
|
||||
out.push({ ...row, finalprice: Number(Number(total).toFixed(2)), km, quoted: true });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---- double-submit protection ----------------------------------------------
|
||||
//
|
||||
// POST /admin/expressbooking/bulk takes no idempotency key, so if a submit times
|
||||
// out the operator cannot tell what landed — and re-sending the file double-books
|
||||
// every row that succeeded. Fingerprints of what has already been submitted this
|
||||
// session are kept so an identical re-submit can at least be questioned.
|
||||
//
|
||||
// Session-scoped on purpose: it guards the realistic accident (pressing Create
|
||||
// twice, or re-uploading the same file minutes later), not a next-day re-run,
|
||||
// which may be a legitimately repeated delivery round.
|
||||
const submitted = new Set();
|
||||
|
||||
export const rowSetFingerprint = (rows) =>
|
||||
(rows || [])
|
||||
.map((r) => `${r.customer_phone}|${r.deliverypincode}|${r.finalprice}`)
|
||||
.sort()
|
||||
.join(';');
|
||||
|
||||
export const wasAlreadySubmitted = (rows) => rows?.length > 0 && submitted.has(rowSetFingerprint(rows));
|
||||
|
||||
const chunk = (arr, size) => {
|
||||
const out = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
};
|
||||
|
||||
// The only bulk-writing function in the assistant.
|
||||
//
|
||||
// Returns a per-row outcome, never a bare success/failure. A partial success
|
||||
// is the normal case for a bulk import, and the operator has to be able to see
|
||||
// exactly which rows landed — otherwise the only safe response to any error is
|
||||
// to assume nothing worked and re-submit everything, which double-books.
|
||||
export const executeCreateBulk = async (rows, shared) => {
|
||||
const started = Date.now();
|
||||
// Recorded BEFORE the request, not after: a timed-out submit is the case that
|
||||
// most needs the warning, and it never reaches a success handler.
|
||||
submitted.add(rowSetFingerprint(rows));
|
||||
// A row may carry its OWN pickup. A bulk FILE shares one kitchen, but a
|
||||
// repeated day's run can span several tenants and locations — collapsing
|
||||
// those onto one shared pickup would silently re-address half the orders.
|
||||
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, r.__pickup ?? shared.__pickup));
|
||||
const batches = chunk(payloads, BULK_MAX);
|
||||
|
||||
const sourceCalls = [];
|
||||
let created = 0;
|
||||
const failures = [];
|
||||
// The ids of what actually landed, so the run can be handed straight to
|
||||
// batch-assign without a re-scan of /admin/bookings to find them again.
|
||||
const createdIds = [];
|
||||
// {index, bookingid} — the same ids, but each still tied to its source row.
|
||||
const createdPairs = [];
|
||||
|
||||
for (let b = 0; b < batches.length; b += 1) {
|
||||
const batch = batches[b];
|
||||
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await createExpressBookingBulk(batch);
|
||||
// Three shapes, most-nested first. The live endpoint returns
|
||||
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
|
||||
// — confirmed against api.doormile.com — and only the two flatter shapes
|
||||
// were checked here. So `res.data` was an object rather than an array,
|
||||
// `res.results` was undefined, perRow fell through to null, and the
|
||||
// whole run was treated as all-or-nothing: the count came out right by
|
||||
// accident (created += batch.length) while EVERY booking id was thrown
|
||||
// away. That is why "Assign the 13 you just created" never appeared —
|
||||
// there were no ids to offer.
|
||||
const perRow = Array.isArray(res?.data?.results)
|
||||
? res.data.results
|
||||
: Array.isArray(res?.data)
|
||||
? res.data
|
||||
: Array.isArray(res?.results)
|
||||
? res.results
|
||||
: null;
|
||||
|
||||
if (res?.success === false) {
|
||||
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason: res.message || 'Rejected' })));
|
||||
sourceCalls.push({
|
||||
name: 'createExpressBookingBulk',
|
||||
target: `POST /admin/expressbooking/bulk${label}`,
|
||||
status: 'error',
|
||||
errorMessage: res.message || 'Rejected'
|
||||
});
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
// The endpoint is documented as returning per-row results. If it does,
|
||||
// trust it row by row; if it doesn't, treat the batch as all-or-nothing
|
||||
// rather than inventing a success count.
|
||||
if (perRow) {
|
||||
perRow.forEach((r, i) => {
|
||||
const index = b * BULK_MAX + i;
|
||||
if (r?.success === false || r?.error) failures.push({ index, reason: r.message || r.error || 'Rejected' });
|
||||
else {
|
||||
created += 1;
|
||||
// Paired with the row it came from, not just collected. A bare list
|
||||
// of ids cannot say WHICH row produced which booking, and the
|
||||
// repeat flow needs exactly that to hand each new order back to the
|
||||
// rider who ran it last time.
|
||||
if (r?.bookingid) {
|
||||
createdIds.push(r.bookingid);
|
||||
createdPairs.push({ index, bookingid: r.bookingid });
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
created += batch.length;
|
||||
}
|
||||
|
||||
sourceCalls.push({
|
||||
name: 'createExpressBookingBulk',
|
||||
target: `POST /admin/expressbooking/bulk${label}`,
|
||||
status: 'complete',
|
||||
stats: `${batch.length} submitted`
|
||||
});
|
||||
} catch (err) {
|
||||
// doormileAxios rejects with the response BODY; the status is attached
|
||||
// as `err.httpStatus`.
|
||||
const status = err.httpStatus;
|
||||
const reason = err.message || 'Request failed';
|
||||
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason })));
|
||||
sourceCalls.push({
|
||||
name: 'createExpressBookingBulk',
|
||||
target: `POST /admin/expressbooking/bulk${label}`,
|
||||
status: 'error',
|
||||
errorMessage: `${status || 'network'} · ${reason}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: created > 0,
|
||||
created,
|
||||
// Empty when the endpoint returned no per-row array — the caller must treat
|
||||
// "no ids" as "cannot offer assignment", not as "nothing was created".
|
||||
createdIds,
|
||||
createdPairs,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
batches: batches.length,
|
||||
sourceCalls: sourceCalls.map((c) => ({ ...c, duration: `${Date.now() - started}ms` }))
|
||||
};
|
||||
};
|
||||
142
src/components/assistant/customerFlow.js
Normal file
142
src/components/assistant/customerFlow.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-customer ||============================== //
|
||||
//
|
||||
// Asks for one field at a time, then shows what it will send and waits for
|
||||
// Submit.
|
||||
//
|
||||
// ---- Why this lives here and not in the router ------------------------------
|
||||
//
|
||||
// A first attempt at this shipped and broke immediately: the operator typed
|
||||
// "create customer", was asked for a phone number, replied "8494948494", and
|
||||
// got "I can't answer that one yet".
|
||||
//
|
||||
// The cause was architectural, not a typo. `answerQuestion` picks an intent by
|
||||
// MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was
|
||||
// routed to the fallback and discarded. Threading a partial draft through the
|
||||
// router's `context` didn't help, because the router had already failed to
|
||||
// choose an intent before the draft was ever consulted.
|
||||
//
|
||||
// So the conversation is owned by the PANEL, which checks for an active flow
|
||||
// BEFORE calling the router at all. A reply mid-flow is never routed. That is
|
||||
// the only arrangement where "8494948494" can't be misread as a question.
|
||||
//
|
||||
// Field set and validation mirror pages/nearle/clients/createCustomer.js:
|
||||
// name and a 10-digit phone are required, everything else is optional and
|
||||
// skippable.
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
|
||||
|
||||
// "skip", "none", "no", "-" all mean "leave it blank". Without this the
|
||||
// operator has no way past an optional field except inventing a value.
|
||||
const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i;
|
||||
|
||||
export const isSkip = (text) => SKIP_RE.test(String(text || '').trim());
|
||||
|
||||
// Ordered. `ask` is the question; `apply` folds the answer into the draft;
|
||||
// `validate` returns an error string to re-ask with, or null to accept.
|
||||
export const CUSTOMER_STEPS = [
|
||||
{
|
||||
field: 'name',
|
||||
ask: 'What’s the customer’s name?',
|
||||
required: true,
|
||||
apply: (draft, text) => {
|
||||
const [firstname, ...rest] = String(text).trim().split(/\s+/);
|
||||
return { ...draft, firstname, lastname: rest.join(' ') || undefined };
|
||||
},
|
||||
validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.')
|
||||
},
|
||||
{
|
||||
field: 'phone',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
required: true,
|
||||
apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }),
|
||||
// Validated against the digits only, so "98765 43210" and "+91 9876543210"
|
||||
// are both accepted rather than rejected on formatting.
|
||||
validate: (text) => {
|
||||
const digits = String(text)
|
||||
.replace(/\D/g, '')
|
||||
.replace(/^91(?=\d{10}$)/, '');
|
||||
return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.';
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
ask: 'Email address? (say “skip” if there isn’t one)',
|
||||
apply: (draft, text) => ({ ...draft, email: String(text).trim() }),
|
||||
validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.')
|
||||
},
|
||||
{
|
||||
field: 'address',
|
||||
ask: 'Address? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, address: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'city',
|
||||
ask: 'City? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, city: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'postcode',
|
||||
ask: 'Postcode? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') })
|
||||
}
|
||||
];
|
||||
|
||||
// Starts the flow, pre-filling anything already said in the opening message —
|
||||
// "create a customer Ramesh 9876543210" should not then ask for the name and
|
||||
// the phone it was just given.
|
||||
export const startCustomerFlow = (text) => {
|
||||
const draft = parseCustomerDraft(text);
|
||||
return advance({ kind: 'createCustomer', step: 0, draft });
|
||||
};
|
||||
|
||||
// Moves to the next step that still needs an answer. Returns either a question
|
||||
// to ask, or the finished proposal.
|
||||
export const advance = (flow) => {
|
||||
let { step } = flow;
|
||||
const { draft } = flow;
|
||||
|
||||
while (step < CUSTOMER_STEPS.length) {
|
||||
const s = CUSTOMER_STEPS[step];
|
||||
const already = s.field === 'name' ? draft.firstname : draft[s.field];
|
||||
if (already) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step }, ask: s.ask, done: false };
|
||||
}
|
||||
|
||||
// Every step visited. The required check is repeated here rather than
|
||||
// trusted from the walk above, so a skipped-but-required field can never
|
||||
// reach a proposal.
|
||||
const { ok, missing } = validateCustomerDraft(draft);
|
||||
if (!ok) {
|
||||
return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false };
|
||||
}
|
||||
return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) };
|
||||
};
|
||||
|
||||
// Applies one answer. Returns the next question, or the completed proposal, or
|
||||
// a re-ask when the answer didn't validate.
|
||||
export const answerStep = (flow, text) => {
|
||||
const s = CUSTOMER_STEPS[flow.step];
|
||||
if (!s) return advance(flow);
|
||||
|
||||
if (isSkip(text)) {
|
||||
if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false };
|
||||
// Skipped optional field: step past it without writing anything, so the
|
||||
// payload builder drops it rather than sending an empty string.
|
||||
return advance({ ...flow, step: flow.step + 1 });
|
||||
}
|
||||
|
||||
const error = s.validate?.(text);
|
||||
if (error) return { flow, ask: error, done: false, retry: true };
|
||||
|
||||
return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) });
|
||||
};
|
||||
|
||||
// Human-readable summary of what will be sent, for the confirm step.
|
||||
export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));
|
||||
83
src/components/assistant/flowEngine.js
Normal file
83
src/components/assistant/flowEngine.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// ==============================|| Doormile AI — conversational flow engine ||============================== //
|
||||
//
|
||||
// One step-walker, shared by every conversational create (orderFlow.js,
|
||||
// bulkFlow.js). It was written inside orderFlow and extracted when the bulk
|
||||
// create became a conversation too — a second copy would have been a third
|
||||
// definition of the same branching rules to keep in sync.
|
||||
//
|
||||
// A step is a plain object:
|
||||
//
|
||||
// id required. Also the draft key the answer lands on.
|
||||
// type 'select' → the panel renders a dropdown (AIFlowStep)
|
||||
// 'rows' → the panel renders the file/paste input
|
||||
// 'text' → answered through the composer
|
||||
// ask the question
|
||||
// when (draft) => boolean. Omitted means always asked. THIS is branching.
|
||||
// options async (draft) => [{ value, label, record? }] — for 'select'
|
||||
// validate (raw, option) => error | null. Re-asks; stores nothing.
|
||||
// resolve async (raw) => { value } | { error }. May fail and re-ask —
|
||||
// geocoding. A value the rest of the flow depends on is never
|
||||
// stored half-resolved.
|
||||
// auto async (draft) => { value?, ask?, patch? }. The step answers itself
|
||||
// from real data and is only ASKED when that fails, with the reason.
|
||||
// apply (draft, value, option) => draft
|
||||
//
|
||||
// A step is skipped when `when` is false OR when `draft[step.id]` is already
|
||||
// set — which is what lets a caller seed the draft (a client login's tenant) or
|
||||
// one step fill several fields (picking an existing customer).
|
||||
|
||||
const applicable = (step, draft) => (typeof step.when === 'function' ? step.when(draft) : true);
|
||||
|
||||
// Finds the next step that applies and hasn't been answered. Async because a
|
||||
// step may answer itself from the network before we know whether to ask it.
|
||||
export const advanceFlow = async (steps, flow) => {
|
||||
let { step, draft } = flow;
|
||||
|
||||
while (step < steps.length) {
|
||||
const s = steps[step];
|
||||
if (!applicable(s, draft) || draft[s.id] !== undefined) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.auto) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const auto = await s.auto(draft);
|
||||
if (auto?.patch) draft = { ...draft, ...auto.patch };
|
||||
if (auto?.value !== undefined) {
|
||||
draft = s.apply(draft, auto.value);
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, step, draft }, step: s, done: false };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, step, draft, complete: true }, done: true, draft };
|
||||
};
|
||||
|
||||
export const startFlow = (steps, kind, draft = {}) => advanceFlow(steps, { kind, step: 0, draft });
|
||||
|
||||
// Applies one answer — typed text, a chosen dropdown option, or a parsed file.
|
||||
export const answerFlowStep = async (steps, flow, raw, option) => {
|
||||
const s = steps[flow.step];
|
||||
if (!s) return advanceFlow(steps, flow);
|
||||
|
||||
if (s.validate) {
|
||||
const error = s.validate(raw, option);
|
||||
if (error) return { flow, step: s, ask: error, done: false, retry: true };
|
||||
}
|
||||
|
||||
let value = raw;
|
||||
if (s.resolve) {
|
||||
const resolved = await s.resolve(raw);
|
||||
if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
|
||||
value = resolved.value;
|
||||
}
|
||||
|
||||
return advanceFlow(steps, { ...flow, step: flow.step + 1, draft: s.apply(flow.draft, value, option) });
|
||||
};
|
||||
2310
src/components/assistant/intents.js
Normal file
2310
src/components/assistant/intents.js
Normal file
File diff suppressed because it is too large
Load Diff
152
src/components/assistant/orderActions.js
Normal file
152
src/components/assistant/orderActions.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createExpressBooking, getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
|
||||
// ==============================|| Doormile AI — create order ||============================== //
|
||||
//
|
||||
// Second write capability. Same contract as actions.js: nothing here mutates
|
||||
// except executeCreateOrder, which the panel calls only when the operator
|
||||
// presses Create.
|
||||
//
|
||||
// Order creation is materially riskier than customer creation — a wrong record
|
||||
// dispatches a real rider.
|
||||
//
|
||||
// ⚠ `pickuplocationid` DOES NOT WORK. express-console-api.md documents it as
|
||||
// the way to reference a stored pickup site, and this file was originally
|
||||
// built on it, but createorder1.js records (in two places) that the field
|
||||
// live-500s on POST /admin/expressbooking — a reported backend bug, with no
|
||||
// frontend workaround other than not using it. The page therefore always
|
||||
// sends the RAW pickup fields, copying them out of the chosen saved location.
|
||||
//
|
||||
// This does the same. The operator still picks a saved location — that part
|
||||
// is good UX and keeps CityGate satisfied, since a stored site has already
|
||||
// passed it — but what goes on the wire is pickupaddress / pickuppincode /
|
||||
// pickupcity / pickuplatitude / pickuplongitude, exactly as the page sends.
|
||||
|
||||
export const CREATE_ORDER_TRIGGER = /\b(?:create|add|place|book|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:order|booking|delivery)\b/i;
|
||||
|
||||
// Doormile's own service tiers (express-console-api.md).
|
||||
export const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
|
||||
|
||||
// CityGate is enforced server-side, before the handler runs. Checking it here
|
||||
// too means the operator is told which cities are open BEFORE submitting,
|
||||
// instead of getting an opaque middleware rejection back.
|
||||
export const OPEN_CITY_PREFIXES = {
|
||||
641: 'Coimbatore',
|
||||
600: 'Chennai',
|
||||
560: 'Bengaluru',
|
||||
500: 'Hyderabad',
|
||||
629: 'Nagercoil'
|
||||
};
|
||||
|
||||
export const cityGateFor = (pincode) => {
|
||||
const prefix = String(pincode || '').slice(0, 3);
|
||||
return OPEN_CITY_PREFIXES[prefix] || null;
|
||||
};
|
||||
|
||||
export const loadOrderTenants = async () => (await getalltenants()) || [];
|
||||
|
||||
// Saved pickup sites for a tenant. Each carries address / city / pincode /
|
||||
// coordinates — which is what the payload copies onto the booking, since
|
||||
// `pickuplocationid` itself 500s (see the note at the top of this file).
|
||||
export const loadPickupLocations = async (tenantid) => {
|
||||
if (!tenantid) return [];
|
||||
return (await getTenantLocations(tenantid)) || [];
|
||||
};
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
|
||||
// Mirrors createorder1.js's own gate, plus the two rules the endpoint enforces
|
||||
// that the page leaves to the server (non-empty parcels, CityGate).
|
||||
export const validateOrderDraft = (d) => {
|
||||
const errors = {};
|
||||
if (!d.tenantid) errors.tenantid = 'Choose a tenant';
|
||||
// Still required: the operator picks a saved site so its address/pincode/
|
||||
// coordinates can be copied onto the booking. The id itself is never sent.
|
||||
if (!d.pickuplocationid) errors.pickuplocationid = 'Choose a pickup location';
|
||||
if (!d.customer_name?.trim()) errors.customer_name = 'Required';
|
||||
if (!PHONE_RE.test(String(d.customer_phone || '').trim())) errors.customer_phone = 'Enter exactly 10 digits';
|
||||
if (!d.deliveryaddress?.trim()) errors.deliveryaddress = 'Required';
|
||||
if (!String(d.deliverypincode || '').trim()) errors.deliverypincode = 'Required';
|
||||
if (!d.itemdescription?.trim()) errors.itemdescription = 'Describe what is being sent';
|
||||
if (d.finalprice === '' || d.finalprice == null || Number.isNaN(Number(d.finalprice))) errors.finalprice = 'Enter an amount';
|
||||
|
||||
// Delivery coordinates come from the address search. Without them the
|
||||
// optimiser has nothing to route against, so refuse rather than send a
|
||||
// booking that can never be dispatched.
|
||||
if (!Number.isFinite(Number(d.deliverylatitude)) || !Number.isFinite(Number(d.deliverylongitude))) {
|
||||
errors.deliveryaddress = 'Pick the address from the suggestions so coordinates are captured';
|
||||
}
|
||||
|
||||
return { ok: Object.keys(errors).length === 0, errors };
|
||||
};
|
||||
|
||||
// The exact body that will be POSTed. Mirrors createorder1.js's own payload —
|
||||
// raw pickup fields, never `pickuplocationid` (see the note at the top).
|
||||
//
|
||||
// `pickup` is the saved location record the operator chose; its address,
|
||||
// pincode, city and coordinates are copied onto the booking.
|
||||
export const buildOrderPayload = (d, pickup) => ({
|
||||
tenantid: Number(d.tenantid),
|
||||
pickupaddress: pickup?.address || '',
|
||||
pickuppincode: String(pickup?.pincode || ''),
|
||||
pickupcity: pickup?.city || '',
|
||||
pickuplatitude: Number(pickup?.latitude) || 0,
|
||||
pickuplongitude: Number(pickup?.longitude) || 0,
|
||||
customer_name: d.customer_name.trim(),
|
||||
customer_phone: String(d.customer_phone).trim(),
|
||||
deliveryaddress: d.deliveryaddress.trim(),
|
||||
deliverypincode: String(d.deliverypincode).trim(),
|
||||
deliverycity: d.deliverycity || '',
|
||||
deliverylatitude: Number(d.deliverylatitude),
|
||||
deliverylongitude: Number(d.deliverylongitude),
|
||||
service_option: SERVICE_OPTIONS.includes(d.service_option) ? d.service_option : 'Normal',
|
||||
finalprice: Number(d.finalprice),
|
||||
notes: d.notes || '',
|
||||
// A parcel entry has no quantity field — N items means N entries, which is
|
||||
// how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
|
||||
parcels: Array.from({ length: Math.max(1, Number(d.quantity) || 1) }, () => ({
|
||||
itemcategory: d.itemcategory || 'General',
|
||||
itemdescription: d.itemdescription.trim(),
|
||||
declaredvalue: Number(d.declaredvalue) || 0
|
||||
}))
|
||||
});
|
||||
|
||||
// The only order-writing function in the assistant.
|
||||
export const executeCreateOrder = async (payload) => {
|
||||
const started = Date.now();
|
||||
const call = {
|
||||
name: 'createExpressBooking',
|
||||
target: 'POST /admin/expressbooking',
|
||||
stats: `tenant ${payload.tenantid}, ${payload.parcels.length} parcel${payload.parcels.length === 1 ? '' : 's'}`
|
||||
};
|
||||
try {
|
||||
const res = await createExpressBooking(payload);
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server rejected the order.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
|
||||
};
|
||||
}
|
||||
const created = res?.data || res;
|
||||
return {
|
||||
ok: true,
|
||||
id: created?.bookingid ?? created?.id,
|
||||
bookingno: created?.bookingno,
|
||||
created,
|
||||
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created ${created?.bookingno || created?.bookingid || '—'}` }]
|
||||
};
|
||||
} catch (err) {
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
const status = err.httpStatus ?? err.response?.status;
|
||||
const serverMessage = err.message || err.error;
|
||||
const message =
|
||||
status === 404 || status === 405
|
||||
? `POST /admin/expressbooking returned ${status} — that route does not exist on the server.`
|
||||
: status
|
||||
? `POST /admin/expressbooking returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`
|
||||
: `${serverMessage || 'The request failed'} — nothing was saved.`;
|
||||
return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
|
||||
}
|
||||
};
|
||||
267
src/components/assistant/orderFlow.js
Normal file
267
src/components/assistant/orderFlow.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import { getAdminPricing, getAdminCustomers, getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from 'utils/distance';
|
||||
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-order ||============================== //
|
||||
//
|
||||
// One question at a time, mirroring createorder1.js's own field set and using a
|
||||
// DROPDOWN wherever that page uses one — business location, customer, category,
|
||||
// weight, service tier. Free text is only for things that genuinely are free
|
||||
// text (a name, an address, a description).
|
||||
//
|
||||
// Two capabilities the customer flow didn't need:
|
||||
//
|
||||
// • BRANCHING. "Existing customer or new?" splits the path: existing skips
|
||||
// straight to picking from the real customer list, new asks for the fields.
|
||||
// Steps carry a `when` predicate and are skipped when it's false.
|
||||
// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
|
||||
// dropdown never shows a stale or invented list.
|
||||
//
|
||||
// The conversation is driven by the PANEL, which intercepts replies before the
|
||||
// router ever sees them — see customerFlow.js for why that matters (a bare
|
||||
// "8494948494" matches no intent and used to be discarded).
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const digits = (t) => String(t || '').replace(/\D/g, '');
|
||||
|
||||
const isStaffLogin = () => {
|
||||
const t = localStorage.getItem('tenantid');
|
||||
return !t || t === '0';
|
||||
};
|
||||
|
||||
// ---- steps ------------------------------------------------------------------
|
||||
//
|
||||
// type: 'select' → the panel renders a dropdown from `options(draft)`
|
||||
// 'text' → answered through the composer
|
||||
// when: omitted means always asked
|
||||
export const ORDER_STEPS = [
|
||||
{
|
||||
id: 'tenantid',
|
||||
type: 'select',
|
||||
ask: 'Which tenant is this order for?',
|
||||
// A client login already has its tenant; only Doormile staff choose.
|
||||
when: () => isStaffLogin(),
|
||||
options: async () => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
},
|
||||
apply: (d, v) => ({ ...d, tenantid: v })
|
||||
},
|
||||
{
|
||||
id: 'pickuplocationid',
|
||||
type: 'select',
|
||||
ask: 'Which business location is this picked up from?',
|
||||
options: async (d) => {
|
||||
const tid = d.tenantid || localStorage.getItem('tenantid');
|
||||
const locations = (await getTenantLocations(tid)) || [];
|
||||
return locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
// The pincode is shown because it decides CityGate — a location outside
|
||||
// the open cities will be refused server-side, and the operator should
|
||||
// see that before choosing rather than after submitting.
|
||||
label: `${l.locationname || l.address || 'Location'}${
|
||||
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
|
||||
}`,
|
||||
record: l
|
||||
}));
|
||||
},
|
||||
validate: (v, option) =>
|
||||
cityGateFor(option?.record?.pincode)
|
||||
? null
|
||||
: `That location’s pincode (${
|
||||
option?.record?.pincode || 'unknown'
|
||||
}) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
|
||||
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
|
||||
},
|
||||
{
|
||||
id: 'customerMode',
|
||||
type: 'select',
|
||||
ask: 'Is this an existing customer, or a new one?',
|
||||
options: async () => [
|
||||
{ value: 'existing', label: 'Existing customer' },
|
||||
{ value: 'new', label: 'New customer' }
|
||||
],
|
||||
apply: (d, v) => ({ ...d, customerMode: v })
|
||||
},
|
||||
{
|
||||
id: 'existingCustomer',
|
||||
type: 'select',
|
||||
ask: 'Which customer?',
|
||||
when: (d) => d.customerMode === 'existing',
|
||||
options: async () => {
|
||||
const customers = (await getAdminCustomers()) || [];
|
||||
return customers
|
||||
.filter((c) => c.phone)
|
||||
.map((c) => ({
|
||||
value: String(c.appcustomerid ?? c.id),
|
||||
label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
|
||||
record: c
|
||||
}));
|
||||
},
|
||||
// Picking an existing customer fills the name and phone, so the two
|
||||
// free-text steps below are skipped by their own `when`.
|
||||
apply: (d, v, option) => ({
|
||||
...d,
|
||||
customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
|
||||
customer_phone: digits(option?.record?.phone)
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'customer_name',
|
||||
type: 'text',
|
||||
ask: 'What’s the customer’s name?',
|
||||
when: (d) => d.customerMode === 'new' && !d.customer_name,
|
||||
validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
|
||||
apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
|
||||
},
|
||||
{
|
||||
id: 'customer_phone',
|
||||
type: 'text',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
when: (d) => !d.customer_phone,
|
||||
validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
|
||||
apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
|
||||
},
|
||||
{
|
||||
id: 'deliveryaddress',
|
||||
type: 'text',
|
||||
ask: 'Where is it being delivered? Give the full address.',
|
||||
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
|
||||
// an address that can't be located is refused here rather than becoming a
|
||||
// booking nothing can dispatch.
|
||||
resolve: async (t) => {
|
||||
const place = await geocodeAddress(String(t).trim()).catch(() => null);
|
||||
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
|
||||
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
|
||||
(place.address_components || []).forEach((c) => {
|
||||
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
|
||||
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
|
||||
});
|
||||
return {
|
||||
value: {
|
||||
...parts,
|
||||
deliverylatitude: place.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place.geometry?.location?.lng?.()
|
||||
}
|
||||
};
|
||||
},
|
||||
apply: (d, v) => ({ ...d, ...v })
|
||||
},
|
||||
{
|
||||
id: 'deliverypincode',
|
||||
type: 'text',
|
||||
ask: 'What’s the delivery pincode?',
|
||||
when: (d) => !d.deliverypincode,
|
||||
validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
|
||||
apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
|
||||
},
|
||||
{
|
||||
id: 'service_option',
|
||||
type: 'select',
|
||||
ask: 'Which service level?',
|
||||
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
|
||||
apply: (d, v) => ({ ...d, service_option: v })
|
||||
},
|
||||
{
|
||||
id: 'itemcategory',
|
||||
type: 'select',
|
||||
ask: 'What kind of parcel is it?',
|
||||
options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
|
||||
apply: (d, v) => ({ ...d, itemcategory: v })
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
type: 'select',
|
||||
ask: 'Roughly how heavy?',
|
||||
options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
|
||||
apply: (d, v) => ({ ...d, weight: v })
|
||||
},
|
||||
{
|
||||
id: 'itemdescription',
|
||||
type: 'text',
|
||||
ask: 'Briefly, what’s inside?',
|
||||
validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
|
||||
apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
|
||||
},
|
||||
{
|
||||
id: 'quantity',
|
||||
type: 'select',
|
||||
ask: 'How many parcels?',
|
||||
options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
|
||||
apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
|
||||
},
|
||||
{
|
||||
id: 'finalprice',
|
||||
type: 'text',
|
||||
ask: 'What should the price be? Enter the amount in ₹.',
|
||||
// `auto` answers a step from real data and only falls back to asking. The
|
||||
// quote is stashed either way so the confirmation can show the distance it
|
||||
// measured, and say why it couldn’t price when it couldn’t.
|
||||
auto: async (d) => {
|
||||
const quote = await priceOrder(d);
|
||||
if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
|
||||
return {
|
||||
patch: { __quote: quote },
|
||||
ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
|
||||
};
|
||||
},
|
||||
validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
|
||||
apply: (d, t) => ({ ...d, finalprice: Number(t) })
|
||||
}
|
||||
];
|
||||
|
||||
// Mirrors createorder1.js's own lists so the bot offers the same choices.
|
||||
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
|
||||
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
|
||||
|
||||
// ---- pricing ----------------------------------------------------------------
|
||||
//
|
||||
// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
|
||||
// this tenant's own pricing row. Quoted, never invented — if no pricing row
|
||||
// matches, the operator is asked for the amount rather than shown a zero.
|
||||
export const priceOrder = async (draft) => {
|
||||
const tid = draft.tenantid || localStorage.getItem('tenantid');
|
||||
const pricing = (await getAdminPricing()) || [];
|
||||
// tenantid is numeric on the pricing row and a string from localStorage — a
|
||||
// strict comparison here silently priced every order at zero once before.
|
||||
const match = pricing.find((p) => String(p.tenantid) === String(tid));
|
||||
|
||||
const pickup = draft.__pickup;
|
||||
if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
|
||||
|
||||
const km = await calculateDrivingDistance(
|
||||
{ latitude: pickup.latitude, longitude: pickup.longitude },
|
||||
{ latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
|
||||
).catch(() => null);
|
||||
|
||||
if (km == null) return { error: 'could not measure the distance' };
|
||||
if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
|
||||
|
||||
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
|
||||
return {
|
||||
km,
|
||||
durationMin: getLastRouteDurationMin(),
|
||||
basePrice: match.baseprice,
|
||||
total: Number(Number(total).toFixed(2))
|
||||
};
|
||||
};
|
||||
|
||||
// ---- engine -----------------------------------------------------------------
|
||||
//
|
||||
// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
|
||||
// These wrappers keep the order-specific names the panel and the tests use.
|
||||
|
||||
export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
|
||||
|
||||
export const startOrderFlow = () => {
|
||||
// A client login already belongs to a tenant, so its step is skipped — but
|
||||
// the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
|
||||
// draft is what makes the skip safe.
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
|
||||
};
|
||||
|
||||
export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);
|
||||
66
src/components/assistant/ragRouter.js
Normal file
66
src/components/assistant/ragRouter.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// ==============================|| Doormile AI — semantic routing client ||============================== //
|
||||
//
|
||||
// Talks to the retrieval sidecar (services/ai) to decide WHICH QUESTION was
|
||||
// asked. It never returns data — every figure still comes from the intent's own
|
||||
// deterministic run(), through the same typed API functions the pages use.
|
||||
//
|
||||
// The whole module is optional by design:
|
||||
//
|
||||
// • REACT_APP_AI_URL unset → disabled, regex matcher only (production today)
|
||||
// • sidecar unreachable → disabled for this call, regex matcher
|
||||
// • slow → aborted at ROUTE_TIMEOUT_MS, regex matcher
|
||||
// • low confidence → not used, regex matcher
|
||||
//
|
||||
// Today's behaviour is the floor. This can raise it, never lower it.
|
||||
|
||||
const BASE = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AI_URL) || (typeof process !== 'undefined' && process.env?.REACT_APP_AI_URL) || '';
|
||||
const ROUTE_TIMEOUT_MS = 400;
|
||||
|
||||
export const isRagEnabled = () => Boolean(BASE);
|
||||
|
||||
// Once the sidecar has failed we stop hammering it on every keystroke-fast
|
||||
// question. Re-armed after a cool-off so a restarted container is picked up
|
||||
// without a page reload.
|
||||
let disabledUntil = 0;
|
||||
const COOL_OFF_MS = 30000;
|
||||
|
||||
const post = async (path, body) => {
|
||||
if (!BASE || Date.now() < disabledUntil) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ROUTE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return await res.json();
|
||||
} catch {
|
||||
// Any failure — offline, timeout, 5xx — is silent by design. The operator
|
||||
// gets the deterministic answer; they should never see plumbing.
|
||||
disabledUntil = Date.now() + COOL_OFF_MS;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns { intentId, confidence, score, margin, alternatives } or null.
|
||||
export const routeQuestion = (text) => post('/route', { text });
|
||||
|
||||
// Documentation passages, verbatim with attribution. No generation step —
|
||||
// summarising would need a hosted model (assistant/CLAUDE.md §2) and would let
|
||||
// a paraphrase drift from what the doc actually says.
|
||||
export const askDocs = (text) => post('/ask', { text });
|
||||
|
||||
// A semantic near-miss must never open a create form. Write intents require
|
||||
// high confidence AND corroboration from the deterministic trigger, so the
|
||||
// worst case is that the operator types the phrase the regex already knows.
|
||||
export const isRouteTrustworthy = (routed) => {
|
||||
if (!routed?.intentId) return false;
|
||||
if (routed.isWrite) return routed.confidence === 'high';
|
||||
return routed.confidence === 'high' || routed.confidence === 'medium';
|
||||
};
|
||||
30
src/components/assistant/repeatFlow.js
Normal file
30
src/components/assistant/repeatFlow.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { findRecentRuns, describeDay } from './repeatRuns';
|
||||
import { startFlow, advanceFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — repeat a run ||============================== //
|
||||
//
|
||||
// One question: which day. Everything after it — resolving customers, the drift
|
||||
// check, re-quoting at today's tariff, the already-repeated guard — is a pass
|
||||
// the panel runs and narrates, exactly like the bulk file's locate/price phase.
|
||||
// None of it is a question, so none of it is a step.
|
||||
|
||||
export const REPEAT_STEPS = [
|
||||
{
|
||||
id: 'day',
|
||||
type: 'select',
|
||||
ask: 'Which day’s orders should I repeat?',
|
||||
options: async () => {
|
||||
const { runs } = await findRecentRuns();
|
||||
return runs.map((r) => ({
|
||||
value: r.day,
|
||||
label: `${describeDay(r.day)} — ${r.count} order${r.count === 1 ? '' : 's'}`,
|
||||
record: r
|
||||
}));
|
||||
},
|
||||
apply: (d, v, option) => ({ ...d, day: v, sourceCount: option?.record?.count })
|
||||
}
|
||||
];
|
||||
|
||||
export const startRepeatFlow = () => startFlow(REPEAT_STEPS, 'repeatRun', {});
|
||||
export const advanceRepeat = (flow) => advanceFlow(REPEAT_STEPS, flow);
|
||||
export const answerRepeatStep = (flow, raw, option) => answerFlowStep(REPEAT_STEPS, flow, raw, option);
|
||||
244
src/components/assistant/repeatRuns.js
Normal file
244
src/components/assistant/repeatRuns.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getAdminCustomers } from 'pages/api/doormileApi';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { groupForBookingStatus } from 'utils/orderStatusGroups';
|
||||
import { scanBookings } from './intents';
|
||||
import { cityGateFor } from './orderActions';
|
||||
import { priceBulkRows } from './bulkOrderActions';
|
||||
|
||||
// ==============================|| Doormile AI — repeat a past day's run ||============================== //
|
||||
//
|
||||
// "I created ten orders yesterday; make the same ten today."
|
||||
//
|
||||
// This is the cheapest write in the assistant, and the reason is worth stating:
|
||||
// a booking already carries 15 of the 17 fields `buildOrderPayload` needs —
|
||||
// both addresses, both pincodes, BOTH SETS OF COORDINATES, the service tier,
|
||||
// the parcels. Only the recipient's name and phone are missing, and those come
|
||||
// from the `appcustomerid` → /admin/customers join the assistant already does.
|
||||
//
|
||||
// So a repeat needs NO geocoding. The ~1 lookup/second Nominatim throttle that
|
||||
// dominates the bulk-file flow does not apply here at all.
|
||||
//
|
||||
// ---- A booking is a snapshot, not a template --------------------------------
|
||||
//
|
||||
// Which is why every row goes through a drift check before it can be repeated.
|
||||
// These are not hypothetical; all three were found in one live page of 36
|
||||
// bookings:
|
||||
//
|
||||
// • `pickupaddress` can be ABSENT entirely (booking 57 carries the pincode
|
||||
// and coordinates but no address key at all) — repeating it blind sends an
|
||||
// empty pickup address.
|
||||
// • `tenantid` can be null (every `Customer_App` booking) — `Number(null)`
|
||||
// is 0, so the payload would claim tenant zero.
|
||||
// • a pickup pincode that was open when the order was placed may not be now,
|
||||
// and CityGate refuses at the middleware, before the handler runs.
|
||||
//
|
||||
// ---- Duplicate safety is INVERTED here --------------------------------------
|
||||
//
|
||||
// Everywhere else in this assistant, near-identical orders are an error to
|
||||
// prevent (`wasAlreadySubmitted`). A repeat run deliberately creates them, so
|
||||
// that guard would misfire on every single run. The question that actually
|
||||
// matters is different: *has this run already been repeated today?* The
|
||||
// endpoint has no idempotency key, so it is answered by looking: today's own
|
||||
// bookings are fingerprinted and any row already present is set aside rather
|
||||
// than booked twice.
|
||||
|
||||
export const REPEAT_TRIGGER = /\b(?:repeat|redo|re-?run)\b|\bsame\s+orders?\s+as\b|\bsame\s+as\s+(?:yesterday|last)\b/i;
|
||||
|
||||
// How far back a run can be recalled from. Beyond a week it stops being "the
|
||||
// usual round" and starts being archaeology.
|
||||
const LOOKBACK_DAYS = 7;
|
||||
|
||||
const dayOf = (value) => {
|
||||
const t = parseDoormileTimestamp(value);
|
||||
return t.isValid() ? t.format('YYYY-MM-DD') : null;
|
||||
};
|
||||
|
||||
// A row is only worth repeating if it was a real order. Cancelled ones are
|
||||
// excluded — repeating a cancellation is never what "same as yesterday" means.
|
||||
const isRepeatable = (b) => groupForBookingStatus(b.status) !== 'cancelled';
|
||||
|
||||
// ---- which days have a run to repeat ---------------------------------------
|
||||
export const findRecentRuns = async () => {
|
||||
const scan = await scanBookings();
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const counts = new Map();
|
||||
|
||||
(scan.rows || []).forEach((b) => {
|
||||
if (!isRepeatable(b)) return;
|
||||
const day = dayOf(b.createdat);
|
||||
if (!day || day === today) return;
|
||||
if (dayjs(today).diff(dayjs(day), 'day') > LOOKBACK_DAYS) return;
|
||||
counts.set(day, (counts.get(day) || 0) + 1);
|
||||
});
|
||||
|
||||
return {
|
||||
scan,
|
||||
runs: [...counts.entries()].map(([day, count]) => ({ day, count })).sort((a, b) => (a.day < b.day ? 1 : -1))
|
||||
};
|
||||
};
|
||||
|
||||
export const describeDay = (day) => {
|
||||
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||||
if (day === yesterday) return 'Yesterday';
|
||||
return dayjs(day).format('ddd D MMM');
|
||||
};
|
||||
|
||||
// ---- one booking → one repeatable row ---------------------------------------
|
||||
//
|
||||
// `__pickup` travels on the ROW, not on the shared draft: a day's run can span
|
||||
// several kitchens and tenants, and collapsing them onto one shared pickup
|
||||
// would silently re-address half the orders.
|
||||
const toRow = (booking, customer, index) => ({
|
||||
line: index + 1,
|
||||
source: booking.bookingno || `#${booking.bookingid}`,
|
||||
tenantid: booking.tenantid,
|
||||
customer_name: customer?.name || [customer?.firstname, customer?.lastname].filter(Boolean).join(' ') || '',
|
||||
customer_phone: String(customer?.phone || customer?.contactno || '').replace(/\D/g, ''),
|
||||
deliveryaddress: booking.deliveryaddress || '',
|
||||
deliverypincode: String(booking.deliverypincode || ''),
|
||||
deliverycity: booking.deliverycity || '',
|
||||
deliverylatitude: booking.deliverylatitude,
|
||||
deliverylongitude: booking.deliverylongitude,
|
||||
service_option: booking.serviceoptions?.[0]?.servicetype || 'Normal',
|
||||
// Deliberately blank: the chosen behaviour is to re-quote at today's tariff,
|
||||
// so this is left for priceBulkRows to fill exactly as an unpriced bulk row
|
||||
// would be. Copying yesterday's number would silently bill an old tariff.
|
||||
finalprice: '',
|
||||
previousPrice: booking.serviceoptions?.[0]?.estimatedprice,
|
||||
itemcategory: booking.parcels?.[0]?.itemcategory || 'General',
|
||||
itemdescription: booking.parcels?.[0]?.itemdescription || 'Order',
|
||||
quantity: Math.max(1, booking.parcels?.length || 1),
|
||||
notes: booking.notes || '',
|
||||
// Who ran this drop last time. Carried so the repeat can hand the new order
|
||||
// back to the same rider — they already know the door, the buzzer and the
|
||||
// customer. Null when yesterday's order was never assigned, which is a
|
||||
// normal case and simply means the copy stays pending.
|
||||
__previousMilerUserId: booking.assignedmileruserid ?? null,
|
||||
__pickup: {
|
||||
address: booking.pickupaddress,
|
||||
pincode: booking.pickuppincode,
|
||||
city: booking.pickupcity,
|
||||
latitude: booking.pickuplatitude,
|
||||
longitude: booking.pickuplongitude
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the drift check --------------------------------------------------------
|
||||
//
|
||||
// Returns a REASON, never a boolean — an operator dropping a row deserves to
|
||||
// know which field went stale.
|
||||
const driftReason = (row) => {
|
||||
if (!row.tenantid) return 'the original had no tenant, so this would be booked against tenant 0';
|
||||
if (!row.__pickup?.address) return 'the original booking carries no pickup address';
|
||||
if (!row.customer_phone) return 'the customer record is gone, so there is no phone number';
|
||||
if (!row.customer_name) return 'the customer record is gone, so there is no name';
|
||||
if (!row.deliveryaddress) return 'no delivery address on the original';
|
||||
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
|
||||
return 'the original has no delivery coordinates, so it could never be routed';
|
||||
}
|
||||
if (!cityGateFor(row.__pickup.pincode)) {
|
||||
return `its pickup pincode (${row.__pickup.pincode || 'unknown'}) is no longer a city Doormile serves`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// What makes two orders "the same order". It has to include the PARCEL, not
|
||||
// just the destination: a run to one address for one customer is a completely
|
||||
// normal thing to do twice in a day with different contents, and judging on
|
||||
// phone + address + pickup alone made three unrelated bookings to the same bus
|
||||
// stand look identical — the repeat then reported all of yesterday as already
|
||||
// created when only one unrelated order existed today.
|
||||
//
|
||||
// Every field here is one a repeat reproduces EXACTLY, which is what makes it a
|
||||
// usable identity. Price is deliberately excluded: it is re-quoted at today's
|
||||
// tariff, so it differs by design on every legitimate repeat.
|
||||
const fingerprint = (row) =>
|
||||
[
|
||||
row.customer_phone,
|
||||
String(row.deliveryaddress || '').toLowerCase(),
|
||||
row.__pickup?.pincode || '',
|
||||
row.service_option,
|
||||
row.itemcategory,
|
||||
row.itemdescription,
|
||||
row.quantity
|
||||
].join('|');
|
||||
|
||||
// ---- assemble the run -------------------------------------------------------
|
||||
export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
|
||||
const [{ scan }, customers] = await Promise.all([findRecentRuns(), getAdminCustomers().catch(() => [])]);
|
||||
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.id, c]));
|
||||
|
||||
const source = (scan.rows || []).filter((b) => isRepeatable(b) && dayOf(b.createdat) === day);
|
||||
const rows = source.map((b, i) => toRow(b, customerMap.get(b.appcustomerid), i));
|
||||
|
||||
// Already repeated today? Today's own bookings are put through the SAME
|
||||
// `toRow` shaping, so both sides of the comparison get identical defaults —
|
||||
// hand-rolling the today side is how the two drifted apart in the first place.
|
||||
//
|
||||
// Counts, not a Set. A Set answers "does anything today look like this", so a
|
||||
// single matching order suppressed EVERY row that shared its fingerprint —
|
||||
// one order created today wiped out all of yesterday's. A multiset answers
|
||||
// the question that was actually meant: how many of these already exist. Two
|
||||
// identical orders yesterday and one today means one still needs creating.
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const todayCounts = new Map();
|
||||
(scan.rows || [])
|
||||
.filter((b) => isRepeatable(b) && dayOf(b.createdat) === today)
|
||||
.forEach((b) => {
|
||||
const key = fingerprint(toRow(b, customerMap.get(b.appcustomerid), 0));
|
||||
todayCounts.set(key, (todayCounts.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const drifted = [];
|
||||
const already = [];
|
||||
const candidates = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const reason = driftReason(row);
|
||||
if (reason) {
|
||||
drifted.push({ ...row, error: reason });
|
||||
return;
|
||||
}
|
||||
// Consume one match per already-existing order, so a second identical row
|
||||
// is still offered once the first has been accounted for.
|
||||
const key = fingerprint(row);
|
||||
const remaining = todayCounts.get(key) || 0;
|
||||
if (remaining > 0) {
|
||||
todayCounts.set(key, remaining - 1);
|
||||
already.push(row);
|
||||
return;
|
||||
}
|
||||
candidates.push(row);
|
||||
});
|
||||
|
||||
// Re-quote at today's tariff. Priced per row because a run can span tenants,
|
||||
// and a tenant's own pricing row is what decides the number.
|
||||
const priced = [];
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
if (shouldStop?.()) break;
|
||||
const row = candidates[i];
|
||||
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
|
||||
priced.push(out);
|
||||
}
|
||||
if (priced.length < candidates.length) priced.push(...candidates.slice(priced.length));
|
||||
|
||||
const valid = priced.filter((r) => !r.priceError && Number(r.finalprice) > 0);
|
||||
const unpriced = priced.filter((r) => r.priceError || !(Number(r.finalprice) > 0));
|
||||
|
||||
return {
|
||||
day,
|
||||
scan,
|
||||
total: rows.length,
|
||||
valid,
|
||||
drifted,
|
||||
already,
|
||||
unpriced,
|
||||
// Every price that moved since the original, so a tariff change is visible
|
||||
// rather than discovered on an invoice.
|
||||
changed: valid.filter((r) => r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice))
|
||||
};
|
||||
};
|
||||
@@ -1,121 +1,219 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
const useDebouncedCallback = (callback, delay) => { const timeoutRef = React.useRef(null); return React.useCallback((...args) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { callback(...args); }, delay); }, [callback, delay]); };
|
||||
import { Loader2, MapPin, X } from 'lucide-react';
|
||||
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
|
||||
const toPlace = (nom) => {
|
||||
const c = nom.address || {};
|
||||
const addr = [
|
||||
{ long_name: c.house_number || '', short_name: c.house_number || '', types: ['street_number'] },
|
||||
{ long_name: c.road || '', short_name: c.road || '', types: ['route'] },
|
||||
{ long_name: c.city || c.town || c.village || '', short_name: c.city || c.town || c.village || '', types: ['locality', 'political'] },
|
||||
{ long_name: c.county || c.state_district || '', short_name: c.county || c.state_district || '', types: ['administrative_area_level_2', 'political'] },
|
||||
{ long_name: c.state || '', short_name: c.state || '', types: ['administrative_area_level_1', 'political'] },
|
||||
{ long_name: c.country || '', short_name: c.country_code?.toUpperCase() || '', types: ['country', 'political'] },
|
||||
{ long_name: c.postcode || '', short_name: c.postcode || '', types: ['postal_code'] }
|
||||
].filter((p) => p.long_name);
|
||||
|
||||
return {
|
||||
address_components: addr,
|
||||
formatted_address: nom.display_name,
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => Number(nom.lat),
|
||||
lng: () => Number(nom.lon)
|
||||
}
|
||||
}
|
||||
};
|
||||
const useDebouncedCallback = (callback, delay) => {
|
||||
const timeoutRef = useRef(null);
|
||||
return React.useCallback(
|
||||
(...args) => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay]
|
||||
);
|
||||
};
|
||||
|
||||
const buildAddressComponents = (addr = {}) => {
|
||||
const components = [];
|
||||
const push = (longName, types) => {
|
||||
if (longName) components.push({ long_name: longName, short_name: longName, types });
|
||||
};
|
||||
push(addr.house_number, ['street_number']);
|
||||
push(addr.road || addr.pedestrian, ['route']);
|
||||
push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
|
||||
push(addr.city_district || addr.county, ['administrative_area_level_3']);
|
||||
push(addr.city || addr.town || addr.village, ['locality']);
|
||||
push(addr.state, ['administrative_area_level_1']);
|
||||
push(addr.country, ['country']);
|
||||
push(addr.postcode, ['postal_code']);
|
||||
return components;
|
||||
};
|
||||
|
||||
export const toPlace = (result) => ({
|
||||
formatted_address: result.display_name || '',
|
||||
name: result.display_name?.split(',')[0] || '',
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => parseFloat(result.lat),
|
||||
lng: () => parseFloat(result.lon)
|
||||
}
|
||||
},
|
||||
address_components: buildAddressComponents(result.address)
|
||||
});
|
||||
|
||||
const buildParams = (query, bias) => {
|
||||
const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
|
||||
if (bias?.lat && bias?.lng) {
|
||||
const d = 0.5; // ~55km soft bias bounding box
|
||||
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export async function geocodeAddress(address, { bias } = {}) {
|
||||
if (!address || !address.trim()) return null;
|
||||
try {
|
||||
const params = buildParams(address.trim(), bias);
|
||||
params.set('limit', '1');
|
||||
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const results = await res.json();
|
||||
if (!results?.length) return null;
|
||||
return toPlace(results[0]);
|
||||
} catch (err) {
|
||||
console.error('geocodeAddress error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const AddressAutocomplete = ({
|
||||
id,
|
||||
label,
|
||||
placeholder,
|
||||
placeholder = 'Search address…',
|
||||
value,
|
||||
onChange,
|
||||
onPlaceSelected,
|
||||
bias,
|
||||
fullWidth = true,
|
||||
disabled = false,
|
||||
status
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
required = false
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [options, setOptions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
/* Which suggestion the arrow keys are on. -1 is "none highlighted", the state
|
||||
the list opens in, so Enter never picks a result the operator hasn't moved
|
||||
to. */
|
||||
const [activeOption, setActiveOption] = useState(-1);
|
||||
|
||||
const wrapRef = useRef(null);
|
||||
const abortCtrl = useRef(null);
|
||||
const activeRef = useRef(0);
|
||||
// Set by pick(); consumed by the search effect. See the comment there.
|
||||
const skipNextSearchRef = useRef(false);
|
||||
const listId = `${id || 'address'}-suggestions`;
|
||||
const optionId = (index) => `${listId}-option-${index}`;
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value || '');
|
||||
if (value !== undefined && value !== inputValue) {
|
||||
setInputValue(value || '');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const biasKey = bias ? `${bias.lat},${bias.lng}` : '';
|
||||
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
|
||||
|
||||
const runSearch = useDebouncedCallback(async (query) => {
|
||||
if (!query || query.length < 3) {
|
||||
setOptions([]);
|
||||
setIsOpen(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortCtrl.current) abortCtrl.current.abort();
|
||||
abortCtrl.current = new AbortController();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = new URL(NOMINATIM_SEARCH_URL);
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('addressdetails', '1');
|
||||
url.searchParams.set('limit', '5');
|
||||
|
||||
if (bias && bias.lat && bias.lng) {
|
||||
url.searchParams.set('lat', bias.lat);
|
||||
url.searchParams.set('lon', bias.lng);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'Accept-Language': 'en-GB,en;q=0.9' },
|
||||
signal: abortCtrl.current.signal
|
||||
const fetchPredictions = useDebouncedCallback((query) => {
|
||||
const seq = ++activeRef.current;
|
||||
const params = buildParams(query, bias);
|
||||
fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((results) => {
|
||||
if (seq !== activeRef.current) return;
|
||||
setLoading(false);
|
||||
setOptions(results || []);
|
||||
setIsOpen((results || []).length > 0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== activeRef.current) return;
|
||||
setLoading(false);
|
||||
setOptions([]);
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const data = await res.json();
|
||||
setOptions(data || []);
|
||||
setIsOpen((data || []).length > 0);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('Nominatim search error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 500);
|
||||
}, 450);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputValue && inputValue !== value) runSearch(inputValue);
|
||||
/* Picking a suggestion writes the resolved address back into inputValue,
|
||||
which used to re-trigger this search: the list reopened over the form the
|
||||
instant it was dismissed, and every selection cost a second Nominatim
|
||||
request against a service that asks for one per second. The pick is not a
|
||||
new query, so skip exactly one run. */
|
||||
if (skipNextSearchRef.current) {
|
||||
skipNextSearchRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!inputValue || inputValue.length < 3) {
|
||||
activeRef.current++;
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
fetchPredictions(inputValue);
|
||||
}, [inputValue, biasKey]);
|
||||
|
||||
/* A fresh result set invalidates the highlight — otherwise index 2 of the old
|
||||
list silently becomes index 2 of the new one. */
|
||||
useEffect(() => {
|
||||
setActiveOption(-1);
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDocDown = (e) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) setIsOpen(false);
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDocDown);
|
||||
return () => document.removeEventListener('mousedown', onDocDown);
|
||||
}, []);
|
||||
|
||||
const pick = (option) => {
|
||||
setInputValue(option.display_name || '');
|
||||
onChange?.(option.display_name || '');
|
||||
const formatted = option.display_name || '';
|
||||
skipNextSearchRef.current = true;
|
||||
activeRef.current++; // drop any in-flight response that would reopen the list
|
||||
setInputValue(formatted);
|
||||
onChange?.(formatted);
|
||||
onPlaceSelected?.(toPlace(option));
|
||||
setIsOpen(false);
|
||||
setActiveOption(-1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Arrow keys, Enter and Escape over the suggestion list.
|
||||
*
|
||||
* Not a nicety: an order cannot be created from typed text — the submit gate
|
||||
* requires the coordinates that only picking a suggestion supplies — so
|
||||
* without this the whole create-order flow needed a mouse.
|
||||
*/
|
||||
const onKeyDown = (e) => {
|
||||
const listOpen = isOpen && options.length > 0;
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
setActiveOption(-1);
|
||||
return;
|
||||
}
|
||||
if (!listOpen) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveOption((current) => (current + 1) % options.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1));
|
||||
} else if (e.key === 'Enter' && activeOption >= 0) {
|
||||
// Only swallow Enter when a suggestion is actually highlighted, so Enter
|
||||
// on a bare query still submits the surrounding form.
|
||||
e.preventDefault();
|
||||
pick(options[activeOption]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative" style={{ width: fullWidth ? '100%' : undefined }}>
|
||||
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
|
||||
<div ref={wrapRef} className={`relative ${className}`} style={{ width: fullWidth ? '100%' : undefined }}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="block text-xs font-semibold text-slate-800 mb-1">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
@@ -123,32 +221,76 @@ const AddressAutocomplete = ({
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen && options.length > 0}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeOption >= 0 ? optionId(activeOption) : undefined}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown(e);
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className={`w-full h-9 pl-3 pr-8 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`}
|
||||
/>
|
||||
{loading && (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<span className="w-4 h-4 rounded-full border-2 border-t-2 border-gray-200 border-t-blue-500 animate-spin"></span>
|
||||
|
||||
{loading ? (
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
) : inputValue && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInputValue('');
|
||||
onChange?.('');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 p-0.5 rounded-full hover:bg-slate-100"
|
||||
title="Clear address"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isOpen && options.length > 0 && (
|
||||
<ul className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto" role="listbox">
|
||||
{options.map((option) => (
|
||||
<li key={option.place_id}>
|
||||
<ul
|
||||
id={listId}
|
||||
className="absolute z-[9999] left-0 right-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-xl max-h-60 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100"
|
||||
role="listbox"
|
||||
aria-label="Address suggestions"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
/* The option role sits on the <li>, which is what aria-activedescendant
|
||||
points at; the button inside stays for the mouse. Without this the
|
||||
popup announced as an empty listbox. */
|
||||
<li
|
||||
key={option.place_id}
|
||||
id={optionId(index)}
|
||||
role="option"
|
||||
aria-selected={index === activeOption}
|
||||
className={index === activeOption ? 'bg-slate-100' : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => setActiveOption(index)}
|
||||
onClick={() => pick(option)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 flex items-start gap-2.5 transition-colors cursor-pointer group"
|
||||
>
|
||||
<span className="block truncate">{option.display_name}</span>
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 group-hover:text-black mt-0.5 shrink-0 transition-colors" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-slate-800 truncate">
|
||||
{option.display_name?.split(',')[0]}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 line-clamp-1">
|
||||
{option.display_name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -168,30 +310,9 @@ AddressAutocomplete.propTypes = {
|
||||
bias: PropTypes.object,
|
||||
fullWidth: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
status: PropTypes.object
|
||||
className: PropTypes.string,
|
||||
inputClassName: PropTypes.string,
|
||||
required: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AddressAutocomplete;
|
||||
|
||||
|
||||
export async function geocodeAddress(address, { bias } = {}) {
|
||||
const url = new URL(NOMINATIM_SEARCH_URL);
|
||||
url.searchParams.set('q', address);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('addressdetails', '1');
|
||||
url.searchParams.set('limit', '1');
|
||||
if (bias && bias.lat && bias.lng) {
|
||||
url.searchParams.set('lat', bias.lat);
|
||||
url.searchParams.set('lon', bias.lng);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url.toString(), { headers: { 'Accept-Language': 'en-GB,en;q=0.9' } });
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) return toPlace(data[0]);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('geocodeAddress error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ export const STATUS_MAP = {
|
||||
pending_pickup: { label: 'Pending Pickup', tone: 'neutral' },
|
||||
miler_assigned: { label: 'Rider Assigned', tone: 'info' },
|
||||
pickup_scheduled: { label: 'Pickup Scheduled', tone: 'info' },
|
||||
converted_to_consignment: { label: 'Picked Up', tone: 'accent' },
|
||||
converted_to_consignment: { label: 'Picked', tone: 'accent' },
|
||||
collected_by_miler: { label: 'Picked', tone: 'accent' },
|
||||
out_for_delivery: { label: 'Out for Delivery', tone: 'warning' },
|
||||
delivered: { label: 'Delivered', tone: 'success' },
|
||||
cancelled: { label: 'Cancelled', tone: 'destructive' },
|
||||
|
||||
39
src/components/third-party/ReactTable.jsx
vendored
39
src/components/third-party/ReactTable.jsx
vendored
@@ -1,31 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// third-party
|
||||
import { CSVLink } from 'react-csv';
|
||||
import { MdDownload } from 'react-icons/md';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
|
||||
// ==============================|| CSV EXPORT ||============================== //
|
||||
// This file used to be Mantis's 596-line react-table v7 toolkit — HeaderSort,
|
||||
// TablePagination, IndeterminateCheckbox, TableRowSelection, DraggableHeader,
|
||||
// DragPreview, DraggableRow, HidingSelect, SortingSelect, EmptyTable. None of
|
||||
// them were imported anywhere: the two consumers (dispatch/Preview.js and
|
||||
// reports/ordersDetails.js) only ever pulled `CSVExport`. Rather than port ten
|
||||
// dead helpers to Astryx, they were deleted and only the export button remains.
|
||||
|
||||
export const CSVExport = ({ data, filename, headers, label, style, btnLoading, onClick }) => (
|
||||
export const CSVExport = ({ data, filename, headers, label, style, btnLoading, onClick, className = '' }) => (
|
||||
<CSVLink data={data} filename={filename} headers={headers} style={{ textDecoration: 'none' }}>
|
||||
<Button
|
||||
label={label || 'Download'}
|
||||
icon={btnLoading ? undefined : <MdDownload />}
|
||||
variant={btnLoading ? 'secondary' : 'primary'}
|
||||
isLoading={btnLoading}
|
||||
isDisabled={btnLoading}
|
||||
tooltip="CSV Export"
|
||||
style={style}
|
||||
<button
|
||||
type="button"
|
||||
disabled={btnLoading}
|
||||
onClick={(e) => onClick?.(e)}
|
||||
/>
|
||||
className={`h-9 px-3.5 bg-black hover:bg-neutral-800 active:scale-[0.98] text-white text-xs font-semibold rounded-md shadow-xs flex items-center gap-1.5 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
|
||||
style={style}
|
||||
title="CSV Export"
|
||||
>
|
||||
{btnLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
|
||||
<span>{label || 'CSV'}</span>
|
||||
</button>
|
||||
</CSVLink>
|
||||
);
|
||||
|
||||
@@ -36,7 +26,8 @@ CSVExport.propTypes = {
|
||||
label: PropTypes.any,
|
||||
style: PropTypes.object,
|
||||
btnLoading: PropTypes.any,
|
||||
onClick: PropTypes.func
|
||||
onClick: PropTypes.func,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default CSVExport;
|
||||
|
||||
Reference in New Issue
Block a user