550 lines
22 KiB
TypeScript
550 lines
22 KiB
TypeScript
import { useAuth } from "@/_core/hooks/useAuth";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarHeader,
|
|
SidebarInset,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarProvider,
|
|
SidebarTrigger,
|
|
useSidebar,
|
|
} from "@/components/ui/sidebar";
|
|
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
|
import { useIsMobile } from "@/hooks/useMobile";
|
|
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User, FileText, QrCode, Shield } from "lucide-react";
|
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
|
import { useLocation } from "wouter";
|
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
|
import { Button } from "./ui/button";
|
|
|
|
// Couleurs et styles par catégorie de menu
|
|
const getCategoryColor = (sectionTitle: string) => {
|
|
const colors: Record<string, string> = {
|
|
"Tableau de bord": "text-blue-500",
|
|
"Gestion": "text-green-500",
|
|
"Configuration": "text-orange-500",
|
|
"Traçabilité": "text-purple-500",
|
|
"Analyses": "text-pink-500",
|
|
"Gestion technique": "text-red-500",
|
|
};
|
|
return colors[sectionTitle] || "text-gray-500";
|
|
};
|
|
|
|
const getCategoryGradient = (sectionTitle: string) => {
|
|
const gradients: Record<string, string> = {
|
|
"Tableau de bord": "from-blue-300 to-blue-400",
|
|
"Gestion": "from-green-300 to-emerald-400",
|
|
"Configuration": "from-orange-300 to-orange-400",
|
|
"Traçabilité": "from-purple-300 to-purple-400",
|
|
"Analyses": "from-pink-300 to-pink-400",
|
|
"Gestion technique": "from-red-300 to-red-400",
|
|
};
|
|
return gradients[sectionTitle] || "from-gray-300 to-gray-400";
|
|
};
|
|
|
|
const getCategoryBgColor = (sectionTitle: string) => {
|
|
const bgColors: Record<string, string> = {
|
|
"Tableau de bord": "bg-blue-50/80 hover:bg-blue-100/90",
|
|
"Gestion": "bg-green-50/80 hover:bg-green-100/90",
|
|
"Configuration": "bg-orange-50/80 hover:bg-orange-100/90",
|
|
"Traçabilité": "bg-purple-50/80 hover:bg-purple-100/90",
|
|
"Analyses": "bg-pink-50/80 hover:bg-pink-100/90",
|
|
"Gestion technique": "bg-red-50/80 hover:bg-red-100/90",
|
|
};
|
|
return bgColors[sectionTitle] || "bg-gray-50/80 hover:bg-gray-100/90";
|
|
};
|
|
|
|
const menuSections = [
|
|
{
|
|
title: "Tableau de bord",
|
|
items: [
|
|
{ icon: LayoutDashboard, label: "Tableau de bord", path: "/admin" },
|
|
]
|
|
},
|
|
{
|
|
title: "Gestion",
|
|
items: [
|
|
{ icon: GraduationCap, label: "Formations", path: "/admin/formations" },
|
|
{ icon: Calendar, label: "Séquences", path: "/admin/sequences" },
|
|
{ icon: CalendarDays, label: "Calendrier", path: "/admin/calendrier" },
|
|
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" },
|
|
{ icon: QrCode, label: "Émargement", path: "/formateur/emargement" },
|
|
{ icon: FileText, label: "Gestion des attestations", path: "/admin/gestion-attestations" },
|
|
]
|
|
},
|
|
{
|
|
title: "Configuration",
|
|
items: [
|
|
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
|
|
{ icon: User, label: "Formateurs", path: "/admin/formateurs" },
|
|
{ icon: FileSpreadsheet, label: "Import Excel", path: "/admin/import-excel" },
|
|
{ icon: Bell, label: "Rappels", path: "/admin/rappels" },
|
|
{ icon: ClipboardList, label: "Questionnaires", path: "/admin/questionnaires" },
|
|
{ icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" },
|
|
{ icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" },
|
|
{ icon: Settings, label: "Paramètres", path: "/admin/parametres" },
|
|
{ icon: FileText, label: "Attestations de formation", path: "/admin/attestations" },
|
|
]
|
|
},
|
|
{
|
|
title: "Traçabilité",
|
|
items: [
|
|
{ icon: Building2, label: "Établissements", path: "/admin/etablissements" },
|
|
{ icon: Mail, label: "Notifications", path: "/admin/notifications" },
|
|
{ icon: TrendingUp, label: "Suivi Questionnaires", path: "/admin/questionnaires/suivi" },
|
|
{ icon: Bell, label: "Rappels", path: "/admin/rappels/historique" },
|
|
{ icon: FileText, label: "Historique Attestations", path: "/admin/historique-attestations" },
|
|
]
|
|
},
|
|
{
|
|
title: "Analyses",
|
|
items: [
|
|
{ icon: TrendingUp, label: "Tableau de bord analytique", path: "/admin/analytics" },
|
|
{ icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" },
|
|
{ icon: BarChart3, label: "Statistiques des rappels", path: "/admin/rappels/statistiques" },
|
|
]
|
|
},
|
|
{
|
|
title: "Gestion technique",
|
|
items: [
|
|
{ icon: Shield, label: "Sécurité SSH (Fail2Ban)", path: "/admin/fail2ban" },
|
|
]
|
|
},
|
|
];
|
|
|
|
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
|
const DEFAULT_WIDTH = 280;
|
|
const MIN_WIDTH = 200;
|
|
const MAX_WIDTH = 480;
|
|
|
|
export default function DashboardLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
|
});
|
|
const { loading, user } = useAuth();
|
|
const [location, setLocation] = useLocation();
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
|
}, [sidebarWidth]);
|
|
|
|
// Rediriger les formateurs vers leur tableau de bord
|
|
useEffect(() => {
|
|
if (user && user.role === 'formateur' && location === '/admin') {
|
|
setLocation('/formateur');
|
|
}
|
|
// Les super formateurs vont sur /admin comme les admins
|
|
if (user && user.role === 'super_formateur' && location === '/formateur') {
|
|
setLocation('/admin');
|
|
}
|
|
}, [user, location, setLocation]);
|
|
|
|
if (loading) {
|
|
return <DashboardLayoutSkeleton />
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
|
|
<div className="flex flex-col items-center gap-6">
|
|
<div className="relative group">
|
|
<div className="relative">
|
|
<img
|
|
src={APP_LOGO}
|
|
alt={APP_TITLE}
|
|
className="h-20 w-20 rounded-xl object-cover shadow"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="text-center space-y-2">
|
|
<h1 className="text-2xl font-bold tracking-tight">{APP_TITLE}</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Veuillez vous connecter pour continuer
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={() => {
|
|
window.location.href = getLoginUrl();
|
|
}}
|
|
size="lg"
|
|
className="w-full shadow-lg hover:shadow-xl transition-all"
|
|
>
|
|
Se connecter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SidebarProvider
|
|
style={
|
|
{
|
|
"--sidebar-width": `${sidebarWidth}px`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
|
|
{children}
|
|
</DashboardLayoutContent>
|
|
</SidebarProvider>
|
|
);
|
|
}
|
|
|
|
type DashboardLayoutContentProps = {
|
|
children: React.ReactNode;
|
|
setSidebarWidth: (width: number) => void;
|
|
};
|
|
|
|
function DashboardLayoutContent({
|
|
children,
|
|
setSidebarWidth,
|
|
}: DashboardLayoutContentProps) {
|
|
const { user, logout } = useAuth();
|
|
const [location, setLocation] = useLocation();
|
|
const { state, toggleSidebar } = useSidebar();
|
|
const isCollapsed = state === "collapsed";
|
|
|
|
// Filtrer les sections de menu selon le rôle
|
|
const filteredMenuSections = menuSections.filter(section => {
|
|
// Les formateurs ne voient pas Configuration, Traçabilité, Analyses ni Gestion technique
|
|
if (user?.role === 'formateur') {
|
|
return section.title !== 'Configuration' && section.title !== 'Traçabilité' && section.title !== 'Analyses' && section.title !== 'Gestion technique';
|
|
}
|
|
// Les super formateurs ne voient pas Gestion technique
|
|
if (user?.role === 'super_formateur') {
|
|
return section.title !== 'Gestion technique';
|
|
}
|
|
return true;
|
|
}).map(section => {
|
|
// Modifier le chemin du tableau de bord pour les formateurs
|
|
if (section.title === 'Tableau de bord' && user?.role === 'formateur') {
|
|
return {
|
|
...section,
|
|
items: section.items.map(item =>
|
|
item.path === '/admin' ? { ...item, path: '/formateur' } : item
|
|
)
|
|
};
|
|
}
|
|
return section;
|
|
});
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
const activeMenuItem = filteredMenuSections.flatMap(section => section.items).find(item => item.path === location);
|
|
const isMobile = useIsMobile();
|
|
|
|
// État pour gérer les sections ouvertes/fermées (persisté dans localStorage)
|
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>(() => {
|
|
// Version du système de menu (incrémenter pour forcer la réinitialisation)
|
|
const MENU_VERSION = 5; // Incrémenté pour forcer la réinitialisation : seul "Gestion" ouvert par défaut
|
|
const savedVersion = localStorage.getItem('menuVersion');
|
|
|
|
// Si la version a changé, ignorer le localStorage existant
|
|
if (savedVersion && parseInt(savedVersion) === MENU_VERSION) {
|
|
const saved = localStorage.getItem('menuSectionsState');
|
|
if (saved) {
|
|
try {
|
|
return JSON.parse(saved);
|
|
} catch {
|
|
// En cas d'erreur, utiliser les valeurs par défaut
|
|
}
|
|
}
|
|
}
|
|
|
|
// Valeurs par défaut : seule la section "Gestion" est ouverte par défaut
|
|
const initial: Record<string, boolean> = {};
|
|
menuSections.forEach(section => {
|
|
// Ouvrir uniquement la section "Gestion" par défaut
|
|
initial[section.title] = section.title === "Gestion";
|
|
});
|
|
|
|
// Sauvegarder la version et l'état initial
|
|
localStorage.setItem('menuVersion', MENU_VERSION.toString());
|
|
localStorage.setItem('menuSectionsState', JSON.stringify(initial));
|
|
|
|
return initial;
|
|
});
|
|
|
|
// Fonction pour toggle une section (mode accordéon : un seul menu ouvert à la fois)
|
|
const toggleSection = (sectionTitle: string) => {
|
|
setOpenSections(prev => {
|
|
const isCurrentlyOpen = prev[sectionTitle];
|
|
|
|
// Si le menu est déjà ouvert, on le ferme
|
|
if (isCurrentlyOpen) {
|
|
const newState = {
|
|
...prev,
|
|
[sectionTitle]: false
|
|
};
|
|
localStorage.setItem('menuSectionsState', JSON.stringify(newState));
|
|
return newState;
|
|
}
|
|
|
|
// Sinon, on ferme tous les menus et on ouvre celui-ci
|
|
const newState: Record<string, boolean> = {};
|
|
Object.keys(prev).forEach(key => {
|
|
newState[key] = key === sectionTitle;
|
|
});
|
|
|
|
// Sauvegarder dans localStorage
|
|
localStorage.setItem('menuSectionsState', JSON.stringify(newState));
|
|
return newState;
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isCollapsed) {
|
|
setIsResizing(false);
|
|
}
|
|
}, [isCollapsed]);
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!isResizing) return;
|
|
|
|
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
|
const newWidth = e.clientX - sidebarLeft;
|
|
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
|
setSidebarWidth(newWidth);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizing(false);
|
|
};
|
|
|
|
if (isResizing) {
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
};
|
|
}, [isResizing, setSidebarWidth]);
|
|
|
|
return (
|
|
<>
|
|
<div className="relative" ref={sidebarRef}>
|
|
<Sidebar
|
|
collapsible="icon"
|
|
className="border-r-0"
|
|
disableTransition={isResizing}
|
|
>
|
|
<SidebarHeader className="h-16 justify-center">
|
|
<div className="flex items-center gap-3 pl-2 group-data-[collapsible=icon]:px-0 transition-all w-full">
|
|
{isCollapsed ? (
|
|
<div className="relative h-8 w-8 shrink-0 group">
|
|
<img
|
|
src={APP_LOGO}
|
|
className="h-8 w-8 rounded-md object-cover ring-1 ring-border"
|
|
alt="Logo"
|
|
/>
|
|
<button
|
|
onClick={toggleSidebar}
|
|
className="absolute inset-0 flex items-center justify-center bg-accent rounded-md ring-1 ring-border opacity-0 group-hover:opacity-100 transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<PanelLeft className="h-4 w-4 text-foreground" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<img
|
|
src={APP_LOGO}
|
|
className="h-8 w-8 rounded-md object-cover ring-1 ring-border shrink-0"
|
|
alt="Logo"
|
|
/>
|
|
<span className="font-semibold tracking-tight truncate">
|
|
{APP_TITLE}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={toggleSidebar}
|
|
className="ml-auto h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
|
>
|
|
<PanelLeft className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</SidebarHeader>
|
|
|
|
<SidebarContent className="gap-0">
|
|
{filteredMenuSections.map((section, sectionIndex) => {
|
|
const isOpen = openSections[section.title] ?? false;
|
|
return (
|
|
<div key={section.title}>
|
|
{sectionIndex > 0 && (
|
|
<div className="border-t border-border my-2" />
|
|
)}
|
|
<button
|
|
onClick={() => toggleSection(section.title)}
|
|
className={`w-full px-3 py-3 flex items-center justify-between ${getCategoryBgColor(section.title)} transition-all duration-300 rounded-lg mx-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring border-l-4 ${isOpen ? 'border-l-' + section.title.toLowerCase().replace(/ /g, '-') : 'border-l-transparent'} shadow-sm hover:shadow-md hover:translate-x-1`}
|
|
style={{
|
|
borderLeftColor: isOpen ? `var(--${section.title.toLowerCase().replace(/ /g, '-')}-color, currentColor)` : 'transparent'
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-1.5 rounded-lg bg-gradient-to-br ${getCategoryGradient(section.title)} shadow-md transition-transform duration-300 ${isOpen ? 'scale-110 rotate-6' : ''}`}>
|
|
<div className="w-3 h-3 bg-white/30 rounded-sm" />
|
|
</div>
|
|
<h3 className={`text-xs font-bold uppercase tracking-wider ${getCategoryColor(section.title)}`}>
|
|
{section.title}
|
|
</h3>
|
|
</div>
|
|
<ChevronDown
|
|
className={`h-4 w-4 transition-all duration-300 ${getCategoryColor(section.title)} ${
|
|
isOpen ? 'rotate-180 scale-110' : ''
|
|
}`}
|
|
/>
|
|
</button>
|
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
|
isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'
|
|
}`}>
|
|
<SidebarMenu className="px-2 py-1 animate-in slide-in-from-top-2">
|
|
{section.items.map(item => {
|
|
const isActive = location === item.path;
|
|
const hasSubItems = 'subItems' in item && item.subItems && Array.isArray(item.subItems) && item.subItems.length > 0;
|
|
const isSubItemActive = hasSubItems && Array.isArray(item.subItems) && item.subItems.some((sub: any) => location === sub.path);
|
|
return (
|
|
<div key={item.path}>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
isActive={isActive || Boolean(isSubItemActive)}
|
|
onClick={() => setLocation(item.path)}
|
|
tooltip={item.label}
|
|
className={`h-11 transition-all duration-300 font-normal hover:translate-x-2 hover:shadow-md ${
|
|
isActive || isSubItemActive
|
|
? `bg-gradient-to-r ${getCategoryGradient(section.title)} text-white shadow-lg scale-105 border-l-4 border-white/30`
|
|
: 'hover:bg-accent/70'
|
|
}`}
|
|
>
|
|
<div className={`p-1.5 rounded-md transition-all duration-300 ${
|
|
isActive || isSubItemActive
|
|
? 'bg-white/20 scale-110'
|
|
: `bg-gradient-to-br ${getCategoryGradient(section.title)}`
|
|
}`}>
|
|
<item.icon
|
|
className={`h-4 w-4 ${isActive || isSubItemActive ? "text-white" : "text-white"}`}
|
|
/>
|
|
</div>
|
|
<span>{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
{(hasSubItems && Array.isArray(item.subItems) ? (
|
|
<div className="ml-6 mt-1 space-y-1">
|
|
{(item.subItems as any[]).map((subItem: any) => {
|
|
const isSubActive = location === subItem.path;
|
|
return (
|
|
<SidebarMenuItem key={subItem.path}>
|
|
<SidebarMenuButton
|
|
isActive={isSubActive}
|
|
onClick={() => setLocation(subItem.path)}
|
|
tooltip={subItem.label}
|
|
className="h-9 text-sm font-normal"
|
|
>
|
|
<span className="ml-2">{subItem.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null)}
|
|
</div>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter className="p-3">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
<Avatar className="h-9 w-9 border shrink-0">
|
|
<AvatarFallback className="text-xs font-medium">
|
|
{user?.name?.charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
|
<p className="text-sm font-medium truncate leading-none">
|
|
{user?.name || "-"}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
|
{user?.email || "-"}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<DropdownMenuItem
|
|
onClick={async () => {
|
|
await logout();
|
|
setLocation('/');
|
|
}}
|
|
className="cursor-pointer text-destructive focus:text-destructive"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span>Déconnexion</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarFooter>
|
|
</Sidebar>
|
|
<div
|
|
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
|
onMouseDown={() => {
|
|
if (isCollapsed) return;
|
|
setIsResizing(true);
|
|
}}
|
|
style={{ zIndex: 50 }}
|
|
/>
|
|
</div>
|
|
|
|
<SidebarInset>
|
|
{isMobile && (
|
|
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
|
|
<div className="flex items-center gap-2">
|
|
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="tracking-tight text-foreground">
|
|
{activeMenuItem?.label ?? APP_TITLE}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<main className="flex-1 p-4">{children}</main>
|
|
</SidebarInset>
|
|
</>
|
|
);
|
|
}
|