Files
doormilxpress_astryx/src/layouts/AdminLayout.jsx

530 lines
23 KiB
JavaScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Activity, Bell, Bike, Car, ChevronDown, Coins, FileSpreadsheet,
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
ShieldAlert, User, UserCheck, Warehouse,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { DOORMILE_MARK_URL } from '@/assets/brand';
import { Avatar } from '@/components/ds/Avatar';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useAuth } from '@/lib/AuthContext';
import { useExceptions } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
/**
* The console shell.
*
* Single 56px header bar, compact search with a keyboard shortcut, hairline tab
* indicator, mobile sheet.
*
* Core destinations sit on the bar; the rest live in two grouped menus on it
* (Fleet Ops, Reports) plus a Settings section inside the account menu, which
* holds the two destinations that are configuration rather than operations
* (Pricing Matrix, Customers Directory).
*
* Said precisely because the previous wording — "three grouped menus (Fleet
* Ops, Reports, Settings)" — sent a reader looking for a third NAV_GROUPS
* entry that does not exist, and hid the fact that the Settings pair is
* reached by a different route from everything else. That difference is
* exactly what made them unreachable on mobile.
*/
const ROOT = '/doormile';
const LOGO_FILTER = {
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',
};
/** Destinations that sit directly on the header bar. */
const NAV = [
{ label: 'Control X', path: '/doormile/control-x' },
{ label: 'Bookings', path: '/doormile/bookings' },
{ label: 'Orders', path: '/doormile/orders' },
{ label: 'Deliveries', path: '/doormile/deliveries' },
{ label: 'Milers', path: '/doormile/milers' },
{ label: 'Clients', path: '/doormile/tenants' },
];
/** Destinations grouped behind a menu, in the order the console lists them. */
const NAV_GROUPS = [
{
label: 'Fleet Ops',
items: [
{ label: 'Hubs', path: '/doormile/hubs', icon: Warehouse },
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car },
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo },
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert },
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity },
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck },
],
},
{
label: 'Reports',
items: [
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary', icon: FileSpreadsheet },
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails', icon: FileText },
{ label: 'Milers Summary', path: '/doormile/reports/riderssummary', icon: Bike },
{ label: 'Profitability', path: '/doormile/reports/profitability', icon: Coins },
],
},
];
function GlobalSearch() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [focused, setFocused] = useState(false);
const inputRef = useRef(null);
/* Cmd/Ctrl+K focuses search — jump-to control shortcut */
useEffect(() => {
const onKey = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<form
onSubmit={(e) => {
e.preventDefault();
/* Orders is the console's search surface: it holds every booking, and
an operator searching from the header is nearly always chasing one
order number or one customer. */
if (query.trim()) navigate(`/doormile/orders?search=${encodeURIComponent(query.trim())}`);
}}
role="search"
className={cn(
'relative hidden items-center transition-all duration-base ease-out md:flex',
focused || query ? 'w-64' : 'w-44'
)}
>
<Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-ink-4" aria-hidden="true" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="Search orders"
aria-label="Search orders"
className="h-8 w-full rounded-lg border border-border bg-surface-subtle pl-8 pr-10 text-body-sm text-ink-1
placeholder:text-ink-4 outline-none transition-colors
focus:border-brand focus:bg-surface focus:ring-2 focus:ring-brand/15"
/>
{!focused && !query && (
<kbd className="pointer-events-none absolute right-2 rounded border border-border bg-surface px-1 text-[10px] font-medium text-ink-4">
⌘K
</kbd>
)}
</form>
);
}
/**
* Open exceptions, as notifications.
*
* The console's real "needs attention" queue is the exceptions register — a
* failed delivery, a damaged parcel, an address that could not be found — so
* that is what the bell shows, rather than a second notion of alerting that
* nothing writes to.
*/
function NotificationsMenu() {
const navigate = useNavigate();
const { data } = useExceptions({ refetchInterval: 60_000 });
const items = useMemo(() => {
const open = (data || []).filter(
(row) => !['resolved', 'closed'].includes(String(row?.status || '').toLowerCase())
);
return open.slice(0, 8).map((row) => ({
id: row.exceptionid ?? row.id,
severity: String(row.severity || '').toLowerCase() === 'high' ? 'critical' : 'warning',
title: row.exceptiontype || row.type || 'Exception raised',
detail: row.description || row.reason || 'No description recorded.',
time: formatDoormileTimestamp(row.createdat, 'DD MMM, hh:mm A', ''),
}));
}, [data]);
const unread = items.length;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={unread ? `Notifications, ${unread} open exceptions` : 'Notifications'}
className="relative grid h-8 w-8 place-items-center rounded-lg text-ink-3 transition-colors
hover:bg-surface-sunken hover:text-ink-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
>
<Bell className="h-4 w-4" aria-hidden="true" />
{unread > 0 && (
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-brand ring-2 ring-white" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
<p className="text-body-sm font-semibold text-ink-1">Open exceptions</p>
{unread > 0 && <Badge variant="soft" size="sm">{unread} open</Badge>}
</div>
<div className="max-h-80 overflow-y-auto">
{items.length === 0 ? (
<p className="px-3 py-6 text-center text-body-sm text-ink-3">Nothing needs attention.</p>
) : (
items.map((n) => (
<button
key={n.id}
type="button"
onClick={() => navigate('/doormile/exceptions')}
className="flex w-full gap-2.5 border-b border-border bg-brand-tint/40 px-3 py-2.5 text-left
last:border-0 hover:bg-brand-tint/70"
>
<span
className={cn(
'mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
n.severity === 'critical' ? 'bg-destructive' : 'bg-warning'
)}
aria-hidden="true"
/>
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">{n.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{n.detail}</p>
{n.time && <p className="mt-1 text-[10px] text-ink-4">{n.time}</p>}
</div>
</button>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { user, isClient, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(() => {
try {
const saved = localStorage.getItem('doormileAssistantOpen');
if (saved !== null) return saved === 'true';
} catch {
/* ignore */
}
return typeof window !== 'undefined' ? window.innerWidth >= 1024 : true;
});
const toggleAssistant = (nextState) => {
setAssistantOpen((prev) => {
const next = typeof nextState === 'function' ? nextState(prev) : nextState;
try {
localStorage.setItem('doormileAssistantOpen', String(next));
} catch {
/* ignore */
}
return next;
});
};
const navItems = useMemo(() => {
if (isClient) {
return NAV.filter((item) => item.path !== '/doormile/tenants');
}
return NAV;
}, [isClient]);
const navGroups = useMemo(() => {
if (isClient) {
return NAV_GROUPS.filter((group) => group.label !== 'Fleet Ops');
}
return NAV_GROUPS;
}, [isClient]);
const allDestinations = useMemo(() => {
return [...navItems, ...navGroups.flatMap((group) => group.items)];
}, [navItems, navGroups]);
const isActive = (path) => {
if (path === ROOT) return location.pathname === ROOT;
return location.pathname === path || location.pathname.startsWith(`${path}/`);
};
const groupIsActive = (group) => group.items.some((item) => isActive(item.path));
const displayName = user?.displayname || user?.authname || user?.name || 'Operator';
const isHome = location.pathname === '/doormile/home' || location.pathname === '/doormile' || location.pathname === '/';
return (
/* Transparent so the ambient canvas painted behind the app reads through.
An opaque shell here would cover it and every page would lose the tint at
once — which is why the canvas is one layer and not a background repeated
on each page. */
<div className="min-h-screen">
{/* `data-app-header` is read by the Doormile AI dock, which measures this
bar to know where the panel should start. */}
<header data-app-header className="sticky top-0 z-50 border-b border-border bg-white/85 backdrop-blur-xl">
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
<Link
to="/doormile/home"
aria-label="Doormile Express — Home"
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 transition-transform active:scale-95"
>
<img
src={DOORMILE_MARK_URL}
alt="Doormile"
className="h-8 w-auto object-contain"
/>
</Link>
{/* Text tabs with a hairline indicator, not filled pills. Each grouped
menu is styled as one more tab, so a destination reached through a
menu still reads as being in the same row as the rest. */}
<nav aria-label="Console" className="hidden min-w-0 flex-1 items-center gap-0.5 lg:flex">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
aria-current={isActive(item.path) ? 'page' : undefined}
className={cn(
'relative whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium transition-colors duration-base',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
isActive(item.path) ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
)}
>
{item.label}
{isActive(item.path) && (
<motion.span
layoutId="adminNavIndicator"
className="absolute inset-x-2.5 -bottom-[13px] h-0.5 rounded-full bg-brand"
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
/>
)}
</Link>
))}
{navGroups.map((group) => (
<DropdownMenu key={group.label}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'relative flex items-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium',
'transition-colors duration-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 cursor-pointer',
groupIsActive(group) ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
)}
>
{group.icon && <group.icon className="h-3.5 w-3.5 opacity-70" />}
<span>{group.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" aria-hidden="true" />
{groupIsActive(group) && (
<motion.span
layoutId="adminNavIndicator"
className="absolute inset-x-2.5 -bottom-[13px] h-0.5 rounded-full bg-brand"
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
/>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
{group.items.map((item) => {
const ItemIcon = item.icon;
const active = isActive(item.path);
return (
<DropdownMenuItem
key={item.path}
onClick={() => navigate(item.path)}
className={cn(
// 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'
)}
>
{ItemIcon && (
<div
className={cn(
'w-7 h-7 rounded-lg flex items-center justify-center shrink-0',
active ? 'bg-brand text-white' : 'bg-slate-100 text-slate-600'
)}
>
<ItemIcon className="w-3.5 h-3.5" />
</div>
)}
{/* Label only. The descriptions under each item
("Sortation centers & facilities", "Linehaul &
middle-mile runs") were removed: this is a jump
menu for people who already know the console, so a
gloss on every row is read once and then skipped
past forever while still costing the height that
made the menu tall enough to cover the page. */}
<div className="min-w-0 flex-1 text-body-sm font-semibold">{item.label}</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
))}
</nav>
<div className="ml-auto flex items-center gap-1.5">
<GlobalSearch />
{/* MileTruth's control is no longer here. It is the floating
launcher at the bottom of the page (AILauncher, mounted beside
the panel below), so this row holds only the utilities that
belong in a header: search, alerts, the account menu. */}
{!isClient && <NotificationsMenu />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 rounded-lg py-1 pl-1 pr-1.5 transition-colors hover:bg-surface-sunken
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50 cursor-pointer"
>
<Avatar name={displayName} size="sm" />
<ChevronDown className="h-3 w-3 text-ink-4" aria-hidden="true" />
<span className="sr-only">Account menu</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-1.5 rounded-2xl shadow-xl border-slate-200">
<DropdownMenuLabel className="font-normal px-2.5 py-2">
<p className="text-body-sm font-bold text-ink-1">{displayName}</p>
<p className="text-caption text-ink-3 truncate">{user?.email || user?.primaryemail || 'admin@doormile.com'}</p>
<Badge variant="soft" size="sm" className="mt-1.5 bg-rose-50 text-rose-700 font-semibold border-rose-200">
{user?.role || 'admin'}
</Badge>
</DropdownMenuLabel>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
<User className="mr-2.5 h-4 w-4 text-slate-500" /> Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer rounded-xl px-2.5 py-2 font-medium">
<Shield className="mr-2.5 h-4 w-4 text-slate-500" /> Change password
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => navigate('/doormile/settings')}
className={cn(
'cursor-pointer rounded-xl px-2.5 py-2 font-medium',
location.pathname.startsWith('/doormile/settings') && 'bg-brand-tint text-brand font-semibold'
)}
>
<Settings className="mr-2.5 h-4 w-4 text-slate-500" /> Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => logout()}
className="cursor-pointer text-destructive focus:text-destructive rounded-xl px-2.5 py-2 font-medium"
>
<LogOut className="mr-2.5 h-4 w-4" /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => setMenuOpen(true)}
aria-label="Open navigation"
className="grid h-8 w-8 place-items-center rounded-lg text-ink-3 hover:bg-surface-sunken hover:text-ink-1 lg:hidden
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
>
<Menu className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
</header>
{/* Mobile menu Sheet — flat, because a menu inside a drawer is one tap
too many on the device where taps are most expensive. */}
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="right" className="w-72 p-0">
<SheetHeader className="border-b border-border px-4 py-4">
<SheetTitle className="text-left font-heading text-title">Doormile Express</SheetTitle>
</SheetHeader>
<nav className="flex max-h-[calc(100dvh-8rem)] flex-col overflow-y-auto p-2">
{allDestinations.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMenuOpen(false)}
className={cn(
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
isActive(item.path) ? 'bg-brand-tint text-brand' : 'text-ink-2 hover:bg-surface-sunken'
)}
>
{item.label}
</Link>
))}
{/* Settings — the pair that lives in the account menu on desktop.
Without this block they existed on the bar's dropdown only, so
a phone could not reach Pricing or Customers at all. */}
<div className="my-2 h-px bg-border" />
<Link
to="/doormile/settings"
onClick={() => setMenuOpen(false)}
className={cn(
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
isActive('/doormile/settings') ? 'bg-brand-tint text-brand font-semibold' : 'text-ink-2 hover:bg-surface-sunken'
)}
>
Settings
</Link>
<div className="my-2 h-px bg-border" />
<Link
to="/doormile/profile"
onClick={() => setMenuOpen(false)}
className="rounded-lg px-3 py-2.5 text-body-sm font-medium text-ink-2 hover:bg-surface-sunken"
>
Profile
</Link>
</nav>
</SheetContent>
</Sheet>
{/* `dai-page` is the handshake with the assistant dock: while the panel
is open, DoormileAI.css reserves exactly its width of padding here so
the page slides open beside it instead of being covered. */}
<div className={cn("dai-page flex flex-col flex-1", !location.pathname.startsWith('/doormile/control-x') && "px-4 md:flex-row lg:px-6")}>
<main className={cn(
"min-w-0 flex-1 flex flex-col",
!location.pathname.startsWith('/doormile/control-x') && location.pathname !== '/doormile/home' && location.pathname !== '/doormile' && "py-6"
)}>
<Outlet />
</main>
</div>
{/* Both guarded by the SAME condition, deliberately adjacent: the home
page runs the assistant as the page itself, so neither the docked
panel nor its launcher belongs there. Splitting these two rules apart
is how you end up with a button that opens nothing. */}
{!isHome && (
<>
<AILauncher isOpen={assistantOpen} onToggle={() => toggleAssistant((open) => !open)} />
<AIPanel isOpen={assistantOpen} onClose={() => toggleAssistant(false)} />
</>
)}
</div>
);
}