Files
doormilxpress_astryx/README.md

15 KiB
Raw Blame History

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.

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.

PageHeader renders clean icon, title, and action slots across all console pages without explanatory descriptive subtitles, eliminating vertical clutter for operators who already know the console.

Environment

Variable Purpose
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.