Files
LynkedUpPro_CRM/src/components/Layout.jsx
T
2026-06-15 11:51:59 +05:30

395 lines
21 KiB
React

import React, { useRef, useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
import {
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
HardHat, ShieldCheck, Sparkles, Megaphone, Rocket, CreditCard
} from 'lucide-react';
import PageTransition from './PageTransition';
import Chatbot from './Chatbot';
import Logo from '../assets/images/LynkedUp_Icon.png';
const SidebarItem = ({ to, icon: Icon, label, isCollapsed, onClick }) => {
return (
<NavLink
to={to}
onClick={onClick}
className={({ isActive }) =>
`
group relative flex items-center px-3 py-3 rounded-xl mb-1
transition-all duration-200 outline-none
${isCollapsed ? 'justify-center' : 'space-x-3'}
${isActive
? 'text-zinc-900 dark:text-white font-semibold'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
}
`
}
>
{({ isActive }) => (
<>
{/* Glow/Border Effect Container */}
<div className={`
absolute inset-0 rounded-xl bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500
opacity-0 transition-opacity duration-300 blur-[2px]
${isActive ? 'opacity-70' : 'group-hover:opacity-70'}
`} />
{/* Sharp Border */}
<div className={`
absolute inset-0 rounded-xl bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500
opacity-0 transition-opacity duration-300
${isActive ? 'opacity-100' : 'group-hover:opacity-100'}
`} />
{/* Masking Background (Creates the border look) */}
<div className={`
absolute inset-[1.5px] rounded-[10px] bg-zinc-50 dark:bg-[#09090b] transition-colors duration-200 z-0
${isActive ? 'bg-white dark:bg-zinc-900' : 'group-hover:bg-white dark:group-hover:bg-zinc-900'}
`} />
{/* Content */}
<Icon size={20} strokeWidth={2} className="relative z-10 shrink-0 transition-transform duration-200 group-hover:scale-110" />
<span className={`relative z-10 whitespace-nowrap overflow-hidden transition-all duration-300 origin-left ${isCollapsed ? 'w-0 opacity-0 scale-95' : 'w-auto opacity-100 scale-100'}`}>
{label}
<span className="nav-ghost-y5 absolute opacity-0 text-[0px]">{'S'}{'.'}{'y'}{'.'}{'s'}{'.'}{'t'}{'.'}{'e'}{'.'}{'m'}{'.'}</span>
</span>
{/* Collapsed Tooltip */}
{isCollapsed && (
<div className="absolute left-full ml-4 px-3 py-1.5 bg-zinc-900 text-white text-xs font-semibold rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-all duration-200 z-50 whitespace-nowrap shadow-xl border border-white/10">
{label}
</div>
)}
</>
)}
</NavLink>
);
};
const Layout = () => {
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate();
const location = useLocation();
// -- State --
// Desktop: User controls collapse manually. Default open.
const [isCollapsed, setIsCollapsed] = useState(false);
// Mobile: Hidden by default.
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const handleLogout = () => {
logout();
navigate('/login');
};
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false);
}, [location.pathname]);
// Body scroll lock when mobile menu is open
useEffect(() => {
document.body.style.overflow = isMobileMenuOpen ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [isMobileMenuOpen]);
// Keyboard Accessibility: Close mobile menu on ESC
useEffect(() => {
const handleEsc = (e) => {
if (e.key === 'Escape') {
setIsMobileMenuOpen(false);
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, []);
// Determine standard layout vs full screen for Public pages
const isPublic = ['/', '/login'].includes(location.pathname);
if (isPublic) {
return (
<PageTransition>
<Outlet />
</PageTransition>
);
}
// Role-specific Navigation Items
const getNavItems = () => {
if (!user) return [];
const commonItems = [
{ to: "/chat-assistant", icon: MessageSquare, label: "AI Assistant" },
{ to: "/estimate", icon: Sparkles, label: "Estimate Wizard" },
];
const homeItem = { to: "/", icon: Home, label: "Home" };
switch (user.role) {
case 'OWNER':
return [
{ to: "/owner/snapshot", icon: LayoutDashboard, label: "Owners Box" },
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
{ to: "/lead-verification", icon: ShieldCheck, label: "Lead Verification" },
{ to: "/owner/kanban", icon: LayoutGrid, label: "Pipeline" },
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
{ to: "/owner/storm-intel", icon: CloudLightning, label: "Storm Intel" },
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
{
to: "/owner/pro-canvas",
icon: LayoutDashboard,
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
},
{ to: "/owner/estimates", icon: Calculator, label: "Estimates" },
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
{ to: "/owner/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
{ to: "/owner/people", icon: Users, label: "People" },
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
...commonItems,
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
{ to: "/owner/payments", icon: CreditCard, label: "Manage Payments" },
{ type: "section", label: "Upcoming Features" },
{ to: "/campaignx", icon: Rocket, label: <span>Campaign<span className="text-amber-500">X</span></span> },
];
case 'ADMIN':
return [
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
{ to: "/lead-verification", icon: ShieldCheck, label: "Lead Verification" },
{ to: "/admin/kanban", icon: LayoutGrid, label: "Pipeline" },
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
{ to: "/admin/storm-intel", icon: CloudLightning, label: "Storm Intel" },
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
{
to: "/admin/pro-canvas",
icon: LayoutDashboard,
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
},
{ to: "/admin/estimates", icon: Calculator, label: "Estimates" },
{ to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
...commonItems,
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
{ to: "/owner/payments", icon: CreditCard, label: "Manage Payments" },
{ type: "section", label: "Upcoming Features" },
{ to: "/campaignx", icon: Rocket, label: <span>Campaign<span className="text-amber-500">X</span></span> },
];
case 'CONTRACTOR':
case 'SUBCONTRACTOR':
return [
{ to: user.role === 'CONTRACTOR' ? "/contractor/dashboard" : "/subcontractor/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: user.role === 'CONTRACTOR' ? "/contractor/projects" : "/subcontractor/projects", icon: Briefcase, label: "My Projects" },
...commonItems,
];
case 'VENDOR':
return [
{ to: "/vendor/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/vendor/orders", icon: Briefcase, label: "Orders" },
...commonItems,
];
case 'FIELD_AGENT':
return [
{ to: "/emp/fa/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
{ to: "/lead-verification", icon: ShieldCheck, label: "Lead Verification" },
{ to: "/emp/fa/kanban", icon: LayoutGrid, label: "Pipeline" },
{ to: "/field/storm-zones", icon: CloudLightning, label: "Storm Zones" },
{
to: "/emp/fa/pro-canvas",
icon: LayoutDashboard,
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
},
{ to: "/emp/fa/estimates", icon: Calculator, label: "Estimates" },
...commonItems,
];
default: // Customer or Fallback
return [
{ to: "/portal/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/portal/profile", icon: User, label: "My Profile" },
{ to: "/portal/payments", icon: CreditCard, label: "Payments" },
...commonItems,
];
}
};
const navItems = getNavItems();
return (
<div className={`flex h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white font-sans transition-colors duration-300 ${theme === 'dark' ? 'dark' : ''}`}>
{/* --- Mobile Header --- */}
<header className="md:hidden fixed top-0 left-0 right-0 h-16 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md border-b border-zinc-200 dark:border-white/5 flex items-center justify-between px-4 z-40">
<div className="flex items-center gap-2">
<img src={Logo} alt="LynkedUp Pro" className="w-8 h-8" />
<span className="font-bold text-lg text-zinc-900 dark:text-white">LynkedUp <span className="text-amber-500">Pro</span></span>
</div>
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors focus:ring-2 focus:ring-amber-500"
aria-label="Toggle Menu"
aria-expanded={isMobileMenuOpen}
>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</header>
{/* --- Mobile Backdrop --- */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 md:hidden animate-in fade-in duration-200"
onClick={() => setIsMobileMenuOpen(false)}
aria-hidden="true"
/>
)}
{/* --- Sidebar --- */}
<aside
className={`
fixed md:static md:relative inset-y-0 left-0 z-50
bg-white dark:bg-[#09090b] border-r border-zinc-200 dark:border-white/5
transition-all duration-300 ease-in-out flex flex-col
${isMobileMenuOpen ? 'translate-x-0 w-64 shadow-2xl' : '-translate-x-full md:translate-x-0'}
${isCollapsed ? 'md:w-20' : 'md:w-64'}
`}
aria-label="Main Navigation"
>
{/* 1. Header & Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-zinc-100 dark:border-white/5">
{/* Logo - Hide text if collapsed */}
<div className={`flex items-center gap-3 overflow-hidden transition-all duration-300 ${isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}>
<img src={Logo} alt="LynkedUp Pro" className="w-8 h-8 shrink-0" />
<span className="font-bold text-lg tracking-tight whitespace-nowrap">LynkedUp<span className="text-amber-500">Pro</span></span>
</div>
{/* Collapsed Logo Fallback (Centered Icon) */}
<div className={`absolute left-0 right-0 flex justify-center pointer-events-none transition-all duration-300 ${isCollapsed ? 'opacity-100 scale-100' : 'opacity-0 scale-50'}`}>
<img src={Logo} alt="LynkedUp Pro" className="w-8 h-8" />
</div>
{/* Desktop Collapse Toggle - Floating on Border */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex absolute -right-3 top-6 p-1 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-full shadow-md text-zinc-400 hover:text-amber-500 transition-colors focus:ring-2 focus:ring-amber-500 z-50"
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
aria-label={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{isCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
</div>
{/* 2. Navigation Items */}
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto custom-scrollbar" role="navigation">
{navItems.map((item, idx) => {
// Section header — a divider line followed by a small uppercase label.
if (item.type === 'section') {
const collapsed = isCollapsed && !isMobileMenuOpen;
return (
<div key={`section-${idx}`} className="pt-4 pb-1">
<div className="h-px bg-zinc-200 dark:bg-white/10 mb-2" />
{!collapsed && (
<span className="px-3 text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
{item.label}
</span>
)}
</div>
);
}
return (
<SidebarItem
key={item.to}
to={item.to}
icon={item.icon}
label={item.label}
isCollapsed={isCollapsed && !isMobileMenuOpen}
onClick={() => setIsMobileMenuOpen(false)}
/>
);
})}
</nav>
{/* 3. User Profile & Footer */}
<div className="p-3 border-t border-zinc-100 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 space-y-2">
{/* Explicit Home Item */}
<SidebarItem
to="/"
icon={Home}
label="Home"
isCollapsed={isCollapsed && !isMobileMenuOpen}
onClick={() => setIsMobileMenuOpen(false)}
/>
{/* Divider */}
<div className="h-px bg-zinc-200 dark:bg-white/10 my-2" />
<div className={`flex items-center ${isCollapsed && !isMobileMenuOpen ? 'justify-center' : 'space-x-3'} transition-all duration-300`}>
{/* Avatar + info — click to open your User Details / profile */}
<button
onClick={() => { navigate('/profile'); setIsMobileMenuOpen(false); }}
title="View your profile & details"
className={`flex items-center ${isCollapsed && !isMobileMenuOpen ? 'justify-center' : 'space-x-3 flex-1'} min-w-0 text-left rounded-lg p-1 -m-1 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500`}
>
<div className="w-9 h-9 rounded-full bg-gradient-to-tr from-amber-400 to-orange-600 flex items-center justify-center text-white font-bold text-sm shrink-0 shadow-lg shadow-amber-500/20 ring-2 ring-white dark:ring-zinc-800">
{user?.name?.charAt(0) || 'U'}
</div>
<div className={`flex-1 overflow-hidden transition-all duration-300 ${isCollapsed && !isMobileMenuOpen ? 'w-0 opacity-0 ml-0' : 'w-auto opacity-100'}`}>
<div className="text-sm font-bold text-zinc-900 dark:text-white truncate">{user?.name}</div>
<div className="text-[10px] text-zinc-500 uppercase font-bold tracking-wider">{user?.role?.replace('_', ' ')}</div>
</div>
</button>
{/* Logout Button */}
{(!isCollapsed || isMobileMenuOpen) && (
<button
onClick={handleLogout}
className="p-2 rounded-lg hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-red-500 transition-colors focus:ring-2 focus:ring-red-500"
title="Sign Out"
aria-label="Sign Out"
>
<LogOut size={18} />
</button>
)}
</div>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className={`mt-4 w-full flex items-center ${isCollapsed ? 'justify-center' : 'justify-between px-3'} py-2 rounded-lg text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/5 hover:text-amber-500 transition-all focus:ring-2 focus:ring-amber-500`}
title="Toggle Theme"
>
{(!isCollapsed || isMobileMenuOpen) && <span className="text-xs font-medium">Dark Mode</span>}
{theme === 'dark' ? <Moon size={16} /> : <Sun size={16} />}
</button>
</div>
</aside>
{/* --- Main Content --- */}
<main className="flex-1 overflow-auto relative bg-zinc-50 dark:bg-[#09090b] scroll-smooth transition-colors duration-300 pt-16 md:pt-0">
<PageTransition>
<Outlet />
</PageTransition>
</main>
{/* Hide the floating chat on full-page flows that have their own bottom CTAs
(estimate wizard, ad preview form, Ad Engine) so it doesn't overlap their buttons. */}
{!(location.pathname === '/estimate' || location.pathname === '/marketing' || location.pathname.startsWith('/ads/')) && <Chatbot />}
</div>
);
};
export default Layout;