Files
doormilxpress_astryx/README.md

245 lines
14 KiB
Markdown

# Doormile Express Console
The operator console for the Doormile Express dispatch platform — orders, deliveries, riders,
dispatch optimisation and reporting, talking to the live `api.doormile.com` admin API.
```bash
npm install
cp .env.example .env.local # point it at the API you want
npm run dev # http://localhost:5173 — opens on /login
```
Production users are warehouse and dispatch staff, not end customers.
## Stack
Vite 6 · React 19 · React Router 6 · React Query 5 · axios · Tailwind + shadcn/ui ·
Framer Motion · Leaflet · dayjs · SheetJS · react-hot-toast.
## Structure
```
src/
main.jsx entry — mounts <App/>, imports index.css
App.jsx providers + the entire route table
index.css design tokens (HSL CSS variables) + utility layers
api/doormile/ the backend seam — see below
client.js axios instance, bearer token, 401 handling
endpoints.js one function per /admin/* route
queries.js the page-facing layer: joins, status mapping, solver calls
notify.js the data layer's toast seam
index.js the single import surface
lib/ domain logic and data hooks
doormileHooks.js React Query hooks over the API — every page reads through here
doormileFormat.js currency/number/distance formatting, search, xlsx export
doormileTimestamp.js the IST wall-clock parse every date must go through
batchBucket.js the Morning/Afternoon/Evening wave windows
orderStatusGroups.js which raw booking enums make up each Orders tab
dispatchPreview.js the optimiser preview's tree/flat-list model
profitability.js revenue, cost and margin for a delivery day
distance.js OSRM driving distance, Haversine fallback
bulkOrderColumns.js the bulk-upload sheet's column map
preferences.js per-browser display settings
AuthContext.jsx the Doormile session
components/
ui/ shadcn primitives
ds/ the design system — the vocabulary pages are written in
doormile/ console-specific pieces (ListToolbar, DispatchMap)
lib/assistant/ Doormile AI — the deterministic operations copilot
intents.js the intent catalog: match → run → answer
scan.js booking scans with honest truncation reporting
vocab.js dates, statuses, batches, typo tolerance
flows.js the three conversational writes + the confirm gate
pageContext.js route → context label + suggested questions
layouts/ AdminLayout — header nav, ⌘K search, exception notifications, AI trigger
pages/ Login + doormile/ (the console)
```
## Routes
The app opens on **`/login`**. Everything else lives under `/doormile/*` behind `ProtectedRoute`,
rendered inside `AdminLayout`.
| Path | Page |
| --- | --- |
| `/login` | Sign-in — outside the protected boundary |
| `/doormile/dispatch` | The dispatch board — live map, rider detail, wave economics |
| `/doormile/dispatch/preview` | The optimiser's plan — reconcile, then commit |
| `/doormile/orders`, `orders/create`, `orders/createorders`, `orders/preview` | Orders, create one, bulk upload, assignment preview |
| `/doormile/deliveries` | Deliveries |
| `/doormile/riders`, `riders/create`, `riders/edit` | Riders, create, edit |
| `/doormile/tenants` · `clients/create` | Clients, create |
| `/doormile/customers` · `customer/create` | Customers, create |
| `/doormile/pricing` | Per-client rate cards + quote simulator |
| `/doormile/hubs` · `vehicles` · `tripsheets` · `exceptions` · `competitive-intel` · `app-users` | Fleet Ops |
| `/doormile/reports/orderssummary`, `ordersdetails`, `riderssummary`, `profitability` | Reports |
| `/doormile/profile` | Account and password |
| `*` | `lib/PageNotFound.jsx` |
Static route segments are declared before dynamic ones, so `orders/create` cannot be read as an
order whose id is `"create"`.
## The backend seam
Everything goes through `src/api/doormile/`, which talks to `api.doormile.com/api/v1/admin/*`.
| File | Role |
| --- | --- |
| `client.js` | One axios instance. Attaches `Bearer localStorage.doormileToken` to every request; a 401 clears the session and returns to `/login`. Rejects with the server's own body, keeping the status on `err.httpStatus`. |
| `endpoints.js` | One named export per route. Thin: build the URL, call, unwrap the envelope. GET/list return `response.data.data`; mutations return the whole `{ success, data \| message }`. |
| `queries.js` | The page-facing layer — the joins, the status mapping, the AI-solver calls, and safe empty defaults for the resources this API has no equivalent for. |
Pages do not import these directly. They use the React Query hooks in `lib/doormileHooks.js`, so a
resource has exactly one cache key across the app and a write on one page invalidates the list
another page is showing.
### What this API cannot do
Several things the previous backend served have no equivalent here. The functions covering them
return safe empty defaults rather than throwing, so a page renders an empty state instead of
crashing:
- **Zones** are not a resource. `applocationid` exists only as a field on a hub, so every zone
picker is derived from the distinct cities across `GET /admin/hubs`.
- **Rider substitution** has no endpoint at all, so the console has no substitution surface.
- **Invoices and expense approvals** have no endpoint.
- **Payment-mode types, item subcategories, rider shifts and vehicle types** are not enumerable.
The AI dispatch optimiser (`routes.workolik.com` / `routemate.workolik.com`) is a separate solver
service with no equivalent in the admin API, and is called directly.
## Doormile AI
An in-console copilot, opened from the header — not a route. It answers questions about live data
by calling **the same endpoints the pages call**, so its numbers and the screen's cannot disagree.
**It is deterministic, not an LLM.** This app has no backend of its own, so there is nowhere to hold
a model key that would not ship to the browser. The trade was taken deliberately: a rich matcher
over typed API calls, rather than a generated answer nobody can verify.
- **Never fabricate a number.** A question no API function covers is left unanswered. A wrong number
from this bot is worse than no answer.
- **Every answer carries its sources** behind a disclosure — which endpoint ran, and what came back.
That is the verifiability contract; do not remove it.
- **A truncated scan is a floor, not a total.** `/admin/bookings` caps `pagesize` at 1000, so scans
drain pages up to a budget and report `truncated`. Counts built on one say "at least", and the
source entry reports an error rather than a green tick.
- **Writes are gated.** The three creates — customer, order, bulk — are conversations, one question
per turn. The bot gathers, shows exactly what will be sent, and the mutation fires only when the
operator presses the button. `executeFlow` is the only mutating function and no `match` calls it.
- **A flow reply never reaches the router.** The panel intercepts it first: the router matches text,
and a bare answer like a phone number matches no intent.
- **Ordering is load-bearing.** Domain intents (rider, tenant, fleet) sit ahead of the generic
order/status ones, and the generic ones additionally guard against another domain's keywords. A
bug shipped once where "how many riders are active" was swallowed by the order-status intent,
because "active" is a valid order status.
## Dispatch is a monitoring board
It reads the deliveries feed, which by construction returns only bookings that **already carry a
rider or a consignment** — so an unassigned order cannot appear on it. Assignment therefore lives on
Orders, which reads raw bookings and can see the pending pile. Running the optimiser from Dispatch
would re-send work that is already assigned; the source console never did either.
Selecting a rider opens their day: live position with a reverse-geocoded area and ping history, the
plan against their GPS trail, and what the day earned. Per-stop actual distance is apportioned from
the trail by each stop's share of the plan — the feed carries no per-stop odometer, so it is an
estimate and the panel says so.
## Status Lifecycle & Backend Status Pairs
The platform manages two distinct layers of state: the initial **PickupBooking** (before pickup) and the subsequent **Consignment** (after pickup). `Converted_To_Consignment` acts solely as an internal backend handoff marker and is **never shown literally** in the UI.
### Status Derivation Matrix
| Phase | Backend State / Combination | Frontend Display Status | Deliveries Page Tab | Badge Tone |
| :--- | :--- | :--- | :--- | :--- |
| **Booking** | `status = Pending_Pickup` | **Pending** | `Pending` | `neutral` (grey) |
| **Booking** | `status = Miler_Assigned` | **Pending** (Deliveries) / **Assigned** (Orders) | `Pending` | `info` (blue) |
| **Booking** | `status = Pickup_Scheduled` + `reachedat == null` | **Accepted** | `Accepted` | `info` (blue) |
| **Booking** | `status = Pickup_Scheduled` + `reachedat != null` | **Arrived** | `Arrived` | `info` (cyan) |
| **Handoff** | `status = Converted_To_Consignment` | *(Switches to `consignmentstatus`)* | — | — |
| **Consignment** | `consignmentstatus = Collected_By_Miler` | **Picked** | `Picked` | `accent` (purple) |
| **Consignment** | `consignmentstatus = Out_for_Delivery` | **Active / Out for Delivery** | `Active` | `warning` (teal/yellow) |
| **Consignment** | `consignmentstatus = Inwarded_At_Hub` / `At_Hub` | **Inwarded At Hub** | `Active` | `info` (blue) |
| **Consignment** | `consignmentstatus = Outwarded_From_Hub` | **Outwarded From Hub** | `Active` | `warning` (teal) |
| **Consignment** | `consignmentstatus = In_Transit_To_Hub` | **In Transit To Hub** | `Active` | `warning` (teal) |
| **Consignment** | `consignmentstatus = Delivered` | **Delivered** | `Delivered` | `success` (green) |
| **Consignment** | `consignmentstatus = Cancelled` / `Returned` / `Failed` | **Cancelled** | `Cancelled` | `destructive` (red) |
| **Consignment** | `consignmentstatus = Skipped` / `RTO` | **Skipped** | `Skipped` | `destructive` (red) |
## Things that look wrong but are not
- **`Converted_To_Consignment` is a handoff marker, not a display state.** A booking's status freezes the moment it is picked up; the lifecycle continues on the consignment. The frontend automatically reads `consignmentstatus` from that point on.
- **The Picked tab is empty on hyperlocal traffic when the collection flag is off.** A matching 3-digit pickup/delivery pincode prefix goes straight to `Out_for_Delivery` at pickup, so those parcels transition directly from Arrived to Active.
- **Update Status refuses half its own options.** The write is consignment-scoped, and Pending, Accepted, Arrived, Picked and Skipped describe states a consignment cannot be in. Those are refused with the reason rather than guessed at.
- **Assigned means different things on Orders and Deliveries.** Orders tracks the *operator's* workflow, so assigning a rider counts as Assigned. Deliveries tracks the *rider's* engagement, so it waits for the rider to accept. This is deliberate, and the two taxonomies live in `orderStatusGroups.js` and `queries.js` respectively.
## Batches, dates and the one rule about time
Waves bucket on a booking's **creation time** (`orderdate`), never on `assigntime` — which is mapped
to the backend's last-modified column and is re-stamped by any status change — and never on
`expecteddeliverytime`, which is the promised slot rather than the wave the order was placed in.
`GET /admin/bookings` takes no date parameter, so the range is applied client-side on that same
field. **Filtering on one timestamp while bucketing on another cannot produce an honest count**, and
both have been tried.
The windows in `lib/batchBucket.js` cover the whole clock, and that is load-bearing: earlier windows
left 40% of the day in a gap where an order belonged to no wave and vanished from every filter.
Every timestamp goes through `parseDoormileTimestamp`. Doormile stores IST wall-clock in naive
columns and some responses carry a false trailing `Z`; parsing that as UTC shifts a row by five and
a half hours, and therefore into the wrong day and the wrong wave.
## Design system
Tokens live in `src/index.css` as HSL CSS variables consumed by `tailwind.config.js`.
| Token | Value |
| --- | --- |
| Primary — Doormile red | `#C8102E` |
| Accent | `#F9E547` |
| Dark Navy | `#333F48` |
| Radius | `0.75rem` |
| Headings | Sora |
| Body | Inter |
`src/components/ds/` is the vocabulary the app writes in — `Surface`, `KpiCard`, `DataTable`,
`PageHeader`, `Modal`, `StatusBadge`, `toast` and the rest, exported through `ds/index.js`.
`src/components/ui/` holds the shadcn primitives those are built on.
`StatusBadge`'s `STATUS_MAP` is the only place a lifecycle status maps to a colour and a label.
Statuses are looked up case-insensitively, and an unmapped value renders as a neutral badge showing
the humanised raw string — so a missing entry is visible rather than silent. Add states there, never
in a page-local table.
## Environment
| Variable | Purpose |
| --- | --- |
| `VITE_DOORMILE_URL` | The admin API. Defaults to `https://api.doormile.com/api/v1`. |
| `VITE_OSRM_URL` | Routing for order pricing. Defaults to the public OSRM demo server, which is rate-limited and not for production. |
## Scripts
| Command | Purpose |
| --- | --- |
| `npm run dev` | Dev server (honours `PORT`, default 5173) |
| `npm run build` | Production build |
| `npm run preview` | Serve the build |
| `npm run lint` | ESLint — currently clean |
| `npm run lint:fix` | ESLint with `--fix` |
| `npm run typecheck` | `tsc -p ./jsconfig.json` with `checkJs` |
## Deployment
`Dockerfile` builds with `node:22-alpine` (capped at 1 GB heap for small hosts) and serves the
static `dist/` from `nginx:alpine` using `nginx.conf` — gzip, immutable `/assets/` caching and an
SPA fallback to `index.html` so client-side routes resolve on refresh.