updates on the ui changes and more things

This commit is contained in:
2026-09-19 11:43:00 +05:30
parent b1747480df
commit 8e87947c5f
43 changed files with 511 additions and 184 deletions

View File

@@ -228,6 +228,8 @@ Statuses are looked up case-insensitively, and an unmapped value renders as a ne
the humanised raw string — so a missing entry is visible rather than silent. Add states there, never the humanised raw string — so a missing entry is visible rather than silent. Add states there, never
in a page-local table. in a page-local table.
`PageHeader` renders clean icon, title, and action slots across all console pages without explanatory descriptive subtitles, eliminating vertical clutter for operators who already know the console.
## Environment ## Environment
| Variable | Purpose | | Variable | Purpose |

View File

@@ -86,8 +86,26 @@ const AuthenticatedApp = () => {
<Route index element={<Navigate to="/doormile/home" replace />} /> <Route index element={<Navigate to="/doormile/home" replace />} />
<Route path="home" element={<Home />} /> <Route path="home" element={<Home />} />
<Route path="dispatch" element={<Dispatch />} /> {/* Control X — the board formerly routed at /doormile/dispatch.
<Route path="dispatch/preview" element={<DispatchPreview />} />
The directory, the component and the .dispatch-container CSS
namespace keep their old names on purpose: they are internal,
and renaming a 5,800-line stylesheet's root class is a large
diff with no user-visible benefit and real risk of missing a
selector. Only what an operator sees and types changes. */}
<Route path="control-x" element={<Dispatch />} />
<Route path="control-x/preview" element={<DispatchPreview />} />
{/* The old paths keep working.
This board is the page operators sit on all day, so it is the
one most likely to be bookmarked, pinned in a browser tab
group, or pasted into a message. Renaming the route without
this would turn every one of those into a 404 — a support
problem with no upside, avoided by two lines. `replace` so the
dead path does not linger in history behind a Back press. */}
<Route path="dispatch" element={<Navigate to="/doormile/control-x" replace />} />
<Route path="dispatch/preview" element={<Navigate to="/doormile/control-x/preview" replace />} />
{/* Static before dynamic, so `orders/create` cannot be read as an {/* Static before dynamic, so `orders/create` cannot be read as an
order whose id is "create". */} order whose id is "create". */}

View File

