Files
doormilxpress_astryx/src/layouts/AdminLayout.jsx
2026-08-27 17:27:13 +05:30

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>
);
}