ui changes
This commit is contained in:
357
src/layouts/AdminLayout.jsx
Normal file
357
src/layouts/AdminLayout.jsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { useEffect, 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 as UserIcon, Users,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { KROW_LOGO_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 { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
|
||||
import { endAdminSession } from '@/pages/admin/session';
|
||||
|
||||
/**
|
||||
* AdminLayout — the shell for the Admin product.
|
||||
*
|
||||
* Built with the new operations console design system: single 56px header bar,
|
||||
* compact search with keyboard shortcut, dedicated design tokens, and fluid layout.
|
||||
*/
|
||||
|
||||
const ADMIN_ROOT = '/admin';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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' },
|
||||
];
|
||||
|
||||
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();
|
||||
if (query.trim()) navigate(`/admin/candidates?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"
|
||||
aria-label="Search the platform"
|
||||
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"
|
||||
/>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsMenu({ items }) {
|
||||
const unread = items.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={unread ? `Notifications, ${unread} unread` : '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"
|
||||
>
|
||||
<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" />
|
||||
)}
|
||||
</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>}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
base44.auth.me().then(setUser).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const signOut = () => {
|
||||
endAdminSession();
|
||||
base44.auth.logout('/admin/login');
|
||||
};
|
||||
|
||||
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}/`)))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AssistantPanelProvider role="admin" pathname={location.pathname}>
|
||||
<div className="min-h-screen bg-surface-subtle">
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<GlobalSearch />
|
||||
<NotificationsMenu items={NOTIFICATIONS} />
|
||||
|
||||
<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-krow-blue/50 cursor-pointer"
|
||||
>
|
||||
<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>
|
||||
</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 />
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/profile')} className="cursor-pointer">
|
||||
<UserIcon className="mr-2 h-4 w-4" /> Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/profile#security')} className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" /> Security
|
||||
</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>
|
||||
</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-krow-blue/50"
|
||||
>
|
||||
<Menu className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</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" />
|
||||
<Link
|
||||
to="/admin/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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</AssistantPanelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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