updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

View File

@@ -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,
},
];

View File

@@ -1,395 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Plus, Search, Menu, ChevronDown, LogOut, User as UserIcon, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Image } from '@/components/ui/image';
import { base44 } from '@/api/base44Client';
import { KROW_LOGO_URL } from '@/assets/brand';
import { logActivity } from '@/lib/userTracking';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import OwliverChatBubble from '@/components/krow/OwliverChatBubble';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import KrowIdPreview from '@/components/krow/KrowIdPreview';
const ROLE_NAV = {
employer: [
{ label: 'Overview', path: '/' },
{ label: 'Positions', path: '/positions' },
{ label: 'Candidates', path: '/candidates' },
{ label: 'Hired History', path: '/hired' },
{ label: 'Talent Pool', path: '/talent-pool' },
{ label: 'Analytics', path: '/analytics' },
],
admin: [
{ label: 'Control Center', path: '/admin' },
{ label: 'Positions', path: '/positions' },
{ label: 'Candidates', path: '/candidates' },
{ label: 'Hired History', path: '/hired' },
{ label: 'Talent Pool', path: '/talent-pool' },
{ label: 'KROW Forge', path: '/university' },
{ label: 'Analytics', path: '/analytics' },
{ label: 'Activity', path: '/tracking' },
],
talent: [
{ label: 'My Portal', path: '/employee' },
{ label: 'KROW Forge', path: '/university' },
{ label: 'Opportunities', path: '/apply' },
],
};
const ROLE_HOME = { employer: '/', admin: '/admin', talent: '/employee' };
const ROLE_LABEL = { employer: 'Employer', admin: 'Admin', talent: 'Talent' };
const STATUS_OPTIONS = ['All Statuses', 'Active', 'Draft', 'Paused', 'Closed'];
export default function Layout() {
const location = useLocation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('All Statuses');
const [menuOpen, setMenuOpen] = useState(false);
const [user, setUser] = useState(null);
const [activeRole, setActiveRole] = useState(() => localStorage.getItem('activeRole') || 'employer');
// Owliver's own open state. Owliver is a separate product with its own UI and
// voice workflow; it is not routed through the Krow AI panel.
const [owliverOpen, setOwliverOpen] = useState(false);
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const handleLogout = () => {
base44.auth.logout('/');
};
useEffect(() => {
const pendingRole = localStorage.getItem('pending_account_type');
if (pendingRole) {
localStorage.removeItem('pending_account_type');
(async () => {
try {
await base44.auth.updateMe({ account_type: pendingRole });
await logActivity('signup', { account_type: pendingRole });
} catch {}
})();
}
}, []);
// Adopt saved/default role from the user's account type on first load.
useEffect(() => {
if (user && !localStorage.getItem('activeRole')) {
const r = ['employer', 'admin', 'talent'].includes(user.account_type) ? user.account_type : 'employer';
setActiveRole(r);
localStorage.setItem('activeRole', r);
}
}, [user]);
const handleRoleChange = (role) => {
setActiveRole(role);
localStorage.setItem('activeRole', role);
navigate(ROLE_HOME[role]);
};
// Anything in the talent portal can open Owliver through this event — the
// portal hero's own CTA uses it.
useEffect(() => {
const openOwliver = () => setOwliverOpen(true);
window.addEventListener('krow:open-owliver', openOwliver);
return () => window.removeEventListener('krow:open-owliver', openOwliver);
}, []);
const navItems = ROLE_NAV[activeRole];
const isTalent = activeRole === 'talent';
const isActive = (path) =>
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
const handleSearch = (e) => {
e.preventDefault();
navigate(`/positions${searchQuery ? `?search=${encodeURIComponent(searchQuery)}` : ''}`);
};
const handleStatusChange = (value) => {
setStatusFilter(value);
const param = value === 'All Statuses' ? '' : `?status=${value.toLowerCase()}`;
navigate(`/positions${param}`);
};
/* The shell is transparent so the ambient canvas reads through. The coloured
mesh and hard-coded slate that used to sit here were this product's own
background, which is the thing the theme pass exists to unify. */
return (
<div className="min-h-screen">
{/* Header */}
<header className="sticky top-0 z-40 glass border-b border-white/40">
<div className="mx-auto max-w-container-wide px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
{/* Brand */}
<button onClick={() => navigate(ROLE_HOME[activeRole])} className="flex items-center gap-2 text-left shrink-0 group">
<Image src={KROW_LOGO_URL} alt="KROW" fittingType="fit" className="h-7 w-[86px] rounded-lg shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform" />
<span className="hidden sm:block text-[10px] text-[#9CA3AF] font-medium tracking-wide leading-tight self-end mb-1">FORGE</span>
</button>
{/* Nav pill (desktop) */}
<nav className="hidden md:flex items-center gap-1 bg-white/60 backdrop-blur-xl rounded-full p-1 border border-white/50 shadow-sm">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={cn(
'relative px-4 py-1.5 rounded-full text-[13px] font-medium transition-all',
isActive(item.path)
? 'text-white'
: 'text-[#6B7280] hover:text-[#111827]'
)}
>
{isActive(item.path) && (
<motion.div
layoutId="navPill"
className="absolute inset-0 rounded-full bg-[#0838E0]"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
<span className="relative z-10">{item.label}</span>
</Link>
))}
</nav>
<div className="flex items-center gap-2">
{/* Role switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="hidden md:inline-flex items-center gap-1.5 h-9 px-3 rounded-full border border-white/60 bg-white/70 backdrop-blur-sm text-[12px] font-semibold text-[#0838E0] hover:bg-white transition-colors">
<Users className="w-3.5 h-3.5" />
{ROLE_LABEL[activeRole]}
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{Object.keys(ROLE_LABEL).map((r) => (
<DropdownMenuItem
key={r}
onClick={() => handleRoleChange(r)}
className={cn('cursor-pointer', r === activeRole && 'text-[#0838E0] font-semibold')}
>
{ROLE_LABEL[r]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="hidden md:inline-flex items-center justify-center w-9 h-9 rounded-full bg-[#0838E0] text-white text-[13px] font-semibold shadow-lg shadow-blue-500/20 hover:scale-105 transition-transform">
{user?.full_name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || <UserIcon className="w-4 h-4" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user?.full_name || 'User'}</p>
<p className="text-xs leading-none text-muted-foreground">{user?.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => (isTalent ? window.dispatchEvent(new CustomEvent('krow:open-owliver')) : navigate('/identity'))}
className="cursor-pointer"
>
<UserIcon className="w-4 h-4 mr-2" />
{isTalent ? 'Build Profile with Owliver' : 'Create KROW Identity'}
</DropdownMenuItem>
{isTalent && location.pathname !== '/me' ? (
<HoverCard openDelay={250} closeDelay={200}>
<HoverCardTrigger asChild>
<DropdownMenuItem onClick={() => navigate('/me')} className="cursor-pointer">
<UserIcon className="w-4 h-4 mr-2" />
My KROW ID
</DropdownMenuItem>
</HoverCardTrigger>
<HoverCardContent side="left" align="start" sideOffset={8} className="w-[380px] p-3 bg-white border border-[#E5E7EB] rounded-2xl shadow-xl">
<KrowIdPreview />
</HoverCardContent>
</HoverCard>
) : (
<DropdownMenuItem onClick={() => navigate('/me')} className="cursor-pointer">
<UserIcon className="w-4 h-4 mr-2" />
My KROW ID
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigate('/profile')} className="cursor-pointer">
<UserIcon className="w-4 h-4 mr-2" />
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer text-red-600 focus:text-red-600">
<LogOut className="w-4 h-4 mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Mobile menu button */}
<button
onClick={() => setMenuOpen(true)}
className="md:hidden inline-flex items-center justify-center w-9 h-9 rounded-xl border border-white/50 glass text-[#374151] hover:bg-white/80 transition-colors"
>
<Menu className="w-5 h-5" />
</button>
</div>
</header>
{/* Control bar (Employer / Admin only) */}
{!isTalent && (
<div className="sticky top-16 z-30 glass border-b border-white/40">
{/* Below `sm` this wraps to two rows: search gets a full-width row of
its own, the filter + action share the next. On one row the search
field was being crushed to ~90px by the status select's intrinsic
width, clipping its own placeholder. From `sm` up it is the
original single 56px-tall row. */}
<div className="mx-auto max-w-container-wide px-4 sm:px-6 py-2.5 sm:py-0 sm:h-14 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:gap-3">
<form onSubmit={handleSearch} className="relative w-full sm:w-auto sm:flex-1 sm:max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9CA3AF]" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search positions..."
type="text"
className="w-full pl-9 pr-4 h-9 text-[13px] rounded-full border border-white/60 bg-white/70 backdrop-blur-sm focus:bg-white focus:border-[#0838E0] focus:outline-none transition-all"
/>
</form>
<div className="flex items-center gap-2 w-full sm:w-auto ml-auto">
<div className="relative flex-1 sm:flex-none min-w-0">
<select
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value)}
className="appearance-none w-full sm:w-auto h-9 pl-3 pr-8 text-[13px] font-medium rounded-full border border-white/60 bg-white/70 backdrop-blur-sm focus:border-[#0838E0] focus:outline-none cursor-pointer"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#374151] pointer-events-none" />
</div>
<button
onClick={() => navigate('/positions/new')}
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold px-4 py-2 rounded-full transition-all shrink-0 hover:scale-[1.02] shadow-sm"
>
<Plus className="w-4 h-4" />
{/* The second row leaves room for the label on mobile too */}
<span>New Position</span>
</button>
</div>
</div>
</div>
)}
{/* Mobile menu Sheet */}
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="right" className="w-72 p-0 glass">
<SheetHeader className="px-5 pt-5 pb-3 border-b border-white/40">
<SheetTitle className="text-left font-heading">Menu</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 p-3">
{/* Role switcher (mobile) */}
<div className="flex gap-1.5 px-1 pb-2">
{Object.keys(ROLE_LABEL).map((r) => (
<button
key={r}
onClick={() => { handleRoleChange(r); setMenuOpen(false); }}
className={cn(
'flex-1 py-2 rounded-xl text-[12px] font-semibold transition-all',
r === activeRole ? 'bg-[#0838E0] text-white' : 'bg-white/60 text-[#374151]'
)}
>
{ROLE_LABEL[r]}
</button>
))}
</div>
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMenuOpen(false)}
className={cn(
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
isActive(item.path)
? 'bg-[#EEF3FE] text-[#0838E0]'
: 'text-[#374151] hover:bg-white/60'
)}
>
{item.label}
</Link>
))}
<button
onClick={() => { setMenuOpen(false); isTalent ? window.dispatchEvent(new CustomEvent('krow:open-owliver')) : navigate('/identity'); }}
className={cn(
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all text-left w-full',
isActive('/identity') ? 'bg-[#EEF3FE] text-[#0838E0]' : 'text-[#374151] hover:bg-white/60'
)}
>
{isTalent ? 'Build Profile with Owliver' : 'Create KROW Identity'}
</button>
<Link
to="/me"
onClick={() => setMenuOpen(false)}
className={cn(
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
isActive('/me') ? 'bg-[#EEF3FE] text-[#0838E0]' : 'text-[#374151] hover:bg-white/60'
)}
>
My KROW ID
</Link>
<Link
to="/profile"
onClick={() => setMenuOpen(false)}
className={cn(
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
isActive('/profile') ? 'bg-[#EEF3FE] text-[#0838E0]' : 'text-[#374151] hover:bg-white/60'
)}
>
Profile
</Link>
<button
onClick={handleLogout}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[14px] font-medium text-red-600 hover:bg-red-50/80 transition-colors w-full"
>
<LogOut className="w-4 h-4" />
Log out
</button>
{!isTalent && (
<button
onClick={() => { setMenuOpen(false); navigate('/positions/new'); }}
className="mt-2 inline-flex items-center justify-center gap-1.5 bg-[#0838E0] text-white text-[14px] font-semibold px-4 py-2.5 rounded-xl transition-all"
>
<Plus className="w-4 h-4" />
New Position
</button>
)}
</nav>
</SheetContent>
</Sheet>
{/* Page shell.
The dashboard is the primary experience: `main` keeps the same measure
it has always had and stays centred, so with the assistant closed this
layout is identical to the original. Opening the assistant animates a
track open beside it; `main` is a flex child, so it reflows smoothly
rather than jumping, and closing restores the original layout exactly. */}
<div className="mx-auto flex max-w-container-wide px-4 sm:px-6 py-6 sm:py-8">
<main className="min-w-0 w-full max-w-7xl flex-1 mx-auto">
<Outlet />
</main>
</div>
{/* Owliver — the independent AI product, with its own branding, voice
experience and workflow. Talent only. Deliberately untouched by the
Krow AI work: different product, different UI, no shared components. */}
{isTalent && <OwliverChatBubble open={owliverOpen} onOpenChange={setOwliverOpen} />}
</div>
);
}