lead-to-policy/src/layout/Shell.tsx
Bhanu Prakash Sai Potteri b95733a5f2 feat: polish sidebar (accent bar, icon chips, AI-status footer) + brand copy
- sidebar: Workspace section label, rounded rows w/ icon chips, sunrise
  active accent bar + ring, pinned live AI-employee status footer
- login: headline "Your AI sales floor for insurance", subtitle "Lead Management"
- tab title "Zino | Lead Management" + zino.svg favicon

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:43:01 +05:30

219 lines
9.9 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import type { LucideIcon } from 'lucide-react';
import { ArrowLeft, CalendarClock, FileText, LogOut, Menu, ShieldCheck, Sparkles, UserCheck, Users, X } from 'lucide-react';
import { cn } from '../lib/cn';
import { Avatar } from '../components/core';
import { ZinoLogo } from '../components/ZinoLogo';
import { canSeeScreen } 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: '/schedule', label: 'Agent Schedule', Icon: CalendarClock, title: 'Agent schedule', subtitle: 'Sales-agent meetings & booking slots' },
{ to: '/qualify', label: 'Qualify & Assign', Icon: UserCheck, title: 'Qualification & Assignment', subtitle: 'Qualify new leads and assign them to an agent' },
{ to: '/engage', label: 'Customer Interaction', Icon: Sparkles, title: 'Customer Interaction', subtitle: 'Log contact, schedule meetings, recommend products' },
{ to: '/documents', label: 'Documents', Icon: FileText, title: 'Document & Assessment', subtitle: 'KYC collection, OCR, eligibility assessment' },
{ to: '/underwriting', label: 'Underwriting & Policy', Icon: ShieldCheck, title: 'Underwriting & Policy Issuance', subtitle: 'Underwrite, issue policy, onboard customer' },
];
// 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 BrandLogo() {
return (
<div className="flex items-center justify-center px-1">
<ZinoLogo className="h-7 w-auto" />
</div>
);
}
/** Sidebar contents — shared between the static desktop rail and the mobile drawer. */
function SidebarInner({ items, activeTo, onNav }: { items: NavItem[]; activeTo: string; onNav: (to: string) => void }) {
return (
<>
<div className="px-1 pt-1 pb-4 border-b border-white/[0.08]">
<BrandLogo />
</div>
<nav className="flex flex-col gap-1">
<div className="px-3 pb-1.5 text-2xs font-bold uppercase tracking-[0.14em] text-on-navy-muted/60">
Workspace
</div>
{items.map((n) => {
const on = activeTo === n.to;
return (
<button
key={n.to}
onClick={() => onNav(n.to)}
className={cn(
'group relative flex items-center gap-3 pl-3 pr-2.5 py-2 rounded-xl border-none cursor-pointer text-left text-[15px] transition-all duration-150',
on
? 'bg-white/[0.08] text-white font-semibold shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] ring-1 ring-white/10'
: 'bg-transparent text-on-navy-muted font-medium hover:bg-white/[0.05] hover:text-on-navy',
)}
>
{/* active accent bar */}
<span
className={cn(
'absolute left-0 top-1/2 -translate-y-1/2 h-5 w-[3px] rounded-r-full bg-gradient-to-b from-sunrise-400 to-sunrise-300 transition-opacity duration-150',
on ? 'opacity-100' : 'opacity-0',
)}
/>
<span
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors duration-150',
on
? 'bg-sunrise-500/18 text-sunrise-300'
: 'bg-white/[0.04] text-on-navy-muted group-hover:text-sunrise-300',
)}
>
<n.Icon size={17} />
</span>
<span className="truncate">{n.label}</span>
</button>
);
})}
</nav>
{/* live AI-employee status — pinned to the bottom */}
<div className="mt-auto pt-4 border-t border-white/[0.08]">
<div className="flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06]">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60 animate-ping" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
</span>
<div className="leading-tight min-w-0">
<div className="text-xs font-semibold text-on-navy">AI employees active</div>
<div className="text-2xs text-on-navy-muted truncate">Qualify · Engage · Underwrite</div>
</div>
</div>
</div>
</>
);
}
function Topbar({ title, subtitle, onMenu, showBack }: { title: string; subtitle: string; onMenu: () => void; showBack: boolean }) {
const { user, logout } = useZino();
const navigate = useNavigate();
return (
<header className="sticky top-0 z-30 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>
{showBack && (
<button
onClick={() => (window.history.length > 1 ? navigate(-1) : navigate('/pipeline'))}
aria-label="Go back"
title="Go back"
className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center shrink-0 hover:text-strong hover:bg-slate-50"
>
<ArrowLeft size={18} />
</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">
<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) + topbar, with routed content. */
export function Shell() {
const location = useLocation();
const navigate = useNavigate();
const { leads } = useLeads();
const { user } = useZino();
const path = location.pathname;
// Nav is RBAC-filtered: a user only sees screens their roles allow.
const visibleNav = useMemo(() => NAV.filter((n) => canSeeScreen(user?.roles, n.to)), [user]);
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 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 min-h-screen bg-app font-sans">
{/* Static rail — lg and up. Pinned; the page scrolls normally beside it. */}
<aside className="hidden lg:flex w-[248px] shrink-0 bg-navy-grad flex-col p-4 gap-6 font-sans sticky top-0 h-screen self-start overflow-y-auto">
<SidebarInner items={visibleNav} activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} />
</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 items={visibleNav} activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} />
</aside>
</div>
)}
<div className="flex-1 flex flex-col min-w-0">
<Topbar title={active.title} subtitle={subtitle} onMenu={() => setNavOpen(true)} showBack={!navMatch} />
<main className="flex-1 p-4 lg:p-7">
<Outlet />
</main>
</div>
</div>
);
}