@@ -154,7 +154,7 @@ const HUBS = {
}; };
const DISPATCH = { const DISPATCH = {
label: 'Dispatch', label: 'Control X',
suggestions: [ suggestions: [
{ icon: LuLayers, text: 'Morning Batch orders today' }, { icon: LuLayers, text: 'Morning Batch orders today' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' }, { icon: LuTimerOff, text: 'Which orders are delayed?' },
@@ -281,7 +281,7 @@ const FOLLOW_UPS = {
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders' // Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
// with the wrong label. // with the wrong label.
const ROUTES = [ const ROUTES = [
['/doormile/dispatch', DISPATCH], ['/doormile/control-x', DISPATCH],
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }], ['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
['/doormile/orders', ORDERS], ['/doormile/orders', ORDERS],
['/doormile/milers', RIDERS], ['/doormile/milers', RIDERS],

View File

@@ -81,7 +81,7 @@ export function BatchEfficiency({ batch, orders = [], tenantId }) {
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
/* A response that is not a flat object — an array, a scalar, an error /* A response that is not a flat object — an array, a scalar, an error
envelope. Shown raw rather than dropped. */ envelope. Shown raw rather than dropped. */
<pre className="overflow-x-auto rounded-lg bg-surface-sunken p-3 text-caption text-ink-2"> <pre className="no-scrollbar overflow-x-auto rounded-lg bg-surface-sunken p-3 text-caption text-ink-2">
{JSON.stringify(result, null, 2)} {JSON.stringify(result, null, 2)}
</pre> </pre>
) : ( ) : (

View File

@@ -170,7 +170,7 @@ export function DataTable({
if (loading) { if (loading) {
return ( return (
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}> <Surface padding="none" radius="md" className={cn('overflow-hidden', className)}>
<SkeletonTable rows={initialPageSize && initialPageSize < 8 ? initialPageSize : 6} columns={columns.length} /> <SkeletonTable rows={initialPageSize && initialPageSize < 8 ? initialPageSize : 6} columns={columns.length} />
</Surface> </Surface>
); );
@@ -178,7 +178,7 @@ export function DataTable({
if (sortedRows.length === 0) { if (sortedRows.length === 0) {
return ( return (
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}> <Surface padding="none" radius="md" className={cn('overflow-hidden', className)}>
{emptyState || ( {emptyState || (
<EmptyState <EmptyState
icon={Inbox} icon={Inbox}
@@ -198,10 +198,24 @@ export function DataTable({
return ( return (
<div className={cn('space-y-4', className)}> <div className={cn('space-y-4', className)}>
<Surface padding="none" radius="xl" className="relative overflow-hidden"> {/* radius="md" (12px), down from "xl".
Surface's xl resolves to rounded-2xl — calc(var(--radius) + 8px) —
which with --radius at 0.75rem is a 20px corner. On a small chip that
reads as soft; on a full-width table it curves far enough in to cut
visibly across the first and last cells of the header row.
12px is `--radius` itself, and matches radiusCard in themes/dt/tokens
— the token meant for a large boxed region, which is what a table is.
A value picked off the scale rather than an arbitrary px, so it stays
in step if the base radius is ever retuned.
Set on all three branches (loading, empty, populated) so a table does
not change shape as its data arrives. */}
<Surface padding="none" radius="md" className="relative overflow-hidden">
<LoadingOverlay show={refreshing} /> <LoadingOverlay show={refreshing} />
<div className="overflow-x-auto"> <div className="no-scrollbar overflow-x-auto">
<table className="w-full border-collapse"> <table className="w-full border-collapse">
{caption && <caption className="sr-only">{caption}</caption>} {caption && <caption className="sr-only">{caption}</caption>}

View File

@@ -32,32 +32,46 @@ export function PageHeader({
} }
: {}; : {};
if (!title && !Icon && !subtitle && !actions && !breadcrumb && !children) {
return null;
}
const hasLeading = Boolean(Icon || title || subtitle);
return ( return (
<Wrapper {...motionProps} className={cn('space-y-4', className)}> <Wrapper {...motionProps} className={cn('space-y-4', className)}>
{breadcrumb} {breadcrumb}
<div className="flex flex-wrap items-start justify-between gap-4"> {(hasLeading || actions) && (
<div className="flex items-start gap-3 min-w-0"> <div className={cn('flex flex-wrap items-center gap-4', hasLeading ? 'justify-between' : 'justify-end')}>
{Icon && ( {hasLeading && (
<span className="grid place-items-center w-10 h-10 rounded-xl bg-brand text-white shadow-brand shrink-0"> <div className="flex items-start gap-3 min-w-0">
<Icon className="w-5 h-5" aria-hidden="true" /> {Icon && (
</span> <span className="grid place-items-center w-10 h-10 rounded-xl bg-brand text-white shadow-brand shrink-0">
)} <Icon className="w-5 h-5" aria-hidden="true" />
<div className="min-w-0"> </span>
<h1
className={cn(
'font-heading text-ink-1 truncate',
size === 'display' ? 'text-display' : 'text-title-lg'
)} )}
> {(title || subtitle) && (
{title} <div className="min-w-0">
</h1> {title && (
{subtitle && <p className="text-body-sm text-ink-3 mt-1">{subtitle}</p>} <h1
</div> className={cn(
</div> 'font-heading text-ink-1 truncate',
size === 'display' ? 'text-display' : 'text-title-lg'
)}
>
{title}
</h1>
)}
{subtitle && <p className="text-body-sm text-ink-3 mt-1">{subtitle}</p>}
</div>
)}
</div>
)}
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>} {actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div> </div>
)}
{children} {children}
</Wrapper> </Wrapper>

View File

@@ -85,7 +85,15 @@ export function Tabs({
// parent — without it a flex item refuses to go below its content width and // parent — without it a flex item refuses to go below its content width and
// overflows the page instead of scrolling inside itself. // overflows the page instead of scrolling inside itself.
const track = (children) => ( const track = (children) => (
<div ref={trackRef} className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5"> <div
ref={trackRef}
// no-scrollbar: the strip scrolls, its bar is not drawn. A grey track
// under a row of pills is louder than the pills. The global
// ::-webkit-scrollbar:horizontal rule covers Chrome/Edge/Safari; this
// class is the Firefox fallback, and it is safe on a track that never
// scrolls vertically.
className="no-scrollbar -mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5"
>
{children} {children}
</div> </div>
); );

View File

@@ -59,7 +59,7 @@ inconsistent.
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
<PageShell> {/* gap={6} between regions */} <PageShell> {/* gap={6} between regions */}
<PageHeader title="…" subtitle="…" live action={<Button …/>} /> <PageHeader title="…" live action={<Button …/>} />
<KpiGrid>…<StatCard/>…</KpiGrid> {/* fills the row, no dead gap */} <KpiGrid>…<StatCard/>…</KpiGrid> {/* fills the row, no dead gap */}
<DataCard <DataCard
toolbar={<FilterToolbar>…selectors, date, search…</FilterToolbar>} toolbar={<FilterToolbar>…selectors, date, search…</FilterToolbar>}

View File

@@ -55,6 +55,36 @@ body {
height: 8px; height: 8px;
} }
/* The four ::-webkit-scrollbar rules around this comment are DEAD in current
browsers. Measured, not assumed — Chrome 152, this app's own stylesheet
loaded, on an element that scrolls horizontally only:
inherits `* { scrollbar-width: thin }` -> 10px bar
scrollbar-width: auto -> 15px bar
scrollbar-width: none -> 0px
Neither the 8px sizing above nor a `::-webkit-scrollbar:horizontal
{ height: 0 }` had any effect. Once an element resolves the STANDARD
`scrollbar-width` property — which the `*` rule above hands every element
in the app — Chrome takes the standard path and ignores the legacy
pseudo-elements entirely. So the console's scrollbars are drawn by
`scrollbar-width: thin` + `scrollbar-color`, and the webkit block is inert.
Kept for older WebKit rather than deleted, but do not reach for it to
change anything: it will look like it should work and will not.
What this means for hiding the SIDEWAYS bar only: the standard property has
no per-axis form, and the webkit `:horizontal` selector that would provide
one is ignored. A bar can therefore only be hidden per-ELEMENT, with
`scrollbar-width: none` — the `.no-scrollbar` utility in index.css. It is
applied to the containers that scroll horizontally ONLY, where there is no
vertical bar for it to take away: every DataTable, every tab strip, the
profitability and batch-efficiency tables, and Dispatch's batch scroller,
strat row and compare timeline.
One container scrolls on both axes — the bulk-upload preview in
MultipleOrders — and keeps both bars deliberately. Hiding its horizontal
bar is not possible without hiding the vertical one the operator needs. */
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background-color: transparent; background-color: transparent;
} }

View File

@@ -47,7 +47,7 @@ const LOGO_FILTER = {
/** Destinations that sit directly on the header bar. */ /** Destinations that sit directly on the header bar. */
const NAV = [ const NAV = [
{ label: 'Dispatch', path: '/doormile/dispatch' }, { label: 'Control X', path: '/doormile/control-x' },
{ label: 'Bookings', path: '/doormile/bookings' }, { label: 'Bookings', path: '/doormile/bookings' },
{ label: 'Orders', path: '/doormile/orders' }, { label: 'Orders', path: '/doormile/orders' },
{ label: 'Deliveries', path: '/doormile/deliveries' }, { label: 'Deliveries', path: '/doormile/deliveries' },
@@ -60,21 +60,21 @@ const NAV_GROUPS = [
{ {
label: 'Fleet Ops', label: 'Fleet Ops',
items: [ items: [
{ label: 'Hubs', path: '/doormile/hubs', icon: Warehouse, desc: 'Sortation centers & facilities' }, { label: 'Hubs', path: '/doormile/hubs', icon: Warehouse },
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car, desc: 'Fleet register & EV telematics' }, { label: 'Vehicles', path: '/doormile/vehicles', icon: Car },
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo, desc: 'Linehaul & middle-mile runs' }, { label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo },
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert, desc: 'Live alerts & delivery blockers' }, { label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert },
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity, desc: 'Benchmarking & market rates' }, { label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity },
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck, desc: 'System operators & roles' }, { label: 'App Users', path: '/doormile/app-users', icon: UserCheck },
], ],
}, },
{ {
label: 'Reports', label: 'Reports',
items: [ items: [
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary', icon: FileSpreadsheet, desc: 'Daily volume & status aggregations' }, { label: 'Orders Summary', path: '/doormile/reports/orderssummary', icon: FileSpreadsheet },
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails', icon: FileText, desc: 'Granular leg-by-leg breakdown' }, { label: 'Orders Details', path: '/doormile/reports/ordersdetails', icon: FileText },
{ label: 'Milers Summary', path: '/doormile/reports/riderssummary', icon: Bike, desc: 'Fleet KPIs & on-duty shifts' }, { label: 'Milers Summary', path: '/doormile/reports/riderssummary', icon: Bike },
{ label: 'Profitability', path: '/doormile/reports/profitability', icon: Coins, desc: 'Gross margin & variable costs' }, { label: 'Profitability', path: '/doormile/reports/profitability', icon: Coins },
], ],
}, },
]; ];
@@ -372,28 +372,31 @@ export default function AdminLayout() {
key={item.path} key={item.path}
onClick={() => navigate(item.path)} onClick={() => navigate(item.path)}
className={cn( className={cn(
'flex items-start gap-2.5 p-2 rounded-xl cursor-pointer transition-colors my-0.5', // items-center, not items-start: with the second
// line gone there is nothing to top-align to, and
// the icon would sit high against a single label.
'flex items-center 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' active ? 'bg-brand-tint/80 text-brand' : 'hover:bg-slate-50 text-ink-1'
)} )}
> >
{ItemIcon && ( {ItemIcon && (
<div <div
className={cn( className={cn(
'w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5', 'w-7 h-7 rounded-lg flex items-center justify-center shrink-0',
active ? 'bg-brand text-white' : 'bg-slate-100 text-slate-600' active ? 'bg-brand text-white' : 'bg-slate-100 text-slate-600'
)} )}
> >
<ItemIcon className="w-3.5 h-3.5" /> <ItemIcon className="w-3.5 h-3.5" />
</div> </div>
)} )}
<div className="min-w-0 flex-1"> {/* Label only. The descriptions under each item
<div className="text-body-sm font-semibold">{item.label}</div> ("Sortation centers & facilities", "Linehaul &
{item.desc && ( middle-mile runs") were removed: this is a jump
<div className="text-[11px] text-ink-3 leading-tight mt-0.5 line-clamp-1"> menu for people who already know the console, so a
{item.desc} gloss on every row is read once and then skipped
</div> past forever while still costing the height that
)} made the menu tall enough to cover the page. */}
</div> <div className="min-w-0 flex-1 text-body-sm font-semibold">{item.label}</div>
</DropdownMenuItem> </DropdownMenuItem>
); );
})} })}
@@ -418,17 +421,25 @@ export default function AdminLayout() {
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. {/* 24px inside a 32px button.
Was `h-9 w-9 max-w-none` — a 36px image in a 32px button,
so the mark overflowed its own control by 2px on every Started at `h-9 w-9 max-w-none` — a 36px image in a 32px
side. Two visible consequences: it stood proud of the button, so the mark overflowed its own control by 2px on
notification bell and avatar beside it, breaking the row's every side. That broke the row's alignment against the
alignment, and it covered the button's own area completely, bell and avatar AND covered the button completely, leaving
so the `hover:bg-surface-sunken` tint had nowhere to show — `hover:bg-surface-sunken` nowhere to render, so the control
the control looked dead on hover while its neighbours lit looked dead on hover while its neighbours lit up.
up. Sized to sit inside the button, like the Bell's 16px
icon does. */} Dropped to 20px to fix both, which read as too faint next
<img className="h-5 w-5 object-contain" src={doormileMark} alt="" aria-hidden="true" /> to the bell. 24px is the ceiling that keeps the fix: it
leaves 4px of button on every side, which is what the
hover tint needs to be visible as a ring around the mark.
Anything larger and the tint disappears again.
Bigger than the Bell's 16px on purpose — this is a brand
mark carrying its own circular frame, so it reads smaller
than its box, where a stroked icon reads full-size. */}
<img className="h-6 w-6 object-contain" src={doormileMark} alt="" aria-hidden="true" />
</button> </button>
)} )}
@@ -554,10 +565,10 @@ 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 flex-1", !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/control-x') && "px-4 md:flex-row lg:px-6")}>
<main className={cn( <main className={cn(
"min-w-0 flex-1 flex flex-col", "min-w-0 flex-1 flex flex-col",
!location.pathname.startsWith('/doormile/dispatch') && location.pathname !== '/doormile/home' && location.pathname !== '/doormile' && "py-6" !location.pathname.startsWith('/doormile/control-x') && location.pathname !== '/doormile/home' && location.pathname !== '/doormile' && "py-6"
)}> )}>
<Outlet /> <Outlet />
</main> </main>

View File

@@ -21,10 +21,10 @@ export default function PageNotFound() {
const quickLinks = [ const quickLinks = [
{ {
title: 'Live Dispatch', title: 'Control X',
desc: 'Real-time fleet tracking & active runs', desc: 'Real-time fleet tracking & active runs',
icon: Radio, icon: Radio,
path: '/doormile/dispatch', path: '/doormile/control-x',
color: 'text-rose-600 bg-rose-50 border-rose-200/70 group-hover:bg-rose-100/70' color: 'text-rose-600 bg-rose-50 border-rose-200/70 group-hover:bg-rose-100/70'
}, },
{ {
@@ -98,11 +98,11 @@ export default function PageNotFound() {
<div className="flex flex-wrap items-center justify-center gap-3 w-full max-w-md mb-9"> <div className="flex flex-wrap items-center justify-center gap-3 w-full max-w-md mb-9">
<button <button
type="button" type="button"
onClick={() => navigate(isAuthenticated ? '/doormile/dispatch' : '/login')} onClick={() => navigate(isAuthenticated ? '/doormile/control-x' : '/login')}
className="flex-1 min-w-[160px] inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-medium text-sm text-white bg-rose-600 hover:bg-rose-700 active:scale-[0.98] shadow-sm shadow-rose-600/20 transition-all cursor-pointer" className="flex-1 min-w-[160px] inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-medium text-sm text-white bg-rose-600 hover:bg-rose-700 active:scale-[0.98] shadow-sm shadow-rose-600/20 transition-all cursor-pointer"
> >
<Home className="w-4 h-4" /> <Home className="w-4 h-4" />
<span>{isAuthenticated ? 'Back to Dispatch' : 'Sign in to Console'}</span> <span>{isAuthenticated ? 'Back to Control X' : 'Sign in to Console'}</span>
</button> </button>
<button <button

View File

@@ -35,8 +35,12 @@ const CATALOG = [
const BY_ROUTE = [ const BY_ROUTE = [
[ [
/^\/doormile\/dispatch/, // Matches both spellings: the board moved to /doormile/control-x, and
'Dispatch', // /doormile/dispatch still resolves through the redirect in App.jsx, so a
// bookmark that lands there should still get this page's suggestions
// rather than the default set.
/^\/doormile\/(control-x|dispatch)/,
'Control X',
['Morning batch orders', 'How many riders are active?', "Repeat yesterday's run", 'How many orders today?'], ['Morning batch orders', 'How many riders are active?', "Repeat yesterday's run", 'How many orders today?'],
], ],
[ [

View File

@@ -31,7 +31,7 @@ export default function Login() {
const { login } = useAuth(); const { login } = useAuth();
const from = location.state?.from; const from = location.state?.from;
const returnTo = typeof from === 'string' && from.startsWith('/doormile') ? from : '/doormile/dispatch'; const returnTo = typeof from === 'string' && from.startsWith('/doormile') ? from : '/doormile/control-x';
const [form, setForm] = useState({ email: '', password: '', remember: true }); const [form, setForm] = useState({ email: '', password: '', remember: true });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);

View File

@@ -65,8 +65,6 @@ export default function ViewProfile() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader icon={User} title="My profile" subtitle="Account and security" />
<Surface radius="xl" padding="lg"> <Surface radius="xl" padding="lg">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<Avatar name={fullName} size="xl" /> <Avatar name={fullName} size="xl" />

View File

@@ -151,9 +151,6 @@ export default function AppUsers() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Users}
title="App users"
subtitle="Staff logins for the Doormile Express console"
actions={ actions={
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" /> New user <Plus className="mr-1.5 h-4 w-4" /> New user

View File

@@ -423,9 +423,6 @@ export default function Bookings() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
title="Bookings"
subtitle="Booked by customers in the app — the counterpart to Orders, which this console creates."
icon={BookMarked}
actions={ actions={
<Button variant="outline" onClick={() => refetch()} loading={isFetching}> <Button variant="outline" onClick={() => refetch()} loading={isFetching}>
<RefreshCw className="mr-1.5 h-4 w-4" /> Refresh <RefreshCw className="mr-1.5 h-4 w-4" /> Refresh
@@ -450,7 +447,7 @@ export default function Bookings() {
</Grid> </Grid>
<div className="flex flex-wrap items-center justify-between gap-3 w-full"> <div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5"> <div className="no-scrollbar -mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs <Tabs
tabs={BOOKING_TABS.map((t) => ({ tabs={BOOKING_TABS.map((t) => ({
value: t.value, value: t.value,

View File

@@ -65,9 +65,6 @@ export default function CreateClient() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Building2}
title="New client"
subtitle="Onboard a tenant company onto the network"
breadcrumb={ breadcrumb={
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>

View File

@@ -69,9 +69,6 @@ export default function CreateCustomer() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Users}
title="New customer"
subtitle="A delivery address and the person to hand the parcel to"
breadcrumb={ breadcrumb={
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>

View File

@@ -406,9 +406,6 @@ export default function Tenants() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Building2}
title="Clients"
subtitle="The tenant companies whose deliveries this network carries"
actions={ actions={
<Button onClick={() => navigate('/doormile/clients/create')}> <Button onClick={() => navigate('/doormile/clients/create')}>
<Plus className="mr-1.5 h-4 w-4" /> New client <Plus className="mr-1.5 h-4 w-4" /> New client
@@ -451,7 +448,7 @@ export default function Tenants() {
{/* Wraps, and the tab strip scrolls in its own track — see Orders.jsx {/* Wraps, and the tab strip scrolls in its own track — see Orders.jsx
for why two `shrink-0` halves scrolled the whole page on a phone. */} for why two `shrink-0` halves scrolled the whole page on a phone. */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full"> <div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5"> <div className="no-scrollbar -mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs <Tabs
tabs={STATUS_TABS.map((entry) => ({ ...entry, count: stats[entry.value] }))} tabs={STATUS_TABS.map((entry) => ({ ...entry, count: stats[entry.value] }))}
value={tab} value={tab}

View File

@@ -181,9 +181,6 @@ export default function CompetitiveIntel() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Radar}
title="Competitive intel"
subtitle="What the other carriers in each area charge"
actions={ actions={
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" /> {onBranches ? 'New branch' : 'New rate'} <Plus className="mr-1.5 h-4 w-4" /> {onBranches ? 'New branch' : 'New rate'}

View File

@@ -142,9 +142,6 @@ export default function Customers() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Users}
title="Customers"
subtitle="The people at the receiving end of every booking"
actions={ actions={
<Button onClick={() => navigate('/doormile/customer/create')}> <Button onClick={() => navigate('/doormile/customer/create')}>
<Plus className="mr-1.5 h-4 w-4" /> New customer <Plus className="mr-1.5 h-4 w-4" /> New customer

View File

@@ -391,18 +391,6 @@ export default function Deliveries() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Truck}
title="Deliveries"
subtitle={`${batchRows.length} dispatched · ${!dateParams.from && !dateParams.to
? 'All dates'
: selectedDate === todayStr
? 'Today'
: selectedDate === dayjs().subtract(1, 'day').format('YYYY-MM-DD')
? 'Yesterday'
: dateParams.from === dateParams.to
? dayjs(dateParams.from).format('DD MMM YYYY')
: `${dayjs(dateParams.from).format('DD MMM')} → ${dayjs(dateParams.to).format('DD MMM YYYY')}`
}`}
actions={ actions={
<DatePicker <DatePicker
value={selectedDate} value={selectedDate}

View File

@@ -59,12 +59,34 @@
background: var(--bg); background: var(--bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
z-index: 1010; z-index: 1010;
/* Nothing in this row may breach the padding above.
It is a nowrap flex row — logo/batch group, .hdr-stats, #clock — and none
of the three declared a shrink priority, so once the intrinsic content
width passed the container the whole row spilled past the 24px gutter and
the clock ended up ~8px from the window edge. Width goes missing here
without the viewport changing: Dispatch renders inside .dai-page, which
body.dai-docked narrows by the assistant dock, so opening MileTruth is
what takes the room.
`min-width: 0` on a flex CONTAINER does nothing by itself; it is set on
the children below. This declares the intent the three rules share. */
gap: 12px;
min-width: 0;
} }
.dispatch-container .logo { .dispatch-container .logo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
/* First to give up space, and deliberately so. This group holds the page
title, the hub selector and the batch label — the batch label can
truncate and still be read, whereas a half-clipped date or a cut-off
clock is just broken. A flex item defaults to min-width:auto and refuses
to go below its content width, which is what forced the overflow onto
the items to its right. */
min-width: 0;
overflow: hidden;
} }
/* The badge is sized to the D ITSELF, not to the image file. /* The badge is sized to the D ITSELF, not to the image file.
@@ -283,6 +305,25 @@
margin-right: 16px; margin-right: 16px;
min-width: 0; min-width: 0;
flex-wrap: nowrap; flex-wrap: nowrap;
/* Shrinkable, but only after the logo group has given up what it can — and
never below the date chip, which is the one control in here an operator
has to be able to read and click. The chip declares its own
flex-shrink: 0 below; the revenue and orders chips absorb the squeeze
first, which is right: they are readouts, not controls. */
flex: 0 1 auto;
}
/* The date chip is a control, not a readout. It keeps its size while the
readouts beside it compress. */
.dispatch-container .hdr-stats .date-chip {
flex-shrink: 0;
}
/* The clock is last in the row and outside .hdr-stats. Fixed size so the
gutter above holds: a shrinking clock clips its own digits, which reads as
a rendering fault rather than a tight layout. */
.dispatch-container #clock {
flex: 0 0 auto;
} }
/* Tabs */ /* Tabs */
@@ -10672,3 +10713,22 @@
opacity: 0; opacity: 0;
} }
} }
/* --------------------------------------------------------------------------
Sideways scrollbars, hidden — Firefox fallback for this page's own
horizontal-only containers.
Chrome, Edge and Safari are already covered globally by the
::-webkit-scrollbar:horizontal rule in globalPolish.css. Firefox has no
per-axis scrollbar control, so these three are named explicitly. Each is
safe: .batch-scroll and #strat-row both set overflow-y: hidden, and the
compare timeline only ever scrolls sideways — none of them has a vertical
bar that `scrollbar-width: none` could take away.
Scrolling itself is untouched. overflow-x stays `auto`.
-------------------------------------------------------------------------- */
.dispatch-container .batch-scroll,
.dispatch-container #strat-row,
.dispatch-container .compare-timeline-scrollable {
scrollbar-width: none;
-ms-overflow-style: none;
}

View File

@@ -3539,7 +3539,7 @@ const Dispatch = ({
<div className="logo-badge"> <div className="logo-badge">
<img src={doormileMark} alt="Doormile" className="logo-badge-img" /> <img src={doormileMark} alt="Doormile" className="logo-badge-img" />
</div> </div>
<div className="logo-name">Dispatch</div> <div className="logo-name">Control X</div>
{appLocations && appLocations.length > 0 && ( {appLocations && appLocations.length > 0 && (
<div className="logo-city-wrap" ref={locationMenuRef}> <div className="logo-city-wrap" ref={locationMenuRef}>
<button <button

View File

@@ -175,7 +175,7 @@ function OrdersBreakdownTable({ orders, getRevenue }) {
} }
return ( return (
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white"> <div className="no-scrollbar overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="w-full text-left text-xs border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
<tr className="bg-slate-50 border-b border-slate-200 text-slate-600 font-semibold"> <tr className="bg-slate-50 border-b border-slate-200 text-slate-600 font-semibold">

View File

@@ -210,9 +210,6 @@ export default function Exceptions() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={AlertTriangle}
title="Exceptions"
subtitle="Parcels that need a decision before they can move again"
actions={ actions={
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" /> Log exception <Plus className="mr-1.5 h-4 w-4" /> Log exception

View File

@@ -551,7 +551,7 @@ export default function Home() {
} }
}, },
actions: [ actions: [
{ label: 'Open Dispatch Board', path: '/doormile/dispatch' }, { label: 'Open Control X', path: '/doormile/control-x' },
{ label: 'View Orders Pipeline', path: '/doormile/orders' } { label: 'View Orders Pipeline', path: '/doormile/orders' }
], ],
timestamp: dayjs().format('hh:mm A') timestamp: dayjs().format('hh:mm A')
@@ -604,7 +604,7 @@ export default function Home() {
}, },
visualData: deriveVisualData(null, 'create order'), visualData: deriveVisualData(null, 'create order'),
actions: [ actions: [
{ label: 'Open Dispatch Board', path: '/doormile/dispatch' }, { label: 'Open Control X', path: '/doormile/control-x' },
{ label: 'View Orders Pipeline', path: '/doormile/orders' } { label: 'View Orders Pipeline', path: '/doormile/orders' }
], ],
timestamp: dayjs().format('hh:mm A') timestamp: dayjs().format('hh:mm A')
@@ -727,7 +727,7 @@ export default function Home() {
? `All ${res.created} orders have been submitted to the dispatch board and scheduled with their designated routes.` ? `All ${res.created} orders have been submitted to the dispatch board and scheduled with their designated routes.`
: `${res.created} orders were created, ${res.failed} failed.`, : `${res.created} orders were created, ${res.failed} failed.`,
actions: [ actions: [
{ label: 'Open Dispatch Board', path: '/doormile/dispatch' }, { label: 'Open Control X', path: '/doormile/control-x' },
{ label: 'View Orders Pipeline', path: '/doormile/orders' } { label: 'View Orders Pipeline', path: '/doormile/orders' }
], ],
timestamp: dayjs().format('hh:mm A') timestamp: dayjs().format('hh:mm A')
@@ -754,7 +754,7 @@ export default function Home() {
title: `Created order ${res.bookingno || res.id || ''}.`, title: `Created order ${res.bookingno || res.id || ''}.`,
summary: 'It appears on the Orders page under Pending until a rider is assigned.', summary: 'It appears on the Orders page under Pending until a rider is assigned.',
actions: [ actions: [
{ label: 'Open Dispatch Board', path: '/doormile/dispatch' }, { label: 'Open Control X', path: '/doormile/control-x' },
{ label: 'View Orders Pipeline', path: '/doormile/orders' } { label: 'View Orders Pipeline', path: '/doormile/orders' }
], ],
timestamp: dayjs().format('hh:mm A') timestamp: dayjs().format('hh:mm A')
@@ -925,18 +925,18 @@ export default function Home() {
actions.push({ label: 'Upload Bulk Orders', path: '/doormile/orders/multiple' }); actions.push({ label: 'Upload Bulk Orders', path: '/doormile/orders/multiple' });
} else if (intent.includes('order') || intent.includes('booking')) { } else if (intent.includes('order') || intent.includes('booking')) {
actions.push({ label: 'Open Orders Pipeline', path: '/doormile/orders' }); actions.push({ label: 'Open Orders Pipeline', path: '/doormile/orders' });
actions.push({ label: 'View Dispatch Board', path: '/doormile/dispatch' }); actions.push({ label: 'View Control X', path: '/doormile/control-x' });
} else if (intent.includes('rider') || intent.includes('miler') || intent.includes('workforce') || intent.includes('coverage')) { } else if (intent.includes('rider') || intent.includes('miler') || intent.includes('workforce') || intent.includes('coverage')) {
actions.push({ label: 'Open Dispatch Board', path: '/doormile/dispatch' }); actions.push({ label: 'Open Control X', path: '/doormile/control-x' });
actions.push({ label: 'View Milers Directory', path: '/doormile/milers' }); actions.push({ label: 'View Milers Directory', path: '/doormile/milers' });
} else if (intent.includes('exception') || intent.includes('attention') || intent.includes('stalled')) { } else if (intent.includes('exception') || intent.includes('attention') || intent.includes('stalled')) {
actions.push({ label: 'Inspect Exceptions', path: '/doormile/exceptions' }); actions.push({ label: 'Inspect Exceptions', path: '/doormile/exceptions' });
actions.push({ label: 'Open Dispatch Board', path: '/doormile/dispatch' }); actions.push({ label: 'Open Control X', path: '/doormile/control-x' });
} else if (intent.includes('pricing') || intent.includes('cost') || intent.includes('profit')) { } else if (intent.includes('pricing') || intent.includes('cost') || intent.includes('profit')) {
actions.push({ label: 'View Profitability Report', path: '/doormile/reports/profitability' }); actions.push({ label: 'View Profitability Report', path: '/doormile/reports/profitability' });
actions.push({ label: 'Open Pricing Matrix', path: '/doormile/pricing' }); actions.push({ label: 'Open Pricing Matrix', path: '/doormile/pricing' });
} else { } else {
actions.push({ label: 'Open Operations Board', path: '/doormile/dispatch' }); actions.push({ label: 'Open Operations Board', path: '/doormile/control-x' });
actions.push({ label: 'View Orders Pipeline', path: '/doormile/orders' }); actions.push({ label: 'View Orders Pipeline', path: '/doormile/orders' });
} }
@@ -1000,7 +1000,7 @@ export default function Home() {
'Try naming a date, a status or a client — or open the board below and read it directly.', 'Try naming a date, a status or a client — or open the board below and read it directly.',
sourceCalls: [], sourceCalls: [],
actions: [ actions: [
{ label: 'Open Dispatch Board', path: '/doormile/dispatch' }, { label: 'Open Control X', path: '/doormile/control-x' },
{ label: 'View Orders Pipeline', path: '/doormile/orders' } { label: 'View Orders Pipeline', path: '/doormile/orders' }
], ],
visualData: null, visualData: null,
@@ -1028,7 +1028,7 @@ export default function Home() {
: 'The request did not complete. Nothing was read, so there are no figures to show.', : 'The request did not complete. Nothing was read, so there are no figures to show.',
visualData: null, visualData: null,
sourceCalls: [], sourceCalls: [],
actions: [{ label: 'Open Dispatch Board', path: '/doormile/dispatch' }], actions: [{ label: 'Open Control X', path: '/doormile/control-x' }],
timestamp: dayjs().format('hh:mm A') timestamp: dayjs().format('hh:mm A')
}; };
setActiveResponse(errorPayload); setActiveResponse(errorPayload);
@@ -1216,12 +1216,12 @@ export default function Home() {
<button <button
onClick={() => { onClick={() => {
setPlusMenuOpen(false); setPlusMenuOpen(false);
navigate('/doormile/dispatch'); navigate('/doormile/control-x');
}} }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-slate-700 hover:bg-slate-50 rounded-xl font-medium text-left cursor-pointer" className="w-full flex items-center gap-2.5 px-3 py-2.5 text-slate-700 hover:bg-slate-50 rounded-xl font-medium text-left cursor-pointer"
> >
<Clock className="w-4 h-4 text-purple-500" /> <Clock className="w-4 h-4 text-purple-500" />
Launch Dispatch Board Launch Control X
</button> </button>
</div> </div>
</> </>
@@ -1314,7 +1314,7 @@ export default function Home() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => navigate('/doormile/dispatch')} onClick={() => navigate('/doormile/control-x')}
className="text-xs font-medium text-slate-500 hover:text-slate-900 transition-colors cursor-pointer ml-1" className="text-xs font-medium text-slate-500 hover:text-slate-900 transition-colors cursor-pointer ml-1"
> >
Workspace Mode Workspace Mode
@@ -1728,12 +1728,12 @@ export default function Home() {
<button <button
onClick={() => { onClick={() => {
setPlusMenuOpen(false); setPlusMenuOpen(false);
navigate('/doormile/dispatch'); navigate('/doormile/control-x');
}} }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-slate-700 hover:bg-slate-50 rounded-xl font-medium text-left cursor-pointer" className="w-full flex items-center gap-2.5 px-3 py-2.5 text-slate-700 hover:bg-slate-50 rounded-xl font-medium text-left cursor-pointer"
> >
<Clock className="w-4 h-4 text-purple-500" /> <Clock className="w-4 h-4 text-purple-500" />
Launch Dispatch Board Launch Control X
</button> </button>
</div> </div>
</> </>
@@ -1815,16 +1815,17 @@ export default function Home() {
</span> </span>
</Link> </Link>
{/* Dispatch Tile */} {/* Control X Tile — the launcher rule from the renames above
still holds: the label names the page it opens. */}
<Link <Link
to="/doormile/dispatch" to="/doormile/control-x"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer" className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
> >
<div className="w-12 h-12 rounded-2xl bg-sky-50 border border-sky-200/90 flex items-center justify-center text-sky-500 group-hover:scale-105 group-hover:bg-sky-100 group-hover:shadow-sm transition-all duration-200"> <div className="w-12 h-12 rounded-2xl bg-sky-50 border border-sky-200/90 flex items-center justify-center text-sky-500 group-hover:scale-105 group-hover:bg-sky-100 group-hover:shadow-sm transition-all duration-200">
<Clock className="w-5 h-5" /> <Clock className="w-5 h-5" />
</div> </div>
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors"> <span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
Dispatch Control X
</span> </span>
</Link> </Link>

View File

@@ -199,9 +199,6 @@ export default function Hubs() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Warehouse}
title="Hubs"
subtitle="Sorting centres and delivery hubs across the network"
actions={ actions={
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" /> New hub <Plus className="mr-1.5 h-4 w-4" /> New hub

View File

@@ -639,7 +639,6 @@ export default function CreateOrder() {
Single Delivery Single Delivery
</span> </span>
</div> </div>
<p className="text-xs text-slate-500 mt-0.5">Enter pickup, delivery details, and calculate rate</p>
</div> </div>
</div> </div>

View File

@@ -677,7 +677,6 @@ export default function MultipleOrders() {
Bulk Upload Bulk Upload
</span> </span>
</div> </div>
<p className="text-xs text-slate-500 mt-0.5">Bulk-create deliveries from CSV/Excel or saved customers</p>
</div> </div>
</div> </div>

View File

@@ -209,7 +209,7 @@ export default function Orders() {
OpenToast('Orders optimised', 'success'); OpenToast('Orders optimised', 'success');
refetchOrders(); refetchOrders();
setSelectedIds([]); setSelectedIds([]);
navigate('/doormile/dispatch/preview', { navigate('/doormile/control-x/preview', {
state: { state: {
dispatchPreviewData: data, dispatchPreviewData: data,
aiMode: 1, aiMode: 1,
@@ -438,9 +438,6 @@ export default function Orders() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Package}
title="Orders"
subtitle="Every booking, grouped by where it sits in the workflow"
actions={ actions={
<> <>
<Button variant="outline" onClick={() => navigate('/doormile/orders/createorders')}> <Button variant="outline" onClick={() => navigate('/doormile/orders/createorders')}>
@@ -459,7 +456,7 @@ export default function Orders() {
sideways rather than this strip. The row now wraps, and the tab strip sideways rather than this strip. The row now wraps, and the tab strip
scrolls inside its own track, so every tab stays reachable. */} scrolls inside its own track, so every tab stays reachable. */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full"> <div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5"> <div className="no-scrollbar -mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs <Tabs
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] ?? 0 }))} tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] ?? 0 }))}
value={tab} value={tab}

View File

@@ -192,9 +192,6 @@ export default function OrdersPreview() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Package}
title="Assignment preview"
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')}>

View File

@@ -255,9 +255,6 @@ export default function Pricing() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Tag}
title="Pricing"
subtitle="Per-client rate cards — what a delivery is quoted at"
actions={ actions={
<> <>
<Button variant="outline" onClick={() => setQuoteOpen(true)}> <Button variant="outline" onClick={() => setQuoteOpen(true)}>

View File

@@ -162,9 +162,6 @@ export default function OrdersDetails() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={ListOrdered}
title="Orders details"
subtitle={`${collected.length} rows loaded · ${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
actions={ actions={
<> <>
<Input <Input

View File

@@ -169,9 +169,6 @@ export default function OrdersSummary() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={BarChart3}
title="Orders summary"
subtitle={`${groupedByLocation ? 'By site' : 'By client'} · ${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
actions={ actions={
<> <>
<Input <Input

View File

@@ -159,9 +159,6 @@ export default function Profitability() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Wallet}
title="Profitability"
subtitle={dayjs(date).format('DD MMM YYYY')}
actions={ actions={
<Input <Input
type="date" type="date"

View File

@@ -119,9 +119,6 @@ export default function RidersSummary() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Bike}
title="Milers summary"
subtitle={`${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
actions={ actions={
<> <>
<Input <Input

View File

@@ -84,9 +84,6 @@ export default function CreateRider() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Bike}
title="New miler"
subtitle="Creates a miler-app login and puts them on a zone"
breadcrumb={ breadcrumb={
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>

View File

@@ -58,7 +58,6 @@ export default function EditRider() {
if (!milerProfileId) { if (!milerProfileId) {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader icon={Bike} title="Edit miler" />
<Alert <Alert
tone="warning" tone="warning"
title="No miler selected" title="No miler selected"
@@ -90,9 +89,6 @@ export default function EditRider() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Bike}
title={form?.displayname || 'Edit miler'}
subtitle="Identity, contact, client and zone"
breadcrumb={ breadcrumb={
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>

View File

@@ -1,6 +1,9 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { import {
Ban, Bell, Bike, Car, CheckCircle2, CircleSlash, Pencil, Plus, Radio, Users, Ban, Bell, Bike, Car, CheckCircle2, CircleSlash, Pencil, Plus, Radio, Users,
} from 'lucide-react'; } from 'lucide-react';
@@ -14,6 +17,7 @@ import {
useRiderSummaryCounts, useVehicles, useRiderSummaryCounts, useVehicles,
} from '@/lib/doormileHooks'; } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext'; import { useZone } from '@/lib/ZoneContext';
import { coordsOf } from '@/lib/coords';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
@@ -152,12 +156,66 @@ export default function Riders() {
sortable: true, sortable: true,
cell: (row) => <StatusBadge status={row.availabilitystatus} dot size="sm" />, cell: (row) => <StatusBadge status={row.availabilitystatus} dot size="sm" />,
}, },
// Live position and freshness, at no extra cost.
//
// getMilers() already returns currentlatitude / currentlongitude /
// lastpingat on every row — the page simply never rendered them, so an
// operator had to open Dispatch's Miler Info view to answer "where is
// this rider and when did we last hear from them". No new request.
//
// Battery, connection, GPS accuracy, speed and heading are deliberately
// NOT columns: they exist only on /admin/milers/:id/logs, so a column
// would cost one request per row — an N+1 on page load. They belong in
// the drawer, which already fetches that endpoint.
{
key: 'lastpingat',
header: 'Last ping',
sortable: true,
hideBelow: 'lg',
cell: (row) => {
const at = row.lastpingat ?? row.lastlocationupdatedat;
if (!at) return <span className="text-caption text-ink-4">Never</span>;
const minutes = dayjs().diff(dayjs(at), 'minute');
// Stale is the useful signal here, not the timestamp. A rider who
// last pinged 40 minutes ago is not being tracked, whatever their
// availability says.
const stale = Number.isFinite(minutes) && minutes > 15;
return (
<span className={stale ? 'text-caption font-medium text-warning' : 'text-caption text-ink-2'}>
{formatDoormileTimestamp(at, 'DD MMM, hh:mm A')}
</span>
);
},
},
{
key: 'location',
header: 'Location',
hideBelow: 'xl',
cell: (row) => {
// Reuses the shared validator rather than a truthiness check: a
// rider with no fix carries 0/0, and `lat && lng` reads that as
// absent while `lat != null` reads it as the Gulf of Guinea. See
// lib/coords.
const pair = coordsOf({ latitude: row.currentlatitude, longitude: row.currentlongitude });
if (!pair) return <span className="text-caption text-ink-4">No fix</span>;
return (
<span className="text-caption font-mono text-ink-2" title={`${pair.latitude}, ${pair.longitude}`}>
{pair.latitude.toFixed(4)}, {pair.longitude.toFixed(4)}
</span>
);
},
},
{ {
key: 'actions', key: 'actions',
header: 'Actions', header: 'Actions',
align: 'right', align: 'right',
cell: (row) => ( cell: (row) => (
<div className="flex justify-end gap-1"> // The row itself is now clickable (onRowClick opens the location
// drawer), so this cell has to swallow its own clicks. Without it
// every action here would ALSO open the drawer behind the thing it
// triggered — including Block miler, which would put a drawer on
// top of a confirmation dialog.
<div className="flex justify-end gap-1" onClick={(e) => e.stopPropagation()}>
<IconButton <IconButton
label="Edit miler" label="Edit miler"
icon={Pencil} icon={Pencil}
@@ -203,9 +261,6 @@ export default function Riders() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Bike}
title="Milers"
subtitle="Who is on the road, what they are on, and where they are"
actions={ actions={
<Button onClick={() => navigate('/doormile/milers/create')}> <Button onClick={() => navigate('/doormile/milers/create')}>
<Plus className="mr-1.5 h-4 w-4" /> New miler <Plus className="mr-1.5 h-4 w-4" /> New miler
@@ -261,7 +316,7 @@ export default function Riders() {
{/* Wraps, and the tab strip scrolls in its own track — see Orders.jsx {/* Wraps, and the tab strip scrolls in its own track — see Orders.jsx
for why two `shrink-0` halves scrolled the whole page on a phone. */} for why two `shrink-0` halves scrolled the whole page on a phone. */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full"> <div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5"> <div className="no-scrollbar -mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs <Tabs
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] }))} tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] }))}
value={tab} value={tab}
@@ -291,6 +346,13 @@ export default function Riders() {
columns={columns} columns={columns}
rows={rows} rows={rows}
getRowId={(row) => row.milerprofileid ?? row.userid} getRowId={(row) => row.milerprofileid ?? row.userid}
// Clicking anywhere on a miler opens their location.
//
// The Radio action button still does the same thing and stays — it
// is the discoverable, keyboard-reachable route, and the row click
// is the fast one. The actions cell stops propagation so the two do
// not both fire.
onRowClick={(row) => setLogsRow(row)}
loading={isLoading} loading={isLoading}
refreshing={isFetching && !isLoading} refreshing={isFetching && !isLoading}
pageSize={10} pageSize={10}
@@ -425,6 +487,165 @@ export default function Riders() {
* the plausible ones is present rather than assuming a shape that would render * the plausible ones is present rather than assuming a shape that would render
* a column of blanks against a real response. * a column of blanks against a real response.
*/ */
// Leaflet's default marker icon resolves its PNGs by relative URL, which a
// bundler rewrites — the pin renders as a broken image without this. Same CDN
// fix Dispatch and DispatchMap already apply; mergeOptions is idempotent, so
// three modules doing it is harmless.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
/**
* Where this miler is, on a map.
*
* Mirrors Control X's Miler Info panel rather than inventing a second
* treatment: same OSM tiles, same single pin, same remount-on-coords trick.
* An operator who learns one should recognise the other.
*
* Position is taken from the newest ping first and the miler record second.
* They are usually the same, but a record can carry a last-known position
* when the log call returns nothing, and a stale pin beats an empty box as
* long as the drawer says when it was taken — which the ping list below does.
*/
function RiderLocationMap({ rider, latest }) {
const pair =
coordsOf({
latitude: latest?.latitude ?? latest?.currentlatitude,
longitude: latest?.longitude ?? latest?.currentlongitude,
}) ||
coordsOf({ latitude: rider?.currentlatitude, longitude: rider?.currentlongitude });
if (!pair) {
return (
<Surface variant="subtle" padding="sm" radius="lg" className="mb-3">
<p className="text-caption text-ink-3">
No GPS fix for this miler yet, so there is nothing to place on a map.
</p>
</Surface>
);
}
const { latitude: lat, longitude: lng } = pair;
return (
<div className="mb-3 overflow-hidden rounded-lg border border-border">
<div className="h-[220px] w-full">
{/* `key` forces a remount when the position changes.
Leaflet reads `center` on mount only, so without this the map
would keep showing the previously selected miler's area while the
pin moved off screen — the same reason Control X keys its map. */}
<MapContainer
key={`${rider?.milerprofileid ?? rider?.userid}-${lat}-${lng}`}
center={[lat, lng]}
zoom={14}
// Off deliberately: this map sits inside a scrolling drawer, and a
// wheel over it would zoom instead of scrolling the page past it.
scrollWheelZoom={false}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="&copy; OpenStreetMap contributors"
/>
<Marker position={[lat, lng]}>
<Popup>
<div style={{ fontWeight: 700, marginBottom: 2 }}>{riderName(rider || {})}</div>
<div style={{ fontSize: 11, color: '#64748b' }}>
{lat.toFixed(5)}, {lng.toFixed(5)}
</div>
</Popup>
</Marker>
</MapContainer>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border bg-surface-subtle px-3 py-1.5">
<span className="text-caption font-mono text-ink-2">
{lat.toFixed(5)}, {lng.toFixed(5)}
</span>
<span className="text-caption text-ink-4">
{latest?.logdate || rider?.lastpingat
? formatDoormileTimestamp(latest?.logdate ?? rider?.lastpingat, 'DD MMM, hh:mm A')
: 'Never reported'}
</span>
</div>
</div>
);
}
/** One ping's coordinates, or a plain statement that there was no fix. */
function formatPingCoords(log) {
const pair = coordsOf({
latitude: log.latitude ?? log.currentlatitude,
longitude: log.longitude ?? log.currentlongitude,
});
if (!pair) return 'No GPS fix on this ping';
return `${pair.latitude.toFixed(5)}, ${pair.longitude.toFixed(5)}`;
}
/**
* The telemetry on a single ping.
*
* Same field set as Dispatch's Miler Info snapshot, deliberately — both read
* the same `/admin/milers/:id/logs` entry, so a field that means "battery" in
* one place must not mean something else here. The shapes are documented
* nowhere (no example JSON in either reference doc), which is why every field
* is read defensively and an absent one is dropped rather than rendered as a
* dash.
*/
function PingTelemetry({ log }) {
const batteryNum = parseInt(String(log.battery ?? '').replace('%', ''), 10);
const hasBattery = Number.isFinite(batteryNum);
const speedNum = Number(log.speed);
const accuracyNum = Number(log.accuracy);
const facts = [
hasBattery && {
label: 'Battery',
value: `${batteryNum}%${log.is_charging ? ' · charging' : ''}`,
// 20% is the threshold Dispatch's snapshot already warns at. A rider
// about to lose their phone stops being trackable and stops being
// contactable, so it is worth colouring rather than just printing.
warn: batteryNum <= 20 && !log.is_charging,
},
log.connection && { label: 'Connection', value: String(log.connection) },
Number.isFinite(accuracyNum) && {
label: 'GPS accuracy',
value: `${accuracyNum} m`,
// Beyond ~50m the fix cannot distinguish one building from the next,
// which is the scale that matters for a doorstep.
warn: accuracyNum > 50,
},
Number.isFinite(speedNum) && { label: 'Speed', value: `${speedNum.toFixed(1)} km/h` },
log.heading != null && { label: 'Heading', value: `${log.heading}°` },
log.location_service && { label: 'Location service', value: String(log.location_service) },
log.is_background != null && {
label: 'App state',
value: log.is_background ? 'Background' : 'Foreground',
// Android throttles location in the background, so a stale position on
// a backgrounded app is expected rather than a fault — worth saying so
// the operator does not chase a rider who is fine.
warn: Boolean(log.is_background),
},
].filter(Boolean);
if (facts.length === 0) return null;
return (
<div className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1.5 border-t border-border pt-2.5">
{facts.map((fact) => (
<div key={fact.label} className="min-w-0">
<span className="text-caption text-ink-4">{fact.label} </span>
<span className={fact.warn ? 'text-caption font-medium text-warning' : 'text-caption font-medium text-ink-2'}>
{fact.value}
</span>
</div>
))}
</div>
);
}
function RiderLogsDrawer({ rider, onClose }) { function RiderLogsDrawer({ rider, onClose }) {
const { data: logs = [], isLoading } = useMilerLogs(rider?.milerprofileid, undefined, undefined, 50); const { data: logs = [], isLoading } = useMilerLogs(rider?.milerprofileid, undefined, undefined, 50);
@@ -432,11 +653,20 @@ function RiderLogsDrawer({ rider, onClose }) {
<Drawer <Drawer
open={Boolean(rider)} open={Boolean(rider)}
onOpenChange={(next) => !next && onClose()} onOpenChange={(next) => !next && onClose()}
title={`Live logs · ${riderName(rider || {})}`} title={`Location · ${riderName(rider || {})}`}
description="The most recent pings this rider's app has sent." description="Where this miler is now, and the pings their app has sent."
icon={Radio} icon={Radio}
size="lg" size="lg"
> >
{/* Map first.
"Where is this miler" is the question the row was clicked to answer;
a list of coordinate pairs is the audit trail behind it, not the
answer itself. Rendered outside the isLoading branch below so the
map appears as soon as the row's own last-known position is known,
rather than waiting on the logs request — the miler record already
carries currentlatitude/currentlongitude. */}
<RiderLocationMap rider={rider} latest={logs?.[0]} />
{isLoading ? ( {isLoading ? (
<p className="text-body-sm text-ink-3">Loading logs…</p> <p className="text-body-sm text-ink-3">Loading logs…</p>
) : !logs || logs.length === 0 ? ( ) : !logs || logs.length === 0 ? (
@@ -450,14 +680,26 @@ function RiderLogsDrawer({ rider, onClose }) {
<p className="text-body-sm font-medium text-ink-1"> <p className="text-body-sm font-medium text-ink-1">
{orDash(log.status ?? log.availabilitystatus ?? log.event)} {orDash(log.status ?? log.availabilitystatus ?? log.event)}
</p> </p>
<p className="text-caption text-ink-3"> <p className="text-caption font-mono text-ink-3">
{log.latitude ?? log.currentlatitude ?? '—'}, {log.longitude ?? log.currentlongitude ?? '—'} {formatPingCoords(log)}
</p> </p>
</div> </div>
<p className="shrink-0 text-caption text-ink-4"> <p className="shrink-0 text-caption text-ink-4">
{formatDoormileTimestamp(log.logdate ?? log.lastpingat ?? log.createdat, 'DD MMM, hh:mm A')} {formatDoormileTimestamp(log.logdate ?? log.lastpingat ?? log.createdat, 'DD MMM, hh:mm A')}
</p> </p>
</div> </div>
{/* The rest of the ping.
This call already returned battery, charging, connection, GPS
accuracy, location service, speed, heading and app state —
the drawer rendered three fields and discarded the other
eight, so the only way to see a rider's battery was to leave
this page for Dispatch's Miler Info view. Nothing new is
fetched here.
Fields are omitted rather than shown as "—": a blank reads as
"we checked and it's empty" when it actually means the app
did not report that field on this ping. */}
<PingTelemetry log={log} />
</Surface> </Surface>
))} ))}
</Stack> </Stack>

View File

@@ -196,9 +196,6 @@ export default function Tripsheets() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Truck}
title="Tripsheets"
subtitle="Hub-to-hub transport and what is loaded on each run"
actions={ actions={
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" /> New tripsheet <Plus className="mr-1.5 h-4 w-4" /> New tripsheet

View File

@@ -209,9 +209,6 @@ export default function Vehicles() {
return ( return (
<Stack space="lg"> <Stack space="lg">
<PageHeader <PageHeader
icon={Car}
title="Vehicles"
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

View File

@@ -15,7 +15,7 @@
"ordersdetails": "Orders Details", "ordersdetails": "Orders Details",
"riderssummary": "Milers Summary", "riderssummary": "Milers Summary",
"profitability": "Profitability", "profitability": "Profitability",
"dispatch": "Dispatch", "dispatch": "Control X",
"customers": "Customers", "customers": "Customers",
"fleetops": "Fleet & Ops", "fleetops": "Fleet & Ops",
"hubs": "Hubs", "hubs": "Hubs",