Milers rebrand, /doormile/home, and a set of console fixes

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>
This commit is contained in:
2026-09-18 19:21:21 +05:30
parent 05f08efc64
commit 1671a5f4b6
35 changed files with 2941 additions and 475 deletions

View File

@@ -18,6 +18,7 @@ import Login from '@/pages/Login';
const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx'));
const Home = lazy(() => import('@/pages/doormile/home/Home.jsx'));
const Orders = lazy(() => import('@/pages/doormile/orders/Orders'));
const Bookings = lazy(() => import('@/pages/doormile/bookings/Bookings'));
const OrdersPreview = lazy(() => import('@/pages/doormile/orders/OrdersPreview'));
@@ -78,11 +79,13 @@ const AuthenticatedApp = () => {
{/* Dispatch is the console's home: it is the board operators sit on
all day, and the one page that answers "what is happening right
now" without a filter being set first. */}
<Route path="/" element={<Navigate to="/doormile/dispatch" replace />} />
<Route path="/" element={<Navigate to="/doormile/home" replace />} />
<Route path="/home" element={<Navigate to="/doormile/home" replace />} />
<Route path="/doormile" element={<AdminLayout />}>
<Route index element={<Navigate to="/doormile/dispatch" replace />} />
<Route index element={<Navigate to="/doormile/home" replace />} />
<Route path="home" element={<Home />} />
<Route path="dispatch" element={<Dispatch />} />
<Route path="dispatch/preview" element={<DispatchPreview />} />

View File

@@ -8,8 +8,9 @@
* page use. Reach for the wordmark only on a dark ground.
*/
/** The circular red D — the brand mark, legible on light surfaces. */
export const DOORMILE_MARK_URL = '/doormile-mark.png';
/** The red D brand mark, legible on light surfaces. */
export const DOORMILE_MARK_URL = '/preloader.png';
export const DOORMILE_D_LOGO_URL = '/preloader.png';
/** The white wordmark. Dark backgrounds only. */
export const DOORMILE_WORDMARK_URL = '/doormile-logo.png';

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -6,21 +6,25 @@ Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `Doo
## 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.
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 **MileTruth** (or **Doormile AI**), subtitle **Operations Copilot**.
- **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.
- **Mounted in**: `src/layouts/AdminLayout.jsx` — rendered alongside the page content.
- **70% / 30% Split Layout**: By default on desktop (`window.innerWidth >= 1024`), the assistant is **open on initial load**, occupying 30% of the viewport (`--dai-dock-width: max(340px, 30vw)`), while the page content occupies the remaining 70%.
- **Visual Separation**: An intentional **24px visible gutter** (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the page content/tables from the AI card so both surfaces are distinct.
- **Smooth Dismissal**: Closing via the header `×` button or navbar toggles `assistantOpen` off, smoothly expanding the page content to 100% full width. State is persisted in `localStorage ('doormileAssistantOpen')`.
- **`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).
- `AIPanel.js` — the portal, slide-over, focus/Escape handling, message state, history persistence, 4 clean action icons (History, Reset, Expand, Close), and the ask() flow.
- `AIWelcome.js` — greeting + suggestion pill chips (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.
- `AIComposer.js` — clean horizontal capsule pill container with textarea, mic, and `<LuSend />` button.
- `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.
- `AIParts.js` — Spark / TypingIndicator / Metric / StatGrid / StateBlock.
- `pageContext.js` — route → context label + suggested questions.
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
- **`DoormileAI.css`** — the panel's stylesheet. Defines inset card geometry (`border-radius: 20px`, elevated shadow, floating margins off top/bottom/right edges on desktop).
- **`Home.jsx` Composer**: Prompt bar features a functional **Send** button (`Send` icon + "Send") replacing the previous Speak button. Top navigation bar features the Doormile red "D" mark.
### UI rules that are load-bearing, not cosmetic

View File

@@ -24,22 +24,41 @@
globalPolish.css, and an unprefixed name there is decided by bundle order.
--------------------------------------------------------------------------- */
:root {
/* 30vw, NOT 30%.
/* 20vw, NOT 20%.
The two consumers resolve a percentage against different boxes: the panel
is position:fixed, so `width: 30%` is 30% of the VIEWPORT, while
`padding-right: 30%` on the content container is 30% of ITS containing
block — which is narrower by the side nav. At 1440px that is 432px of
panel against 410px of reserved strip, and the page slides 22px underneath
the panel. A viewport unit resolves the same for both.
is position:fixed, so `width: 20%` is 20% of the VIEWPORT, while
`padding-right: 20%` on the content container is 20% of ITS containing
block — which is narrower by the side nav. The page would slide underneath
the panel by the difference. A viewport unit resolves the same for both.
max() rather than a separate min-width for the same reason: a min-width
that only the panel knows about would reintroduce the mismatch on narrow
screens. */
screens.
The floor matters more at 20vw than it did at 30vw: 20vw is 256px on a
1280px laptop, which is too narrow for the composer and a message thread.
320px is the floor, so below 1600px the panel stops shrinking and holds —
and the page keeps the rest. On a 1920px monitor 20vw is 384px.
Narrowed from 30vw on request. It is the reason the Deliveries tab strip
started overflowing, so the extra 10vw goes back to the page. */
/* Default = the NORMAL step. AIPanel overwrites this property on the
document element when the operator toggles the width, so the panel and
the page's reserved strip always read the same value. */
--dai-dock-width: max(300px, 25vw);
/* clamp, not max: the cap is what makes this work on a large monitor.
With `max(320px, 20vw)` the panel just kept growing — 20vw is 512px at
2560px and 768px at 4K, which is a chat column wider than most documents,
while the board beside it gets squeezed for no benefit. A conversation
does not read better past ~460px.
So three regimes, one expression:
< 1600px floor at 320px (20vw would be 256px on a 1280 laptop —
too narrow for the composer and the thread)
1600-2300 20vw (scales with the screen, as asked)
> 2300px cap at 460px (the page keeps everything above this) */
--dai-dock-width: clamp(320px, 20vw, 460px);
--dai-duration: 240ms;
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -73,7 +92,10 @@
--dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
--dai-live: #10b981;
--dai-panel-width: 428px;
/* --dai-panel-width was declared here and at the 1279px breakpoint, and read
nowhere: the panel and the page's reserved strip both size from
--dai-dock-width. Removed so nobody tunes it expecting the panel to
change. */
--dai-inset: 12px;
--dai-duration: 240ms;
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
@@ -119,22 +141,22 @@
seam has depth without the panel detaching. */
.dai-panel {
position: fixed;
/* 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;
/* Inset card geometry matching the reference design:
Floating cleanly off the top nav, bottom edge, and right edge with rounded corners. */
top: calc(var(--dai-dock-top, 57px) + 14px);
right: 16px;
bottom: 14px;
z-index: 1200;
width: var(--dai-dock-width);
width: calc(var(--dai-dock-width) - 24px);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--dai-surface);
border-left: 1px solid var(--dai-border);
border-radius: 0;
box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
transform: translateX(100%);
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 20px;
box-shadow: 0 4px 24px -2px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04);
transform: translateX(calc(100% + 32px));
visibility: hidden;
transition:
transform var(--dai-duration) var(--dai-ease),
@@ -157,11 +179,15 @@
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);
/* Reserves the dock width plus a generous 20px gutter so the page content
(tables, cards) never collides with the sidebar and stays distinct. */
padding-right: calc(var(--dai-dock-width) + 16px);
}
/* ---- Mobile: there is no 30% worth having -------------------------------
@@ -171,10 +197,14 @@ body.dai-docked .astryx-layout-content {
@media (max-width: 900px) {
.dai-panel {
top: 0;
right: 0;
bottom: 0;
width: 100%;
z-index: 1301;
border-left: none;
border: none;
border-radius: 0;
box-shadow: var(--dai-shadow);
transform: translateX(100%);
}
.dai-scrim {
display: block;
@@ -182,6 +212,7 @@ body.dai-docked .astryx-layout-content {
.dai-scrim[data-open='true'] {
opacity: 1;
}
body.dai-docked .dai-page,
body.dai-docked .astryx-layout-content {
padding-right: 0;
}
@@ -194,20 +225,22 @@ body.dai-docked .astryx-layout-content {
-------------------------------------------------------------------------- */
.dai-root .dai-header {
flex: 0 0 auto;
padding: 14px 12px 12px 14px;
border-bottom: 1px solid var(--dai-border);
padding: 14px 16px;
border-bottom: 1px solid #f1f5f9;
background: #ffffff;
}
.dai-root .dai-title {
font-size: 15px;
font-weight: 650;
font-size: 16px;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--dai-text);
color: #0f172a;
}
.dai-root .dai-subtitle {
font-size: 12px;
line-height: 1.3;
color: var(--dai-text-muted);
color: #64748b;
font-weight: 500;
}
/* The AI mark. A soft gradient orb — not a robot face. */
.dai-root .dai-spark {
@@ -215,8 +248,9 @@ body.dai-docked .astryx-layout-content {
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: 999px;
background: var(--dai-surface);
border-radius: 10px;
background: #fff1f2;
border: 1px solid #ffe4e6;
overflow: hidden;
}
/* The mark's own canvas is only 66.8% content — a third of every edge is
@@ -277,15 +311,7 @@ body.dai-docked .astryx-layout-content {
}
/* Page-context strip — "Orders · Today · All locations" */
.dai-root .dai-context {
flex: 0 0 auto;
padding: 7px 14px;
font-size: 11.5px;
color: var(--dai-text-muted);
background: var(--dai-surface-alt);
border-bottom: 1px solid var(--dai-border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: none;
}
/* --------------------------------------------------------------------------
Welcome state
@@ -360,61 +386,87 @@ body.dai-docked .astryx-layout-content {
They wrap, they never scroll. A horizontal scroller can always leave a chip
half-visible at the edge, and a half-visible button is a bug no amount of
fade masking fixes. */
.dai-root .dai-suggestions-bar .dai-section-label {
display: none;
}
.dai-root .dai-suggestions {
/* Row + wrap, which is what the comment above always claimed and what the
component asks for — SuggestionChips renders an HStack with wrap="wrap".
This rule said `column`, overriding it, so four chips became four full
rows and the strip ate a third of the panel before a single question had
been asked. Two short chips now share a row and only long ones take one to
themselves. */
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
align-content: flex-start;
}
.dai-suggestion {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 8px;
max-width: 100%;
padding: 5px 10px;
/* Trimmed from 8px 18px. At the 320px floor the old horizontal padding put
36px of empty pill around every label, which is what stopped two chips
ever fitting side by side. */
padding: 7px 13px;
text-align: left;
/* A chip is one line. Without this the flex row can hand a chip a width
narrower than its label and the text wraps inside the pill, which is what
made the strip look ragged. The ellipsis itself is on
.dai-suggestion-text — see there. */
white-space: nowrap;
overflow: hidden;
font: inherit;
font-size: 12px;
line-height: 1.35;
font-size: 13px;
line-height: 1.4;
font-weight: 500;
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
/* 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;
color: #334155;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 9999px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
transition: all 140ms ease;
}
.dai-suggestion:hover {
background: var(--dai-surface-alt);
border-color: var(--dai-border-strong);
background: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06);
}
.dai-suggestion:active {
transform: translateY(0);
}
.dai-suggestion:focus-visible {
outline: 2px solid var(--dai-accent);
outline: 2px solid #0f172a;
outline-offset: 2px;
}
.dai-root .dai-suggestion-icon {
flex: 0 0 auto;
color: var(--dai-text-muted);
color: #64748b;
transition: color 140ms ease;
}
.dai-suggestion:hover .dai-suggestion-icon {
color: var(--dai-text-secondary);
color: #0f172a;
}
.dai-root .dai-suggestion-text {
/* The ellipsis lives here, not on the button: text-overflow needs the
element that actually holds the text, and the button is a flex container
whose children are the icon and this span. min-width:0 is what lets the
span shrink below its content width so the ellipsis can engage at all —
a flex item defaults to min-width:auto and would push the pill wider
instead. The button already carries title={item.text}, so the full
question is still available when a label is cut. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Plain text link-button ("View more", "Sources") */
.dai-link {
@@ -695,26 +747,29 @@ body.dai-docked .astryx-layout-content {
-------------------------------------------------------------------------- */
.dai-root .dai-composer-wrap {
flex: 0 0 auto;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--dai-border);
background: var(--dai-surface);
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid #f1f5f9;
background: #ffffff;
}
.dai-root .dai-composer {
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 10px 8px 14px;
border: 1px solid #e2e8f0;
border-radius: 9999px;
background: #f8fafc;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
padding: 6px 6px 6px 16px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
transition:
border-color 140ms ease,
box-shadow 140ms ease;
box-shadow 140ms ease,
background-color 140ms ease;
}
.dai-root .dai-composer[data-focused='true'] {
border-color: var(--dai-accent);
box-shadow: 0 0 0 3px var(--dai-accent-ring);
background: #ffffff;
border-color: #cbd5e1;
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.05);
}
/* --------------------------------------------------------------------------
@@ -811,13 +866,13 @@ body.dai-docked .astryx-layout-content {
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dai-accent);
color: var(--dai-accent-contrast);
background: #2563eb;
color: #ffffff;
cursor: pointer;
transition:
background-color 140ms ease,
@@ -825,29 +880,32 @@ body.dai-docked .astryx-layout-content {
transform 140ms ease;
}
.dai-root .dai-send:hover:not(:disabled) {
opacity: 0.86;
background: #1d4ed8;
transform: scale(1.05);
}
.dai-root .dai-send:active:not(:disabled) {
transform: scale(0.94);
transform: scale(0.96);
}
.dai-root .dai-send:focus-visible {
outline: 2px solid var(--dai-accent);
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Disabled is the resting state until something is typed, so it has to read as
"not yet", not as "broken" — a filled grey circle, same shape and weight. */
.dai-root .dai-send:disabled {
background: var(--dai-surface-hover);
color: var(--dai-text-muted);
background: #f1f5f9;
color: #94a3b8;
cursor: default;
}
.dai-root .dai-composer textarea {
display: block;
flex: 1 1 auto;
width: 100%;
min-width: 0;
border: none;
outline: none;
resize: none;
padding: 0;
padding: 4px 0;
margin: 0;
font: inherit;
font-size: 13.5px;
@@ -914,15 +972,36 @@ body.dai-docked .astryx-layout-content {
/* --------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@media (max-width: 1279px) {
.dai-root {
--dai-panel-width: 396px;
/* Tablet and small laptop, 768-900px: the panel is a full-width overlay with a
scrim (see the max-width:900px block above), so the only thing left to do is
stop the suggestion strip from taking a third of a short screen — there is
much less height to spend here than on a desktop. */
@media (max-width: 900px) {
.dai-root .dai-suggestions-bar {
max-height: 26vh;
}
}
/* The block that used to sit here set `--dai-panel-width: 396px` at 1279px.
That variable is declared and never read — the panel sizes from
--dai-dock-width — so the rule had no effect on anything. Removed rather
than left looking load-bearing. */
@media (max-width: 767px) {
.dai-root {
--dai-inset: 0px;
}
/* Phone: chips get a touch target and room to breathe, and the strip is
capped harder still because the on-screen keyboard takes the bottom half
of the viewport the moment the composer is focused. */
.dai-root .dai-suggestions-bar {
max-height: 22vh;
padding: 8px 12px 6px;
}
.dai-suggestion {
padding: 9px 14px;
font-size: 13px;
}
.dai-panel {
width: 100vw;
max-width: 100vw;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { LuArrowUp } from 'react-icons/lu';
import { LuSend } from 'react-icons/lu';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
@@ -111,7 +111,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
return (
<VStack className="dai-composer-wrap" gap={1} padding={0}>
<VStack className="dai-composer" data-focused={isFocused} gap={1} padding={0}>
<HStack className="dai-composer" data-focused={isFocused} gap={1} padding={0} vAlign="center">
<textarea
ref={textareaRef}
rows={1}
@@ -120,13 +120,9 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
aria-label="Ask Doormile AI about operations"
placeholder={placeholder || 'Ask MileTruth anything...'}
aria-label="Ask MileTruth about operations"
/>
{/* Both controls group on the RIGHT. `justify="between"` pushed the mic
to the far left and the send button to the far right, leaving a
wide dead gap between two things that do the same job — put a
message in. Together they read as one action cluster. */}
<HStack gap={1} padding={0} vAlign="center" justify="end">
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
<button
@@ -136,10 +132,10 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
disabled={!canSend}
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
>
<LuArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
<LuSend size={15} aria-hidden="true" />
</button>
</HStack>
</VStack>
</HStack>
<Text className="dai-hint">
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
</Text>

View File

@@ -43,7 +43,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
<HStack gap={1} padding={0} vAlign="center">
<Spark size="sm" />
<Text className="dai-msg-name">Doormile AI</Text>
<Text className="dai-msg-name">MileTruth</Text>
</HStack>
<HStack className="dai-msg-actions" gap={0} padding={0}>
<IconButton

View File

@@ -4,8 +4,17 @@ import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { AiOutlineClose as CloseOutlined, AiOutlineMore as MoreOutlined } from 'react-icons/ai';
import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose, LuHistory, LuTrash2, LuMessageSquare } from 'react-icons/lu';
import { AiOutlineMore as MoreOutlined } from 'react-icons/ai';
import {
LuChevronDown,
LuHistory,
LuTrash2,
LuMessageSquare,
LuRotateCcw,
LuMaximize2,
LuMinimize2,
LuX
} from 'react-icons/lu';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
@@ -51,14 +60,17 @@ const CONVERSATIONS_KEY = 'doormileBotConversations';
const CONVERSATIONS_LIMIT = 25;
const PIN_THRESHOLD_PX = 48;
// Dock widths. Each is a percentage of the viewport with a pixel floor — 25vw
// is only 320px on a 1280 laptop, and the floor catches anything narrower than
// that, below which the panel stops being readable. Both values are also what
// the page reserves beside the panel, so they must stay a single expression
// (see the --dai-dock-width comment in DoormileAI.css for why a percentage
// cannot be used here).
const DOCK_NORMAL = 'max(300px, 25vw)';
const DOCK_WIDE = 'max(340px, 30vw)';
// Dock widths. Each is a viewport fraction with a pixel floor — 20vw is only
// 256px on a 1280 laptop, and the floor catches anything narrower than that,
// below which the composer and the thread stop being readable. Both values are
// also what the page reserves beside the panel, so they must stay a single
// expression (see the --dai-dock-width comment in DoormileAI.css for why a
// percentage cannot be used here).
// Must stay byte-identical to the --dai-dock-width default in DoormileAI.css:
// the CSS value applies before this effect runs on first paint, and any
// difference between the two shows as the page jumping sideways on load.
const DOCK_NORMAL = 'clamp(320px, 20vw, 460px)';
const DOCK_WIDE = 'clamp(420px, 30vw, 680px)';
const WIDTH_KEY = 'doormileBotWidth';
// Seeded from the clock, not from 1.
@@ -1378,7 +1390,7 @@ const AIPanel = ({ isOpen, onClose }) => {
? nextSteps.length > 0
? nextSteps
: pageContext.suggestions.slice(0, 4)
: [...pageContext.suggestions, ...(pageContext.more || [])];
: pageContext.suggestions.slice(0, 4);
// Few chips => the strip is fixed and always fully visible. Twenty => it may
// shrink and scroll. See the [data-compact] rule in DoormileAI.css.
@@ -1401,7 +1413,7 @@ const AIPanel = ({ isOpen, onClose }) => {
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"
aria-label="MileTruth — Operations Copilot"
tabIndex={-1}
>
{/* ---- header ---- */}
@@ -1410,30 +1422,16 @@ const AIPanel = ({ isOpen, onClose }) => {
<HStack gap={1.5} padding={0} vAlign="center">
<Spark size="md" />
<VStack gap={0} padding={0}>
<Text className="dai-title">Doormile AI</Text>
<Text className="dai-title">MileTruth</Text>
<Text className="dai-subtitle">Operations Copilot</Text>
</VStack>
</HStack>
<HStack gap={1} padding={0} vAlign="center">
<LiveIndicator />
{/* No `size` prop on the icon — deliberately.
Its two siblings here are Ant Design icons, which render at
1em and so inherit the button's 16px. Pinning this one to 15
made it a pixel smaller than the ⋯ and × beside it, which is
exactly the kind of one-pixel mismatch that reads as "off"
without being obviously wrong. Left unset, react-icons also
defaults to 1em and all three track the button together.
Panel icons rather than chevrons: a bare chevron next to a
close button is ambiguous (collapse? navigate? close?),
whereas these draw the side panel itself getting wider or
narrower, which is literally what the control does. */}
<IconButton
size="sm"
variant="ghost"
label={view === 'history' ? 'Back to the conversation' : 'Conversation history'}
tooltip={view === 'history' ? 'Back to the conversation' : 'History'}
label={view === 'history' ? 'Back to conversation' : 'Conversation history'}
tooltip={view === 'history' ? 'Back to conversation' : 'History'}
aria-pressed={view === 'history'}
icon={view === 'history' ? <LuMessageSquare /> : <LuHistory />}
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
@@ -1441,39 +1439,32 @@ const AIPanel = ({ isOpen, onClose }) => {
<IconButton
size="sm"
variant="ghost"
label={isWide ? 'Narrow the assistant' : 'Widen the assistant'}
tooltip={isWide ? 'Narrow to 25%' : 'Widen to 30%'}
aria-pressed={isWide}
icon={isWide ? <LuPanelRightClose /> : <LuPanelRightOpen />}
onClick={() => setIsWide((v) => !v)}
label="New conversation"
tooltip="New conversation"
icon={<LuRotateCcw />}
onClick={newConversation}
/>
<DropdownMenu
hasChevron={false}
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
items={[
// These were both wired to clearConversation, so the safe
// option and the destructive one did the same thing. Now
// "New" keeps the thread and "Delete" says that it does not.
{ label: 'New conversation', onClick: newConversation },
{ label: 'Conversation history', onClick: () => setView('history') },
{ label: 'Delete this conversation', onClick: clearConversation }
]}
<IconButton
size="sm"
variant="ghost"
label={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
tooltip={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
aria-pressed={isWide}
icon={isWide ? <LuMinimize2 /> : <LuMaximize2 />}
onClick={() => setIsWide((v) => !v)}
/>
<IconButton
size="sm"
variant="ghost"
label="Close assistant"
tooltip="Close assistant"
icon={<CloseOutlined />}
icon={<LuX />}
onClick={onClose}
/>
</HStack>
</HStack>
</VStack>
{/* ---- page context ---- */}
<Text className="dai-context">{pageContext.label} · Today · All locations</Text>
{/* ---- conversation / welcome ---- */}
<VStack className="dai-scroll-wrap" gap={0} padding={0}>
<VStack ref={scrollRef} className="dai-scroll" gap={0} padding={0} onScroll={handleScroll}>
@@ -1525,7 +1516,7 @@ const AIPanel = ({ isOpen, onClose }) => {
<VStack className="dai-msg" gap={1.5} padding={0}>
<HStack gap={1} padding={0} vAlign="center">
<Spark size="sm" />
<Text className="dai-msg-name">Doormile AI</Text>
<Text className="dai-msg-name">MileTruth</Text>
</HStack>
<TypingIndicator />
</VStack>
@@ -1563,7 +1554,7 @@ const AIPanel = ({ isOpen, onClose }) => {
)}
{/* ---- composer ---- */}
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, riders, hubs…" />
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, milers, hubs…" />
</VStack>
</>,
document.body

View File

@@ -38,7 +38,7 @@ LiveIndicator.propTypes = { label: PropTypes.string };
// Thinking state. Three dots plus two shimmer bars — enough to read as
// "working", not enough to be a light show.
export const TypingIndicator = () => (
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="Doormile AI is thinking">
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="MileTruth is thinking">
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
<span />
<span />

View File

@@ -77,7 +77,7 @@ const AIWelcome = () => {
<Text className="dai-welcome-lead">How can I help with today&rsquo;s operations?</Text>
</VStack>
<Text className="dai-welcome-note">
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
I read live orders, milers, vehicles and hubs. Every number comes from a real call — I never estimate one.
</Text>
</VStack>
);

View File

@@ -23,13 +23,13 @@ const DoormileAITrigger = () => {
return (
<>
<Tooltip content="Doormile AI">
<Tooltip content="MileTruth">
<button
ref={buttonRef}
type="button"
className="dai-root dai-trigger"
data-active={isOpen}
aria-label="Open Doormile AI"
aria-label="Open MileTruth"
aria-expanded={isOpen}
onClick={() => (isOpen ? close() : setIsOpen(true))}
>

View File

@@ -309,7 +309,7 @@ export function AssistantPanel({ open, onClose }) {
<aside
role="dialog"
aria-label="Doormile AI"
aria-label="MileTruth"
className="fixed right-0 top-0 z-50 flex h-dvh w-full flex-col border-l border-border bg-surface shadow-xl sm:w-[26rem]"
>
<header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
@@ -318,7 +318,7 @@ export function AssistantPanel({ open, onClose }) {
<Sparkles className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-body-sm font-semibold text-ink-1">Doormile AI</p>
<p className="text-body-sm font-semibold text-ink-1">MileTruth</p>
<p className="truncate text-caption text-ink-3">Operations copilot · {label}</p>
</div>
</div>

View File

@@ -14,6 +14,28 @@ import { Badge } from '@/components/ui/badge';
* between tabs the same way the main nav pill does. `layoutGroupId` must be
* unique when two tab sets are on screen at once, otherwise the indicator
* animates between them.
*
* ── The strip scrolls itself ──
*
* A tab row is one non-wrapping line whose width is decided by its caller's
* data, and it is routinely wider than the space it is given: Deliveries has
* ten status tabs at roughly 1050px, and the assistant dock takes 30% of the
* viewport back (40% expanded) and now opens by default above 1024px. On a
* 1350px screen that leaves about 900px, so the last two tabs — Delivered and
* Cancelled — were simply unreachable. Not merely clipped: with nothing
* scrollable around them there was no gesture that could bring them back.
*
* Four of the six pages using this component had each hand-rolled the same
* wrapper to cope; the two that had not were broken. That ratio is the
* argument for putting it here. A component that renders a row of unknown
* length owns its own overflow — leaving it to every caller means it works
* until somebody adds a tab, and then it silently stops.
*
* The negative margin paired with equal padding is not cosmetic: `overflow-x`
* clips the focus ring on the first and last tab, and the pair gives the ring
* room without shifting the strip's visual position. It also makes the wrapper
* idempotent, so the four pages that still nest their own around it render
* identically.
*/
/** @param {any} props */
export function Tabs({
@@ -30,20 +52,62 @@ export function Tabs({
const fallbackId = React.useId();
const groupId = layoutGroupId || fallbackId;
const trackRef = React.useRef(null);
const activeRef = React.useRef(null);
// Keep the selected tab in view.
//
// Selection moves without a click — a deep link, a cleared filter, restored
// state — and landing on a page whose active tab sits off the right edge
// looks identical to landing on a page with no tab selected at all.
//
// Scrolls the TRACK's own scrollLeft rather than calling scrollIntoView,
// which walks every scrollable ancestor and would yank the whole page when
// the strip happens to sit below the fold.
React.useEffect(() => {
const track = trackRef.current;
const active = activeRef.current;
if (!track || !active) return;
const left = active.offsetLeft;
const right = left + active.offsetWidth;
const viewLeft = track.scrollLeft;
const viewRight = viewLeft + track.clientWidth;
if (left >= viewLeft && right <= viewRight) return;
track.scrollTo({
left: Math.max(0, left - (track.clientWidth - active.offsetWidth) / 2),
behavior: 'smooth',
});
}, [value]);
// The scroll track. `min-w-0` is what actually lets it shrink inside a flex
// parent — without it a flex item refuses to go below its content width and
// overflows the page instead of scrolling inside itself.
const track = (children) => (
<div ref={trackRef} className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
{children}
</div>
);
if (variant === 'underline') {
return (
<div role="tablist" aria-label={ariaLabel} className={cn('flex items-center gap-1 border-b border-border', className)}>
return track(
// `w-max` lets the row grow past the track so there is something to
// scroll; `min-w-full` keeps the bottom border spanning the full width
// when the tabs do not fill it.
<div role="tablist" aria-label={ariaLabel} className={cn('flex w-max min-w-full items-center gap-1 border-b border-border', className)}>
{normalized.map((tab) => {
const active = tab.value === value;
return (
<button
key={tab.value}
ref={active ? activeRef : undefined}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange?.(tab.value)}
className={cn(
'relative inline-flex items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
'relative inline-flex shrink-0 items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
size === 'sm' ? 'text-body-sm' : 'text-body',
active ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
)}
@@ -67,12 +131,14 @@ export function Tabs({
);
}
return (
return track(
<div
role="tablist"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
// `w-max` so the pill rail sizes to its tabs and overflows the track
// rather than compressing them into each other.
'inline-flex w-max items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
className
)}
>
@@ -81,12 +147,16 @@ export function Tabs({
return (
<button
key={tab.value}
ref={active ? activeRef : undefined}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange?.(tab.value)}
className={cn(
'relative inline-flex items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
// shrink-0: without it the flex row squashes ten tabs into the
// available width instead of overflowing, and the track has
// nothing to scroll.
'relative inline-flex shrink-0 items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
size === 'sm' ? 'px-3 py-1 text-caption' : 'px-4 py-1.5 text-body-sm',
active ? 'text-white' : 'text-ink-3 hover:text-ink-1'
)}

View File

@@ -2,10 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Bell, ChevronDown, LogOut, Menu, Search, Shield, User,
Activity, Bell, Bike, Building2, Car, ChevronDown, Coins, FileSpreadsheet,
FileText, IndianRupee, ListTodo, LogOut, Menu, Search, Settings, Shield,
ShieldAlert, User, UserCheck, Users, Warehouse,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { DOORMILE_WORDMARK_URL } from '@/assets/brand';
import { DOORMILE_MARK_URL, DOORMILE_WORDMARK_URL } from '@/assets/brand';
import { Avatar } from '@/components/ds/Avatar';
import { Badge } from '@/components/ui/badge';
import {
@@ -23,23 +25,22 @@ import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
* The console shell.
*
* Single 56px header bar, compact search with a keyboard shortcut, hairline tab
* indicator, mobile sheet — the design is unchanged. What changed is what it
* points at: the Doormile Express destinations rather than the hiring ones.
* indicator, mobile sheet.
*
* Seven destinations sit on the bar and the remaining eleven live in two
* grouped menus (Fleet Ops, Reports), built from the same DropdownMenu the
* account menu already uses. Eighteen flat tabs would not fit a 56px bar at any
* width, and a console header should recede rather than become the loudest
* thing on the page.
* Core destinations sit on the bar; the rest live in two grouped menus on it
* (Fleet Ops, Reports) plus a Settings section inside the account menu, which
* holds the two destinations that are configuration rather than operations
* (Pricing Matrix, Customers Directory).
*
* Said precisely because the previous wording — "three grouped menus (Fleet
* Ops, Reports, Settings)" — sent a reader looking for a third NAV_GROUPS
* entry that does not exist, and hid the fact that the Settings pair is
* reached by a different route from everything else. That difference is
* exactly what made them unreachable on mobile.
*/
const ROOT = '/doormile';
/* `doormile-logo.png` is a white wordmark — invisible on this bar's light
chrome as-is. Recolored to brand red via filter rather than swapping the
asset, same trick the source console's own TopNav uses on the identical
file, so the two headers render the mark in the same color from the same
source PNG. */
const LOGO_FILTER = {
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',
};
@@ -52,8 +53,6 @@ const NAV = [
{ label: 'Deliveries', path: '/doormile/deliveries' },
{ label: 'Milers', path: '/doormile/milers' },
{ label: 'Clients', path: '/doormile/tenants' },
{ label: 'Pricing', path: '/doormile/pricing' },
{ label: 'Customers', path: '/doormile/customers' },
];
/** Destinations grouped behind a menu, in the order the console lists them. */
@@ -61,27 +60,45 @@ const NAV_GROUPS = [
{
label: 'Fleet Ops',
items: [
{ label: 'Hubs', path: '/doormile/hubs' },
{ label: 'Vehicles', path: '/doormile/vehicles' },
{ label: 'Tripsheets', path: '/doormile/tripsheets' },
{ label: 'Exceptions', path: '/doormile/exceptions' },
{ label: 'Competitive Intel', path: '/doormile/competitive-intel' },
{ label: 'App Users', path: '/doormile/app-users' },
{ label: 'Hubs', path: '/doormile/hubs', icon: Warehouse, desc: 'Sortation centers & facilities' },
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car, desc: 'Fleet register & EV telematics' },
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo, desc: 'Linehaul & middle-mile runs' },
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert, desc: 'Live alerts & delivery blockers' },
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity, desc: 'Benchmarking & market rates' },
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck, desc: 'System operators & roles' },
],
},
{
label: 'Reports',
items: [
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary' },
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails' },
{ label: 'Riders Summary', path: '/doormile/reports/riderssummary' },
{ label: 'Profitability', path: '/doormile/reports/profitability' },
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary', icon: FileSpreadsheet, desc: 'Daily volume & status aggregations' },
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails', icon: FileText, desc: 'Granular leg-by-leg breakdown' },
{ label: 'Milers Summary', path: '/doormile/reports/riderssummary', icon: Bike, desc: 'Fleet KPIs & on-duty shifts' },
{ label: 'Profitability', path: '/doormile/reports/profitability', icon: Coins, desc: 'Gross margin & variable costs' },
],
},
];
/** Every destination, flattened — what the mobile sheet renders. */
const ALL_DESTINATIONS = [...NAV, ...NAV_GROUPS.flatMap((group) => group.items)];
/* Configuration destinations, kept off the operations bar.
*
* Declared here rather than written inline in the account dropdown because
* two surfaces need them: that dropdown on desktop, and the mobile sheet.
* When they lived only in the dropdown's JSX, moving them off the bar quietly
* removed them from the phone entirely — the sheet builds its list from NAV
* plus NAV_GROUPS, and these were in neither. Both pages stayed routed and
* reachable by typed URL, which is the kind of gap nobody reports and nobody
* finds.
*
* (This replaces an ALL_DESTINATIONS constant that flattened NAV and
* NAV_GROUPS. Its comment claimed it was "what the mobile sheet renders"; the
* sheet had moved to a role-filtered local of the same name in lower case, so
* the constant had no readers at all and would have reintroduced the same
* omission if anyone had trusted it.)
*/
const SETTINGS_NAV = [
{ label: 'Pricing Matrix', path: '/doormile/pricing', icon: IndianRupee },
{ label: 'Customers Directory', path: '/doormile/customers', icon: Users },
];
function GlobalSearch() {
const navigate = useNavigate();
@@ -225,7 +242,27 @@ export default function AdminLayout() {
const navigate = useNavigate();
const { user, isClient, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(() => {
try {
const saved = localStorage.getItem('doormileAssistantOpen');
if (saved !== null) return saved === 'true';
} catch {
/* ignore */
}
return typeof window !== 'undefined' ? window.innerWidth >= 1024 : true;
});
const toggleAssistant = (nextState) => {
setAssistantOpen((prev) => {
const next = typeof nextState === 'function' ? nextState(prev) : nextState;
try {
localStorage.setItem('doormileAssistantOpen', String(next));
} catch {
/* ignore */
}
return next;
});
};
const navItems = useMemo(() => {
if (isClient) {
@@ -253,6 +290,7 @@ export default function AdminLayout() {
const groupIsActive = (group) => group.items.some((item) => isActive(item.path));
const displayName = user?.displayname || user?.authname || user?.name || 'Operator';
const isHome = location.pathname === '/doormile/home' || location.pathname === '/doormile' || location.pathname === '/';
return (
/* Transparent so the ambient canvas painted behind the app reads through.
@@ -265,22 +303,14 @@ export default function AdminLayout() {
<header data-app-header className="sticky top-0 z-50 border-b border-border bg-white/85 backdrop-blur-xl">
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
<Link
to="/doormile/dispatch"
aria-label="Doormile Express — Dispatch"
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
to="/doormile/home"
aria-label="Doormile Express — Home"
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 transition-transform active:scale-95"
>
{/* Full wordmark, not just the circular mark — matches the source
console's own TopNav, which renders this same logo at the
same relative scale inside its bar. */}
{/* Capped below `sm`: at its natural 209px the wordmark plus the
170px action cluster overflowed a 375px phone and scrolled the
whole document sideways. Scaling the mark is the one thing here
that can give — the controls beside it all have to stay. */}
<img
src={DOORMILE_WORDMARK_URL}
alt="Doormile Express"
style={LOGO_FILTER}
className="h-7 w-auto max-w-[120px] object-contain sm:max-w-none"
src={DOORMILE_MARK_URL}
alt="Doormile"
className="h-8 w-auto object-contain"
/>
</Link>
@@ -316,13 +346,14 @@ export default function AdminLayout() {
<button
type="button"
className={cn(
'relative flex items-center gap-1 whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium',
'transition-colors duration-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
'relative flex items-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium',
'transition-colors duration-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 cursor-pointer',
groupIsActive(group) ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
)}
>
{group.label}
<ChevronDown className="h-3 w-3" aria-hidden="true" />
{group.icon && <group.icon className="h-3.5 w-3.5 opacity-70" />}
<span>{group.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" aria-hidden="true" />
{groupIsActive(group) && (
<motion.span
layoutId="adminNavIndicator"
@@ -332,16 +363,40 @@ export default function AdminLayout() {
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
{group.items.map((item) => (
<DropdownMenuItem
key={item.path}
onClick={() => navigate(item.path)}
className={cn('cursor-pointer', isActive(item.path) && 'text-brand')}
>
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuContent align="start" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
{group.items.map((item) => {
const ItemIcon = item.icon;
const active = isActive(item.path);
return (
<DropdownMenuItem
key={item.path}
onClick={() => navigate(item.path)}
className={cn(
'flex items-start gap-2.5 p-2 rounded-xl cursor-pointer transition-colors my-0.5',
active ? 'bg-brand-tint/80 text-brand' : 'hover:bg-slate-50 text-ink-1'
)}
>
{ItemIcon && (
<div
className={cn(
'w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5',
active ? 'bg-brand text-white' : 'bg-slate-100 text-slate-600'
)}
>
<ItemIcon className="w-3.5 h-3.5" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-body-sm font-semibold">{item.label}</div>
{item.desc && (
<div className="text-[11px] text-ink-3 leading-tight mt-0.5 line-clamp-1">
{item.desc}
</div>
)}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
))}
@@ -350,22 +405,32 @@ export default function AdminLayout() {
<div className="ml-auto flex items-center gap-1.5">
<GlobalSearch />
{/* Doormile AI. A header control rather than a route: it answers
about live data while the operator stays on the screen that
raised the question. */}
<button
type="button"
onClick={() => setAssistantOpen((open) => !open)}
aria-label={assistantOpen ? 'Close Doormile AI' : 'Open Doormile AI'}
aria-expanded={assistantOpen}
className={cn(
'grid h-8 w-8 place-items-center rounded-lg transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
assistantOpen ? 'bg-brand-tint text-brand' : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<img className="h-9 w-9 max-w-none object-contain" src={doormileMark} alt="" aria-hidden="true" />
</button>
{/* MileTruth. A header control rather than a route: shown on all pages except the home page */}
{!isHome && (
<button
type="button"
onClick={() => toggleAssistant((open) => !open)}
aria-label={assistantOpen ? 'Close MileTruth' : 'Open MileTruth'}
aria-expanded={assistantOpen}
className={cn(
'grid h-8 w-8 place-items-center rounded-lg transition-colors cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
assistantOpen ? 'bg-brand-tint text-brand' : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
{/* 20px inside a 32px button.
Was `h-9 w-9 max-w-none` — a 36px image in a 32px button,
so the mark overflowed its own control by 2px on every
side. Two visible consequences: it stood proud of the
notification bell and avatar beside it, breaking the row's
alignment, and it covered the button's own area completely,
so the `hover:bg-surface-sunken` tint had nowhere to show —
the control looked dead on hover while its neighbours lit
up. Sized to sit inside the button, like the Bell's 16px
icon does. */}
<img className="h-5 w-5 object-contain" src={doormileMark} alt="" aria-hidden="true" />
</button>
)}
{!isClient && <NotificationsMenu />}
@@ -382,27 +447,40 @@ export default function AdminLayout() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="font-normal">
<p className="text-body-sm font-semibold text-ink-1">{displayName}</p>
<p className="text-caption text-ink-3">{user?.email || user?.primaryemail}</p>
<Badge variant="soft" size="sm" className="mt-1.5">
{user?.role || 'Administrator'}
<DropdownMenuContent align="end" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
<DropdownMenuLabel className="font-normal px-2.5 py-2">
<p className="text-body-sm font-bold text-ink-1">{displayName}</p>
<p className="text-caption text-ink-3 truncate">{user?.email || user?.primaryemail || 'admin@doormile.com'}</p>
<Badge variant="soft" size="sm" className="mt-1.5 bg-rose-50 text-rose-700 font-semibold border-rose-200">
{user?.role || 'admin'}
</Badge>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer">
<User className="mr-2 h-4 w-4" /> Profile
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
<User className="mr-2.5 h-4 w-4 text-slate-500" /> Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" /> Change password
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
<Shield className="mr-2.5 h-4 w-4 text-slate-500" /> Change password
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator className="my-1" />
<div className="px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<Settings className="w-3 h-3 text-slate-400" /> Settings
</div>
{SETTINGS_NAV.map((item) => (
<DropdownMenuItem
key={item.path}
onClick={() => navigate(item.path)}
className="cursor-pointer rounded-xl px-2.5 py-2 font-medium"
>
<item.icon className="mr-2.5 h-4 w-4 text-slate-500" /> {item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => logout()}
className="cursor-pointer text-destructive focus:text-destructive"
className="cursor-pointer text-destructive focus:text-destructive rounded-xl px-2.5 py-2 font-medium"
>
<LogOut className="mr-2 h-4 w-4" /> Sign out
<LogOut className="mr-2.5 h-4 w-4" /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -441,6 +519,26 @@ export default function AdminLayout() {
{item.label}
</Link>
))}
{/* Settings — the pair that lives in the account menu on desktop.
Without this block they existed on the bar's dropdown only, so
a phone could not reach Pricing or Customers at all. */}
<div className="my-2 h-px bg-border" />
<div className="px-3 pb-1 text-[10px] font-bold uppercase tracking-wider text-ink-3">
Settings
</div>
{SETTINGS_NAV.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMenuOpen(false)}
className={cn(
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
isActive(item.path) ? 'bg-brand-tint text-brand' : 'text-ink-2 hover:bg-surface-sunken'
)}
>
{item.label}
</Link>
))}
<div className="my-2 h-px bg-border" />
<Link
to="/doormile/profile"
@@ -456,13 +554,16 @@ export default function AdminLayout() {
{/* `dai-page` is the handshake with the assistant dock: while the panel
is open, DoormileAI.css reserves exactly its width of padding here so
the page slides open beside it instead of being covered. */}
<div className={cn("dai-page flex flex-col", !location.pathname.startsWith('/doormile/dispatch') && "px-4 md:flex-row lg:px-6")}>
<main className={cn("min-w-0 flex-1", !location.pathname.startsWith('/doormile/dispatch') && "py-6")}>
<div className={cn("dai-page flex flex-col flex-1", !location.pathname.startsWith('/doormile/dispatch') && "px-4 md:flex-row lg:px-6")}>
<main className={cn(
"min-w-0 flex-1 flex flex-col",
!location.pathname.startsWith('/doormile/dispatch') && location.pathname !== '/doormile/home' && location.pathname !== '/doormile' && "py-6"
)}>
<Outlet />
</main>
</div>
{assistantOpen && <AIPanel isOpen={assistantOpen} onClose={() => setAssistantOpen(false)} />}
{!isHome && <AIPanel isOpen={assistantOpen} onClose={() => toggleAssistant(false)} />}
</div>
);
}

View File

@@ -6,21 +6,25 @@ Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `Doo
## 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.
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 **MileTruth** (or **Doormile AI**), subtitle **Operations Copilot**.
- **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.
- **Mounted in**: `src/layouts/AdminLayout.jsx` — rendered alongside the page content.
- **70% / 30% Split Layout**: By default on desktop (`window.innerWidth >= 1024`), the assistant is **open on initial load**, occupying 30% of the viewport (`--dai-dock-width: max(340px, 30vw)`), while the page content occupies the remaining 70%.
- **Visual Separation**: An intentional **24px visible gutter** (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the page content/tables from the AI card so both surfaces are distinct.
- **Smooth Dismissal**: Closing via the header `×` button or navbar toggles `assistantOpen` off, smoothly expanding the page content to 100% full width. State is persisted in `localStorage ('doormileAssistantOpen')`.
- **`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).
- `AIPanel.js` — the portal, slide-over, focus/Escape handling, message state, history persistence, 4 clean action icons (History, Reset, Expand, Close), and the ask() flow.
- `AIWelcome.js` — greeting + suggestion pill chips (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.
- `AIComposer.js` — clean horizontal capsule pill container with textarea, mic, and `<LuSend />` button.
- `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.
- `AIParts.js` — Spark / TypingIndicator / Metric / StatGrid / StateBlock.
- `pageContext.js` — route → context label + suggested questions.
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
- **`DoormileAI.css`** — the panel's stylesheet. Defines inset card geometry (`border-radius: 20px`, elevated shadow, floating margins off top/bottom/right edges on desktop).
- **`Home.jsx` Composer**: Prompt bar features a functional **Send** button (`Send` icon + "Send") replacing the previous Speak button. Top navigation bar features the Doormile red "D" mark.
### UI rules that are load-bearing, not cosmetic

View File

@@ -39,7 +39,7 @@
/* Default = the NORMAL step. AIPanel overwrites this property on the
document element when the operator toggles the width, so the panel and
the page's reserved strip always read the same value. */
--dai-dock-width: max(300px, 25vw);
--dai-dock-width: max(340px, 30vw);
--dai-duration: 240ms;
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -119,22 +119,22 @@
seam has depth without the panel detaching. */
.dai-panel {
position: fixed;
/* 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;
/* Inset card geometry matching the reference design:
Floating cleanly off the top nav, bottom edge, and right edge with rounded corners. */
top: calc(var(--dai-dock-top, 57px) + 14px);
right: 16px;
bottom: 14px;
z-index: 1200;
width: var(--dai-dock-width);
width: calc(var(--dai-dock-width) - 24px);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--dai-surface);
border-left: 1px solid var(--dai-border);
border-radius: 0;
box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
transform: translateX(100%);
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 20px;
box-shadow: 0 4px 24px -2px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04);
transform: translateX(calc(100% + 32px));
visibility: hidden;
transition:
transform var(--dai-duration) var(--dai-ease),
@@ -157,11 +157,15 @@
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);
/* Reserves the dock width plus a generous 20px gutter so the page content
(tables, cards) never collides with the sidebar and stays distinct. */
padding-right: calc(var(--dai-dock-width) + 16px);
}
/* ---- Mobile: there is no 30% worth having -------------------------------
@@ -171,10 +175,14 @@ body.dai-docked .astryx-layout-content {
@media (max-width: 900px) {
.dai-panel {
top: 0;
right: 0;
bottom: 0;
width: 100%;
z-index: 1301;
border-left: none;
border: none;
border-radius: 0;
box-shadow: var(--dai-shadow);
transform: translateX(100%);
}
.dai-scrim {
display: block;
@@ -182,6 +190,7 @@ body.dai-docked .astryx-layout-content {
.dai-scrim[data-open='true'] {
opacity: 1;
}
body.dai-docked .dai-page,
body.dai-docked .astryx-layout-content {
padding-right: 0;
}
@@ -194,20 +203,22 @@ body.dai-docked .astryx-layout-content {
-------------------------------------------------------------------------- */
.dai-root .dai-header {
flex: 0 0 auto;
padding: 14px 12px 12px 14px;
border-bottom: 1px solid var(--dai-border);
padding: 14px 16px;
border-bottom: 1px solid #f1f5f9;
background: #ffffff;
}
.dai-root .dai-title {
font-size: 15px;
font-weight: 650;
font-size: 16px;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--dai-text);
color: #0f172a;
}
.dai-root .dai-subtitle {
font-size: 12px;
line-height: 1.3;
color: var(--dai-text-muted);
color: #64748b;
font-weight: 500;
}
/* The AI mark. A soft gradient orb — not a robot face. */
.dai-root .dai-spark {
@@ -215,8 +226,9 @@ body.dai-docked .astryx-layout-content {
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: 999px;
background: var(--dai-surface);
border-radius: 10px;
background: #fff1f2;
border: 1px solid #ffe4e6;
overflow: hidden;
}
/* The mark's own canvas is only 66.8% content — a third of every edge is
@@ -277,15 +289,7 @@ body.dai-docked .astryx-layout-content {
}
/* Page-context strip — "Orders · Today · All locations" */
.dai-root .dai-context {
flex: 0 0 auto;
padding: 7px 14px;
font-size: 11.5px;
color: var(--dai-text-muted);
background: var(--dai-surface-alt);
border-bottom: 1px solid var(--dai-border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: none;
}
/* --------------------------------------------------------------------------
Welcome state
@@ -360,58 +364,57 @@ body.dai-docked .astryx-layout-content {
They wrap, they never scroll. A horizontal scroller can always leave a chip
half-visible at the edge, and a half-visible button is a bug no amount of
fade masking fixes. */
.dai-root .dai-suggestions-bar .dai-section-label {
display: none;
}
.dai-root .dai-suggestions {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.dai-suggestion {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 8px;
max-width: 100%;
padding: 5px 10px;
padding: 8px 18px;
text-align: left;
font: inherit;
font-size: 12px;
line-height: 1.35;
font-size: 13px;
line-height: 1.4;
font-weight: 500;
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
/* 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;
color: #334155;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 9999px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
transition: all 140ms ease;
}
.dai-suggestion:hover {
background: var(--dai-surface-alt);
border-color: var(--dai-border-strong);
background: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06);
}
.dai-suggestion:active {
transform: translateY(0);
}
.dai-suggestion:focus-visible {
outline: 2px solid var(--dai-accent);
outline: 2px solid #0f172a;
outline-offset: 2px;
}
.dai-root .dai-suggestion-icon {
flex: 0 0 auto;
color: var(--dai-text-muted);
color: #64748b;
transition: color 140ms ease;
}
.dai-suggestion:hover .dai-suggestion-icon {
color: var(--dai-text-secondary);
color: #0f172a;
}
.dai-root .dai-suggestion-text {
min-width: 0;
@@ -695,26 +698,29 @@ body.dai-docked .astryx-layout-content {
-------------------------------------------------------------------------- */
.dai-root .dai-composer-wrap {
flex: 0 0 auto;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--dai-border);
background: var(--dai-surface);
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid #f1f5f9;
background: #ffffff;
}
.dai-root .dai-composer {
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 10px 8px 14px;
border: 1px solid #e2e8f0;
border-radius: 9999px;
background: #f8fafc;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
padding: 6px 6px 6px 16px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
transition:
border-color 140ms ease,
box-shadow 140ms ease;
box-shadow 140ms ease,
background-color 140ms ease;
}
.dai-root .dai-composer[data-focused='true'] {
border-color: var(--dai-accent);
box-shadow: 0 0 0 3px var(--dai-accent-ring);
background: #ffffff;
border-color: #cbd5e1;
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.05);
}
/* --------------------------------------------------------------------------
@@ -811,13 +817,13 @@ body.dai-docked .astryx-layout-content {
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dai-accent);
color: var(--dai-accent-contrast);
background: #2563eb;
color: #ffffff;
cursor: pointer;
transition:
background-color 140ms ease,
@@ -825,29 +831,32 @@ body.dai-docked .astryx-layout-content {
transform 140ms ease;
}
.dai-root .dai-send:hover:not(:disabled) {
opacity: 0.86;
background: #1d4ed8;
transform: scale(1.05);
}
.dai-root .dai-send:active:not(:disabled) {
transform: scale(0.94);
transform: scale(0.96);
}
.dai-root .dai-send:focus-visible {
outline: 2px solid var(--dai-accent);
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Disabled is the resting state until something is typed, so it has to read as
"not yet", not as "broken" — a filled grey circle, same shape and weight. */
.dai-root .dai-send:disabled {
background: var(--dai-surface-hover);
color: var(--dai-text-muted);
background: #f1f5f9;
color: #94a3b8;
cursor: default;
}
.dai-root .dai-composer textarea {
display: block;
flex: 1 1 auto;
width: 100%;
min-width: 0;
border: none;
outline: none;
resize: none;
padding: 0;
padding: 4px 0;
margin: 0;
font: inherit;
font-size: 13.5px;

View File

@@ -372,8 +372,10 @@ export default function Bookings() {
{
key: 'route',
header: 'Route',
width: '260px',
className: 'max-w-[260px]',
accessor: (row) => (
<div className="max-w-xs">
<div className="max-w-[260px] min-w-0 overflow-hidden">
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
{orDash(row.pickupaddress)}
</p>
@@ -385,7 +387,8 @@ export default function Bookings() {
},
{
key: 'rider',
header: 'Rider',
header: 'Miler',
width: '140px',
hideBelow: 'md',
accessor: (row) => {
const riderId = String(row.assignedmileruserid || row.mileruserid || '');
@@ -394,7 +397,7 @@ export default function Bookings() {
return <span className="text-body-sm font-medium text-ink-1">{name}</span>;
}
if (riderId) {
return <span className="text-caption text-ink-2">Rider #{riderId}</span>;
return <span className="text-caption text-ink-2">Miler #{riderId}</span>;
}
return <span className="text-caption text-ink-4">Unassigned</span>;
},

View File

@@ -213,6 +213,7 @@ export default function Deliveries() {
key: 'orderid',
header: 'Order & client',
sortable: true,
width: '130px',
cell: (row) => (
<div className="min-w-0">
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.orderid)}</p>
@@ -223,9 +224,11 @@ export default function Deliveries() {
{
key: 'route',
header: 'Route (pickup → drop)',
width: '260px',
className: 'max-w-[260px]',
sortValue: (row) => row.pickupaddress || '',
cell: (row) => (
<div className="min-w-0">
<div className="max-w-[260px] min-w-0 overflow-hidden">
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
<span className="font-medium text-ink-1">Pickup</span>
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
@@ -239,17 +242,18 @@ export default function Deliveries() {
},
{
key: 'ridername',
header: 'Rider',
header: 'Miler',
sortable: true,
width: '140px',
cell: (row) => {
const riderKey = String(row.userid || row.milerprofileid || '');
const info = riderLookup.get(riderKey);
const name =
(info?.name && !info.name.startsWith('Rider #'))
(info?.name && !info.name.startsWith('Miler #') && !info.name.startsWith('Rider #'))
? info.name
: (row.ridername && !row.ridername.startsWith('Rider #'))
: (row.ridername && !row.ridername.startsWith('Miler #') && !row.ridername.startsWith('Rider #'))
? row.ridername
: (info?.name || row.ridername || (row.userid ? `Rider #${row.userid}` : '—'));
: (info?.name || row.ridername || (row.userid ? `Miler #${row.userid}` : '—'));
const contact = info?.phone || row.ridercontact;
return (
@@ -264,6 +268,7 @@ export default function Deliveries() {
{
key: 'eta',
header: 'ETA & distance',
width: '120px',
sortValue: (row) => row.expecteddeliverytime || '',
cell: (row) => (
<div className="min-w-0">
@@ -279,6 +284,7 @@ export default function Deliveries() {
key: 'deliverycharges',
header: 'Amount',
align: 'right',
width: '85px',
sortValue: (row) => Number(row.deliverycharges) || 0,
accessor: (row) => currency(row.deliverycharges),
hideBelow: 'lg',
@@ -286,6 +292,7 @@ export default function Deliveries() {
{
key: 'orderstatus',
header: 'Status',
width: '120px',
sortable: true,
cell: (row) => (
<div className="flex flex-col items-start gap-0.5">
@@ -300,6 +307,7 @@ export default function Deliveries() {
key: 'actions',
header: 'Actions',
align: 'right',
width: '130px',
cell: (row) => (
<div className="flex justify-end gap-1">
<IconButton
@@ -328,7 +336,7 @@ export default function Deliveries() {
{['pending', 'accepted', 'arrived'].includes(row.orderstatus) ? (
<IconButton
label="Change rider"
label="Change miler"
icon={Bike}
variant="ghost"
size="sm"
@@ -373,7 +381,7 @@ export default function Deliveries() {
{ key: 'orderid', header: 'Order' },
{ key: 'tenantname', header: 'Client' },
{ key: 'deliverycustomer', header: 'Customer' },
{ key: 'ridername', header: 'Rider' },
{ key: 'ridername', header: 'Miler' },
{ key: 'orderstatus', header: 'Status' },
{ key: 'kms', header: 'Km' },
{ key: 'deliverycharges', header: 'Amount' },
@@ -398,24 +406,24 @@ export default function Deliveries() {
actions={
<DatePicker
value={selectedDate}
onChange={setSelectedDate}
allowAll
onChange={(d) => {
if (d) {
const s = dayjs(d).format('YYYY-MM-DD');
setDateParams({ from: s, to: s });
setSelectedDate(s);
}
}}
/>
}
/>
<Stack space="sm">
{/* Eight status tabs are far wider than a phone. Scrolled in their own
track so the strip moves instead of the whole document — see
Orders.jsx for the same fix. */}
<div className="-mx-1 max-w-full overflow-x-auto px-1 py-0.5">
<Tabs tabs={tabs} value={tab} onChange={setTab} className="shrink-0" />
</div>
<Tabs tabs={tabs} value={tab} onChange={setTab} />
<ListToolbar
search={search}
onSearchChange={setSearch}
placeholder="Search by order, customer, rider or address"
placeholder="Search by order, client, miler, customer or address"
count={{ shown: rows.length, total: batchRows.length, noun: 'deliveries' }}
actions={
<Button
@@ -457,10 +465,10 @@ export default function Deliveries() {
<Select value={riderId} onValueChange={setRiderId}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="All riders" />
<SelectValue placeholder="All milers" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All riders</SelectItem>
<SelectItem value={ALL}>All milers</SelectItem>
{(riders || []).map((rider) => (
<SelectItem key={rider.userid} value={String(rider.userid)}>
{rider.label}
@@ -474,8 +482,8 @@ export default function Deliveries() {
columns={columns}
rows={rows}
getRowId={(row) => row.orderheaderid}
loading={isLoading}
refreshing={isFetching && !isLoading}
loading={isLoading && !data}
refreshing={isFetching && Boolean(data)}
pageSize={25}
defaultSort={{ key: 'orderid', direction: 'desc' }}
isFiltered={isFiltered}
@@ -486,29 +494,36 @@ export default function Deliveries() {
setRiderId(ALL);
}}
emptyState={
isFiltered ? undefined : (
<EmptyState
icon={Package}
title="Nothing dispatched here"
description="A booking appears once it has a rider or has become a consignment."
/>
)
<EmptyState
icon={Truck}
title={isFiltered ? 'No matching deliveries' : 'No deliveries found'}
description={
isFiltered
? 'Try clearing search or filters to see more orders.'
: 'Dispatch orders from the Orders page to populate deliveries.'
}
action={
isFiltered
? {
label: 'Clear filters',
onClick: () => {
setSearch('');
setBatch('all');
setTenantId(ALL);
setRiderId(ALL);
},
}
: undefined
}
/>
}
/>
{data?.nextPage ? (
<div className="flex justify-center">
<Button variant="outline" onClick={() => setPage(data.nextPage)} loading={isFetching}>
Load more
</Button>
</div>
) : null}
</Stack>
<Modal
open={Boolean(riderRow)}
onOpenChange={(open) => !open && setRiderRow(null)}
title="Change rider"
title="Change miler"
description={riderRow ? `Order ${riderRow.orderid}` : undefined}
icon={Bike}
busy={changeRider.isPending}
@@ -533,10 +548,10 @@ export default function Deliveries() {
</>
}
>
<Field label="Rider">
<Field label="Miler">
<Select value={selectedRider} onValueChange={setSelectedRider}>
<SelectTrigger>
<SelectValue placeholder="Choose a rider" />
<SelectValue placeholder="Choose a miler" />
</SelectTrigger>
<SelectContent>
{(riders || []).map((rider) => (

View File

@@ -224,11 +224,11 @@ const ActiveSection = ({
</div>
)}
{/* Rider & Telemetry Row */}
{/* Miler & Telemetry Row */}
<div className="flex items-center justify-between mt-1 text-[11px] text-slate-600 bg-slate-50 px-2 py-1 rounded">
<div className="flex items-center gap-1.5 truncate">
<span className="font-semibold text-slate-900 truncate">
🛵 {o.rider_name || o.ridername || rider?.name || `Rider #${rid}`}
🛵 {o.rider_name || o.ridername || rider?.name || `Miler #${rid}`}
</span>
{(rider?.battery != null || rider?.batterystatus != null) && (
<span className="text-[10px] text-emerald-700 bg-emerald-100/80 px-1 py-0.2 rounded font-medium">
@@ -238,12 +238,12 @@ const ActiveSection = ({
</div>
{(rider?.phone || o.riderphone || o.deliverycontactno) && (
<a
href={`https://api.whatsapp.com/send?phone=${String(rider?.phone || o.riderphone || o.deliverycontactno).replace(/[^0-9]/g, '')}&text=${encodeURIComponent(`Hi ${o.rider_name || 'Rider'}, regarding Order #${o.orderid} to ${o.deliverycustomer || 'Customer'}`)}`}
href={`https://api.whatsapp.com/send?phone=${String(rider?.phone || o.riderphone || o.deliverycontactno).replace(/[^0-9]/g, '')}&text=${encodeURIComponent(`Hi ${o.rider_name || 'Miler'}, regarding Order #${o.orderid} to ${o.deliverycustomer || 'Customer'}`)}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[10px] font-semibold text-emerald-700 hover:text-emerald-800 bg-emerald-50 hover:bg-emerald-100 px-1.5 py-0.5 rounded transition-colors flex items-center gap-1 shrink-0 ml-1"
title="Chat with rider on WhatsApp"
title="Chat with miler on WhatsApp"
>
💬 WhatsApp
</a>

View File

@@ -3927,7 +3927,7 @@ const Dispatch = ({
<button
className={`sbt ${viewMode === 'riders' ? 'active' : ''}`}
onClick={() => {
logger.info('View mode changed: By Rider');
logger.info('View mode changed: By Miler');
setViewMode('riders');
handleRiderFocus(null);
setFocusedKitchen(null);
@@ -3937,7 +3937,7 @@ const Dispatch = ({
<span className="sbt-icon">
<MdDirectionsBike />
</span>{' '}
By Rider
By Miler
</button>
{!embedded && (
<button
@@ -3982,12 +3982,12 @@ const Dispatch = ({
setFocusedKitchen(null);
setFocusedZone(null);
}}
title="Live GPS / battery / status snapshot for a rider"
title="Live GPS / battery / status snapshot for a miler"
>
<span className="sbt-icon">
<MdInfoOutline />
</span>{' '}
Rider Info
Miler Info
</button>
</div>
@@ -4004,9 +4004,9 @@ const Dispatch = ({
<div className="rider-info-mode">
<div className="ri-sidebar">
<div className="ri-sb-head">
<div className="ri-sb-title">Riders</div>
<div className="ri-sb-title">Milers</div>
<div className="ri-sb-sub">
{ridersAllDay.length} {ridersAllDay.length === 1 ? 'rider' : 'riders'} today
{ridersAllDay.length} {ridersAllDay.length === 1 ? 'miler' : 'milers'} today
</div>
</div>
<div className="ri-search">
@@ -4032,7 +4032,7 @@ const Dispatch = ({
if (matched.length === 0) {
return (
<div className="ri-empty">
{riderInfoSearch ? `No riders match "${riderInfoSearch}"` : 'No riders have orders today'}
{riderInfoSearch ? `No milers match "${riderInfoSearch}"` : 'No milers have orders today'}
</div>
);
}
@@ -4067,16 +4067,16 @@ const Dispatch = ({
<div className="ri-placeholder-icon">
<MdInfoOutline />
</div>
<div className="ri-placeholder-title">Pick a rider</div>
<div className="ri-placeholder-title">Pick a miler</div>
<div className="ri-placeholder-sub">
Select a rider from the list on the left to see their live GPS, battery, connection, and current order snapshot.
Select a miler from the list on the left to see their live GPS, battery, connection, and current order snapshot.
</div>
</div>
) : (
<>
{riderInfoFetching && !riderInfoData && <div className="ri-loading">Loading rider snapshot…</div>}
{riderInfoFetching && !riderInfoData && <div className="ri-loading">Loading miler snapshot…</div>}
{riderInfoIsError && <div className="ri-error">Couldn’t load this rider’s log. {riderInfoError?.message || ''}</div>}
{riderInfoIsError && <div className="ri-error">Couldn’t load this miler’s log. {riderInfoError?.message || ''}</div>}
{riderInfoData &&
(() => {
@@ -4091,7 +4091,7 @@ const Dispatch = ({
return (
<div className="ri-snapshot">
<div className="ri-snap-head">
<div className="ri-snap-name">{d.username || `Rider #${d.userid}`}</div>
<div className="ri-snap-name">{d.username || `Miler #${d.userid}`}</div>
<div className="ri-snap-meta">
<span>#{d.userid}</span>
{d.status && <span className={`ri-status ri-status-${statusKey}`}>{d.status}</span>}
@@ -4222,7 +4222,7 @@ const Dispatch = ({
{riderInfoArea?.area || 'Locating area…'}
</Tooltip>
<Popup>
<div style={{ fontWeight: 700, marginBottom: 2 }}>{d.username || `Rider #${d.userid}`}</div>
<div style={{ fontWeight: 700, marginBottom: 2 }}>{d.username || `Miler #${d.userid}`}</div>
{riderInfoArea?.area && (
<div style={{ fontSize: 12, color: '#0f172a', marginBottom: 4 }}>{riderInfoArea.area}</div>
)}
@@ -4276,7 +4276,7 @@ const Dispatch = ({
<div className="sb-header">
<div className="sb-header-top">
<div className="sb-header-title">
<span className="sb-title-text">RIDER DISPATCH</span>
<span className="sb-title-text">MILER DISPATCH</span>
</div>
<span className="sb-header-scope" title={activeStats.label}>
<span className="sb-scope-dot" />
@@ -4300,7 +4300,7 @@ const Dispatch = ({
</span>
<div className="sb-tile-body">
<div className="sb-tile-value">{activeStats.riders}</div>
<div className="sb-tile-label">{activeStats.riders === 1 ? 'Rider' : 'Riders'}</div>
<div className="sb-tile-label">{activeStats.riders === 1 ? 'Miler' : 'Milers'}</div>
</div>
</div>
</div>
@@ -4589,7 +4589,7 @@ const Dispatch = ({
<button
type="button"
className="zone-order-change-rider"
title="Change rider"
title="Change miler"
onClick={(e) => {
e.stopPropagation();
onChangeRider(o, focusedRider);
@@ -4687,7 +4687,7 @@ const Dispatch = ({
<Ico>
<MdTwoWheeler />
</Ico>
{focusedKitchen.riders.size} riders
{focusedKitchen.riders.size} {focusedKitchen.riders.size === 1 ? 'miler' : 'milers'}
</span>
</div>
{/* Render the kitchen's orders with the same zone-order-card layout
@@ -4795,7 +4795,7 @@ const Dispatch = ({
<button
type="button"
className="zone-order-change-rider"
title="Change rider"
title="Change miler"
onClick={(e) => {
e.stopPropagation();
handleTriggerChangeRider(o, riderForOrder);
@@ -5075,8 +5075,8 @@ const Dispatch = ({
: viewMode === 'kitchens'
? 'Kitchen dispatch'
: isAllActiveView
? 'Active rider dispatch'
: 'Rider dispatch'}
? 'Active miler dispatch'
: 'Miler dispatch'}
</div>
)}
<div id="rider-cards">
@@ -5219,7 +5219,7 @@ const Dispatch = ({
<div className="rcard-info">
<div className="rcard-name">{k.kitchenName}</div>
<div className="rcard-zone">
{k.riders.size} riders ·{' '}
{k.riders.size} {k.riders.size === 1 ? 'miler' : 'milers'} ·{' '}
<Ico>
<MdAccountBalanceWallet />
</Ico>
@@ -5243,7 +5243,7 @@ const Dispatch = ({
</Ico>
{k.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km
</span>
<span>{k.riders.size} riders</span>
<span>{k.riders.size} {k.riders.size === 1 ? 'miler' : 'milers'}</span>
</div>
<div className="step-ids">
{Array.from(k.riders)

View File

@@ -169,7 +169,7 @@ function OrdersBreakdownTable({ orders, getRevenue }) {
<div className="p-6 text-center bg-slate-50 rounded-lg border border-slate-100">
<Receipt className="w-6 h-6 text-slate-400 mx-auto mb-1.5" />
<p className="text-xs font-semibold text-slate-700">No orders yet</p>
<p className="text-[11px] text-slate-500">No orders assigned to this rider yet.</p>
<p className="text-[11px] text-slate-500">No orders assigned to this miler yet.</p>
</div>
);
}
@@ -404,7 +404,7 @@ export default function ProfitabilitySection({
color: moneyColor(dailyIsProfit)
},
{
title: 'Riders active',
title: 'Milers active',
value: String(activeEnriched.length),
icon: <Users className="w-4 h-4" />,
color: STATUS.info
@@ -442,7 +442,7 @@ export default function ProfitabilitySection({
<div>
<h2 className="text-base font-bold text-slate-900 tracking-tight">Profitability overview</h2>
<p className="text-xs text-slate-500 font-medium mt-0.5">
{activeEnriched.length} rider{activeEnriched.length !== 1 ? 's' : ''} · {profitCount} profitable · {lossCount} at loss
{activeEnriched.length} miler{activeEnriched.length !== 1 ? 's' : ''} · {profitCount} profitable · {lossCount} at loss
</p>
</div>
</div>
@@ -473,14 +473,14 @@ export default function ProfitabilitySection({
))}
</div>
{/* Riders List */}
{/* Milers List */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-800">Riders list</h3>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-800">Milers list</h3>
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center p-12 bg-white rounded-xl border border-slate-200 text-center shadow-xs">
<Users className="w-8 h-8 text-slate-300 mb-2" />
<p className="text-sm font-semibold text-slate-800">No riders to show</p>
<p className="text-xs text-slate-500 mt-1">No riders have orders in this slot.</p>
<p className="text-sm font-semibold text-slate-800">No milers to show</p>
<p className="text-xs text-slate-500 mt-1">No milers have orders in this slot.</p>
</div>
) : (
<div className="space-y-2.5">

File diff suppressed because it is too large Load Diff

View File

@@ -265,12 +265,12 @@ export default function Orders() {
const handleManualAssign = async () => {
if (!selectedRiderId || !selectedIds.length) {
OpenToast('Please select a rider first', 'warning');
OpenToast('Please select a miler first', 'warning');
return;
}
const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
if (!targetRider) {
OpenToast('Please select a valid rider', 'warning');
OpenToast('Please select a valid miler', 'warning');
return;
}
const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id;
@@ -284,7 +284,7 @@ export default function Orders() {
if (targetRider.milerprofileid) {
notifyMiler(targetRider.milerprofileid, 'DoormileXpress', `${selectedIds.length} orders assigned to you`).catch(() => {});
}
OpenToast(`${selectedIds.length} order${selectedIds.length === 1 ? '' : 's'} assigned to ${targetRider.displayname || targetRider.name || 'rider'}`, 'success');
OpenToast(`${selectedIds.length} order${selectedIds.length === 1 ? '' : 's'} assigned to ${targetRider.displayname || targetRider.name || 'miler'}`, 'success');
refetchOrders();
setSelectedIds([]);
setManualAssignOpen(false);
@@ -302,6 +302,7 @@ export default function Orders() {
key: 'bookingno',
header: 'Order',
sortable: true,
width: '130px',
accessor: (row) => row.bookingno || `#${row.bookingid}`,
cell: (row) => {
const created = parseDoormileTimestamp(row.createdat);
@@ -320,6 +321,8 @@ export default function Orders() {
{
key: 'route',
header: 'Route (pickup → drop)',
width: '260px',
className: 'max-w-[260px]',
sortValue: (row) => row.pickupaddress || '',
cell: (row) => {
const customer = customerMap.get(row.appcustomerid);
@@ -335,7 +338,7 @@ export default function Orders() {
const flow = classifyBooking(row, flowAnchors);
const isCustomerPickup = flow.flow !== FLOW.FORWARD && flow.pickupSource === 'customer';
return (
<div className="min-w-0">
<div className="max-w-[260px] min-w-0 overflow-hidden">
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
<span className={isCustomerPickup ? 'font-medium text-violet-700' : 'font-medium text-ink-1'}>
{isCustomerPickup ? 'Customer pickup' : 'Pickup'}
@@ -366,6 +369,7 @@ export default function Orders() {
key: 'kms',
header: 'Km',
align: 'center',
width: '75px',
sortValue: (row) =>
haversineKm(row.pickuplatitude, row.pickuplongitude, row.deliverylatitude, row.deliverylongitude) ?? -1,
accessor: (row) =>
@@ -376,6 +380,7 @@ export default function Orders() {
key: 'charges',
header: 'Charges',
align: 'right',
width: '90px',
sortValue: (row) => Number(chargeOf(row)) || 0,
accessor: (row) => currency(chargeOf(row)),
hideBelow: 'md',
@@ -383,6 +388,7 @@ export default function Orders() {
{
key: 'status',
header: 'Status',
width: '130px',
sortable: true,
cell: (row) => {
const rawStatus = String(row.status || '').trim().toLowerCase();
@@ -637,22 +643,22 @@ export default function Orders() {
open={manualAssignOpen}
onOpenChange={setManualAssignOpen}
title={`Manual Assign ${selectedIds.length} Order${selectedIds.length === 1 ? '' : 's'}`}
description="Choose an active rider to assign the selected orders directly."
description="Choose an active miler to assign the selected orders directly."
>
<div className="space-y-4 pt-2">
<div>
<label className="block text-xs font-semibold text-slate-800 mb-1.5">
Select Rider <span className="text-red-500">*</span>
Select Miler <span className="text-red-500">*</span>
</label>
<select
value={selectedRiderId}
onChange={(e) => setSelectedRiderId(e.target.value)}
className="w-full h-10 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black"
>
<option value="">-- Choose an available rider --</option>
<option value="">-- Choose an available miler --</option>
{riders.map((r) => {
const rId = String(r.userid || r.mileruserid || r.id);
const rName = r.displayname || r.name || `Rider #${rId}`;
const rName = r.displayname || r.name || `Miler #${rId}`;
const rPhone = r.mobile || r.phone ? ` · ${r.mobile || r.phone}` : '';
const rStatus = r.status ? ` [${r.status}]` : '';
return (

View File

@@ -164,7 +164,7 @@ export default function OrdersPreview() {
},
{
key: 'rider',
header: 'Rider',
header: 'Miler',
cell: (row) => (
<Select
value={row.userid ? String(row.userid) : ''}
@@ -194,7 +194,7 @@ export default function OrdersPreview() {
<PageHeader
icon={Package}
title="Assignment preview"
subtitle="Nothing is committed until you assign — riders can still be changed here"
subtitle="Nothing is committed until you assign — milers can still be changed here"
actions={
<>
<Button variant="outline" onClick={() => navigate('/doormile/orders')}>
@@ -213,7 +213,7 @@ export default function OrdersPreview() {
<Grid cols={4}>
<KpiCard icon={Package} value={number(stats.orders, '0')} label="Orders" tone="brand" index={0} />
<KpiCard icon={Bike} value={number(stats.riders, '0')} label="Riders" tone="navy" index={1} />
<KpiCard icon={Bike} value={number(stats.riders, '0')} label="Milers" tone="navy" index={1} />
<KpiCard icon={Package} value={formatKm(stats.kms)} label="Total distance" tone="accent" index={2} />
<KpiCard icon={Package} value={currency(stats.amount, '₹0')} label="Total charge" tone="success" index={3} />
</Grid>

View File

@@ -116,7 +116,7 @@ export default function OrdersDetails() {
},
{
key: 'ridername',
header: 'Rider',
header: 'Miler',
sortable: true,
accessor: (row) => orDash(row.ridername),
hideBelow: 'md',
@@ -153,7 +153,7 @@ export default function OrdersDetails() {
{ key: 'deliverycustomer', header: 'Customer' },
{ key: 'pickupaddress', header: 'Pickup' },
{ key: 'deliveryaddress', header: 'Drop' },
{ key: 'ridername', header: 'Rider' },
{ key: 'ridername', header: 'Miler' },
{ key: 'kms', header: 'Km' },
{ key: 'deliverycharges', header: 'Amount' },
{ key: 'orderstatus', header: 'Status' },
@@ -217,10 +217,10 @@ export default function OrdersDetails() {
<Select value={riderId} onValueChange={setRiderId}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="All riders" />
<SelectValue placeholder="All milers" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All riders</SelectItem>
<SelectItem value={ALL}>All milers</SelectItem>
{(riders || []).map((rider) => (
<SelectItem key={rider.userid} value={String(rider.userid)}>
{rider.label}

View File

@@ -85,7 +85,7 @@ export default function Profitability() {
() => [
{
key: 'riderName',
header: 'Rider',
header: 'Miler',
sortable: true,
cell: (row) => (
<div className="min-w-0">

View File

@@ -58,11 +58,11 @@ export default function RidersSummary() {
() => [
{
key: 'displayname',
header: 'Rider',
header: 'Miler',
sortable: true,
cell: (row) => (
<div className="min-w-0">
<p className="truncate text-body-sm font-semibold text-ink-1">{row.displayname || `Rider #${row.userid}`}</p>
<p className="truncate text-body-sm font-semibold text-ink-1">{row.displayname || `Miler #${row.userid}`}</p>
<p className="text-caption text-ink-3">{row.hubname || row.phone || '—'}</p>
</div>
),
@@ -120,7 +120,7 @@ export default function RidersSummary() {
<Stack space="lg">
<PageHeader
icon={Bike}
title="Riders summary"
title="Milers summary"
subtitle={`${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
actions={
<>
@@ -143,7 +143,7 @@ export default function RidersSummary() {
/>
<Grid cols={4}>
<KpiCard icon={Bike} value={number(stats.riders, '0')} label="Riders active" tone="brand" index={0} loading={isLoading} />
<KpiCard icon={Bike} value={number(stats.riders, '0')} label="Milers active" tone="brand" index={0} loading={isLoading} />
<KpiCard icon={Package} value={number(stats.orders, '0')} label="Orders assigned" tone="navy" index={1} loading={isLoading} />
<KpiCard
icon={CheckCircle2}
@@ -156,7 +156,7 @@ export default function RidersSummary() {
<KpiCard
icon={IndianRupee}
value={currency(stats.amount, '₹0')}
label="Rider charges"
label="Miler charges"
tone="accent"
index={3}
loading={isLoading}
@@ -167,12 +167,12 @@ export default function RidersSummary() {
<ListToolbar
search={search}
onSearchChange={setSearch}
placeholder="Search by rider, phone or hub"
count={{ shown: rows.length, total: (summary || []).length, noun: 'riders' }}
placeholder="Search by miler, phone or hub"
count={{ shown: rows.length, total: (summary || []).length, noun: 'milers' }}
actions={
<Button
variant="ghost"
onClick={() => exportRows(rows, columns, `riders-summary-${range.from}`)}
onClick={() => exportRows(rows, columns, `milers-summary-${range.from}`)}
disabled={rows.length === 0}
>
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
@@ -210,7 +210,7 @@ export default function RidersSummary() {
<EmptyState
icon={Bike}
title="Nothing in this range"
description="No rider activity was recorded for the dates selected."
description="No miler activity was recorded for the dates selected."
/>
}
/>

View File

@@ -58,13 +58,13 @@ export default function EditRider() {
if (!milerProfileId) {
return (
<Stack space="lg">
<PageHeader icon={Bike} title="Edit rider" />
<PageHeader icon={Bike} title="Edit miler" />
<Alert
tone="warning"
title="No rider selected"
action={<Button onClick={() => navigate('/doormile/riders')}>Go to Riders</Button>}
title="No miler selected"
action={<Button onClick={() => navigate('/doormile/milers')}>Go to Milers</Button>}
>
Open a rider from the Riders table — this page needs the record that was clicked.
Open a miler from the Milers table — this page needs the record that was clicked.
</Alert>
</Stack>
);
@@ -114,7 +114,7 @@ export default function EditRider() {
/>
{isLoading && !form ? (
<LoadingState message="Loading rider…" />
<LoadingState message="Loading miler…" />
) : (
<form onSubmit={submit}>
<Surface radius="xl" padding="lg">
@@ -176,7 +176,7 @@ export default function EditRider() {
</div>
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/riders')}>
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/milers')}>
Cancel
</Button>
<Button type="submit" loading={updateMiler.isPending}>

View File

@@ -95,7 +95,7 @@ export default function Riders() {
() => [
{
key: 'rider',
header: 'Rider',
header: 'Miler',
sortable: true,
accessor: riderName,
cell: (row) => (
@@ -283,8 +283,8 @@ export default function Riders() {
<p className="text-caption text-ink-3">
{rows.length === (counts[tab] ?? counts.all)
? `${counts[tab] ?? counts.all} riders`
: `${rows.length} of ${counts[tab] ?? counts.all} riders`}
? `${counts[tab] ?? counts.all} milers`
: `${rows.length} of ${counts[tab] ?? counts.all} milers`}
</p>
<DataTable
@@ -301,9 +301,9 @@ export default function Riders() {
debouncedSearch ? undefined : (
<EmptyState
icon={Bike}
title="No riders here"
description="Onboard a rider to start assigning deliveries."
action={{ label: 'New rider', icon: Plus, onClick: () => navigate('/doormile/riders/create') }}
title="No milers here"
description="Onboard a miler to start assigning deliveries."
action={{ label: 'New miler', icon: Plus, onClick: () => navigate('/doormile/milers/create') }}
/>
)
}
@@ -313,9 +313,9 @@ export default function Riders() {
<ConfirmModal
open={Boolean(blockRow)}
onOpenChange={(open) => !open && setBlockRow(null)}
title="Block this rider?"
title="Block this miler?"
description={`${riderName(blockRow || {})} will stop receiving assignments until they are unblocked.`}
confirmLabel="Block rider"
confirmLabel="Block miler"
busy={blockMiler.isPending}
onConfirm={() => {
blockMiler.mutate(
@@ -371,8 +371,8 @@ export default function Riders() {
<Modal
open={Boolean(notifyRow)}
onOpenChange={(open) => !open && setNotifyRow(null)}
title="Notify this rider"
description={`${riderName(notifyRow || {})} — delivered to the rider app as a push notification.`}
title="Notify this miler"
description={`${riderName(notifyRow || {})} — delivered to the miler app as a push notification.`}
icon={Bell}
busy={notifyMiler.isPending}
footer={

View File

@@ -356,7 +356,7 @@ export default function Tripsheets() {
<SelectItem value={NO_DRIVER}>No driver</SelectItem>
{(milers || []).map((miler) => (
<SelectItem key={miler.userid} value={String(miler.userid)}>
{miler.displayname || miler.authname || `Rider #${miler.userid}`}
{miler.displayname || miler.authname || `Miler #${miler.userid}`}
</SelectItem>
))}
</SelectContent>

View File

@@ -211,7 +211,7 @@ export default function Vehicles() {
<PageHeader
icon={Car}
title="Vehicles"
subtitle="The fleet register riders are assigned from"
subtitle="The fleet register milers are assigned from"
actions={
<Button onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" /> New vehicle
@@ -284,7 +284,7 @@ export default function Vehicles() {
<EmptyState
icon={Car}
title="No vehicles yet"
description="Register the first vehicle so riders have something to be assigned to."
description="Register the first vehicle so milers have something to be assigned to."
action={{ label: 'New vehicle', icon: Plus, onClick: openCreate }}
/>
)

View File

@@ -7,13 +7,13 @@
"orders": "Orders",
"orderspreview": "Orders Preview",
"deliveries": "Deliveries",
"riders": "Riders",
"riders": "Milers",
"tenants": "Tenants",
"pricing": "Pricing",
"reports": "Reports",
"ordersummary": "Orders Summary",
"ordersdetails": "Orders Details",
"riderssummary": "Riders Summary",
"riderssummary": "Milers Summary",
"profitability": "Profitability",
"dispatch": "Dispatch",
"customers": "Customers",