220 lines
9.3 KiB
TypeScript
220 lines
9.3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { Bell, FileText, FileSignature, LogOut, Menu, Search, ShieldCheck, Sparkles, UserCheck, Users, X } from 'lucide-react';
|
|
import { cn } from '../lib/cn';
|
|
import { Avatar } from '../components/core';
|
|
import { AI_EMPLOYEES } from '../api/config';
|
|
import { useZino } from '../api/provider';
|
|
import { useLeads } from '../api/leads';
|
|
import type { Lead } from '../types';
|
|
|
|
interface NavItem {
|
|
to: string;
|
|
label: string;
|
|
Icon: LucideIcon;
|
|
title: string;
|
|
subtitle: string;
|
|
}
|
|
|
|
const NAV: NavItem[] = [
|
|
{ to: '/pipeline', label: 'Lead Pipeline', Icon: Users, title: 'Lead pipeline', subtitle: '' },
|
|
{ to: '/assign', label: 'Assign', Icon: UserCheck, title: 'Assign leads', subtitle: 'Route qualified leads to a sales agent' },
|
|
{ to: '/recommend', label: 'Recommend', Icon: Sparkles, title: 'Recommend product', subtitle: 'Build and send a product recommendation' },
|
|
{ to: '/documents', label: 'Documents', Icon: FileText, title: 'Document collection', subtitle: 'KYC capture with OCR auto-extraction' },
|
|
{ to: '/underwriting', label: 'Underwriting', Icon: ShieldCheck, title: 'Underwriting', subtitle: 'Risk assessment & verdict' },
|
|
{ to: '/issue', label: 'Issue Policy', Icon: FileSignature, title: 'Issue policy', subtitle: 'Generate policy number & dates' },
|
|
];
|
|
|
|
interface RosterEntry {
|
|
name: string;
|
|
role: string;
|
|
active: number;
|
|
}
|
|
|
|
// Live AI-employee roster: each config employee + count of in-flight leads it
|
|
// currently owns (`ownerAi` is false on terminal states, so those drop off).
|
|
function buildRoster(leads: Lead[]): RosterEntry[] {
|
|
return Object.values(AI_EMPLOYEES).map((e) => ({
|
|
name: e.name,
|
|
role: e.role,
|
|
active: leads.filter((l) => l.ownerAi && l.owner === e.name).length,
|
|
}));
|
|
}
|
|
|
|
// Pipeline nav subtitle, computed live: "N leads · X% hot · Y% AI-handled".
|
|
function pipelineSubtitle(leads: Lead[]): string {
|
|
if (!leads.length) return 'No leads yet';
|
|
const total = leads.length;
|
|
const hot = Math.round((leads.filter((l) => l.segment === 'Hot').length / total) * 100);
|
|
const ai = Math.round((leads.filter((l) => l.ownerAi).length / total) * 100);
|
|
return `${total.toLocaleString('en-IN')} leads · ${hot}% hot · ${ai}% AI-handled`;
|
|
}
|
|
|
|
function AriaLogo() {
|
|
return (
|
|
<div className="flex items-center gap-2.5">
|
|
<span className="w-8 h-8 rounded-[9px] bg-sunrise shadow-sunrise flex items-center justify-center text-white font-extrabold text-lg font-numeric">
|
|
A
|
|
</span>
|
|
<span className="text-lg font-extrabold tracking-[-0.02em] text-on-navy">aria</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Sidebar contents — shared between the static desktop rail and the mobile drawer. */
|
|
function SidebarInner({ activeTo, onNav, roster }: { activeTo: string; onNav: (to: string) => void; roster: RosterEntry[] }) {
|
|
return (
|
|
<>
|
|
<div className="px-2 py-1">
|
|
<AriaLogo />
|
|
</div>
|
|
|
|
<nav className="flex flex-col gap-1">
|
|
{NAV.map((n) => {
|
|
const on = activeTo === n.to;
|
|
return (
|
|
<button
|
|
key={n.to}
|
|
onClick={() => onNav(n.to)}
|
|
className={cn(
|
|
'flex items-center gap-3 px-3 py-[11px] rounded-md border-none cursor-pointer text-left text-base transition-all duration-150',
|
|
on
|
|
? 'bg-[rgba(251,169,76,0.14)] text-white font-semibold'
|
|
: 'bg-transparent text-on-navy-muted font-medium hover:bg-[rgba(251,169,76,0.07)]',
|
|
)}
|
|
>
|
|
<n.Icon size={18} className={cn('shrink-0', on && 'text-sunrise-300')} />
|
|
{n.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div className="mt-auto flex flex-col gap-2.5">
|
|
<div className="text-[10px] font-bold uppercase tracking-[0.08em] text-on-navy-muted px-2">
|
|
AI Employees
|
|
</div>
|
|
{roster.map((e) => (
|
|
<div key={e.name} className="flex items-center gap-2.5 p-2 rounded-md bg-[rgba(255,255,255,0.04)]">
|
|
<Avatar name={e.name} ai size={28} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-xs font-semibold text-on-navy truncate">{e.name}</div>
|
|
<div className="text-[10px] text-on-navy-muted">{e.role}</div>
|
|
</div>
|
|
<span className="text-[10px] font-bold text-emerald-300 flex items-center gap-1">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
|
{e.active}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Topbar({ title, subtitle, onMenu }: { title: string; subtitle: string; onMenu: () => void }) {
|
|
const { user, logout } = useZino();
|
|
const navigate = useNavigate();
|
|
return (
|
|
<header className="h-16 shrink-0 bg-card border-b border-border-subtle flex items-center px-4 lg:px-7 gap-3 lg:gap-5 font-sans">
|
|
<button
|
|
onClick={onMenu}
|
|
aria-label="Open menu"
|
|
className="lg:hidden w-[38px] h-[38px] -ml-1 rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center shrink-0"
|
|
>
|
|
<Menu size={20} />
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="m-0 text-lg font-bold text-strong truncate">{title}</h1>
|
|
{subtitle && <div className="text-xs text-faint mt-px truncate">{subtitle}</div>}
|
|
</div>
|
|
<div className="flex items-center gap-3.5">
|
|
<button className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center">
|
|
<Search size={18} />
|
|
</button>
|
|
<div className="relative">
|
|
<button className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center">
|
|
<Bell size={18} />
|
|
</button>
|
|
<span className="absolute -top-0.5 -right-0.5 w-[9px] h-[9px] rounded-full bg-sunrise-500 border-2 border-card" />
|
|
</div>
|
|
<div className="flex items-center gap-2 pl-1.5">
|
|
<Avatar name={user?.name || 'User'} size={36} />
|
|
<div className="hidden md:block leading-tight whitespace-nowrap">
|
|
<div className="text-sm font-semibold text-strong">{user?.name || 'User'}</div>
|
|
<div className="text-2xs text-faint">{user?.email || ''}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
logout();
|
|
navigate('/login', { replace: true });
|
|
}}
|
|
title="Sign out"
|
|
className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center ml-1"
|
|
>
|
|
<LogOut size={17} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/** App shell — navy sidebar (nav + AI-employee roster) + topbar, with routed content. */
|
|
export function Shell() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { leads } = useLeads();
|
|
const path = location.pathname;
|
|
const navMatch = NAV.find((n) => path.startsWith(n.to));
|
|
// Routes reached contextually (no sidebar entry) still get a topbar title.
|
|
const active =
|
|
navMatch ??
|
|
(path.startsWith('/leads')
|
|
? { to: '/leads', label: 'Lead 360', Icon: Users, title: 'Lead 360', subtitle: 'Full lead context, AI decision stream & next action' }
|
|
: path.startsWith('/new')
|
|
? { to: '/new', label: 'New lead', Icon: Users, title: 'Capture lead', subtitle: 'Add a new lead to the pipeline' }
|
|
: NAV[0]);
|
|
|
|
const roster = useMemo(() => buildRoster(leads), [leads]);
|
|
const subtitle = active.to === '/pipeline' ? pipelineSubtitle(leads) : active.subtitle;
|
|
|
|
const [navOpen, setNavOpen] = useState(false);
|
|
// Close the mobile drawer whenever the route changes.
|
|
useEffect(() => setNavOpen(false), [path]);
|
|
|
|
return (
|
|
<div className="flex h-screen overflow-hidden bg-app font-sans">
|
|
{/* Static rail — lg and up. */}
|
|
<aside className="hidden lg:flex w-[248px] shrink-0 bg-navy-grad flex-col p-4 gap-6 font-sans">
|
|
<SidebarInner activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
|
|
</aside>
|
|
|
|
{/* Drawer — below lg. */}
|
|
{navOpen && (
|
|
<div className="fixed inset-0 z-50 lg:hidden">
|
|
<div className="absolute inset-0 bg-black/40" onClick={() => setNavOpen(false)} />
|
|
<aside className="absolute inset-y-0 left-0 w-[248px] max-w-[82%] bg-navy-grad flex flex-col p-4 gap-6 font-sans shadow-2xl">
|
|
<button
|
|
onClick={() => setNavOpen(false)}
|
|
aria-label="Close menu"
|
|
className="absolute top-3 right-3 w-9 h-9 rounded-md text-on-navy-muted hover:text-on-navy flex items-center justify-center bg-transparent border-none cursor-pointer"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
<SidebarInner activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
|
|
</aside>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
<Topbar title={active.title} subtitle={subtitle} onMenu={() => setNavOpen(true)} />
|
|
<main className="flex-1 overflow-auto p-4 lg:p-7">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|