"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 { Suspense, useMemo, 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"; 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 ChargeabilityIcon() { return ; } function GraphIcon() { return ; } function NotificationsIcon() { return ; } function BroadcastIcon() { return ; } function AdminIcon() { 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: "/reports/chargeability", label: "Chargeability", 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. * Example: when pathname is `/vacations/my`, the item `/vacations` must NOT * highlight because `/vacations/my` is a more-specific registered route. */ 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; // pathname starts with `href/...` — but a more-specific registered route may match. // If another nav href is a longer prefix match, this shorter one should NOT be active. const hasMoreSpecificSibling = ALL_NAV_HREFS.some( (other) => other !== href && other.length > href.length && pathname.startsWith(other), ); return !hasMoreSpecificSibling; } function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => void }) { const pathname = usePathname(); const [prefsOpen, setPrefsOpen] = useState(false); // Memoize active href set — avoids O(n²) on every render 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"; // Sections and sub-groups auto-expand when the current route matches an item inside them 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] })); }; return ( <> {prefsOpen && setPrefsOpen(false)} />} ); } export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) { const [chatOpen, setChatOpen] = useState(false); return (
setChatOpen(true)} />
{children}
{chatOpen && setChatOpen(false)} />}
{/* Floating chat FAB — always visible when panel is closed */} {!chatOpen && ( )}
); }