lead-to-policy/src/layout/Shell.tsx
2026-06-23 11:20:31 +05:30

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>
);
}