a58b99a33a
CI / Architecture Guardrails (pull_request) Successful in 4m26s
CI / Assistant Split Regression (pull_request) Successful in 5m38s
CI / Lint (pull_request) Successful in 6m6s
CI / Typecheck (pull_request) Successful in 6m34s
CI / Build (pull_request) Successful in 4m13s
CI / Unit Tests (pull_request) Failing after 10m20s
CI / E2E Tests (pull_request) Successful in 5m28s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m14s
CI / Release Images (pull_request) Has been skipped
AppShell.tsx top-left brand → Nexus (desktop sidebar + mobile top-bar), shell echo strings, prisma schema header, test fixture token, playwright runtime DB URL. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1017 lines
35 KiB
TypeScript
1017 lines
35 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";
|
|
import {
|
|
IconFrame,
|
|
DashboardIcon,
|
|
ResourcesIcon,
|
|
ProjectsIcon,
|
|
EstimatesIcon,
|
|
AllocationsIcon,
|
|
TimelineIcon,
|
|
StaffingIcon,
|
|
VacationIcon,
|
|
RolesIcon,
|
|
SkillsIcon,
|
|
MarketplaceIcon,
|
|
ChargeabilityIcon,
|
|
BenchIcon,
|
|
ReportBuilderIcon,
|
|
GraphIcon,
|
|
InsightsIcon,
|
|
NotificationsIcon,
|
|
BroadcastIcon,
|
|
ActivityLogIcon,
|
|
AdminIcon,
|
|
BlueprintIcon,
|
|
ClientsIcon,
|
|
CountryIcon,
|
|
OrgUnitIcon,
|
|
CategoryIcon,
|
|
LevelsIcon,
|
|
ImportIcon,
|
|
CalcRulesIcon,
|
|
UsersIcon,
|
|
SystemRolesIcon,
|
|
SecurityIcon,
|
|
SettingsIcon,
|
|
WebhooksIcon,
|
|
ScenariosIcon,
|
|
CollapseIcon,
|
|
HamburgerIcon,
|
|
CloseIcon,
|
|
} from "./nav-icons.js";
|
|
|
|
const SIDEBAR_COLLAPSED_KEY = "nexus_sidebar_collapsed";
|
|
|
|
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: "/scenarios",
|
|
label: "Scenarios",
|
|
icon: <ScenariosIcon />,
|
|
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
|
|
},
|
|
{
|
|
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: "/bench",
|
|
label: "Bench",
|
|
icon: <BenchIcon />,
|
|
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 transition-colors",
|
|
collapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
|
|
isActive
|
|
? "font-medium text-brand-700 dark:border-l-2 dark:border-brand-400/70 dark:text-brand-300"
|
|
: "font-medium text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/[0.05] dark:hover:text-white",
|
|
)}
|
|
>
|
|
{isActive && (
|
|
<motion.div
|
|
layoutId="nav-indicator"
|
|
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-50 to-brand-100/60 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-500/15 dark:to-brand-400/[0.08] dark:ring-brand-900/40"
|
|
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
|
/>
|
|
)}
|
|
<IconFrame isActive={isActive}>{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-white/[0.08] dark:from-[rgb(var(--surface-elevated))] dark:to-[rgb(var(--surface-elevated))]",
|
|
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">
|
|
Nex<span className="text-brand-600">us</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-white/[0.05] 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-700 dark:border-l-2 dark:border-brand-400/70 dark:text-brand-300"
|
|
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/[0.05] dark:hover:text-white",
|
|
)}
|
|
>
|
|
{isActive && (
|
|
<motion.div
|
|
layoutId="nav-indicator"
|
|
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-50 to-brand-100/60 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-500/15 dark:to-brand-400/[0.08] 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-white/[0.05]",
|
|
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-white/[0.05]",
|
|
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-white/[0.05]",
|
|
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-white/[0.05] 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(
|
|
"sidebar-panel hidden shrink-0 border-r border-gray-200/60 bg-white/95 shadow-[1px_0_12px_rgba(0,0,0,0.03)] backdrop-blur-xl backdrop-saturate-150 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="sidebar-panel 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 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="sidebar-panel sticky top-0 z-20 flex items-center border-b border-gray-200/60 bg-white/85 px-4 py-2 backdrop-blur-xl 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">
|
|
Nex<span className="text-brand-600">us</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>
|
|
);
|
|
}
|