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>
435 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|