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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user