"use client"; import type { ReactNode } from "react"; import { signOut } from "next-auth/react"; import Link from "next/link"; import type { Route } from "next"; import { usePathname } from "next/navigation"; import { clsx } from "clsx"; import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { PreferencesModal } from "./PreferencesModal.js"; import { ThemeProvider } from "./ThemeProvider.js"; import { PageTransition } from "./PageTransition.js"; import { NotificationBell } from "../notifications/NotificationBell.js"; import { ChatPanel } from "../assistant/ChatPanel.js"; import { NavProgressBar } from "~/components/ui/NavProgressBar.js"; const SIDEBAR_COLLAPSED_KEY = "planarchy_sidebar_collapsed"; function IconFrame({ children }: { children: ReactNode }) { return ( {children} ); } function DashboardIcon() { return ; } function ResourcesIcon() { return ; } function ProjectsIcon() { return ; } function EstimatesIcon() { return ; } function AllocationsIcon() { return ; } function TimelineIcon() { return ; } function StaffingIcon() { return ; } function VacationIcon() { return ; } function RolesIcon() { return ; } function SkillsIcon() { return ; } function MarketplaceIcon() { return ; } function ChargeabilityIcon() { return ; } function ReportBuilderIcon() { return ; } function GraphIcon() { return ; } function NotificationsIcon() { return ; } function BroadcastIcon() { return ; } function AdminIcon() { return ; } function CollapseIcon({ collapsed }: { collapsed: boolean }) { return ( ); } function HamburgerIcon() { return ( ); } function CloseIcon() { return ( ); } type NavItem = { href: string; label: string; icon: ReactNode; roles: string[] }; type NavSection = { label: string; collapsed?: boolean; items: NavItem[] }; const navSections: NavSection[] = [ { label: "Planning", items: [ { href: "/dashboard", label: "Dashboard", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, { href: "/timeline", label: "Timeline", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, { href: "/allocations", label: "Allocations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/staffing", label: "Staffing", icon: , roles: ["ADMIN", "MANAGER"] }, { href: "/notifications", label: "Notifications", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, ], }, { label: "Estimating", collapsed: true, items: [ { href: "/estimates", label: "Estimates", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, { href: "/admin/rate-cards", label: "Rate Cards", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/admin/effort-rules", label: "Effort Rules", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, ], }, { label: "Resources", items: [ { href: "/resources", label: "Resources", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/projects", label: "Projects", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, { href: "/roles", label: "Roles", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, ], }, { label: "Analytics", items: [ { href: "/analytics/skills", label: "Skills Analytics", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, { href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/reports/chargeability", label: "Chargeability", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/reports/builder", label: "Report Builder", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/analytics/computation-graph", label: "Computation Graph", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, ], }, { label: "Time Off", items: [ { href: "/vacations/my", label: "My Vacations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, { href: "/vacations", label: "Vacation Mgmt", icon: , roles: ["ADMIN", "MANAGER"] }, ], }, ]; type AdminNavItem = { href: string; label: string; icon: ReactNode }; type AdminSubGroup = { label: string; collapsed: boolean; items: AdminNavItem[] }; type AdminEntry = AdminNavItem | AdminSubGroup; function isSubGroup(entry: AdminEntry): entry is AdminSubGroup { return "items" in entry; } const adminNavEntries: AdminEntry[] = [ { href: "/admin/blueprints", label: "Blueprints", icon: }, { href: "/admin/clients", label: "Clients", icon: }, { label: "ACN-Orga", collapsed: true, items: [ { href: "/admin/countries", label: "Countries", icon: }, { href: "/admin/org-units", label: "Org Units", icon: }, { href: "/admin/utilization-categories", label: "Util. Categories", icon: }, { href: "/admin/management-levels", label: "Mgmt Levels", icon: }, ], }, { href: "/admin/calculation-rules", label: "Calc. Rules", icon: }, { href: "/admin/users", label: "Users", icon: }, { href: "/admin/system-roles", label: "System Roles", icon: }, { href: "/admin/settings", label: "Settings", icon: }, { href: "/admin/skill-import", label: "Skill Import", icon: }, { href: "/admin/notifications", label: "Broadcasts", icon: }, ]; /** * Collect every href registered in the sidebar so that the active-check * can determine whether a more-specific sibling matches the current path. */ const ALL_NAV_HREFS: string[] = (() => { const hrefs: string[] = []; for (const section of navSections) { for (const item of section.items) hrefs.push(item.href); } for (const entry of adminNavEntries) { if (isSubGroup(entry)) { for (const item of entry.items) hrefs.push(item.href); } else { hrefs.push(entry.href); } } return hrefs; })(); function isNavItemActive(pathname: string, href: string): boolean { if (pathname === href) return true; if (!pathname.startsWith(href + "/")) return false; const hasMoreSpecificSibling = ALL_NAV_HREFS.some( (other) => other !== href && other.length > href.length && pathname.startsWith(other), ); return !hasMoreSpecificSibling; } /* ------------------------------------------------------------------ */ /* Tooltip wrapper — shows label on hover when sidebar is collapsed */ /* ------------------------------------------------------------------ */ function NavTooltip({ label, show, children, }: { label: string; show: boolean; children: ReactNode; }) { if (!show) return <>{children}; return (
{children}
{label}
); } /* ------------------------------------------------------------------ */ /* Memoized nav item — prevents re-render of inactive items */ /* ------------------------------------------------------------------ */ /** Routes that benefit from eager prefetching (loaded while user reads current page). */ const PREFETCH_ROUTES = new Set(["/dashboard", "/timeline", "/projects", "/resources", "/allocations"]); const NavItemLink = memo(function NavItemLink({ href, label, icon, isActive, collapsed, onClick, }: { href: string; label: string; icon: ReactNode; isActive: boolean; collapsed: boolean; onClick?: (() => void) | undefined; }) { const linkProps = { ...(onClick ? { onClick } : {}), ...(PREFETCH_ROUTES.has(href) ? { prefetch: true as const } : {}), }; return ( {isActive && ( )} {icon} {!collapsed && {label}} ); }); /* ------------------------------------------------------------------ */ /* Sidebar component */ /* ------------------------------------------------------------------ */ function SidebarContent({ userRole, onChatOpen, sidebarCollapsed, onToggleCollapse, onNavClick, onPrefsOpen, }: { userRole: string; onChatOpen: () => void; sidebarCollapsed: boolean; onToggleCollapse: () => void; onNavClick?: () => void; onPrefsOpen: () => void; }) { const pathname = usePathname(); const activeHrefSet = useMemo(() => { const set = new Set(); for (const href of ALL_NAV_HREFS) { if (isNavItemActive(pathname, href)) set.add(href); } return set; }, [pathname]); const visibleSections = navSections .map((section) => ({ ...section, items: section.items.filter((item) => item.roles.includes(userRole)), })) .filter((section) => section.items.length > 0); const showAdmin = userRole === "ADMIN"; const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER"; const [collapsedSections, setCollapsedSections] = useState>(() => { const initial: Record = {}; for (const section of visibleSections) { if (section.collapsed) { const hasActiveRoute = section.items.some((item) => activeHrefSet.has(item.href)); initial[section.label] = !hasActiveRoute; } } for (const entry of adminNavEntries) { if (isSubGroup(entry) && entry.collapsed) { const hasActiveRoute = entry.items.some((item) => activeHrefSet.has(item.href)); initial[entry.label] = !hasActiveRoute; } } return initial; }); const toggleSection = (label: string) => { setCollapsedSections((prev) => ({ ...prev, [label]: !prev[label] })); }; const handleLinkClick = () => { onNavClick?.(); }; return ( <> {/* Logo */}
{!sidebarCollapsed && (

Planarchy

Resource Planning

)}
{/* Nav links */}
{visibleSections.map((section, idx) => { const isCollapsed = collapsedSections[section.label] ?? false; const isCollapsible = section.collapsed === true; return (
0 ? "mt-2" : ""}> {/* Section header — hidden when sidebar collapsed */} {!sidebarCollapsed && ( )} {/* Collapsed sidebar: show a thin divider between sections instead of header */} {sidebarCollapsed && idx > 0 && (
)} {(!isCollapsed || sidebarCollapsed) && (
{section.items.map((item) => ( ))}
)}
); })} {showManagerSection && showAdmin && (
{!sidebarCollapsed && (
Admin
)} {sidebarCollapsed && (
)}
{adminNavEntries.map((entry) => { if (isSubGroup(entry)) { const subCollapsed = collapsedSections[entry.label] ?? false; if (sidebarCollapsed) { // In collapsed mode, show sub-group items directly as icon-only return entry.items.map((item) => ( )); } return (
{!subCollapsed && (
{entry.items.map((item) => { const isActive = activeHrefSet.has(item.href); return ( {isActive && ( )} {item.label} ); })}
)}
); } return ( ); })}
)}
{/* Bottom actions */}
{!sidebarCollapsed && ( Notifications )}
{/* Collapse toggle */}
); } /* ------------------------------------------------------------------ */ /* Desktop sidebar wrapper */ /* ------------------------------------------------------------------ */ function DesktopSidebar({ userRole, onChatOpen, sidebarCollapsed, onToggleCollapse, onPrefsOpen, }: { userRole: string; onChatOpen: () => void; sidebarCollapsed: boolean; onToggleCollapse: () => void; onPrefsOpen: () => void; }) { return ( ); } /* ------------------------------------------------------------------ */ /* Mobile sidebar overlay */ /* ------------------------------------------------------------------ */ function MobileSidebar({ open, onClose, userRole, onChatOpen, onPrefsOpen, }: { open: boolean; onClose: () => void; userRole: string; onChatOpen: () => void; onPrefsOpen: () => void; }) { return ( {open && ( <> {/* Backdrop */} {/* Slide-in sidebar */} {/* Close button */} { onChatOpen(); onClose(); }} sidebarCollapsed={false} onToggleCollapse={() => {}} onNavClick={onClose} onPrefsOpen={() => { onPrefsOpen(); onClose(); }} /> )} ); } /* ------------------------------------------------------------------ */ /* AppShell (main export) */ /* ------------------------------------------------------------------ */ export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) { const [chatOpen, setChatOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [prefsOpen, setPrefsOpen] = useState(false); const pathname = usePathname(); const contentRef = useRef(null); // Read collapsed state from localStorage on mount (avoid SSR hydration mismatch) useEffect(() => { try { const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY); if (stored === "true") setSidebarCollapsed(true); } catch { // localStorage unavailable — ignore } }, []); // Persist collapsed state const handleToggleCollapse = useCallback(() => { setSidebarCollapsed((prev) => { const next = !prev; try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next)); } catch { // ignore } return next; }); }, []); // Scroll to top on route change useEffect(() => { contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, [pathname]); // Close mobile sidebar on route change useEffect(() => { setMobileOpen(false); }, [pathname]); return (
{/* Desktop sidebar */} setChatOpen(true)} sidebarCollapsed={sidebarCollapsed} onToggleCollapse={handleToggleCollapse} onPrefsOpen={() => setPrefsOpen(true)} /> {/* Mobile sidebar overlay */} setMobileOpen(false)} userRole={userRole} onChatOpen={() => setChatOpen(true)} onPrefsOpen={() => setPrefsOpen(true)} /> {/* Main content area */}
{/* Mobile hamburger */}
Planarchy
{children}
{chatOpen && setChatOpen(false)} />}
{/* Floating chat FAB */} {!chatOpen && ( )} {prefsOpen && setPrefsOpen(false)} />}
); }