updates on the ui changes and changed into doormile
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
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, LayoutGrid, LogOut, Menu, Search, Settings, Shield,
|
||||
Bell, ChevronDown, LogOut, Menu, Search, Shield, Sparkles, User,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { DOORMILE_WORDMARK_URL } from '@/assets/brand';
|
||||
import { Avatar } from '@/components/ds/Avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -14,38 +13,74 @@ import {
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
|
||||
import { endAdminSession } from '@/lib/admin/session';
|
||||
import { useCurrentUser } from '@/lib/krowHooks';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import { useExceptions } from '@/lib/doormileHooks';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
|
||||
|
||||
/**
|
||||
* AdminLayout — the shell for the Admin product.
|
||||
* The console shell.
|
||||
*
|
||||
* Built with the new operations console design system: single 56px header bar,
|
||||
* compact search with keyboard shortcut, dedicated design tokens, and fluid layout.
|
||||
* 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 ADMIN_ROOT = '/admin';
|
||||
const ROOT = '/doormile';
|
||||
|
||||
/**
|
||||
* The visible Admin navigation — Admin routes only.
|
||||
*
|
||||
* Employer and Talent destinations are deliberately absent, KROW Forge
|
||||
* (/university) included: it belongs to the Talent product, and linking out of an
|
||||
* operations console into another product's navigation loses your place rather
|
||||
* than saving you a step. Their code and their own entry points are untouched.
|
||||
*/
|
||||
/* `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: 'Control Center', path: ADMIN_ROOT },
|
||||
{ label: 'Positions', path: '/admin/positions' },
|
||||
{ label: 'Candidates', path: '/admin/candidates' },
|
||||
{ label: 'Hired History', path: '/admin/hired' },
|
||||
{ label: 'Talent Pool', path: '/admin/talent-pool' },
|
||||
{ label: 'KROW Forge', path: '/admin/university' },
|
||||
{ label: 'Analytics', path: '/admin/analytics' },
|
||||
{ label: 'Activity', path: '/admin/activity' },
|
||||
{ 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('');
|
||||
@@ -68,7 +103,10 @@ function GlobalSearch() {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) navigate(`/admin/candidates?search=${encodeURIComponent(query.trim())}`);
|
||||
/* 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(
|
||||
@@ -83,11 +121,11 @@ function GlobalSearch() {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder="Search"
|
||||
aria-label="Search the platform"
|
||||
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-krow-blue focus:bg-surface focus:ring-2 focus:ring-krow-blue/15"
|
||||
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">
|
||||
@@ -98,55 +136,82 @@ function GlobalSearch() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsMenu({ items }) {
|
||||
const unread = items.filter((n) => !n.read).length;
|
||||
/**
|
||||
* 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} unread` : 'Notifications'}
|
||||
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-krow-blue/50"
|
||||
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-krow-blue ring-2 ring-white" />
|
||||
<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">Notifications</p>
|
||||
{unread > 0 && <Badge variant="soft" size="sm">{unread} new</Badge>}
|
||||
<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.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
'flex gap-2.5 border-b border-border px-3 py-2.5 last:border-0',
|
||||
!n.read && 'bg-krow-blue-tint/40'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
n.severity === 'critical' ? 'bg-destructive'
|
||||
: n.severity === 'warning' ? 'bg-warning' : 'bg-krow-blue'
|
||||
)}
|
||||
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>
|
||||
<p className="mt-1 text-[10px] text-ink-4">{n.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
@@ -156,220 +221,219 @@ function NotificationsMenu({ items }) {
|
||||
export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
/* Shared cache rather than a local fetch, so saving the profile updates the
|
||||
header avatar and account menu without a reload. */
|
||||
const { data: user } = useCurrentUser();
|
||||
|
||||
const signOut = () => {
|
||||
endAdminSession();
|
||||
base44.auth.logout('/admin/login');
|
||||
};
|
||||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === ADMIN_ROOT) return location.pathname === ADMIN_ROOT || location.pathname === '/overview';
|
||||
const alias = path.replace('/admin', '');
|
||||
return (
|
||||
location.pathname === path ||
|
||||
location.pathname.startsWith(`${path}/`) ||
|
||||
(Boolean(alias) && alias !== '/' && (location.pathname === alias || location.pathname.startsWith(`${alias}/`)))
|
||||
);
|
||||
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 (
|
||||
<AssistantPanelProvider role="admin" pathname={location.pathname}>
|
||||
{/* Transparent so the ambient canvas painted behind the app reads through.
|
||||
An opaque shell here would cover it and every Admin page would lose the
|
||||
tint at once — which is exactly why the canvas is one layer and not a
|
||||
background repeated on each page. */}
|
||||
<div className="min-h-screen">
|
||||
{/* Single bar header in the new Admin design system */}
|
||||
<header className="sticky top-0 z-40 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={ADMIN_ROOT}
|
||||
aria-label="KROW Admin — Control Center"
|
||||
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<img src={KROW_LOGO_URL} alt="KROW" className="h-6 w-auto object-contain" />
|
||||
</Link>
|
||||
/* 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-40 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. */}
|
||||
<img src={DOORMILE_WORDMARK_URL} alt="Doormile Express" style={LOGO_FILTER} className="h-7 w-auto object-contain" />
|
||||
</Link>
|
||||
|
||||
{/* Text tabs with a hairline indicator, not filled pills. Eight
|
||||
destinations means eight competing shapes if each one is a pill,
|
||||
and a console header should recede — the underline states position
|
||||
using the brand blue without turning the chrome into the loudest
|
||||
thing on the page. */}
|
||||
<nav aria-label="Admin" 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-krow-blue/50',
|
||||
isActive(item.path) ? 'text-krow-blue' : '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-krow-blue"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<GlobalSearch />
|
||||
<NotificationsMenu items={NOTIFICATIONS} />
|
||||
|
||||
<DropdownMenu>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<DropdownMenu key={group.label}>
|
||||
<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-krow-blue/50 cursor-pointer"
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<Avatar name={user?.full_name || 'Admin'} size="sm" />
|
||||
<ChevronDown className="h-3 w-3 text-ink-4" aria-hidden="true" />
|
||||
<span className="sr-only">Account menu</span>
|
||||
{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="end" className="w-60">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{user?.full_name || 'Admin'}</p>
|
||||
<p className="text-caption text-ink-3">{user?.email}</p>
|
||||
<Badge variant="soft" size="sm" className="mt-1.5">Platform Administrator</Badge>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Account configuration, then the one thing people come to
|
||||
the account menu for by name, then the workforce workspace.
|
||||
Profile is not dropped — it is the first entry inside
|
||||
Settings, where account information belongs. */}
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/settings')} className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" /> Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/profile#security')} className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" /> Security
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/workspace')} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" /> Workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={signOut}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" /> Sign out
|
||||
</DropdownMenuItem>
|
||||
<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>
|
||||
|
||||
<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-krow-blue/50"
|
||||
>
|
||||
<Menu className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" 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>
|
||||
</header>
|
||||
|
||||
{/* Mobile menu Sheet */}
|
||||
<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">Admin</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col p-2">
|
||||
{NAV.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-krow-blue-tint text-krow-blue' : 'text-ink-2 hover:bg-surface-sunken'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="my-2 h-px bg-border" />
|
||||
{[
|
||||
{ label: 'Settings', path: '/admin/settings' },
|
||||
{ label: 'Security', path: '/admin/profile#security' },
|
||||
{ label: 'Workspace', path: '/admin/workspace' },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="rounded-lg px-3 py-2.5 text-body-sm font-medium text-ink-2 hover:bg-surface-sunken"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Main layout body */}
|
||||
<div className="flex flex-col px-4 md:flex-row lg:px-6">
|
||||
<main className="min-w-0 flex-1 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<AssistantPanel
|
||||
stickyClassName="top-14 pt-6"
|
||||
panelHeightClassName="h-[calc(100dvh_-_6.5rem)]"
|
||||
/>
|
||||
</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>
|
||||
</AssistantPanelProvider>
|
||||
|
||||
{assistantOpen && <AIPanel isOpen={assistantOpen} onClose={() => setAssistantOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NOTIFICATIONS = [
|
||||
{
|
||||
id: 1,
|
||||
severity: 'warning',
|
||||
title: 'Banquet Captain has no applicants',
|
||||
detail: 'Live for 21 days with zero applications. Pay band may be below market.',
|
||||
time: '2 hours ago',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
severity: 'warning',
|
||||
title: '8 applicants awaiting screening',
|
||||
detail: 'Oldest has been waiting 5 days across Event Security and Picker.',
|
||||
time: '4 hours ago',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
severity: 'info',
|
||||
title: 'Sofia Mendez scored 89',
|
||||
detail: 'Screened for Event Server – Fine Dining and ready for a decision.',
|
||||
time: 'Yesterday',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
severity: 'info',
|
||||
title: '3 hires completed onboarding checks',
|
||||
detail: 'Chef Antoine Dubois, Marcus Williams and Marco Rivera.',
|
||||
time: '2 days ago',
|
||||
read: true,
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user