Files
CapaKraken/apps/web/src/components/layout/AppShell.tsx
T

926 lines
44 KiB
TypeScript

"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 = "capakraken_sidebar_collapsed";
function IconFrame({ children }: { children: ReactNode }) {
return (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-white/60 bg-white/80 text-slate-600 shadow-sm transition-shadow duration-200 hover:shadow-[0_0_12px_rgba(var(--accent-500),0.15)] dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
{children}
</span>
);
}
function DashboardIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 13h6V5H4v8zm10 6h6V5h-6v14zM4 19h6v-2H4v2zm0-4h6v-2H4v2zm10 4h6v-6h-6v6z" /></svg>;
}
function ResourcesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2m18 0v-2a4 4 0 00-3-3.87M14 3.13a4 4 0 010 7.75M12 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>;
}
function ProjectsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 7h18M7 3v4m10-4v4M5 11h14v8H5z" /></svg>;
}
function EstimatesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7h8M8 11h4m-4 4h8M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg>;
}
function AllocationsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7V3m8 4V3M4 11h16M5 5h14a1 1 0 011 1v13a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1z" /></svg>;
}
function TimelineIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 6h16M4 12h10M4 18h7m9-8h-4m4 6h-7" /></svg>;
}
function StaffingIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 20l9-5-9-5-9 5 9 5zm0-10l9-5-9-5-9 5 9 5zm0 10v-10" /></svg>;
}
function VacationIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 19c3-4 6-6 8-6s5 2 8 6M7 12c.8-2.5 2.5-4 5-4s4.2 1.5 5 4M12 8V4" /></svg>;
}
function RolesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M7 7h10v10H7zM4 4h4m8 0h4m-4 16h4M4 20h4" /></svg>;
}
function SkillsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 3l2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.2 6.5 20.2l1-6.2L3 9.6l6.2-.9L12 3z" /></svg>;
}
function MarketplaceIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 3h18l-2 9H5L3 3zm0 0l-1-1m6 16a1 1 0 102 0 1 1 0 00-2 0zm10 0a1 1 0 102 0 1 1 0 00-2 0zM5 12h14" /></svg>;
}
function ChargeabilityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5 17l4-4 3 3 7-8M19 19H5V5" /></svg>;
}
function ReportBuilderIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>;
}
function GraphIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
}
function InsightsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>;
}
function NotificationsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>;
}
function BroadcastIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg>;
}
function ActivityLogIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>;
}
function AdminIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
}
function BlueprintIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>;
}
function ClientsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>;
}
function CountryIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>;
}
function OrgUnitIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" /></svg>;
}
function CategoryIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /></svg>;
}
function LevelsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 4h18M3 8h12M3 12h8M3 16h14M3 20h10" /></svg>;
}
function ImportIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>;
}
function CalcRulesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>;
}
function UsersIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>;
}
function SystemRolesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>;
}
function SecurityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>;
}
function SettingsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>;
}
function WebhooksIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>;
}
function CollapseIcon({ collapsed }: { collapsed: boolean }) {
return (
<svg
className={clsx("h-4 w-4 transition-transform duration-200", collapsed && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
);
}
function HamburgerIcon() {
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
}
function CloseIcon() {
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
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: <DashboardIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
{ href: "/notifications", label: "Notifications", icon: <NotificationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
],
},
{
label: "Estimating",
collapsed: true,
items: [
{ href: "/estimates", label: "Estimates", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Resources",
items: [
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Analytics",
items: [
{ href: "/analytics/skills", label: "Skills Hub", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/insights", label: "AI Insights", icon: <InsightsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Time Off",
items: [
{ href: "/vacations/my", label: "My Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/vacations", label: "Vacation Mgmt", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER"] },
],
},
{
label: "Account",
items: [
{ href: "/account/security", label: "Security", icon: <SecurityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
],
},
];
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: <BlueprintIcon /> },
{ href: "/admin/clients", label: "Clients", icon: <ClientsIcon /> },
{
label: "ACN-Orga",
collapsed: true,
items: [
{ href: "/admin/countries", label: "Countries", icon: <CountryIcon /> },
{ href: "/admin/org-units", label: "Org Units", icon: <OrgUnitIcon /> },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <CategoryIcon /> },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: <LevelsIcon /> },
{ href: "/admin/imports", label: "Data Import", icon: <ImportIcon /> },
],
},
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
{ href: "/admin/vacations", label: "Vacations & Holidays", icon: <VacationIcon /> },
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
{ href: "/admin/webhooks", label: "Webhooks", icon: <WebhooksIcon /> },
{ href: "/admin/activity-log", label: "Activity Log", icon: <ActivityLogIcon /> },
];
/**
* 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 (
<div className="group/tip relative">
{children}
<div className="pointer-events-none absolute left-full top-1/2 z-50 ml-2 -translate-y-1/2 whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg transition-opacity group-hover/tip:opacity-100 dark:bg-slate-700">
{label}
<div className="absolute right-full top-1/2 -translate-y-1/2 border-4 border-transparent border-r-gray-900 dark:border-r-slate-700" />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* 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 (
<NavTooltip label={label} show={collapsed}>
<Link
href={href as Route}
{...linkProps}
className={clsx(
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
collapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<IconFrame>{icon}</IconFrame>
{!collapsed && <span className="relative flex-1">{label}</span>}
</Link>
</NavTooltip>
);
});
/* ------------------------------------------------------------------ */
/* 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<string>();
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<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
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 */}
<div className={clsx(
"border-b border-gray-200/80 dark:border-slate-800",
sidebarCollapsed ? "px-3 py-4" : "px-6 py-6",
)}>
<div className={clsx(
"inline-flex items-center rounded-2xl border border-brand-200/70 bg-gradient-to-br from-white to-brand-50 shadow-sm dark:border-brand-900/50 dark:from-slate-950 dark:to-slate-900",
sidebarCollapsed ? "p-2" : "gap-3 px-4 py-3",
)}>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-brand-600 text-white shadow-lg shadow-brand-600/25">
<DashboardIcon />
</div>
{!sidebarCollapsed && (
<div className="overflow-hidden">
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Capa<span className="text-brand-600">Kraken</span>
</h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource & Capacity Planning</p>
</div>
)}
</div>
</div>
{/* Nav links */}
<div className={clsx("flex-1 overflow-y-auto py-5", sidebarCollapsed ? "px-2" : "px-4")}>
{visibleSections.map((section, idx) => {
const isCollapsed = collapsedSections[section.label] ?? false;
const isCollapsible = section.collapsed === true;
return (
<div key={section.label} className={idx > 0 ? "mt-2" : ""}>
{/* Section header — hidden when sidebar collapsed */}
{!sidebarCollapsed && (
<button
type="button"
onClick={isCollapsible ? () => toggleSection(section.label) : undefined}
className={clsx(
"flex w-full items-center px-3 pb-1.5 pt-3",
isCollapsible && "cursor-pointer hover:text-gray-600 dark:hover:text-gray-300",
)}
>
<span className="flex-1 text-left text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
{section.label}
</span>
{isCollapsible && (
<svg
className={clsx(
"h-3 w-3 text-gray-400 transition-transform dark:text-gray-500",
isCollapsed ? "-rotate-90" : "rotate-0",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</button>
)}
{/* Collapsed sidebar: show a thin divider between sections instead of header */}
{sidebarCollapsed && idx > 0 && (
<div className="mx-3 my-2 border-t border-gray-200/60 dark:border-slate-800" />
)}
<AnimatePresence initial={false}>
{(!isCollapsed || sidebarCollapsed) && (
<motion.div
initial={sidebarCollapsed ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="space-y-0.5">
{section.items.map((item) => (
<NavItemLink
key={item.href}
href={item.href}
label={item.label}
icon={item.icon}
isActive={activeHrefSet.has(item.href)}
collapsed={sidebarCollapsed}
onClick={handleLinkClick}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
{showManagerSection && showAdmin && (
<div className="mt-2">
{!sidebarCollapsed && (
<div className="px-3 pb-1.5 pt-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
Admin
</span>
</div>
)}
{sidebarCollapsed && (
<div className="mx-3 my-2 border-t border-gray-200/60 dark:border-slate-800" />
)}
<div className="space-y-0.5">
{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) => (
<NavItemLink
key={item.href}
href={item.href}
label={item.label}
icon={item.icon}
isActive={activeHrefSet.has(item.href)}
collapsed
onClick={handleLinkClick}
/>
));
}
return (
<div key={entry.label}>
<button
type="button"
onClick={() => toggleSection(entry.label)}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-gray-500 transition-all hover:bg-gray-100/90 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-slate-900 dark:hover:text-gray-200"
>
<IconFrame><AdminIcon /></IconFrame>
<span className="flex-1 text-left">{entry.label}</span>
<svg
className={clsx(
"h-3 w-3 transition-transform",
subCollapsed ? "-rotate-90" : "rotate-0",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<AnimatePresence initial={false}>
{!subCollapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="ml-4 space-y-0.5 border-l border-gray-200 pl-2 dark:border-slate-800">
{entry.items.map((item) => {
const isActive = activeHrefSet.has(item.href);
return (
<Link
key={item.href}
href={item.href as Route}
onClick={handleLinkClick}
className={clsx(
"group relative flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-colors",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<span className="relative flex-1">{item.label}</span>
</Link>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
return (
<NavItemLink
key={entry.href}
href={entry.href}
label={entry.label}
icon={entry.icon}
isActive={activeHrefSet.has(entry.href)}
collapsed={sidebarCollapsed}
onClick={handleLinkClick}
/>
);
})}
</div>
</div>
)}
</div>
{/* Bottom actions */}
<div className={clsx(
"space-y-1 border-t border-gray-200/80 dark:border-slate-800",
sidebarCollapsed ? "p-2" : "p-4",
)}>
<NavTooltip label="Notifications" show={sidebarCollapsed}>
<div className={clsx(
"flex items-center",
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-2 px-3 py-2",
)}>
<NotificationBell />
{!sidebarCollapsed && (
<span className="text-xs uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">Notifications</span>
)}
</div>
</NavTooltip>
<NavTooltip label="HartBOT" show={sidebarCollapsed}>
<button
type="button"
onClick={onChatOpen}
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
)}
>
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</IconFrame>
{!sidebarCollapsed && <span>HartBOT</span>}
</button>
</NavTooltip>
<NavTooltip label="Preferences" show={sidebarCollapsed}>
<button
type="button"
onClick={onPrefsOpen}
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
)}
>
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</IconFrame>
{!sidebarCollapsed && <span>Preferences</span>}
</button>
</NavTooltip>
<NavTooltip label="Sign out" show={sidebarCollapsed}>
<button
type="button"
onClick={() => void signOut({ callbackUrl: "/auth/signin" })}
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
)}
>
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</IconFrame>
{!sidebarCollapsed && <span>Sign out</span>}
</button>
</NavTooltip>
{/* Collapse toggle */}
<NavTooltip label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} show={sidebarCollapsed}>
<button
type="button"
onClick={onToggleCollapse}
className={clsx(
"flex w-full items-center rounded-2xl text-sm text-gray-400 transition-colors hover:bg-gray-100/90 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-slate-900 dark:hover:text-gray-300",
sidebarCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5",
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<CollapseIcon collapsed={sidebarCollapsed} />
</span>
{!sidebarCollapsed && <span>Collapse</span>}
</button>
</NavTooltip>
</div>
</>
);
}
/* ------------------------------------------------------------------ */
/* Desktop sidebar wrapper */
/* ------------------------------------------------------------------ */
function DesktopSidebar({
userRole,
onChatOpen,
sidebarCollapsed,
onToggleCollapse,
onPrefsOpen,
}: {
userRole: string;
onChatOpen: () => void;
sidebarCollapsed: boolean;
onToggleCollapse: () => void;
onPrefsOpen: () => void;
}) {
return (
<nav
className={clsx(
"hidden shrink-0 border-r border-gray-200/60 bg-white/60 shadow-[1px_0_12px_rgba(0,0,0,0.03)] backdrop-blur-2xl backdrop-saturate-150 dark:border-slate-800 dark:bg-slate-950/75 dark:shadow-none lg:flex lg:flex-col",
"transition-[width] duration-200 ease-out overflow-hidden",
sidebarCollapsed ? "w-[72px]" : "w-72",
)}
>
<SidebarContent
userRole={userRole}
onChatOpen={onChatOpen}
sidebarCollapsed={sidebarCollapsed}
onToggleCollapse={onToggleCollapse}
onPrefsOpen={onPrefsOpen}
/>
</nav>
);
}
/* ------------------------------------------------------------------ */
/* Mobile sidebar overlay */
/* ------------------------------------------------------------------ */
function MobileSidebar({
open,
onClose,
userRole,
onChatOpen,
onPrefsOpen,
}: {
open: boolean;
onClose: () => void;
userRole: string;
onChatOpen: () => void;
onPrefsOpen: () => void;
}) {
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm lg:hidden"
onClick={onClose}
/>
{/* Slide-in sidebar */}
<motion.nav
initial={{ x: -288 }}
animate={{ x: 0 }}
exit={{ x: -288 }}
transition={{ type: "spring", stiffness: 400, damping: 35 }}
className="fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-gray-200/60 bg-white/95 shadow-2xl backdrop-blur-2xl backdrop-saturate-150 dark:border-slate-800 dark:bg-slate-950/95 lg:hidden"
>
{/* Close button */}
<button
type="button"
onClick={onClose}
className="absolute right-3 top-5 z-10 flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-slate-800 dark:hover:text-gray-300"
>
<CloseIcon />
</button>
<SidebarContent
userRole={userRole}
onChatOpen={() => {
onChatOpen();
onClose();
}}
sidebarCollapsed={false}
onToggleCollapse={() => {}}
onNavClick={onClose}
onPrefsOpen={() => {
onPrefsOpen();
onClose();
}}
/>
</motion.nav>
</>
)}
</AnimatePresence>
);
}
/* ------------------------------------------------------------------ */
/* 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<HTMLElement>(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 (
<ThemeProvider>
<Suspense>
<NavProgressBar />
</Suspense>
<div className="flex h-screen bg-transparent">
{/* Desktop sidebar */}
<DesktopSidebar
userRole={userRole}
onChatOpen={() => setChatOpen(true)}
sidebarCollapsed={sidebarCollapsed}
onToggleCollapse={handleToggleCollapse}
onPrefsOpen={() => setPrefsOpen(true)}
/>
{/* Mobile sidebar overlay */}
<MobileSidebar
open={mobileOpen}
onClose={() => setMobileOpen(false)}
userRole={userRole}
onChatOpen={() => setChatOpen(true)}
onPrefsOpen={() => setPrefsOpen(true)}
/>
{/* Main content area */}
<main ref={contentRef} className="flex-1 overflow-auto bg-transparent">
{/* Mobile hamburger */}
<div className="sticky top-0 z-20 flex items-center border-b border-gray-200/60 bg-white/80 px-4 py-2 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-950/80 lg:hidden">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-slate-800"
>
<HamburgerIcon />
</button>
<span className="ml-3 font-display text-sm font-semibold text-gray-900 dark:text-gray-50">
Capa<span className="text-brand-600">Kraken</span>
</span>
</div>
<PageTransition>{children}</PageTransition>
</main>
{chatOpen && <ChatPanel onClose={() => setChatOpen(false)} />}
</div>
{/* Floating chat FAB */}
{!chatOpen && (
<button
type="button"
onClick={() => setChatOpen(true)}
data-testid="assistant-open-button"
className="fixed bottom-6 right-6 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-brand-600 text-white shadow-lg shadow-brand-600/30 transition-all hover:bg-brand-700 hover:shadow-xl hover:shadow-brand-600/40 active:scale-95"
title="HartBOT"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</button>
)}
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
</ThemeProvider>
);
}