Files
teOS/apps/web/src/components/layout/sidebar.tsx
Flexomatic81 b1238b7bb8 refactor: move integrations overview from main nav into admin area
Remove the top-level "Integrationen" sidebar entry and add an "Uebersicht"
tab to the admin integrations page with summary stats and integration cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:32:40 +01:00

435 lines
12 KiB
TypeScript

'use client';
import { useState } from 'react';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { useTranslations } from 'next-intl';
import { motion, AnimatePresence } from 'framer-motion';
import {
LayoutDashboard,
Target,
Users,
Plug,
Shield,
Settings,
ChevronLeft,
ChevronRight,
ChevronDown,
ClipboardList,
CalendarClock,
GraduationCap,
UserCog,
Clock,
CalendarDays,
Network,
Building2,
UserPlus,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useSidebarStore } from '@/stores/sidebar-store';
import type { UserRole } from '@/types';
/** Navigation item configuration with role-based access */
interface NavItem {
key: string;
href: string;
icon: LucideIcon;
requiredRoles?: UserRole[];
children?: NavItem[];
exactMatch?: boolean;
}
/** Main navigation structure with role requirements */
const mainNavItems: NavItem[] = [
{
key: 'dashboard',
href: '/dashboard',
icon: LayoutDashboard,
// No requiredRoles = available to all authenticated users
},
{
key: 'lean',
href: '/lean',
icon: Target,
requiredRoles: ['employee', 'department_head', 'manager', 'admin'],
children: [
{
key: 's3Planning',
href: '/lean/s3-planning',
icon: ClipboardList,
},
{
key: 'morningMeeting',
href: '/lean/morning-meeting',
icon: CalendarClock,
},
{
key: 'skillMatrix',
href: '/lean/skill-matrix',
icon: GraduationCap,
},
],
},
{
key: 'hr',
href: '/hr',
icon: Users,
requiredRoles: ['department_head', 'manager', 'admin'],
children: [
{
key: 'employees',
href: '/hr/employees',
icon: UserCog,
},
{
key: 'timeTracking',
href: '/hr/time-tracking',
icon: Clock,
},
{
key: 'absences',
href: '/hr/absences',
icon: CalendarDays,
},
{
key: 'orgChart',
href: '/hr/org-chart',
icon: Network,
},
],
},
];
const bottomNavItems: NavItem[] = [
{
key: 'admin',
href: '/admin',
icon: Shield,
requiredRoles: ['admin'],
children: [
{
key: 'users',
href: '/admin/users',
icon: UserPlus,
},
{
key: 'departments',
href: '/admin/departments',
icon: Building2,
},
{
key: 'integrations',
href: '/admin/integrations',
icon: Plug,
},
{
key: 'systemSettings',
href: '/admin/settings',
icon: Settings,
},
],
},
{
key: 'settings',
href: '/settings',
icon: Settings,
// Available to all users
},
];
interface SidebarProps {
locale: string;
}
/**
* Check if user has access to a navigation item based on roles
*/
function hasAccess(item: NavItem, userRoles: string[]): boolean {
// If no roles required, everyone has access
if (!item.requiredRoles || item.requiredRoles.length === 0) {
return true;
}
// Check if user has any of the required roles
return item.requiredRoles.some((role) => userRoles.includes(role));
}
/**
* Filter navigation items based on user roles
*/
function filterNavItems(items: NavItem[], userRoles: string[]): NavItem[] {
return items
.filter((item) => hasAccess(item, userRoles))
.map((item) => ({
...item,
children: item.children?.filter((child) => hasAccess(child, userRoles)),
}));
}
/**
* Collapsible sidebar component with role-based navigation
* - 240px when expanded
* - 64px when collapsed (icons only)
* - Sub-navigation for nested items
*/
export function Sidebar({ locale }: SidebarProps) {
const pathname = usePathname();
const { data: session } = useSession();
const t = useTranslations('navigation');
const { isExpanded, toggleSidebar } = useSidebarStore();
// Get user roles, defaulting to empty array
const userRoles = session?.user?.roles || [];
// Filter navigation based on user roles
const filteredMainNav = filterNavItems(mainNavItems, userRoles);
const filteredBottomNav = filterNavItems(bottomNavItems, userRoles);
const isActive = (href: string, exactMatch?: boolean) => {
const localePath = `/${locale}${href}`;
if (exactMatch) return pathname === localePath;
return pathname === localePath || pathname.startsWith(`${localePath}/`);
};
const isParentActive = (item: NavItem) => {
if (isActive(item.href)) return true;
return item.children?.some((child) => isActive(child.href, child.exactMatch)) || false;
};
return (
<TooltipProvider delayDuration={0}>
<motion.aside
initial={false}
animate={{ width: isExpanded ? 240 : 64 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-sidebar-border bg-sidebar"
>
{/* Logo */}
<div className="flex h-16 items-center justify-between px-4">
<Link href={`/${locale}/dashboard`} className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold">
t
</div>
<AnimatePresence mode="wait">
{isExpanded && (
<motion.span
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 'auto' }}
exit={{ opacity: 0, width: 0 }}
transition={{ duration: 0.2 }}
className="text-xl font-bold text-sidebar-foreground overflow-hidden"
>
OS
</motion.span>
)}
</AnimatePresence>
</Link>
</div>
<Separator className="bg-sidebar-border" />
{/* Main Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto px-2 py-4">
{filteredMainNav.map((item) => (
<NavItemComponent
key={item.key}
item={item}
locale={locale}
isExpanded={isExpanded}
isActive={isActive}
isParentActive={isParentActive}
t={t}
/>
))}
</nav>
<Separator className="bg-sidebar-border" />
{/* Bottom Navigation */}
<nav className="space-y-1 px-2 py-4">
{filteredBottomNav.map((item) => (
<NavItemComponent
key={item.key}
item={item}
locale={locale}
isExpanded={isExpanded}
isActive={isActive}
isParentActive={isParentActive}
t={t}
/>
))}
</nav>
{/* Collapse Button */}
<div className="border-t border-sidebar-border p-2">
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="w-full justify-center text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
{isExpanded ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
</motion.aside>
</TooltipProvider>
);
}
interface NavItemComponentProps {
item: NavItem;
locale: string;
isExpanded: boolean;
isActive: (href: string, exactMatch?: boolean) => boolean;
isParentActive: (item: NavItem) => boolean;
t: (key: string) => string;
}
function NavItemComponent({
item,
locale,
isExpanded,
isActive,
isParentActive,
t,
}: NavItemComponentProps) {
const Icon = item.icon;
const active = isActive(item.href, item.exactMatch);
const parentActive = isParentActive(item);
const hasChildren = item.children && item.children.length > 0;
const [isOpen, setIsOpen] = useState(parentActive);
// Simple link (no children)
if (!hasChildren) {
const linkContent = (
<Link
href={`/${locale}${item.href}`}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
active
? 'bg-sidebar-primary text-sidebar-primary-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
)}
>
<Icon className="h-5 w-5 shrink-0" />
{isExpanded && <span>{t(item.key)}</span>}
</Link>
);
if (!isExpanded) {
return (
<Tooltip>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
<TooltipContent side="right">{t(item.key)}</TooltipContent>
</Tooltip>
);
}
return linkContent;
}
// Collapsible item with children
if (!isExpanded) {
// When collapsed, show tooltip with sub-navigation
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/${locale}${item.href}`}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
parentActive
? 'bg-sidebar-primary text-sidebar-primary-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
)}
>
<Icon className="h-5 w-5 shrink-0" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="flex flex-col gap-1 p-2">
<span className="font-medium">{t(item.key)}</span>
<Separator className="my-1" />
{item.children?.map((child) => (
<Link
key={child.key}
href={`/${locale}${child.href}`}
className={cn(
'rounded px-2 py-1 text-sm transition-colors hover:bg-accent',
isActive(child.href, child.exactMatch) && 'bg-accent font-medium'
)}
>
{t(child.key)}
</Link>
))}
</TooltipContent>
</Tooltip>
);
}
// Expanded collapsible
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<button
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
parentActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
)}
>
<Icon className="h-5 w-5 shrink-0" />
<span className="flex-1 text-left">{t(item.key)}</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="h-4 w-4" />
</motion.div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="ml-4 mt-1 space-y-1 border-l border-sidebar-border pl-4"
>
{item.children?.map((child) => {
const ChildIcon = child.icon;
const childActive = isActive(child.href, child.exactMatch);
return (
<Link
key={child.key}
href={`/${locale}${child.href}`}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
childActive
? 'bg-sidebar-primary text-sidebar-primary-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
)}
>
<ChildIcon className="h-4 w-4 shrink-0" />
<span>{t(child.key)}</span>
</Link>
);
})}
</motion.div>
</CollapsibleContent>
</Collapsible>
);
}