Two bodies of work that arrived in one working tree and are intertwined in
four files, so they commit together rather than pretending to a split the
diffs do not have.
TEAM WORK (pre-existing in the tree, uncommitted)
- "Riders" -> "Milers" across en.json and ~20 pages. Done carefully:
Deliveries.jsx checks BOTH prefixes ("Miler #" and "Rider #") so rows
written before the rename still render, and routes and query keys stay
rider*/riderssummary — renaming those would have broken bookmarks and
cache keys.
- MileTruth assistant rework: rename from "Doormile AI", HStack composer,
maximise/minimise/reset controls, open state persisted to localStorage.
- New /doormile/home landing page; / and /doormile now redirect there
instead of /doormile/dispatch. AdminLayout nav restructured with icons
and descriptions.
FIXES
Tab strip was unreachable (Deliveries: Delivered and Cancelled)
The pill variant is one non-wrapping inline-flex row. Given less width
than it needs, flex COMPRESSED it — 872px squeezed into 780px, truncating
labels inside the buttons — and with nothing scrollable no gesture could
recover the last two tabs. Four of six tab pages had each hand-rolled the
same wrapper; Deliveries and CompetitiveIntel had not. Fixed in the
component, so no page can forget it. Verified in a browser at the real
content width: 102px of scroll range, "Cancelled" reachable. 11 tests.
Home.jsx crash: Cannot read properties of null (reading 'flow')
Introduced by the change below, which made deriveVisualData return null.
All 12 .flow/.table dereferences are now behind visual?. gates.
Home.jsx presented fabricated figures as live operations
deriveVisualData keyword-matched the prompt and returned hardcoded values
for whole topics — revenue as a flat Rs 48,650, a workforce of 46 with 38
active, six named hubs, four booking numbers — ignoring the real result it
had been handed. Those branches sat ABOVE the one reading res.stats, so
the correct code was unreachable: asking about revenue could not return
the real number because a literal answered first.
Two were worse than wrong figures. The default branch returned a staffing
table for ANY unmatched question. And the no-answer path built a
sourceCalls entry claiming /admin/milers had been called, status
"complete", "46 milers scanned" — forging the provenance trail that exists
so an operator can check where a number came from. A fabricated figure is
a bug; a fabricated citation defeats the mechanism for catching one.
190 lines removed. Every branch now reads the result and returns null when
there is nothing to draw. No-answer says so; errors report as errors.
Found while verifying: 'Inactive'.includes('active') is true, so every hub
counted as active — carried from the original, whose own sample data
tripped it (six hubs, one Inactive, shown as 6/6). Now an explicit
vocabulary match, with unknown treated as down: a hub wrongly shown
offline gets investigated, one wrongly shown online hides an outage.
Home launcher tiles named pages that do not exist
Every tile now names its destination. Task Board -> Tripsheets,
Staff -> Milers, Availability -> Milers Summary (it pointed at the same
page as the tile beside it), Compliance -> Exceptions,
Invoices -> Bookings, Payroll -> Profitability,
AI Reports -> Orders Summary. Group headings: WORKFORCE SUITE -> FLEET,
FINANCE & CRM -> CLIENTS & REVENUE, AI TOOLS -> REPORTS & AI. "Invoices",
"Payroll" and "CRM" named systems Doormile does not run.
Four hardcoded badges removed (3, 38, 7, "New"). None was computed. A red
badge means "this many things need you", and one that never changes
teaches operators to ignore the real ones.
Navbar MileTruth control misaligned, hover dead
One cause: a 36px image (h-9 w-9 max-w-none) inside a 32px button. It
stood proud of the bell and avatar AND covered its own button, leaving
hover:bg-surface-sunken nowhere to render. Now 20px.
Assistant dock 30% -> 20%, and responsive
clamp(320px, 20vw, 460px) rather than max(): the cap is what makes a large
monitor work, since 20vw is 768px at 4K. Verified across seven screen
classes — 384px/20% at 1920, capped at 460px beyond 2300px, full-width
overlay at or below 900px. At 1024px the 320px floor wins (31%), because
20vw would be 205px and too narrow for the composer.
Suggestion chips took a third of the panel
.dai-suggestions was flex-direction:column, overriding the component's own
wrap="wrap", so four chips became four full rows. Row + wrap, trimmed
padding, ellipsis on the text span where text-overflow can act. Measured
at the 320px floor: 4 rows/177px -> 2 rows/84px, nothing clipped.
Pricing and Customers were unreachable on mobile
Moving them off the nav bar into the account dropdown removed them from
the phone entirely — the mobile sheet builds from NAV plus NAV_GROUPS and
they were in neither. Both stayed routed, so only a typed URL reached
them. Now one SETTINGS_NAV array that the dropdown and the sheet share.
calculateDrivingDistance was called and never imported
CreateOrder.jsx:371 — a ReferenceError on every customer-pickup order with
a pinned collection address, thrown synchronously inside a useEffect so
the .catch() on that chain could never see it. Neither the build nor the
lint config catches an unbound identifier in a .jsx file: eslint.config.js
spreads pluginJs.configs.recommended and then declares its own rules
object, which replaces the spread rules wholesale, so no-undef has never
run. A repo-wide sweep with it enabled found this was the only instance.
Pre-existing; found while auditing the coordinate changes.
Dead code and stale docs
ALL_DESTINATIONS (declared, never read; its comment claimed it was what
the mobile sheet renders). --dai-panel-width (declared twice, read
nowhere) and the max-width:1279px block that only set it. AdminLayout's
header comment said "three grouped menus (Fleet Ops, Reports, Settings)"
when there are two, and Settings is a section in the account menu — that
difference in routing is what caused the mobile gap above.
VERIFIED
878 tests across 26 suites, build clean, no new lint problems. The tab
strip, dock width, chip wrapping and navbar sizing were measured in a real
browser against the shipped stylesheet. deriveVisualData is module-private,
so it was extracted and driven through 16 logic checks.
NOT VERIFIED
/doormile/home has never been rendered in a browser, and the responsive
pass covers the assistant panel only — no page has been viewed at any
breakpoint. Both need a signed-in session. DataTable carries its own
overflow-x-auto and only two fixed widths above 390px exist in src/pages,
but that is grep, not eyes.
STILL OPEN
customerAppBookings.js lost its customerstatus/customerstage grouping in
49ee0c5 and has not been restored, so the Bookings tabs still read only the
operational status. The Status column (Bookings.jsx:98, :415) was never
stage-aware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
15 KiB
Markdown
254 lines
15 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)
|
||
```
|
||
|
||
## Operations Copilot (MileTruth AI) & Workspace Layout
|
||
|
||
The console integrates a docked operations copilot (**MileTruth**) across all admin routes via `AdminLayout.jsx`:
|
||
- **70% Content / 30% AI Sidebar**: Main workspace occupies 70% width on the left and the MileTruth copilot occupies 30% width on the right (`--dai-dock-width: max(340px, 30vw)`).
|
||
- **Physical Separation**: An extra 24px visible gutter (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the tables/cards from the AI sidebar card.
|
||
- **Elevated Inset Card**: The sidebar card floats with `rounded-2xl` corners (20px), subtle borders (`1px solid #e2e8f0`), soft shadow, and top/right/bottom margins.
|
||
- **Default Open & Dismissal**: Opens by default on desktop (`window.innerWidth >= 1024`, persisted in `localStorage`). The close button (`×`) smoothly collapses the sidebar, animating the page content to 100% full width. Re-opening is accessible anytime via the Doormile 'D' mark in the top navbar.
|
||
- **Home Page (`Home.jsx`)**: Prompt composer features a dedicated **Send** button (`Send` icon + "Send") triggering `handleAsk()`.
|
||
|
||
## Routes
|
||
|
||
The app opens on **`/login`**. Everything else lives under `/doormile/*` behind `ProtectedRoute`,
|
||
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.
|