450 lines
20 KiB
JavaScript
450 lines
20 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 {
|
|
Bell, ChevronDown, LogOut, Menu, Search, Shield, User,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { DOORMILE_WORDMARK_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 doormileMark from 'assets/images/doormile-mark.png';
|
|
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
|
|
|
|
/**
|
|
* The console shell.
|
|
*
|
|
* Single 56px header bar, compact search with a keyboard shortcut, hairline tab
|
|
* indicator, mobile sheet — the design is unchanged. What changed is what it
|
|
* points at: the Doormile Express destinations rather than the hiring ones.
|
|
*
|
|
* Seven destinations sit on the bar and the remaining eleven live in two
|
|
* grouped menus (Fleet Ops, Reports), built from the same DropdownMenu the
|
|
* account menu already uses. Eighteen flat tabs would not fit a 56px bar at any
|
|
* width, and a console header should recede rather than become the loudest
|
|
* thing on the page.
|
|
*/
|
|
|
|
const ROOT = '/doormile';
|
|
|
|
/* `doormile-logo.png` is a white wordmark — invisible on this bar's light
|
|
chrome as-is. Recolored to brand red via filter rather than swapping the
|
|
asset, same trick the source console's own TopNav uses on the identical
|
|
file, so the two headers render the mark in the same color from the same
|
|
source PNG. */
|
|
const LOGO_FILTER = {
|
|
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',
|
|
};
|
|
|
|
/** Destinations that sit directly on the header bar. */
|
|
const NAV = [
|
|
{ label: 'Dispatch', path: '/doormile/dispatch' },
|
|
{ label: 'Orders', path: '/doormile/orders' },
|
|
{ label: 'Deliveries', path: '/doormile/deliveries' },
|
|
{ label: 'Riders', path: '/doormile/riders' },
|
|
{ label: 'Clients', path: '/doormile/tenants' },
|
|
{ label: 'Pricing', path: '/doormile/pricing' },
|
|
{ label: 'Customers', path: '/doormile/customers' },
|
|
];
|
|
|
|
/** Destinations grouped behind a menu, in the order the console lists them. */
|
|
const NAV_GROUPS = [
|
|
{
|
|
label: 'Fleet Ops',
|
|
items: [
|
|
{ label: 'Hubs', path: '/doormile/hubs' },
|
|
{ label: 'Vehicles', path: '/doormile/vehicles' },
|
|
{ label: 'Tripsheets', path: '/doormile/tripsheets' },
|
|
{ label: 'Exceptions', path: '/doormile/exceptions' },
|
|
{ label: 'Competitive Intel', path: '/doormile/competitive-intel' },
|
|
{ label: 'App Users', path: '/doormile/app-users' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Reports',
|
|
items: [
|
|
{ label: 'Orders Summary', path: '/doormile/reports/orderssummary' },
|
|
{ label: 'Orders Details', path: '/doormile/reports/ordersdetails' },
|
|
{ label: 'Riders Summary', path: '/doormile/reports/riderssummary' },
|
|
{ label: 'Profitability', path: '/doormile/reports/profitability' },
|
|
],
|
|
},
|
|
];
|
|
|
|
/** Every destination, flattened — what the mobile sheet renders. */
|
|
const ALL_DESTINATIONS = [...NAV, ...NAV_GROUPS.flatMap((group) => group.items)];
|
|
|
|
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, logout } = useAuth();
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [assistantOpen, setAssistantOpen] = useState(false);
|
|
|
|
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';
|
|
|
|
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/dispatch"
|
|
aria-label="Doormile Express — Dispatch"
|
|
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
|
|
>
|
|
{/* Full wordmark, not just the circular mark — matches the source
|
|
console's own TopNav, which renders this same logo at the
|
|
same relative scale inside its bar. */}
|
|
{/* Capped below `sm`: at its natural 209px the wordmark plus the
|
|
170px action cluster overflowed a 375px phone and scrolled the
|
|
whole document sideways. Scaling the mark is the one thing here
|
|
that can give — the controls beside it all have to stay. */}
|
|
<img
|
|
src={DOORMILE_WORDMARK_URL}
|
|
alt="Doormile Express"
|
|
style={LOGO_FILTER}
|
|
className="h-7 w-auto max-w-[120px] object-contain sm:max-w-none"
|
|
/>
|
|
</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">
|
|
{NAV.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>
|
|
))}
|
|
|
|
{NAV_GROUPS.map((group) => (
|
|
<DropdownMenu key={group.label}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'relative flex items-center gap-1 whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium',
|
|
'transition-colors duration-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
|
groupIsActive(group) ? 'text-brand' : 'text-ink-3 hover:text-ink-1'
|
|
)}
|
|
>
|
|
{group.label}
|
|
<ChevronDown className="h-3 w-3" 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-52">
|
|
{group.items.map((item) => (
|
|
<DropdownMenuItem
|
|
key={item.path}
|
|
onClick={() => navigate(item.path)}
|
|
className={cn('cursor-pointer', isActive(item.path) && 'text-brand')}
|
|
>
|
|
{item.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="ml-auto flex items-center gap-1.5">
|
|
<GlobalSearch />
|
|
|
|
{/* Doormile AI. A header control rather than a route: it answers
|
|
about live data while the operator stays on the screen that
|
|
raised the question. */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setAssistantOpen((open) => !open)}
|
|
aria-label={assistantOpen ? 'Close Doormile AI' : 'Open Doormile AI'}
|
|
aria-expanded={assistantOpen}
|
|
className={cn(
|
|
'grid h-8 w-8 place-items-center rounded-lg transition-colors',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
|
assistantOpen ? 'bg-brand-tint text-brand' : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
|
)}
|
|
>
|
|
<img className="h-9 w-9 max-w-none object-contain" src={doormileMark} alt="" aria-hidden="true" />
|
|
</button>
|
|
|
|
<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-60">
|
|
<DropdownMenuLabel className="font-normal">
|
|
<p className="text-body-sm font-semibold text-ink-1">{displayName}</p>
|
|
<p className="text-caption text-ink-3">{user?.email || user?.primaryemail}</p>
|
|
<Badge variant="soft" size="sm" className="mt-1.5">
|
|
{user?.role || 'Administrator'}
|
|
</Badge>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => navigate('/doormile/profile')} className="cursor-pointer">
|
|
<User className="mr-2 h-4 w-4" /> Profile
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => navigate('/doormile/profile#security')} className="cursor-pointer">
|
|
<Shield className="mr-2 h-4 w-4" /> Change password
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => logout()}
|
|
className="cursor-pointer text-destructive focus:text-destructive"
|
|
>
|
|
<LogOut className="mr-2 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">
|
|
{ALL_DESTINATIONS.map((item) => (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
onClick={() => setMenuOpen(false)}
|
|
className={cn(
|
|
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
|
|
isActive(item.path) ? 'bg-brand-tint text-brand' : 'text-ink-2 hover:bg-surface-sunken'
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
<div className="my-2 h-px bg-border" />
|
|
<Link
|
|
to="/doormile/profile"
|
|
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", !location.pathname.startsWith('/doormile/dispatch') && "px-4 md:flex-row lg:px-6")}>
|
|
<main className={cn("min-w-0 flex-1", !location.pathname.startsWith('/doormile/dispatch') && "py-6")}>
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
|
|
{assistantOpen && <AIPanel isOpen={assistantOpen} onClose={() => setAssistantOpen(false)} />}
|
|
</div>
|
|
);
|
|
}
|