8f9cb9dd48
- EstimatesPage: tabbed hub with Estimates and Templates tabs, KPI strip, search + filter - EstimateDetailModal: right-side drawer showing scope of work, computed line items, and financial summary from template + roof area - TemplateEditorModal: create/edit/duplicate templates with segmented mode toggle - Owner-only template management (create, edit, duplicate, delete); Admin/FA read-only view - Duplicate mode: toggle between 'Save as new copy' (default, editable name) and 'Replace original' - MASTER_TEMPLATES and MOCK_ESTIMATES moved into mockStore with full CRUD helpers - TemplateSelectionModal now reads from mockStore instead of local stub - Sidebar nav updated to point to /estimates hub for all roles - Routes added: /emp/fa/estimates, /admin/estimates, /owner/estimates
345 lines
17 KiB
React
345 lines
17 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
|
|
} 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" },
|
|
];
|
|
|
|
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: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
|
{ 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" },
|
|
...commonItems,
|
|
];
|
|
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: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
|
{ 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" },
|
|
...commonItems,
|
|
];
|
|
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: "/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: "/customer/profile", icon: LayoutDashboard, label: "Dashboard" },
|
|
...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) => (
|
|
<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 */}
|
|
<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>
|
|
|
|
{/* Info */}
|
|
<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>
|
|
|
|
{/* 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>
|
|
|
|
<Chatbot />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Layout;
|