Compare commits
2 Commits
05f08efc64
...
b1747480df
| Author | SHA1 | Date | |
|---|---|---|---|
| b1747480df | |||
| 1671a5f4b6 |
@@ -60,6 +60,15 @@ src/
|
|||||||
pages/ Login + doormile/ (the console)
|
pages/ Login + doormile/ (the console)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Operations Copilot (MileTruth AI) & Workspace Layout
|
||||||
|
|
||||||
|
The console integrates a docked operations copilot (**MileTruth**) across all admin routes via `AdminLayout.jsx`:
|
||||||
|
- **70% Content / 30% AI Sidebar**: Main workspace occupies 70% width on the left and the MileTruth copilot occupies 30% width on the right (`--dai-dock-width: max(340px, 30vw)`).
|
||||||
|
- **Physical Separation**: An extra 24px visible gutter (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the tables/cards from the AI sidebar card.
|
||||||
|
- **Elevated Inset Card**: The sidebar card floats with `rounded-2xl` corners (20px), subtle borders (`1px solid #e2e8f0`), soft shadow, and top/right/bottom margins.
|
||||||
|
- **Default Open & Dismissal**: Opens by default on desktop (`window.innerWidth >= 1024`, persisted in `localStorage`). The close button (`×`) smoothly collapses the sidebar, animating the page content to 100% full width. Re-opening is accessible anytime via the Doormile 'D' mark in the top navbar.
|
||||||
|
- **Home Page (`Home.jsx`)**: Prompt composer features a dedicated **Send** button (`Send` icon + "Send") triggering `handleAsk()`.
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
The app opens on **`/login`**. Everything else lives under `/doormile/*` behind `ProtectedRoute`,
|
The app opens on **`/login`**. Everything else lives under `/doormile/*` behind `ProtectedRoute`,
|
||||||
|
|||||||
BIN
public/preloader.png
Normal file
BIN
public/preloader.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -18,6 +18,7 @@ import Login from '@/pages/Login';
|
|||||||
const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
|
const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
|
||||||
const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.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 Orders = lazy(() => import('@/pages/doormile/orders/Orders'));
|
||||||
const Bookings = lazy(() => import('@/pages/doormile/bookings/Bookings'));
|
const Bookings = lazy(() => import('@/pages/doormile/bookings/Bookings'));
|
||||||
const OrdersPreview = lazy(() => import('@/pages/doormile/orders/OrdersPreview'));
|
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
|
{/* 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
|
all day, and the one page that answers "what is happening right
|
||||||
now" without a filter being set first. */}
|
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 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" element={<Dispatch />} />
|
||||||
<Route path="dispatch/preview" element={<DispatchPreview />} />
|
<Route path="dispatch/preview" element={<DispatchPreview />} />
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
* page use. Reach for the wordmark only on a dark ground.
|
* page use. Reach for the wordmark only on a dark ground.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** The circular red D — the brand mark, legible on light surfaces. */
|
/** The red D brand mark, legible on light surfaces. */
|
||||||
export const DOORMILE_MARK_URL = '/doormile-mark.png';
|
export const DOORMILE_MARK_URL = '/preloader.png';
|
||||||
|
export const DOORMILE_D_LOGO_URL = '/preloader.png';
|
||||||
|
|
||||||
/** The white wordmark. Dark backgrounds only. */
|
/** The white wordmark. Dark backgrounds only. */
|
||||||
export const DOORMILE_WORDMARK_URL = '/doormile-logo.png';
|
export const DOORMILE_WORDMARK_URL = '/doormile-logo.png';
|
||||||
|
|||||||
BIN
src/assets/images/preloader.png
Normal file
BIN
src/assets/images/preloader.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -6,21 +6,28 @@ Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `Doo
|
|||||||
|
|
||||||
## 1. What this is
|
## 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.
|
||||||
|
- **80% / 20% Split Layout**: By default on desktop (`window.innerWidth >= 1024`), the assistant is **open on initial load**, occupying 20% of the viewport (`--dai-dock-width: clamp(320px, 20vw, 460px)`), while the page content occupies the remaining 80%. Narrowed from 30vw on request — that width is what pushed the Deliveries tab strip past its container, so the 10vw goes back to the page.
|
||||||
|
- `clamp` rather than `max` because the **cap** is what makes a large monitor work: 20vw is 768px at 4K, a chat column wider than most documents. Three regimes — a 320px floor below ~1600px (20vw would be 256px on a 1280 laptop, too narrow for the composer and thread), 20vw between ~1600–2300px, and a 460px cap above that. At 1024px the floor wins, so the panel is 31% there, not 20%.
|
||||||
|
- At or below 900px the panel is a **full-width overlay with a scrim** and reserves no padding at all.
|
||||||
|
- `DOCK_NORMAL` in `AIPanel.jsx` must stay **byte-identical** to this value. The CSS applies before the effect that sets the property runs on first paint, so any difference between the two shows as the page jumping sideways on load.
|
||||||
|
- **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.
|
- **`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:
|
- **`DoormileAI/`** — all UI:
|
||||||
- `index.js` — the trigger button; owns open/closed state and returns focus to itself on close.
|
- `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.
|
- `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 cards (empty-thread state only).
|
- `AIWelcome.js` — greeting + suggestion pill chips (empty-thread state only).
|
||||||
- `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
|
- `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).
|
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
|
||||||
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
|
- `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.
|
- `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
|
### UI rules that are load-bearing, not cosmetic
|
||||||
|
|
||||||
|
|||||||
@@ -24,22 +24,41 @@
|
|||||||
globalPolish.css, and an unprefixed name there is decided by bundle order.
|
globalPolish.css, and an unprefixed name there is decided by bundle order.
|
||||||
--------------------------------------------------------------------------- */
|
--------------------------------------------------------------------------- */
|
||||||
:root {
|
:root {
|
||||||
/* 30vw, NOT 30%.
|
/* 20vw, NOT 20%.
|
||||||
|
|
||||||
The two consumers resolve a percentage against different boxes: the panel
|
The two consumers resolve a percentage against different boxes: the panel
|
||||||
is position:fixed, so `width: 30%` is 30% of the VIEWPORT, while
|
is position:fixed, so `width: 20%` is 20% of the VIEWPORT, while
|
||||||
`padding-right: 30%` on the content container is 30% of ITS containing
|
`padding-right: 20%` on the content container is 20% of ITS containing
|
||||||
block — which is narrower by the side nav. At 1440px that is 432px of
|
block — which is narrower by the side nav. The page would slide underneath
|
||||||
panel against 410px of reserved strip, and the page slides 22px underneath
|
the panel by the difference. A viewport unit resolves the same for both.
|
||||||
the panel. A viewport unit resolves the same for both.
|
|
||||||
|
|
||||||
max() rather than a separate min-width for the same reason: a min-width
|
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
|
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
|
/* Default = the NORMAL step. AIPanel overwrites this property on the
|
||||||
document element when the operator toggles the width, so the panel and
|
document element when the operator toggles the width, so the panel and
|
||||||
the page's reserved strip always read the same value. */
|
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-duration: 240ms;
|
||||||
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
--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-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
|
||||||
--dai-live: #10b981;
|
--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-inset: 12px;
|
||||||
--dai-duration: 240ms;
|
--dai-duration: 240ms;
|
||||||
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
@@ -119,22 +141,22 @@
|
|||||||
seam has depth without the panel detaching. */
|
seam has depth without the panel detaching. */
|
||||||
.dai-panel {
|
.dai-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
/* Set from JS by AIPanel — Astryx does not publish a header-height token,
|
/* Inset card geometry matching the reference design:
|
||||||
despite --appshell-header-height looking like one. */
|
Floating cleanly off the top nav, bottom edge, and right edge with rounded corners. */
|
||||||
top: var(--dai-dock-top, 57px);
|
top: calc(var(--dai-dock-top, 57px) + 14px);
|
||||||
right: 0;
|
right: 16px;
|
||||||
bottom: 0;
|
bottom: 14px;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
width: var(--dai-dock-width);
|
width: calc(var(--dai-dock-width) - 24px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--dai-surface);
|
background: #ffffff;
|
||||||
border-left: 1px solid var(--dai-border);
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 0;
|
border-radius: 20px;
|
||||||
box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 4px 24px -2px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||||
transform: translateX(100%);
|
transform: translateX(calc(100% + 32px));
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition:
|
transition:
|
||||||
transform var(--dai-duration) var(--dai-ease),
|
transform var(--dai-duration) var(--dai-ease),
|
||||||
@@ -157,11 +179,15 @@
|
|||||||
The transition sits on the container unconditionally so the page slides back
|
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
|
when the panel closes too — a rule that only exists while `.dai-docked` is
|
||||||
applied cannot animate its own removal. */
|
applied cannot animate its own removal. */
|
||||||
|
.dai-page,
|
||||||
.astryx-layout-content {
|
.astryx-layout-content {
|
||||||
transition: padding-right var(--dai-duration) var(--dai-ease);
|
transition: padding-right var(--dai-duration) var(--dai-ease);
|
||||||
}
|
}
|
||||||
|
body.dai-docked .dai-page,
|
||||||
body.dai-docked .astryx-layout-content {
|
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 -------------------------------
|
/* ---- Mobile: there is no 30% worth having -------------------------------
|
||||||
@@ -171,10 +197,14 @@ body.dai-docked .astryx-layout-content {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.dai-panel {
|
.dai-panel {
|
||||||
top: 0;
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 1301;
|
z-index: 1301;
|
||||||
border-left: none;
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
box-shadow: var(--dai-shadow);
|
box-shadow: var(--dai-shadow);
|
||||||
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
.dai-scrim {
|
.dai-scrim {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -182,6 +212,7 @@ body.dai-docked .astryx-layout-content {
|
|||||||
.dai-scrim[data-open='true'] {
|
.dai-scrim[data-open='true'] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
body.dai-docked .dai-page,
|
||||||
body.dai-docked .astryx-layout-content {
|
body.dai-docked .astryx-layout-content {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
@@ -194,20 +225,22 @@ body.dai-docked .astryx-layout-content {
|
|||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.dai-root .dai-header {
|
.dai-root .dai-header {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 14px 12px 12px 14px;
|
padding: 14px 16px;
|
||||||
border-bottom: 1px solid var(--dai-border);
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
.dai-root .dai-title {
|
.dai-root .dai-title {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 650;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--dai-text);
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
.dai-root .dai-subtitle {
|
.dai-root .dai-subtitle {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.3;
|
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. */
|
/* The AI mark. A soft gradient orb — not a robot face. */
|
||||||
.dai-root .dai-spark {
|
.dai-root .dai-spark {
|
||||||
@@ -215,8 +248,9 @@ body.dai-docked .astryx-layout-content {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-radius: 999px;
|
border-radius: 10px;
|
||||||
background: var(--dai-surface);
|
background: #fff1f2;
|
||||||
|
border: 1px solid #ffe4e6;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* The mark's own canvas is only 66.8% content — a third of every edge is
|
/* 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" */
|
/* Page-context strip — "Orders · Today · All locations" */
|
||||||
.dai-root .dai-context {
|
.dai-root .dai-context {
|
||||||
flex: 0 0 auto;
|
display: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Welcome state
|
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
|
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
|
half-visible at the edge, and a half-visible button is a bug no amount of
|
||||||
fade masking fixes. */
|
fade masking fixes. */
|
||||||
|
.dai-root .dai-suggestions-bar .dai-section-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.dai-root .dai-suggestions {
|
.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%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
align-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dai-suggestion {
|
.dai-suggestion {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
max-width: 100%;
|
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;
|
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: inherit;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1.35;
|
line-height: 1.4;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--dai-text);
|
color: #334155;
|
||||||
background: var(--dai-surface);
|
background: #ffffff;
|
||||||
border: 1px solid var(--dai-border);
|
border: 1px solid #e2e8f0;
|
||||||
/* 14px, pinned — NOT 999px.
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||||
On a one-line chip a fully round radius already resolves to about 14px, so
|
|
||||||
these look identical. The difference shows on a chip whose question wraps
|
|
||||||
to two lines: 999px would resolve to half of ~46px and the chip stops
|
|
||||||
reading as a chip and starts reading as a card, so one long suggestion
|
|
||||||
would look like a different component from the nineteen beside it. */
|
|
||||||
border-radius: 14px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition: all 140ms ease;
|
||||||
background-color 140ms ease,
|
|
||||||
border-color 140ms ease,
|
|
||||||
color 140ms ease,
|
|
||||||
transform 140ms ease;
|
|
||||||
}
|
}
|
||||||
.dai-suggestion:hover {
|
.dai-suggestion:hover {
|
||||||
background: var(--dai-surface-alt);
|
background: #f8fafc;
|
||||||
border-color: var(--dai-border-strong);
|
border-color: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
.dai-suggestion:active {
|
.dai-suggestion:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.dai-suggestion:focus-visible {
|
.dai-suggestion:focus-visible {
|
||||||
outline: 2px solid var(--dai-accent);
|
outline: 2px solid #0f172a;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
.dai-root .dai-suggestion-icon {
|
.dai-root .dai-suggestion-icon {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: var(--dai-text-muted);
|
color: #64748b;
|
||||||
transition: color 140ms ease;
|
transition: color 140ms ease;
|
||||||
}
|
}
|
||||||
.dai-suggestion:hover .dai-suggestion-icon {
|
.dai-suggestion:hover .dai-suggestion-icon {
|
||||||
color: var(--dai-text-secondary);
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
.dai-root .dai-suggestion-text {
|
.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;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
/* Plain text link-button ("View more", "Sources") */
|
/* Plain text link-button ("View more", "Sources") */
|
||||||
.dai-link {
|
.dai-link {
|
||||||
@@ -695,26 +747,29 @@ body.dai-docked .astryx-layout-content {
|
|||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.dai-root .dai-composer-wrap {
|
.dai-root .dai-composer-wrap {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||||
border-top: 1px solid var(--dai-border);
|
border-top: 1px solid #f1f5f9;
|
||||||
background: var(--dai-surface);
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
.dai-root .dai-composer {
|
.dai-root .dai-composer {
|
||||||
border: 1px solid var(--dai-border-strong);
|
border: 1px solid #e2e8f0;
|
||||||
/* 14px, the same corner the suggestion chips use. At 5px the input was the
|
border-radius: 9999px;
|
||||||
one sharp-cornered thing in a panel of rounded surfaces, and it sat
|
background: #f8fafc;
|
||||||
directly beneath the chips where the mismatch was most visible. */
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||||
border-radius: 14px;
|
padding: 6px 6px 6px 16px;
|
||||||
background: var(--dai-surface);
|
display: flex;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
align-items: center;
|
||||||
padding: 10px 10px 8px 14px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
transition:
|
transition:
|
||||||
border-color 140ms ease,
|
border-color 140ms ease,
|
||||||
box-shadow 140ms ease;
|
box-shadow 140ms ease,
|
||||||
|
background-color 140ms ease;
|
||||||
}
|
}
|
||||||
.dai-root .dai-composer[data-focused='true'] {
|
.dai-root .dai-composer[data-focused='true'] {
|
||||||
border-color: var(--dai-accent);
|
background: #ffffff;
|
||||||
box-shadow: 0 0 0 3px var(--dai-accent-ring);
|
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;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 30px;
|
width: 32px;
|
||||||
height: 30px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--dai-accent);
|
background: #2563eb;
|
||||||
color: var(--dai-accent-contrast);
|
color: #ffffff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background-color 140ms ease,
|
background-color 140ms ease,
|
||||||
@@ -825,29 +880,32 @@ body.dai-docked .astryx-layout-content {
|
|||||||
transform 140ms ease;
|
transform 140ms ease;
|
||||||
}
|
}
|
||||||
.dai-root .dai-send:hover:not(:disabled) {
|
.dai-root .dai-send:hover:not(:disabled) {
|
||||||
opacity: 0.86;
|
background: #1d4ed8;
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
.dai-root .dai-send:active:not(:disabled) {
|
.dai-root .dai-send:active:not(:disabled) {
|
||||||
transform: scale(0.94);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
.dai-root .dai-send:focus-visible {
|
.dai-root .dai-send:focus-visible {
|
||||||
outline: 2px solid var(--dai-accent);
|
outline: 2px solid #2563eb;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
/* Disabled is the resting state until something is typed, so it has to read as
|
/* 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. */
|
"not yet", not as "broken" — a filled grey circle, same shape and weight. */
|
||||||
.dai-root .dai-send:disabled {
|
.dai-root .dai-send:disabled {
|
||||||
background: var(--dai-surface-hover);
|
background: #f1f5f9;
|
||||||
color: var(--dai-text-muted);
|
color: #94a3b8;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.dai-root .dai-composer textarea {
|
.dai-root .dai-composer textarea {
|
||||||
display: block;
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
padding: 0;
|
padding: 4px 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
@@ -914,15 +972,36 @@ body.dai-docked .astryx-layout-content {
|
|||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Responsive
|
Responsive
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
@media (max-width: 1279px) {
|
/* Tablet and small laptop, 768-900px: the panel is a full-width overlay with a
|
||||||
.dai-root {
|
scrim (see the max-width:900px block above), so the only thing left to do is
|
||||||
--dai-panel-width: 396px;
|
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) {
|
@media (max-width: 767px) {
|
||||||
.dai-root {
|
.dai-root {
|
||||||
--dai-inset: 0px;
|
--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 {
|
.dai-panel {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { LuArrowUp } from 'react-icons/lu';
|
import { LuSend } from 'react-icons/lu';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
@@ -111,7 +111,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack className="dai-composer-wrap" gap={1} padding={0}>
|
<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
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -120,13 +120,9 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder || 'Ask MileTruth anything...'}
|
||||||
aria-label="Ask Doormile AI about operations"
|
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">
|
<HStack gap={1} padding={0} vAlign="center" justify="end">
|
||||||
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
|
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
|
||||||
<button
|
<button
|
||||||
@@ -136,10 +132,10 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
|||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
|
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>
|
</button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</HStack>
|
||||||
<Text className="dai-hint">
|
<Text className="dai-hint">
|
||||||
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
|
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
|
|||||||
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
|
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
|
||||||
<HStack gap={1} padding={0} vAlign="center">
|
<HStack gap={1} padding={0} vAlign="center">
|
||||||
<Spark size="sm" />
|
<Spark size="sm" />
|
||||||
<Text className="dai-msg-name">Doormile AI</Text>
|
<Text className="dai-msg-name">MileTruth</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack className="dai-msg-actions" gap={0} padding={0}>
|
<HStack className="dai-msg-actions" gap={0} padding={0}>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import PropTypes from 'prop-types';
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { AiOutlineClose as CloseOutlined, AiOutlineMore as MoreOutlined } from 'react-icons/ai';
|
import { AiOutlineMore as MoreOutlined } from 'react-icons/ai';
|
||||||
import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose, LuHistory, LuTrash2, LuMessageSquare } from 'react-icons/lu';
|
import {
|
||||||
|
LuChevronDown,
|
||||||
|
LuHistory,
|
||||||
|
LuTrash2,
|
||||||
|
LuMessageSquare,
|
||||||
|
LuRotateCcw,
|
||||||
|
LuMaximize2,
|
||||||
|
LuMinimize2,
|
||||||
|
LuX
|
||||||
|
} from 'react-icons/lu';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
@@ -51,14 +60,17 @@ const CONVERSATIONS_KEY = 'doormileBotConversations';
|
|||||||
const CONVERSATIONS_LIMIT = 25;
|
const CONVERSATIONS_LIMIT = 25;
|
||||||
const PIN_THRESHOLD_PX = 48;
|
const PIN_THRESHOLD_PX = 48;
|
||||||
|
|
||||||
// Dock widths. Each is a percentage of the viewport with a pixel floor — 25vw
|
// Dock widths. Each is a viewport fraction with a pixel floor — 20vw is only
|
||||||
// is only 320px on a 1280 laptop, and the floor catches anything narrower than
|
// 256px on a 1280 laptop, and the floor catches anything narrower than that,
|
||||||
// that, below which the panel stops being readable. Both values are also what
|
// below which the composer and the thread stop being readable. Both values are
|
||||||
// the page reserves beside the panel, so they must stay a single expression
|
// also what the page reserves beside the panel, so they must stay a single
|
||||||
// (see the --dai-dock-width comment in DoormileAI.css for why a percentage
|
// expression (see the --dai-dock-width comment in DoormileAI.css for why a
|
||||||
// cannot be used here).
|
// percentage cannot be used here).
|
||||||
const DOCK_NORMAL = 'max(300px, 25vw)';
|
// Must stay byte-identical to the --dai-dock-width default in DoormileAI.css:
|
||||||
const DOCK_WIDE = 'max(340px, 30vw)';
|
// 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';
|
const WIDTH_KEY = 'doormileBotWidth';
|
||||||
|
|
||||||
// Seeded from the clock, not from 1.
|
// Seeded from the clock, not from 1.
|
||||||
@@ -1378,7 +1390,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
? nextSteps.length > 0
|
? nextSteps.length > 0
|
||||||
? nextSteps
|
? nextSteps
|
||||||
: pageContext.suggestions.slice(0, 4)
|
: 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
|
// Few chips => the strip is fixed and always fully visible. Twenty => it may
|
||||||
// shrink and scroll. See the [data-compact] rule in DoormileAI.css.
|
// 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
|
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. */
|
lie to exactly the users who cannot see that it is docked. */
|
||||||
role="complementary"
|
role="complementary"
|
||||||
aria-label="Doormile AI — Operations Copilot"
|
aria-label="MileTruth — Operations Copilot"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{/* ---- header ---- */}
|
{/* ---- header ---- */}
|
||||||
@@ -1410,30 +1422,16 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
<HStack gap={1.5} padding={0} vAlign="center">
|
<HStack gap={1.5} padding={0} vAlign="center">
|
||||||
<Spark size="md" />
|
<Spark size="md" />
|
||||||
<VStack gap={0} padding={0}>
|
<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>
|
<Text className="dai-subtitle">Operations Copilot</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack gap={1} padding={0} vAlign="center">
|
<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
|
<IconButton
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
label={view === 'history' ? 'Back to the conversation' : 'Conversation history'}
|
label={view === 'history' ? 'Back to conversation' : 'Conversation history'}
|
||||||
tooltip={view === 'history' ? 'Back to the conversation' : 'History'}
|
tooltip={view === 'history' ? 'Back to conversation' : 'History'}
|
||||||
aria-pressed={view === 'history'}
|
aria-pressed={view === 'history'}
|
||||||
icon={view === 'history' ? <LuMessageSquare /> : <LuHistory />}
|
icon={view === 'history' ? <LuMessageSquare /> : <LuHistory />}
|
||||||
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
||||||
@@ -1441,39 +1439,32 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
label={isWide ? 'Narrow the assistant' : 'Widen the assistant'}
|
label="New conversation"
|
||||||
tooltip={isWide ? 'Narrow to 25%' : 'Widen to 30%'}
|
tooltip="New conversation"
|
||||||
aria-pressed={isWide}
|
icon={<LuRotateCcw />}
|
||||||
icon={isWide ? <LuPanelRightClose /> : <LuPanelRightOpen />}
|
onClick={newConversation}
|
||||||
onClick={() => setIsWide((v) => !v)}
|
|
||||||
/>
|
/>
|
||||||
<DropdownMenu
|
<IconButton
|
||||||
hasChevron={false}
|
size="sm"
|
||||||
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
|
variant="ghost"
|
||||||
items={[
|
label={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
|
||||||
// These were both wired to clearConversation, so the safe
|
tooltip={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
|
||||||
// option and the destructive one did the same thing. Now
|
aria-pressed={isWide}
|
||||||
// "New" keeps the thread and "Delete" says that it does not.
|
icon={isWide ? <LuMinimize2 /> : <LuMaximize2 />}
|
||||||
{ label: 'New conversation', onClick: newConversation },
|
onClick={() => setIsWide((v) => !v)}
|
||||||
{ label: 'Conversation history', onClick: () => setView('history') },
|
|
||||||
{ label: 'Delete this conversation', onClick: clearConversation }
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
label="Close assistant"
|
label="Close assistant"
|
||||||
tooltip="Close assistant"
|
tooltip="Close assistant"
|
||||||
icon={<CloseOutlined />}
|
icon={<LuX />}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
{/* ---- page context ---- */}
|
|
||||||
<Text className="dai-context">{pageContext.label} · Today · All locations</Text>
|
|
||||||
|
|
||||||
{/* ---- conversation / welcome ---- */}
|
{/* ---- conversation / welcome ---- */}
|
||||||
<VStack className="dai-scroll-wrap" gap={0} padding={0}>
|
<VStack className="dai-scroll-wrap" gap={0} padding={0}>
|
||||||
<VStack ref={scrollRef} className="dai-scroll" gap={0} padding={0} onScroll={handleScroll}>
|
<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}>
|
<VStack className="dai-msg" gap={1.5} padding={0}>
|
||||||
<HStack gap={1} padding={0} vAlign="center">
|
<HStack gap={1} padding={0} vAlign="center">
|
||||||
<Spark size="sm" />
|
<Spark size="sm" />
|
||||||
<Text className="dai-msg-name">Doormile AI</Text>
|
<Text className="dai-msg-name">MileTruth</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<TypingIndicator />
|
<TypingIndicator />
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -1563,7 +1554,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- composer ---- */}
|
{/* ---- 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>
|
</VStack>
|
||||||
</>,
|
</>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ LiveIndicator.propTypes = { label: PropTypes.string };
|
|||||||
// Thinking state. Three dots plus two shimmer bars — enough to read as
|
// Thinking state. Three dots plus two shimmer bars — enough to read as
|
||||||
// "working", not enough to be a light show.
|
// "working", not enough to be a light show.
|
||||||
export const TypingIndicator = () => (
|
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">
|
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const AIWelcome = () => {
|
|||||||
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
<Text className="dai-welcome-note">
|
<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>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ const DoormileAITrigger = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip content="Doormile AI">
|
<Tooltip content="MileTruth">
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="dai-root dai-trigger"
|
className="dai-root dai-trigger"
|
||||||
data-active={isOpen}
|
data-active={isOpen}
|
||||||
aria-label="Open Doormile AI"
|
aria-label="Open MileTruth"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ export function AssistantPanel({ open, onClose }) {
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
role="dialog"
|
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]"
|
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">
|
<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" />
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<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>
|
<p className="truncate text-caption text-ink-3">Operations copilot · {label}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,28 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
* between tabs the same way the main nav pill does. `layoutGroupId` must be
|
* 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
|
* unique when two tab sets are on screen at once, otherwise the indicator
|
||||||
* animates between them.
|
* 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 */
|
/** @param {any} props */
|
||||||
export function Tabs({
|
export function Tabs({
|
||||||
@@ -30,20 +52,62 @@ export function Tabs({
|
|||||||
const fallbackId = React.useId();
|
const fallbackId = React.useId();
|
||||||
const groupId = layoutGroupId || fallbackId;
|
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') {
|
if (variant === 'underline') {
|
||||||
return (
|
return track(
|
||||||
<div role="tablist" aria-label={ariaLabel} className={cn('flex items-center gap-1 border-b border-border', className)}>
|
// `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) => {
|
{normalized.map((tab) => {
|
||||||
const active = tab.value === value;
|
const active = tab.value === value;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
|
ref={active ? activeRef : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
onClick={() => onChange?.(tab.value)}
|
onClick={() => onChange?.(tab.value)}
|
||||||
className={cn(
|
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',
|
size === 'sm' ? 'text-body-sm' : 'text-body',
|
||||||
active ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
|
active ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
|
||||||
)}
|
)}
|
||||||
@@ -67,12 +131,14 @@ export function Tabs({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return track(
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -81,12 +147,16 @@ export function Tabs({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
|
ref={active ? activeRef : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
onClick={() => onChange?.(tab.value)}
|
onClick={() => onChange?.(tab.value)}
|
||||||
className={cn(
|
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',
|
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'
|
active ? 'text-white' : 'text-ink-3 hover:text-ink-1'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
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';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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 { Avatar } from '@/components/ds/Avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@@ -23,23 +25,22 @@ import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
|
|||||||
* The console shell.
|
* The console shell.
|
||||||
*
|
*
|
||||||
* Single 56px header bar, compact search with a keyboard shortcut, hairline tab
|
* Single 56px header bar, compact search with a keyboard shortcut, hairline tab
|
||||||
* indicator, mobile sheet — the design is unchanged. What changed is what it
|
* indicator, mobile sheet.
|
||||||
* points at: the Doormile Express destinations rather than the hiring ones.
|
|
||||||
*
|
*
|
||||||
* Seven destinations sit on the bar and the remaining eleven live in two
|
* Core destinations sit on the bar; the rest live in two grouped menus on it
|
||||||
* grouped menus (Fleet Ops, Reports), built from the same DropdownMenu the
|
* (Fleet Ops, Reports) plus a Settings section inside the account menu, which
|
||||||
* account menu already uses. Eighteen flat tabs would not fit a 56px bar at any
|
* holds the two destinations that are configuration rather than operations
|
||||||
* width, and a console header should recede rather than become the loudest
|
* (Pricing Matrix, Customers Directory).
|
||||||
* thing on the page.
|
*
|
||||||
|
* 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';
|
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 = {
|
const LOGO_FILTER = {
|
||||||
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',
|
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: 'Deliveries', path: '/doormile/deliveries' },
|
||||||
{ label: 'Milers', path: '/doormile/milers' },
|
{ label: 'Milers', path: '/doormile/milers' },
|
||||||
{ label: 'Clients', path: '/doormile/tenants' },
|
{ 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. */
|
/** Destinations grouped behind a menu, in the order the console lists them. */
|
||||||
@@ -61,27 +60,45 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
label: 'Fleet Ops',
|
label: 'Fleet Ops',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Hubs', path: '/doormile/hubs' },
|
{ label: 'Hubs', path: '/doormile/hubs', icon: Warehouse, desc: 'Sortation centers & facilities' },
|
||||||
{ label: 'Vehicles', path: '/doormile/vehicles' },
|
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car, desc: 'Fleet register & EV telematics' },
|
||||||
{ label: 'Tripsheets', path: '/doormile/tripsheets' },
|
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo, desc: 'Linehaul & middle-mile runs' },
|
||||||
{ label: 'Exceptions', path: '/doormile/exceptions' },
|
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert, desc: 'Live alerts & delivery blockers' },
|
||||||
{ label: 'Competitive Intel', path: '/doormile/competitive-intel' },
|
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity, desc: 'Benchmarking & market rates' },
|
||||||
{ label: 'App Users', path: '/doormile/app-users' },
|
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck, desc: 'System operators & roles' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary' },
|
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary', icon: FileSpreadsheet, desc: 'Daily volume & status aggregations' },
|
||||||
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails' },
|
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails', icon: FileText, desc: 'Granular leg-by-leg breakdown' },
|
||||||
{ label: 'Riders Summary', path: '/doormile/reports/riderssummary' },
|
{ label: 'Milers Summary', path: '/doormile/reports/riderssummary', icon: Bike, desc: 'Fleet KPIs & on-duty shifts' },
|
||||||
{ label: 'Profitability', path: '/doormile/reports/profitability' },
|
{ label: 'Profitability', path: '/doormile/reports/profitability', icon: Coins, desc: 'Gross margin & variable costs' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Every destination, flattened — what the mobile sheet renders. */
|
/* Configuration destinations, kept off the operations bar.
|
||||||
const ALL_DESTINATIONS = [...NAV, ...NAV_GROUPS.flatMap((group) => group.items)];
|
*
|
||||||
|
* 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() {
|
function GlobalSearch() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -225,7 +242,27 @@ export default function AdminLayout() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isClient, logout } = useAuth();
|
const { user, isClient, logout } = useAuth();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
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(() => {
|
const navItems = useMemo(() => {
|
||||||
if (isClient) {
|
if (isClient) {
|
||||||
@@ -253,6 +290,7 @@ export default function AdminLayout() {
|
|||||||
const groupIsActive = (group) => group.items.some((item) => isActive(item.path));
|
const groupIsActive = (group) => group.items.some((item) => isActive(item.path));
|
||||||
|
|
||||||
const displayName = user?.displayname || user?.authname || user?.name || 'Operator';
|
const displayName = user?.displayname || user?.authname || user?.name || 'Operator';
|
||||||
|
const isHome = location.pathname === '/doormile/home' || location.pathname === '/doormile' || location.pathname === '/';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* Transparent so the ambient canvas painted behind the app reads through.
|
/* 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">
|
<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">
|
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
|
||||||
<Link
|
<Link
|
||||||
to="/doormile/dispatch"
|
to="/doormile/home"
|
||||||
aria-label="Doormile Express — Dispatch"
|
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"
|
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
|
<img
|
||||||
src={DOORMILE_WORDMARK_URL}
|
src={DOORMILE_MARK_URL}
|
||||||
alt="Doormile Express"
|
alt="Doormile"
|
||||||
style={LOGO_FILTER}
|
className="h-8 w-auto object-contain"
|
||||||
className="h-7 w-auto max-w-[120px] object-contain sm:max-w-none"
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -316,13 +346,14 @@ export default function AdminLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex items-center gap-1 whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium',
|
'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',
|
'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'
|
groupIsActive(group) ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{group.label}
|
{group.icon && <group.icon className="h-3.5 w-3.5 opacity-70" />}
|
||||||
<ChevronDown className="h-3 w-3" aria-hidden="true" />
|
<span>{group.label}</span>
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-60" aria-hidden="true" />
|
||||||
{groupIsActive(group) && (
|
{groupIsActive(group) && (
|
||||||
<motion.span
|
<motion.span
|
||||||
layoutId="adminNavIndicator"
|
layoutId="adminNavIndicator"
|
||||||
@@ -332,16 +363,40 @@ export default function AdminLayout() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-52">
|
<DropdownMenuContent align="start" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => {
|
||||||
<DropdownMenuItem
|
const ItemIcon = item.icon;
|
||||||
key={item.path}
|
const active = isActive(item.path);
|
||||||
onClick={() => navigate(item.path)}
|
return (
|
||||||
className={cn('cursor-pointer', isActive(item.path) && 'text-brand')}
|
<DropdownMenuItem
|
||||||
>
|
key={item.path}
|
||||||
{item.label}
|
onClick={() => navigate(item.path)}
|
||||||
</DropdownMenuItem>
|
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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
))}
|
))}
|
||||||
@@ -350,22 +405,32 @@ export default function AdminLayout() {
|
|||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
<GlobalSearch />
|
<GlobalSearch />
|
||||||
|
|
||||||
{/* Doormile AI. A header control rather than a route: it answers
|
{/* MileTruth. A header control rather than a route: shown on all pages except the home page */}
|
||||||
about live data while the operator stays on the screen that
|
{!isHome && (
|
||||||
raised the question. */}
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => toggleAssistant((open) => !open)}
|
||||||
onClick={() => setAssistantOpen((open) => !open)}
|
aria-label={assistantOpen ? 'Close MileTruth' : 'Open MileTruth'}
|
||||||
aria-label={assistantOpen ? 'Close Doormile AI' : 'Open Doormile AI'}
|
aria-expanded={assistantOpen}
|
||||||
aria-expanded={assistantOpen}
|
className={cn(
|
||||||
className={cn(
|
'grid h-8 w-8 place-items-center rounded-lg transition-colors cursor-pointer',
|
||||||
'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',
|
||||||
'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'
|
||||||
assistantOpen ? 'bg-brand-tint text-brand' : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{/* 20px inside a 32px button.
|
||||||
<img className="h-9 w-9 max-w-none object-contain" src={doormileMark} alt="" aria-hidden="true" />
|
Was `h-9 w-9 max-w-none` — a 36px image in a 32px button,
|
||||||
</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 />}
|
{!isClient && <NotificationsMenu />}
|
||||||
|
|
||||||
@@ -382,27 +447,40 @@ export default function AdminLayout() {
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="end" className="w-60">
|
<DropdownMenuContent align="end" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal px-2.5 py-2">
|
||||||
<p className="text-body-sm font-semibold text-ink-1">{displayName}</p>
|
<p className="text-body-sm font-bold text-ink-1">{displayName}</p>
|
||||||
<p className="text-caption text-ink-3">{user?.email || user?.primaryemail}</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">
|
<Badge variant="soft" size="sm" className="mt-1.5 bg-rose-50 text-rose-700 font-semibold border-rose-200">
|
||||||
{user?.role || 'Administrator'}
|
{user?.role || 'admin'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator className="my-1" />
|
||||||
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer">
|
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
|
||||||
<User className="mr-2 h-4 w-4" /> Profile
|
<User className="mr-2.5 h-4 w-4 text-slate-500" /> Profile
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer">
|
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
|
||||||
<Shield className="mr-2 h-4 w-4" /> Change password
|
<Shield className="mr-2.5 h-4 w-4 text-slate-500" /> Change password
|
||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
onClick={() => logout()}
|
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>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -441,6 +519,26 @@ export default function AdminLayout() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</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" />
|
<div className="my-2 h-px bg-border" />
|
||||||
<Link
|
<Link
|
||||||
to="/doormile/profile"
|
to="/doormile/profile"
|
||||||
@@ -456,13 +554,16 @@ export default function AdminLayout() {
|
|||||||
{/* `dai-page` is the handshake with the assistant dock: while the panel
|
{/* `dai-page` is the handshake with the assistant dock: while the panel
|
||||||
is open, DoormileAI.css reserves exactly its width of padding here so
|
is open, DoormileAI.css reserves exactly its width of padding here so
|
||||||
the page slides open beside it instead of being covered. */}
|
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")}>
|
<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", !location.pathname.startsWith('/doormile/dispatch') && "py-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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assistantOpen && <AIPanel isOpen={assistantOpen} onClose={() => setAssistantOpen(false)} />}
|
{!isHome && <AIPanel isOpen={assistantOpen} onClose={() => toggleAssistant(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,29 @@ Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `Doo
|
|||||||
|
|
||||||
## 1. What this is
|
## 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.
|
||||||
|
- **80% / 20% Split Layout**: By default on desktop (`window.innerWidth >= 1024`), the assistant is **open on initial load**, occupying 20% of the viewport (`--dai-dock-width: clamp(320px, 20vw, 460px)`), while the page content occupies the remaining 80%. Narrowed from 30vw on request — that width is what pushed the Deliveries tab strip past its container, so the 10vw goes back to the page.
|
||||||
|
- `clamp` rather than `max` because the **cap** is what makes a large monitor work: 20vw is 768px at 4K, a chat column wider than most documents. Three regimes — a 320px floor below ~1600px (20vw would be 256px on a 1280 laptop, too narrow for the composer and thread), 20vw between ~1600–2300px, and a 460px cap above that. At 1024px the floor wins, so the panel is 31% there, not 20%.
|
||||||
|
- At or below 900px the panel is a **full-width overlay with a scrim** and reserves no padding at all.
|
||||||
|
- `DOCK_NORMAL` in `AIPanel.jsx` must stay **byte-identical** to this value. The CSS applies before the effect that sets the property runs on first paint, so any difference between the two shows as the page jumping sideways on load.
|
||||||
|
- **Note:** this file is a duplicate of `src/components/assistant/CLAUDE.md`, and `src/lib/assistant/DoormileAI.css` is a dead copy of the stylesheet — nothing imports it. Only the `src/components/assistant/` copies are live.
|
||||||
|
- **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.
|
- **`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:
|
- **`DoormileAI/`** — all UI:
|
||||||
- `index.js` — the trigger button; owns open/closed state and returns focus to itself on close.
|
- `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.
|
- `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 cards (empty-thread state only).
|
- `AIWelcome.js` — greeting + suggestion pill chips (empty-thread state only).
|
||||||
- `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
|
- `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).
|
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
|
||||||
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
|
- `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.
|
- `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
|
### UI rules that are load-bearing, not cosmetic
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
/* Default = the NORMAL step. AIPanel overwrites this property on the
|
/* Default = the NORMAL step. AIPanel overwrites this property on the
|
||||||
document element when the operator toggles the width, so the panel and
|
document element when the operator toggles the width, so the panel and
|
||||||
the page's reserved strip always read the same value. */
|
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-duration: 240ms;
|
||||||
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
@@ -119,22 +119,22 @@
|
|||||||
seam has depth without the panel detaching. */
|
seam has depth without the panel detaching. */
|
||||||
.dai-panel {
|
.dai-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
/* Set from JS by AIPanel — Astryx does not publish a header-height token,
|
/* Inset card geometry matching the reference design:
|
||||||
despite --appshell-header-height looking like one. */
|
Floating cleanly off the top nav, bottom edge, and right edge with rounded corners. */
|
||||||
top: var(--dai-dock-top, 57px);
|
top: calc(var(--dai-dock-top, 57px) + 14px);
|
||||||
right: 0;
|
right: 16px;
|
||||||
bottom: 0;
|
bottom: 14px;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
width: var(--dai-dock-width);
|
width: calc(var(--dai-dock-width) - 24px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--dai-surface);
|
background: #ffffff;
|
||||||
border-left: 1px solid var(--dai-border);
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 0;
|
border-radius: 20px;
|
||||||
box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 4px 24px -2px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||||
transform: translateX(100%);
|
transform: translateX(calc(100% + 32px));
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition:
|
transition:
|
||||||
transform var(--dai-duration) var(--dai-ease),
|
transform var(--dai-duration) var(--dai-ease),
|
||||||
@@ -157,11 +157,15 @@
|
|||||||
The transition sits on the container unconditionally so the page slides back
|
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
|
when the panel closes too — a rule that only exists while `.dai-docked` is
|
||||||
applied cannot animate its own removal. */
|
applied cannot animate its own removal. */
|
||||||
|
.dai-page,
|
||||||
.astryx-layout-content {
|
.astryx-layout-content {
|
||||||
transition: padding-right var(--dai-duration) var(--dai-ease);
|
transition: padding-right var(--dai-duration) var(--dai-ease);
|
||||||
}
|
}
|
||||||
|
body.dai-docked .dai-page,
|
||||||
body.dai-docked .astryx-layout-content {
|
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 -------------------------------
|
/* ---- Mobile: there is no 30% worth having -------------------------------
|
||||||
@@ -171,10 +175,14 @@ body.dai-docked .astryx-layout-content {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.dai-panel {
|
.dai-panel {
|
||||||
top: 0;
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 1301;
|
z-index: 1301;
|
||||||
border-left: none;
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
box-shadow: var(--dai-shadow);
|
box-shadow: var(--dai-shadow);
|
||||||
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
.dai-scrim {
|
.dai-scrim {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -182,6 +190,7 @@ body.dai-docked .astryx-layout-content {
|
|||||||
.dai-scrim[data-open='true'] {
|
.dai-scrim[data-open='true'] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
body.dai-docked .dai-page,
|
||||||
body.dai-docked .astryx-layout-content {
|
body.dai-docked .astryx-layout-content {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
@@ -194,20 +203,22 @@ body.dai-docked .astryx-layout-content {
|
|||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.dai-root .dai-header {
|
.dai-root .dai-header {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 14px 12px 12px 14px;
|
padding: 14px 16px;
|
||||||
border-bottom: 1px solid var(--dai-border);
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
.dai-root .dai-title {
|
.dai-root .dai-title {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 650;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--dai-text);
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
.dai-root .dai-subtitle {
|
.dai-root .dai-subtitle {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.3;
|
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. */
|
/* The AI mark. A soft gradient orb — not a robot face. */
|
||||||
.dai-root .dai-spark {
|
.dai-root .dai-spark {
|
||||||
@@ -215,8 +226,9 @@ body.dai-docked .astryx-layout-content {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-radius: 999px;
|
border-radius: 10px;
|
||||||
background: var(--dai-surface);
|
background: #fff1f2;
|
||||||
|
border: 1px solid #ffe4e6;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* The mark's own canvas is only 66.8% content — a third of every edge is
|
/* 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" */
|
/* Page-context strip — "Orders · Today · All locations" */
|
||||||
.dai-root .dai-context {
|
.dai-root .dai-context {
|
||||||
flex: 0 0 auto;
|
display: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Welcome state
|
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
|
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
|
half-visible at the edge, and a half-visible button is a bug no amount of
|
||||||
fade masking fixes. */
|
fade masking fixes. */
|
||||||
|
.dai-root .dai-suggestions-bar .dai-section-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.dai-root .dai-suggestions {
|
.dai-root .dai-suggestions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dai-suggestion {
|
.dai-suggestion {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 5px 10px;
|
padding: 8px 18px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1.35;
|
line-height: 1.4;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--dai-text);
|
color: #334155;
|
||||||
background: var(--dai-surface);
|
background: #ffffff;
|
||||||
border: 1px solid var(--dai-border);
|
border: 1px solid #e2e8f0;
|
||||||
/* 14px, pinned — NOT 999px.
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||||
On a one-line chip a fully round radius already resolves to about 14px, so
|
|
||||||
these look identical. The difference shows on a chip whose question wraps
|
|
||||||
to two lines: 999px would resolve to half of ~46px and the chip stops
|
|
||||||
reading as a chip and starts reading as a card, so one long suggestion
|
|
||||||
would look like a different component from the nineteen beside it. */
|
|
||||||
border-radius: 14px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition: all 140ms ease;
|
||||||
background-color 140ms ease,
|
|
||||||
border-color 140ms ease,
|
|
||||||
color 140ms ease,
|
|
||||||
transform 140ms ease;
|
|
||||||
}
|
}
|
||||||
.dai-suggestion:hover {
|
.dai-suggestion:hover {
|
||||||
background: var(--dai-surface-alt);
|
background: #f8fafc;
|
||||||
border-color: var(--dai-border-strong);
|
border-color: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
.dai-suggestion:active {
|
.dai-suggestion:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.dai-suggestion:focus-visible {
|
.dai-suggestion:focus-visible {
|
||||||
outline: 2px solid var(--dai-accent);
|
outline: 2px solid #0f172a;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
.dai-root .dai-suggestion-icon {
|
.dai-root .dai-suggestion-icon {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: var(--dai-text-muted);
|
color: #64748b;
|
||||||
transition: color 140ms ease;
|
transition: color 140ms ease;
|
||||||
}
|
}
|
||||||
.dai-suggestion:hover .dai-suggestion-icon {
|
.dai-suggestion:hover .dai-suggestion-icon {
|
||||||
color: var(--dai-text-secondary);
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
.dai-root .dai-suggestion-text {
|
.dai-root .dai-suggestion-text {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -695,26 +698,29 @@ body.dai-docked .astryx-layout-content {
|
|||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.dai-root .dai-composer-wrap {
|
.dai-root .dai-composer-wrap {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||||
border-top: 1px solid var(--dai-border);
|
border-top: 1px solid #f1f5f9;
|
||||||
background: var(--dai-surface);
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
.dai-root .dai-composer {
|
.dai-root .dai-composer {
|
||||||
border: 1px solid var(--dai-border-strong);
|
border: 1px solid #e2e8f0;
|
||||||
/* 14px, the same corner the suggestion chips use. At 5px the input was the
|
border-radius: 9999px;
|
||||||
one sharp-cornered thing in a panel of rounded surfaces, and it sat
|
background: #f8fafc;
|
||||||
directly beneath the chips where the mismatch was most visible. */
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||||
border-radius: 14px;
|
padding: 6px 6px 6px 16px;
|
||||||
background: var(--dai-surface);
|
display: flex;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
align-items: center;
|
||||||
padding: 10px 10px 8px 14px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
transition:
|
transition:
|
||||||
border-color 140ms ease,
|
border-color 140ms ease,
|
||||||
box-shadow 140ms ease;
|
box-shadow 140ms ease,
|
||||||
|
background-color 140ms ease;
|
||||||
}
|
}
|
||||||
.dai-root .dai-composer[data-focused='true'] {
|
.dai-root .dai-composer[data-focused='true'] {
|
||||||
border-color: var(--dai-accent);
|
background: #ffffff;
|
||||||
box-shadow: 0 0 0 3px var(--dai-accent-ring);
|
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;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 30px;
|
width: 32px;
|
||||||
height: 30px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--dai-accent);
|
background: #2563eb;
|
||||||
color: var(--dai-accent-contrast);
|
color: #ffffff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background-color 140ms ease,
|
background-color 140ms ease,
|
||||||
@@ -825,29 +831,32 @@ body.dai-docked .astryx-layout-content {
|
|||||||
transform 140ms ease;
|
transform 140ms ease;
|
||||||
}
|
}
|
||||||
.dai-root .dai-send:hover:not(:disabled) {
|
.dai-root .dai-send:hover:not(:disabled) {
|
||||||
opacity: 0.86;
|
background: #1d4ed8;
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
.dai-root .dai-send:active:not(:disabled) {
|
.dai-root .dai-send:active:not(:disabled) {
|
||||||
transform: scale(0.94);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
.dai-root .dai-send:focus-visible {
|
.dai-root .dai-send:focus-visible {
|
||||||
outline: 2px solid var(--dai-accent);
|
outline: 2px solid #2563eb;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
/* Disabled is the resting state until something is typed, so it has to read as
|
/* 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. */
|
"not yet", not as "broken" — a filled grey circle, same shape and weight. */
|
||||||
.dai-root .dai-send:disabled {
|
.dai-root .dai-send:disabled {
|
||||||
background: var(--dai-surface-hover);
|
background: #f1f5f9;
|
||||||
color: var(--dai-text-muted);
|
color: #94a3b8;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.dai-root .dai-composer textarea {
|
.dai-root .dai-composer textarea {
|
||||||
display: block;
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
padding: 0;
|
padding: 4px 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
|
|||||||
@@ -372,8 +372,10 @@ export default function Bookings() {
|
|||||||
{
|
{
|
||||||
key: 'route',
|
key: 'route',
|
||||||
header: 'Route',
|
header: 'Route',
|
||||||
|
width: '260px',
|
||||||
|
className: 'max-w-[260px]',
|
||||||
accessor: (row) => (
|
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 || ''}>
|
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
||||||
{orDash(row.pickupaddress)}
|
{orDash(row.pickupaddress)}
|
||||||
</p>
|
</p>
|
||||||
@@ -385,7 +387,8 @@ export default function Bookings() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'rider',
|
key: 'rider',
|
||||||
header: 'Rider',
|
header: 'Miler',
|
||||||
|
width: '140px',
|
||||||
hideBelow: 'md',
|
hideBelow: 'md',
|
||||||
accessor: (row) => {
|
accessor: (row) => {
|
||||||
const riderId = String(row.assignedmileruserid || row.mileruserid || '');
|
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>;
|
return <span className="text-body-sm font-medium text-ink-1">{name}</span>;
|
||||||
}
|
}
|
||||||
if (riderId) {
|
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>;
|
return <span className="text-caption text-ink-4">Unassigned</span>;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export default function Deliveries() {
|
|||||||
key: 'orderid',
|
key: 'orderid',
|
||||||
header: 'Order & client',
|
header: 'Order & client',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
width: '130px',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.orderid)}</p>
|
<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',
|
key: 'route',
|
||||||
header: 'Route (pickup → drop)',
|
header: 'Route (pickup → drop)',
|
||||||
|
width: '260px',
|
||||||
|
className: 'max-w-[260px]',
|
||||||
sortValue: (row) => row.pickupaddress || '',
|
sortValue: (row) => row.pickupaddress || '',
|
||||||
cell: (row) => (
|
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 || ''}>
|
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
||||||
<span className="font-medium text-ink-1">Pickup</span>
|
<span className="font-medium text-ink-1">Pickup</span>
|
||||||
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
|
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
|
||||||
@@ -239,17 +242,18 @@ export default function Deliveries() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ridername',
|
key: 'ridername',
|
||||||
header: 'Rider',
|
header: 'Miler',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
width: '140px',
|
||||||
cell: (row) => {
|
cell: (row) => {
|
||||||
const riderKey = String(row.userid || row.milerprofileid || '');
|
const riderKey = String(row.userid || row.milerprofileid || '');
|
||||||
const info = riderLookup.get(riderKey);
|
const info = riderLookup.get(riderKey);
|
||||||
const name =
|
const name =
|
||||||
(info?.name && !info.name.startsWith('Rider #'))
|
(info?.name && !info.name.startsWith('Miler #') && !info.name.startsWith('Rider #'))
|
||||||
? info.name
|
? info.name
|
||||||
: (row.ridername && !row.ridername.startsWith('Rider #'))
|
: (row.ridername && !row.ridername.startsWith('Miler #') && !row.ridername.startsWith('Rider #'))
|
||||||
? row.ridername
|
? 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;
|
const contact = info?.phone || row.ridercontact;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -264,6 +268,7 @@ export default function Deliveries() {
|
|||||||
{
|
{
|
||||||
key: 'eta',
|
key: 'eta',
|
||||||
header: 'ETA & distance',
|
header: 'ETA & distance',
|
||||||
|
width: '120px',
|
||||||
sortValue: (row) => row.expecteddeliverytime || '',
|
sortValue: (row) => row.expecteddeliverytime || '',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -279,6 +284,7 @@ export default function Deliveries() {
|
|||||||
key: 'deliverycharges',
|
key: 'deliverycharges',
|
||||||
header: 'Amount',
|
header: 'Amount',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
|
width: '85px',
|
||||||
sortValue: (row) => Number(row.deliverycharges) || 0,
|
sortValue: (row) => Number(row.deliverycharges) || 0,
|
||||||
accessor: (row) => currency(row.deliverycharges),
|
accessor: (row) => currency(row.deliverycharges),
|
||||||
hideBelow: 'lg',
|
hideBelow: 'lg',
|
||||||
@@ -286,6 +292,7 @@ export default function Deliveries() {
|
|||||||
{
|
{
|
||||||
key: 'orderstatus',
|
key: 'orderstatus',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
|
width: '120px',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex flex-col items-start gap-0.5">
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
@@ -300,6 +307,7 @@ export default function Deliveries() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
|
width: '130px',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -328,7 +336,7 @@ export default function Deliveries() {
|
|||||||
|
|
||||||
{['pending', 'accepted', 'arrived'].includes(row.orderstatus) ? (
|
{['pending', 'accepted', 'arrived'].includes(row.orderstatus) ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Change rider"
|
label="Change miler"
|
||||||
icon={Bike}
|
icon={Bike}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -373,7 +381,7 @@ export default function Deliveries() {
|
|||||||
{ key: 'orderid', header: 'Order' },
|
{ key: 'orderid', header: 'Order' },
|
||||||
{ key: 'tenantname', header: 'Client' },
|
{ key: 'tenantname', header: 'Client' },
|
||||||
{ key: 'deliverycustomer', header: 'Customer' },
|
{ key: 'deliverycustomer', header: 'Customer' },
|
||||||
{ key: 'ridername', header: 'Rider' },
|
{ key: 'ridername', header: 'Miler' },
|
||||||
{ key: 'orderstatus', header: 'Status' },
|
{ key: 'orderstatus', header: 'Status' },
|
||||||
{ key: 'kms', header: 'Km' },
|
{ key: 'kms', header: 'Km' },
|
||||||
{ key: 'deliverycharges', header: 'Amount' },
|
{ key: 'deliverycharges', header: 'Amount' },
|
||||||
@@ -398,24 +406,24 @@ export default function Deliveries() {
|
|||||||
actions={
|
actions={
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={setSelectedDate}
|
onChange={(d) => {
|
||||||
allowAll
|
if (d) {
|
||||||
|
const s = dayjs(d).format('YYYY-MM-DD');
|
||||||
|
setDateParams({ from: s, to: s });
|
||||||
|
setSelectedDate(s);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack space="sm">
|
<Stack space="sm">
|
||||||
{/* Eight status tabs are far wider than a phone. Scrolled in their own
|
<Tabs tabs={tabs} value={tab} onChange={setTab} />
|
||||||
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>
|
|
||||||
|
|
||||||
<ListToolbar
|
<ListToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
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' }}
|
count={{ shown: rows.length, total: batchRows.length, noun: 'deliveries' }}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
@@ -457,10 +465,10 @@ export default function Deliveries() {
|
|||||||
|
|
||||||
<Select value={riderId} onValueChange={setRiderId}>
|
<Select value={riderId} onValueChange={setRiderId}>
|
||||||
<SelectTrigger className="w-full sm:w-48">
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
<SelectValue placeholder="All riders" />
|
<SelectValue placeholder="All milers" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={ALL}>All riders</SelectItem>
|
<SelectItem value={ALL}>All milers</SelectItem>
|
||||||
{(riders || []).map((rider) => (
|
{(riders || []).map((rider) => (
|
||||||
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
||||||
{rider.label}
|
{rider.label}
|
||||||
@@ -474,8 +482,8 @@ export default function Deliveries() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
getRowId={(row) => row.orderheaderid}
|
getRowId={(row) => row.orderheaderid}
|
||||||
loading={isLoading}
|
loading={isLoading && !data}
|
||||||
refreshing={isFetching && !isLoading}
|
refreshing={isFetching && Boolean(data)}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
defaultSort={{ key: 'orderid', direction: 'desc' }}
|
defaultSort={{ key: 'orderid', direction: 'desc' }}
|
||||||
isFiltered={isFiltered}
|
isFiltered={isFiltered}
|
||||||
@@ -486,29 +494,36 @@ export default function Deliveries() {
|
|||||||
setRiderId(ALL);
|
setRiderId(ALL);
|
||||||
}}
|
}}
|
||||||
emptyState={
|
emptyState={
|
||||||
isFiltered ? undefined : (
|
<EmptyState
|
||||||
<EmptyState
|
icon={Truck}
|
||||||
icon={Package}
|
title={isFiltered ? 'No matching deliveries' : 'No deliveries found'}
|
||||||
title="Nothing dispatched here"
|
description={
|
||||||
description="A booking appears once it has a rider or has become a consignment."
|
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>
|
</Stack>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(riderRow)}
|
open={Boolean(riderRow)}
|
||||||
onOpenChange={(open) => !open && setRiderRow(null)}
|
onOpenChange={(open) => !open && setRiderRow(null)}
|
||||||
title="Change rider"
|
title="Change miler"
|
||||||
description={riderRow ? `Order ${riderRow.orderid}` : undefined}
|
description={riderRow ? `Order ${riderRow.orderid}` : undefined}
|
||||||
icon={Bike}
|
icon={Bike}
|
||||||
busy={changeRider.isPending}
|
busy={changeRider.isPending}
|
||||||
@@ -533,10 +548,10 @@ export default function Deliveries() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Field label="Rider">
|
<Field label="Miler">
|
||||||
<Select value={selectedRider} onValueChange={setSelectedRider}>
|
<Select value={selectedRider} onValueChange={setSelectedRider}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Choose a rider" />
|
<SelectValue placeholder="Choose a miler" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(riders || []).map((rider) => (
|
{(riders || []).map((rider) => (
|
||||||
|
|||||||
@@ -224,11 +224,11 @@ const ActiveSection = ({
|
|||||||
</div>
|
</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 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">
|
<div className="flex items-center gap-1.5 truncate">
|
||||||
<span className="font-semibold text-slate-900 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>
|
</span>
|
||||||
{(rider?.battery != null || rider?.batterystatus != null) && (
|
{(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">
|
<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>
|
</div>
|
||||||
{(rider?.phone || o.riderphone || o.deliverycontactno) && (
|
{(rider?.phone || o.riderphone || o.deliverycontactno) && (
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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"
|
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
|
💬 WhatsApp
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -3927,7 +3927,7 @@ const Dispatch = ({
|
|||||||
<button
|
<button
|
||||||
className={`sbt ${viewMode === 'riders' ? 'active' : ''}`}
|
className={`sbt ${viewMode === 'riders' ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
logger.info('View mode changed: By Rider');
|
logger.info('View mode changed: By Miler');
|
||||||
setViewMode('riders');
|
setViewMode('riders');
|
||||||
handleRiderFocus(null);
|
handleRiderFocus(null);
|
||||||
setFocusedKitchen(null);
|
setFocusedKitchen(null);
|
||||||
@@ -3937,7 +3937,7 @@ const Dispatch = ({
|
|||||||
<span className="sbt-icon">
|
<span className="sbt-icon">
|
||||||
<MdDirectionsBike />
|
<MdDirectionsBike />
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
By Rider
|
By Miler
|
||||||
</button>
|
</button>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<button
|
<button
|
||||||
@@ -3982,12 +3982,12 @@ const Dispatch = ({
|
|||||||
setFocusedKitchen(null);
|
setFocusedKitchen(null);
|
||||||
setFocusedZone(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">
|
<span className="sbt-icon">
|
||||||
<MdInfoOutline />
|
<MdInfoOutline />
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
Rider Info
|
Miler Info
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4004,9 +4004,9 @@ const Dispatch = ({
|
|||||||
<div className="rider-info-mode">
|
<div className="rider-info-mode">
|
||||||
<div className="ri-sidebar">
|
<div className="ri-sidebar">
|
||||||
<div className="ri-sb-head">
|
<div className="ri-sb-head">
|
||||||
<div className="ri-sb-title">Riders</div>
|
<div className="ri-sb-title">Milers</div>
|
||||||
<div className="ri-sb-sub">
|
<div className="ri-sb-sub">
|
||||||
{ridersAllDay.length} {ridersAllDay.length === 1 ? 'rider' : 'riders'} today
|
{ridersAllDay.length} {ridersAllDay.length === 1 ? 'miler' : 'milers'} today
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ri-search">
|
<div className="ri-search">
|
||||||
@@ -4032,7 +4032,7 @@ const Dispatch = ({
|
|||||||
if (matched.length === 0) {
|
if (matched.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="ri-empty">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4067,16 +4067,16 @@ const Dispatch = ({
|
|||||||
<div className="ri-placeholder-icon">
|
<div className="ri-placeholder-icon">
|
||||||
<MdInfoOutline />
|
<MdInfoOutline />
|
||||||
</div>
|
</div>
|
||||||
<div className="ri-placeholder-title">Pick a rider</div>
|
<div className="ri-placeholder-title">Pick a miler</div>
|
||||||
<div className="ri-placeholder-sub">
|
<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>
|
||||||
</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 &&
|
{riderInfoData &&
|
||||||
(() => {
|
(() => {
|
||||||
@@ -4091,7 +4091,7 @@ const Dispatch = ({
|
|||||||
return (
|
return (
|
||||||
<div className="ri-snapshot">
|
<div className="ri-snapshot">
|
||||||
<div className="ri-snap-head">
|
<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">
|
<div className="ri-snap-meta">
|
||||||
<span>#{d.userid}</span>
|
<span>#{d.userid}</span>
|
||||||
{d.status && <span className={`ri-status ri-status-${statusKey}`}>{d.status}</span>}
|
{d.status && <span className={`ri-status ri-status-${statusKey}`}>{d.status}</span>}
|
||||||
@@ -4222,7 +4222,7 @@ const Dispatch = ({
|
|||||||
{riderInfoArea?.area || 'Locating area…'}
|
{riderInfoArea?.area || 'Locating area…'}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Popup>
|
<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 && (
|
{riderInfoArea?.area && (
|
||||||
<div style={{ fontSize: 12, color: '#0f172a', marginBottom: 4 }}>{riderInfoArea.area}</div>
|
<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">
|
||||||
<div className="sb-header-top">
|
<div className="sb-header-top">
|
||||||
<div className="sb-header-title">
|
<div className="sb-header-title">
|
||||||
<span className="sb-title-text">RIDER DISPATCH</span>
|
<span className="sb-title-text">MILER DISPATCH</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="sb-header-scope" title={activeStats.label}>
|
<span className="sb-header-scope" title={activeStats.label}>
|
||||||
<span className="sb-scope-dot" />
|
<span className="sb-scope-dot" />
|
||||||
@@ -4300,7 +4300,7 @@ const Dispatch = ({
|
|||||||
</span>
|
</span>
|
||||||
<div className="sb-tile-body">
|
<div className="sb-tile-body">
|
||||||
<div className="sb-tile-value">{activeStats.riders}</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4589,7 +4589,7 @@ const Dispatch = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zone-order-change-rider"
|
className="zone-order-change-rider"
|
||||||
title="Change rider"
|
title="Change miler"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onChangeRider(o, focusedRider);
|
onChangeRider(o, focusedRider);
|
||||||
@@ -4687,7 +4687,7 @@ const Dispatch = ({
|
|||||||
<Ico>
|
<Ico>
|
||||||
<MdTwoWheeler />
|
<MdTwoWheeler />
|
||||||
</Ico>
|
</Ico>
|
||||||
{focusedKitchen.riders.size} riders
|
{focusedKitchen.riders.size} {focusedKitchen.riders.size === 1 ? 'miler' : 'milers'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Render the kitchen's orders with the same zone-order-card layout
|
{/* Render the kitchen's orders with the same zone-order-card layout
|
||||||
@@ -4795,7 +4795,7 @@ const Dispatch = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zone-order-change-rider"
|
className="zone-order-change-rider"
|
||||||
title="Change rider"
|
title="Change miler"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleTriggerChangeRider(o, riderForOrder);
|
handleTriggerChangeRider(o, riderForOrder);
|
||||||
@@ -5075,8 +5075,8 @@ const Dispatch = ({
|
|||||||
: viewMode === 'kitchens'
|
: viewMode === 'kitchens'
|
||||||
? 'Kitchen dispatch'
|
? 'Kitchen dispatch'
|
||||||
: isAllActiveView
|
: isAllActiveView
|
||||||
? 'Active rider dispatch'
|
? 'Active miler dispatch'
|
||||||
: 'Rider dispatch'}
|
: 'Miler dispatch'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div id="rider-cards">
|
<div id="rider-cards">
|
||||||
@@ -5219,7 +5219,7 @@ const Dispatch = ({
|
|||||||
<div className="rcard-info">
|
<div className="rcard-info">
|
||||||
<div className="rcard-name">{k.kitchenName}</div>
|
<div className="rcard-name">{k.kitchenName}</div>
|
||||||
<div className="rcard-zone">
|
<div className="rcard-zone">
|
||||||
{k.riders.size} riders ·{' '}
|
{k.riders.size} {k.riders.size === 1 ? 'miler' : 'milers'} ·{' '}
|
||||||
<Ico>
|
<Ico>
|
||||||
<MdAccountBalanceWallet />
|
<MdAccountBalanceWallet />
|
||||||
</Ico>
|
</Ico>
|
||||||
@@ -5243,7 +5243,7 @@ const Dispatch = ({
|
|||||||
</Ico>
|
</Ico>
|
||||||
{k.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km
|
{k.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km
|
||||||
</span>
|
</span>
|
||||||
<span>{k.riders.size} riders</span>
|
<span>{k.riders.size} {k.riders.size === 1 ? 'miler' : 'milers'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="step-ids">
|
<div className="step-ids">
|
||||||
{Array.from(k.riders)
|
{Array.from(k.riders)
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function OrdersBreakdownTable({ orders, getRevenue }) {
|
|||||||
<div className="p-6 text-center bg-slate-50 rounded-lg border border-slate-100">
|
<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" />
|
<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-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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -404,7 +404,7 @@ export default function ProfitabilitySection({
|
|||||||
color: moneyColor(dailyIsProfit)
|
color: moneyColor(dailyIsProfit)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Riders active',
|
title: 'Milers active',
|
||||||
value: String(activeEnriched.length),
|
value: String(activeEnriched.length),
|
||||||
icon: <Users className="w-4 h-4" />,
|
icon: <Users className="w-4 h-4" />,
|
||||||
color: STATUS.info
|
color: STATUS.info
|
||||||
@@ -442,7 +442,7 @@ export default function ProfitabilitySection({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-slate-900 tracking-tight">Profitability overview</h2>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,14 +473,14 @@ export default function ProfitabilitySection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Riders List */}
|
{/* Milers List */}
|
||||||
<div className="space-y-3">
|
<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 ? (
|
{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">
|
<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" />
|
<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-sm font-semibold text-slate-800">No milers to show</p>
|
||||||
<p className="text-xs text-slate-500 mt-1">No riders have orders in this slot.</p>
|
<p className="text-xs text-slate-500 mt-1">No milers have orders in this slot.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
|
|||||||
2028
src/pages/doormile/home/Home.jsx
Normal file
2028
src/pages/doormile/home/Home.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -265,12 +265,12 @@ export default function Orders() {
|
|||||||
|
|
||||||
const handleManualAssign = async () => {
|
const handleManualAssign = async () => {
|
||||||
if (!selectedRiderId || !selectedIds.length) {
|
if (!selectedRiderId || !selectedIds.length) {
|
||||||
OpenToast('Please select a rider first', 'warning');
|
OpenToast('Please select a miler first', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
|
const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
|
||||||
if (!targetRider) {
|
if (!targetRider) {
|
||||||
OpenToast('Please select a valid rider', 'warning');
|
OpenToast('Please select a valid miler', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id;
|
const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id;
|
||||||
@@ -284,7 +284,7 @@ export default function Orders() {
|
|||||||
if (targetRider.milerprofileid) {
|
if (targetRider.milerprofileid) {
|
||||||
notifyMiler(targetRider.milerprofileid, 'DoormileXpress', `${selectedIds.length} orders assigned to you`).catch(() => {});
|
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();
|
refetchOrders();
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setManualAssignOpen(false);
|
setManualAssignOpen(false);
|
||||||
@@ -302,6 +302,7 @@ export default function Orders() {
|
|||||||
key: 'bookingno',
|
key: 'bookingno',
|
||||||
header: 'Order',
|
header: 'Order',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
width: '130px',
|
||||||
accessor: (row) => row.bookingno || `#${row.bookingid}`,
|
accessor: (row) => row.bookingno || `#${row.bookingid}`,
|
||||||
cell: (row) => {
|
cell: (row) => {
|
||||||
const created = parseDoormileTimestamp(row.createdat);
|
const created = parseDoormileTimestamp(row.createdat);
|
||||||
@@ -320,6 +321,8 @@ export default function Orders() {
|
|||||||
{
|
{
|
||||||
key: 'route',
|
key: 'route',
|
||||||
header: 'Route (pickup → drop)',
|
header: 'Route (pickup → drop)',
|
||||||
|
width: '260px',
|
||||||
|
className: 'max-w-[260px]',
|
||||||
sortValue: (row) => row.pickupaddress || '',
|
sortValue: (row) => row.pickupaddress || '',
|
||||||
cell: (row) => {
|
cell: (row) => {
|
||||||
const customer = customerMap.get(row.appcustomerid);
|
const customer = customerMap.get(row.appcustomerid);
|
||||||
@@ -335,7 +338,7 @@ export default function Orders() {
|
|||||||
const flow = classifyBooking(row, flowAnchors);
|
const flow = classifyBooking(row, flowAnchors);
|
||||||
const isCustomerPickup = flow.flow !== FLOW.FORWARD && flow.pickupSource === 'customer';
|
const isCustomerPickup = flow.flow !== FLOW.FORWARD && flow.pickupSource === 'customer';
|
||||||
return (
|
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 || ''}>
|
<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'}>
|
<span className={isCustomerPickup ? 'font-medium text-violet-700' : 'font-medium text-ink-1'}>
|
||||||
{isCustomerPickup ? 'Customer pickup' : 'Pickup'}
|
{isCustomerPickup ? 'Customer pickup' : 'Pickup'}
|
||||||
@@ -366,6 +369,7 @@ export default function Orders() {
|
|||||||
key: 'kms',
|
key: 'kms',
|
||||||
header: 'Km',
|
header: 'Km',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
width: '75px',
|
||||||
sortValue: (row) =>
|
sortValue: (row) =>
|
||||||
haversineKm(row.pickuplatitude, row.pickuplongitude, row.deliverylatitude, row.deliverylongitude) ?? -1,
|
haversineKm(row.pickuplatitude, row.pickuplongitude, row.deliverylatitude, row.deliverylongitude) ?? -1,
|
||||||
accessor: (row) =>
|
accessor: (row) =>
|
||||||
@@ -376,6 +380,7 @@ export default function Orders() {
|
|||||||
key: 'charges',
|
key: 'charges',
|
||||||
header: 'Charges',
|
header: 'Charges',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
|
width: '90px',
|
||||||
sortValue: (row) => Number(chargeOf(row)) || 0,
|
sortValue: (row) => Number(chargeOf(row)) || 0,
|
||||||
accessor: (row) => currency(chargeOf(row)),
|
accessor: (row) => currency(chargeOf(row)),
|
||||||
hideBelow: 'md',
|
hideBelow: 'md',
|
||||||
@@ -383,6 +388,7 @@ export default function Orders() {
|
|||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
|
width: '130px',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cell: (row) => {
|
cell: (row) => {
|
||||||
const rawStatus = String(row.status || '').trim().toLowerCase();
|
const rawStatus = String(row.status || '').trim().toLowerCase();
|
||||||
@@ -637,22 +643,22 @@ export default function Orders() {
|
|||||||
open={manualAssignOpen}
|
open={manualAssignOpen}
|
||||||
onOpenChange={setManualAssignOpen}
|
onOpenChange={setManualAssignOpen}
|
||||||
title={`Manual Assign ${selectedIds.length} Order${selectedIds.length === 1 ? '' : 's'}`}
|
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 className="space-y-4 pt-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-800 mb-1.5">
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedRiderId}
|
value={selectedRiderId}
|
||||||
onChange={(e) => setSelectedRiderId(e.target.value)}
|
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"
|
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) => {
|
{riders.map((r) => {
|
||||||
const rId = String(r.userid || r.mileruserid || r.id);
|
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 rPhone = r.mobile || r.phone ? ` · ${r.mobile || r.phone}` : '';
|
||||||
const rStatus = r.status ? ` [${r.status}]` : '';
|
const rStatus = r.status ? ` [${r.status}]` : '';
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function OrdersPreview() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'rider',
|
key: 'rider',
|
||||||
header: 'Rider',
|
header: 'Miler',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<Select
|
<Select
|
||||||
value={row.userid ? String(row.userid) : ''}
|
value={row.userid ? String(row.userid) : ''}
|
||||||
@@ -194,7 +194,7 @@ export default function OrdersPreview() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Package}
|
icon={Package}
|
||||||
title="Assignment preview"
|
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={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => navigate('/doormile/orders')}>
|
<Button variant="outline" onClick={() => navigate('/doormile/orders')}>
|
||||||
@@ -213,7 +213,7 @@ export default function OrdersPreview() {
|
|||||||
|
|
||||||
<Grid cols={4}>
|
<Grid cols={4}>
|
||||||
<KpiCard icon={Package} value={number(stats.orders, '0')} label="Orders" tone="brand" index={0} />
|
<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={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} />
|
<KpiCard icon={Package} value={currency(stats.amount, '₹0')} label="Total charge" tone="success" index={3} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function OrdersDetails() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ridername',
|
key: 'ridername',
|
||||||
header: 'Rider',
|
header: 'Miler',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
accessor: (row) => orDash(row.ridername),
|
accessor: (row) => orDash(row.ridername),
|
||||||
hideBelow: 'md',
|
hideBelow: 'md',
|
||||||
@@ -153,7 +153,7 @@ export default function OrdersDetails() {
|
|||||||
{ key: 'deliverycustomer', header: 'Customer' },
|
{ key: 'deliverycustomer', header: 'Customer' },
|
||||||
{ key: 'pickupaddress', header: 'Pickup' },
|
{ key: 'pickupaddress', header: 'Pickup' },
|
||||||
{ key: 'deliveryaddress', header: 'Drop' },
|
{ key: 'deliveryaddress', header: 'Drop' },
|
||||||
{ key: 'ridername', header: 'Rider' },
|
{ key: 'ridername', header: 'Miler' },
|
||||||
{ key: 'kms', header: 'Km' },
|
{ key: 'kms', header: 'Km' },
|
||||||
{ key: 'deliverycharges', header: 'Amount' },
|
{ key: 'deliverycharges', header: 'Amount' },
|
||||||
{ key: 'orderstatus', header: 'Status' },
|
{ key: 'orderstatus', header: 'Status' },
|
||||||
@@ -217,10 +217,10 @@ export default function OrdersDetails() {
|
|||||||
|
|
||||||
<Select value={riderId} onValueChange={setRiderId}>
|
<Select value={riderId} onValueChange={setRiderId}>
|
||||||
<SelectTrigger className="w-full sm:w-48">
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
<SelectValue placeholder="All riders" />
|
<SelectValue placeholder="All milers" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={ALL}>All riders</SelectItem>
|
<SelectItem value={ALL}>All milers</SelectItem>
|
||||||
{(riders || []).map((rider) => (
|
{(riders || []).map((rider) => (
|
||||||
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
||||||
{rider.label}
|
{rider.label}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function Profitability() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: 'riderName',
|
key: 'riderName',
|
||||||
header: 'Rider',
|
header: 'Miler',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -58,11 +58,11 @@ export default function RidersSummary() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: 'displayname',
|
key: 'displayname',
|
||||||
header: 'Rider',
|
header: 'Miler',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="min-w-0">
|
<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>
|
<p className="text-caption text-ink-3">{row.hubname || row.phone || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -120,7 +120,7 @@ export default function RidersSummary() {
|
|||||||
<Stack space="lg">
|
<Stack space="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Bike}
|
icon={Bike}
|
||||||
title="Riders summary"
|
title="Milers summary"
|
||||||
subtitle={`${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
|
subtitle={`${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
@@ -143,7 +143,7 @@ export default function RidersSummary() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid cols={4}>
|
<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={Package} value={number(stats.orders, '0')} label="Orders assigned" tone="navy" index={1} loading={isLoading} />
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={CheckCircle2}
|
icon={CheckCircle2}
|
||||||
@@ -156,7 +156,7 @@ export default function RidersSummary() {
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
icon={IndianRupee}
|
icon={IndianRupee}
|
||||||
value={currency(stats.amount, '₹0')}
|
value={currency(stats.amount, '₹0')}
|
||||||
label="Rider charges"
|
label="Miler charges"
|
||||||
tone="accent"
|
tone="accent"
|
||||||
index={3}
|
index={3}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -167,12 +167,12 @@ export default function RidersSummary() {
|
|||||||
<ListToolbar
|
<ListToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
placeholder="Search by rider, phone or hub"
|
placeholder="Search by miler, phone or hub"
|
||||||
count={{ shown: rows.length, total: (summary || []).length, noun: 'riders' }}
|
count={{ shown: rows.length, total: (summary || []).length, noun: 'milers' }}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => exportRows(rows, columns, `riders-summary-${range.from}`)}
|
onClick={() => exportRows(rows, columns, `milers-summary-${range.from}`)}
|
||||||
disabled={rows.length === 0}
|
disabled={rows.length === 0}
|
||||||
>
|
>
|
||||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||||
@@ -210,7 +210,7 @@ export default function RidersSummary() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Bike}
|
icon={Bike}
|
||||||
title="Nothing in this range"
|
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."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ export default function EditRider() {
|
|||||||
if (!milerProfileId) {
|
if (!milerProfileId) {
|
||||||
return (
|
return (
|
||||||
<Stack space="lg">
|
<Stack space="lg">
|
||||||
<PageHeader icon={Bike} title="Edit rider" />
|
<PageHeader icon={Bike} title="Edit miler" />
|
||||||
<Alert
|
<Alert
|
||||||
tone="warning"
|
tone="warning"
|
||||||
title="No rider selected"
|
title="No miler selected"
|
||||||
action={<Button onClick={() => navigate('/doormile/riders')}>Go to Riders</Button>}
|
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>
|
</Alert>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -114,7 +114,7 @@ export default function EditRider() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading && !form ? (
|
{isLoading && !form ? (
|
||||||
<LoadingState message="Loading rider…" />
|
<LoadingState message="Loading miler…" />
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<Surface radius="xl" padding="lg">
|
<Surface radius="xl" padding="lg">
|
||||||
@@ -176,7 +176,7 @@ export default function EditRider() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={updateMiler.isPending}>
|
<Button type="submit" loading={updateMiler.isPending}>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default function Riders() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: 'rider',
|
key: 'rider',
|
||||||
header: 'Rider',
|
header: 'Miler',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
accessor: riderName,
|
accessor: riderName,
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
@@ -283,8 +283,8 @@ export default function Riders() {
|
|||||||
|
|
||||||
<p className="text-caption text-ink-3">
|
<p className="text-caption text-ink-3">
|
||||||
{rows.length === (counts[tab] ?? counts.all)
|
{rows.length === (counts[tab] ?? counts.all)
|
||||||
? `${counts[tab] ?? counts.all} riders`
|
? `${counts[tab] ?? counts.all} milers`
|
||||||
: `${rows.length} of ${counts[tab] ?? counts.all} riders`}
|
: `${rows.length} of ${counts[tab] ?? counts.all} milers`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -301,9 +301,9 @@ export default function Riders() {
|
|||||||
debouncedSearch ? undefined : (
|
debouncedSearch ? undefined : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Bike}
|
icon={Bike}
|
||||||
title="No riders here"
|
title="No milers here"
|
||||||
description="Onboard a rider to start assigning deliveries."
|
description="Onboard a miler to start assigning deliveries."
|
||||||
action={{ label: 'New rider', icon: Plus, onClick: () => navigate('/doormile/riders/create') }}
|
action={{ label: 'New miler', icon: Plus, onClick: () => navigate('/doormile/milers/create') }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -313,9 +313,9 @@ export default function Riders() {
|
|||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={Boolean(blockRow)}
|
open={Boolean(blockRow)}
|
||||||
onOpenChange={(open) => !open && setBlockRow(null)}
|
onOpenChange={(open) => !open && setBlockRow(null)}
|
||||||
title="Block this rider?"
|
title="Block this miler?"
|
||||||
description={`${riderName(blockRow || {})} will stop receiving assignments until they are unblocked.`}
|
description={`${riderName(blockRow || {})} will stop receiving assignments until they are unblocked.`}
|
||||||
confirmLabel="Block rider"
|
confirmLabel="Block miler"
|
||||||
busy={blockMiler.isPending}
|
busy={blockMiler.isPending}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
blockMiler.mutate(
|
blockMiler.mutate(
|
||||||
@@ -371,8 +371,8 @@ export default function Riders() {
|
|||||||
<Modal
|
<Modal
|
||||||
open={Boolean(notifyRow)}
|
open={Boolean(notifyRow)}
|
||||||
onOpenChange={(open) => !open && setNotifyRow(null)}
|
onOpenChange={(open) => !open && setNotifyRow(null)}
|
||||||
title="Notify this rider"
|
title="Notify this miler"
|
||||||
description={`${riderName(notifyRow || {})} — delivered to the rider app as a push notification.`}
|
description={`${riderName(notifyRow || {})} — delivered to the miler app as a push notification.`}
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
busy={notifyMiler.isPending}
|
busy={notifyMiler.isPending}
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export default function Tripsheets() {
|
|||||||
<SelectItem value={NO_DRIVER}>No driver</SelectItem>
|
<SelectItem value={NO_DRIVER}>No driver</SelectItem>
|
||||||
{(milers || []).map((miler) => (
|
{(milers || []).map((miler) => (
|
||||||
<SelectItem key={miler.userid} value={String(miler.userid)}>
|
<SelectItem key={miler.userid} value={String(miler.userid)}>
|
||||||
{miler.displayname || miler.authname || `Rider #${miler.userid}`}
|
{miler.displayname || miler.authname || `Miler #${miler.userid}`}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export default function Vehicles() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Car}
|
icon={Car}
|
||||||
title="Vehicles"
|
title="Vehicles"
|
||||||
subtitle="The fleet register riders are assigned from"
|
subtitle="The fleet register milers are assigned from"
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> New vehicle
|
<Plus className="mr-1.5 h-4 w-4" /> New vehicle
|
||||||
@@ -284,7 +284,7 @@ export default function Vehicles() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Car}
|
icon={Car}
|
||||||
title="No vehicles yet"
|
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 }}
|
action={{ label: 'New vehicle', icon: Plus, onClick: openCreate }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
"orders": "Orders",
|
"orders": "Orders",
|
||||||
"orderspreview": "Orders Preview",
|
"orderspreview": "Orders Preview",
|
||||||
"deliveries": "Deliveries",
|
"deliveries": "Deliveries",
|
||||||
"riders": "Riders",
|
"riders": "Milers",
|
||||||
"tenants": "Tenants",
|
"tenants": "Tenants",
|
||||||
"pricing": "Pricing",
|
"pricing": "Pricing",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"ordersummary": "Orders Summary",
|
"ordersummary": "Orders Summary",
|
||||||
"ordersdetails": "Orders Details",
|
"ordersdetails": "Orders Details",
|
||||||
"riderssummary": "Riders Summary",
|
"riderssummary": "Milers Summary",
|
||||||
"profitability": "Profitability",
|
"profitability": "Profitability",
|
||||||
"dispatch": "Dispatch",
|
"dispatch": "Dispatch",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
|
|||||||
147
tests/lib/Tabs.test.jsx
Normal file
147
tests/lib/Tabs.test.jsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
// framer-motion's layout animations need a real layout engine; jsdom has none.
|
||||||
|
// The sliding indicator is not what this file is about, so it is stubbed to a
|
||||||
|
// plain span and the structural assertions below stay meaningful.
|
||||||
|
jest.mock('framer-motion', () => ({
|
||||||
|
motion: {
|
||||||
|
span: ({ layoutId, transition, ...rest }) => <span data-testid="indicator" {...rest} />,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Tabs } from '@/components/ds/Tabs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab strip must stay reachable when it is wider than its container.
|
||||||
|
*
|
||||||
|
* Deliveries renders ten status tabs — roughly 1050px — and the assistant dock
|
||||||
|
* takes 30% of the viewport back, 40% expanded, and opens by default above
|
||||||
|
* 1024px. The last two tabs, Delivered and Cancelled, were 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 hand-rolled their
|
||||||
|
* own wrapper; the two that had not were broken.
|
||||||
|
*
|
||||||
|
* jsdom does not lay anything out, so these assert the STRUCTURE that makes
|
||||||
|
* scrolling possible rather than the scrolling itself. That is the part that
|
||||||
|
* regressed — the wrapper was deleted from Deliveries.jsx — so it is the part
|
||||||
|
* worth pinning.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The real strip from Deliveries, in order.
|
||||||
|
const DELIVERY_TABS = [
|
||||||
|
{ value: 'all', label: 'All', count: 39 },
|
||||||
|
{ value: 'pending', label: 'Pending', count: 32 },
|
||||||
|
{ value: 'accepted', label: 'Accepted', count: 3 },
|
||||||
|
{ value: 'arrived', label: 'Arrived', count: 0 },
|
||||||
|
{ value: 'picked', label: 'Picked', count: 0 },
|
||||||
|
{ value: 'active', label: 'Active', count: 1 },
|
||||||
|
{ value: 'skipped', label: 'Skipped', count: 0 },
|
||||||
|
{ value: 'delivered', label: 'Delivered', count: 3 },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled', count: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const trackOf = (container) => container.querySelector('.overflow-x-auto');
|
||||||
|
|
||||||
|
describe('Tabs — overflow', () => {
|
||||||
|
it.each(['pill', 'underline'])('wraps the %s strip in its own scroll track', (variant) => {
|
||||||
|
const { container } = render(
|
||||||
|
<Tabs tabs={DELIVERY_TABS} value="all" onChange={() => {}} variant={variant} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const track = trackOf(container);
|
||||||
|
expect(track).not.toBeNull();
|
||||||
|
// max-w-full bounds it; min-w-0 is what actually lets a flex item shrink
|
||||||
|
// below its content width instead of overflowing the page.
|
||||||
|
expect(track.className).toContain('max-w-full');
|
||||||
|
expect(track.className).toContain('min-w-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the tablist inside the track, not around it', () => {
|
||||||
|
const { container } = render(<Tabs tabs={DELIVERY_TABS} value="all" onChange={() => {}} />);
|
||||||
|
const track = trackOf(container);
|
||||||
|
expect(track.querySelector('[role="tablist"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every tab at its natural width so the row overflows rather than squashing', () => {
|
||||||
|
render(<Tabs tabs={DELIVERY_TABS} value="all" onChange={() => {}} />);
|
||||||
|
screen.getAllByRole('tab').forEach((tab) => {
|
||||||
|
// Without shrink-0 the flex row compresses ten tabs into the available
|
||||||
|
// width and the track has nothing to scroll.
|
||||||
|
expect(tab.className).toContain('shrink-0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the focus ring room to escape the clip', () => {
|
||||||
|
// overflow-x clips the ring on the first and last tab. The negative margin
|
||||||
|
// paired with equal padding buys it space without moving the strip, and is
|
||||||
|
// what makes the wrapper safe to nest inside the four pages that still
|
||||||
|
// have their own.
|
||||||
|
const { container } = render(<Tabs tabs={DELIVERY_TABS} value="all" onChange={() => {}} />);
|
||||||
|
const track = trackOf(container);
|
||||||
|
expect(track.className).toContain('-mx-1');
|
||||||
|
expect(track.className).toContain('px-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tabs — reachability', () => {
|
||||||
|
it('renders the tabs that were cut off, Delivered and Cancelled included', () => {
|
||||||
|
render(<Tabs tabs={DELIVERY_TABS} value="all" onChange={() => {}} />);
|
||||||
|
expect(screen.getAllByRole('tab')).toHaveLength(DELIVERY_TABS.length);
|
||||||
|
expect(screen.getByRole('tab', { name: /Delivered/ })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('tab', { name: /Cancelled/ })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects a tab at the far end of the strip', () => {
|
||||||
|
const Harness = () => {
|
||||||
|
const [tab, setTab] = useState('all');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs tabs={DELIVERY_TABS} value={tab} onChange={setTab} />
|
||||||
|
<output data-testid="selected">{tab}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Cancelled/ }));
|
||||||
|
expect(screen.getByTestId('selected').textContent).toBe('cancelled');
|
||||||
|
expect(screen.getByRole('tab', { name: /Cancelled/ }).getAttribute('aria-selected')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls the selected tab into view without touching the page', () => {
|
||||||
|
// scrollIntoView walks every scrollable ancestor and would yank the whole
|
||||||
|
// document when the strip sits below the fold; the component moves the
|
||||||
|
// track's own scrollLeft instead. Fail loudly if that ever changes.
|
||||||
|
const scrollIntoView = jest.fn();
|
||||||
|
const original = window.HTMLElement.prototype.scrollIntoView;
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||||
|
try {
|
||||||
|
render(<Tabs tabs={DELIVERY_TABS} value="cancelled" onChange={() => {}} />);
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tabs — unchanged behaviour', () => {
|
||||||
|
it('still accepts plain strings', () => {
|
||||||
|
render(<Tabs tabs={['One', 'Two']} value="One" onChange={() => {}} />);
|
||||||
|
expect(screen.getAllByRole('tab')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still forwards className to the tablist, not the track', () => {
|
||||||
|
// The four pages that wrap Tabs themselves pass `shrink-0` through this.
|
||||||
|
const { container } = render(
|
||||||
|
<Tabs tabs={DELIVERY_TABS} value="all" onChange={() => {}} className="shrink-0" />
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[role="tablist"]').className).toContain('shrink-0');
|
||||||
|
expect(trackOf(container).className).not.toContain('shrink-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders counts', () => {
|
||||||
|
render(<Tabs tabs={DELIVERY_TABS} value="all" onChange={() => {}} />);
|
||||||
|
expect(screen.getByRole('tab', { name: /All/ }).textContent).toContain('39');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user