updates on the ui changes and more things
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
22
src/App.jsx
22
src/App.jsx
@@ -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". */}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 */
|
||||||
@@ -10671,4 +10712,23 @@
|
|||||||
transform: scale(2.2);
|
transform: scale(2.2);
|
||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="© 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user