feat: complete tOS project with HR, LEAN, Dashboard and Integrations modules
Full enterprise web operating system including: - Next.js 14 frontend with App Router, i18n (DE/EN), shadcn/ui - NestJS 10 backend with Prisma, JWT auth, Swagger docs - Keycloak 24 SSO with role-based access control - HR module (employees, time tracking, absences, org chart) - LEAN module (3S planning, morning meeting SQCDM, skill matrix) - Integrations module (PlentyONE, Zulip, Todoist, FreeScout, Nextcloud, ecoDMS, GembaDocs) - Dashboard with customizable drag & drop widget grid - Docker Compose infrastructure (PostgreSQL 16, Redis 7, Keycloak 24) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
451
apps/web/src/components/layout/sidebar.tsx
Normal file
451
apps/web/src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
'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,
|
||||
MessageSquare,
|
||||
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[];
|
||||
}
|
||||
|
||||
/** 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'integrations',
|
||||
href: '/integrations',
|
||||
icon: Plug,
|
||||
requiredRoles: ['manager', 'admin'],
|
||||
children: [
|
||||
{
|
||||
key: 'overview',
|
||||
href: '/integrations',
|
||||
icon: Plug,
|
||||
},
|
||||
{
|
||||
key: 'plentyOne',
|
||||
href: '/integrations/plentyone',
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
key: 'zulip',
|
||||
href: '/integrations/zulip',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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: '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) => {
|
||||
const localePath = `/${locale}${href}`;
|
||||
return pathname === localePath || pathname.startsWith(`${localePath}/`);
|
||||
};
|
||||
|
||||
const isParentActive = (item: NavItem) => {
|
||||
if (isActive(item.href)) return true;
|
||||
return item.children?.some((child) => isActive(child.href)) || 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) => 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);
|
||||
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) && '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);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user