Files
formation-manager-itinova/client/src/components/DashboardLayout.tsx
Manus b64dedc488 Checkpoint: Système d'émargement numérique avec QR code et génération automatique d'attestations :
## Base de données
-  Table `presences` créée (inscriptionId, dateFormationId, heurePresence, modeValidation, validateurId)
-  Colonne `qrCodeToken` ajoutée à la table `sequences`

## Backend
-  Génération de QR code unique par séquence (qrCodeGenerator.ts)
-  Procédures tRPC pour l'émargement (presences.generateQRCode, presences.valider, presences.listBySequence)
-  Fonctions de base de données (presenceDb.ts) pour gérer les présences
-  Génération automatique d'attestation après validation de toutes les présences
-  Intégration avec le service d'attestations existant

## Interface formateur (/formateur/emargement)
-  Sélection de la séquence
-  Génération et affichage du QR code en modal
-  Liste des inscrits avec statut de présence par date
-  Validation manuelle de présence possible
-  Actualisation en temps réel des présences
-  Entrée de menu "Émargement" dans DashboardLayout

## Interface publique (/emargement/:token)
-  Page de scan QR code accessible sans authentification
-  Recherche de l'apprenant par email
-  Sélection de la formation et de la date
-  Validation automatique de la présence via QR code
-  Écrans de confirmation (succès/erreur)
-  Gestion des erreurs (QR invalide, déjà émargé, etc.)

## Génération automatique d'attestations
-  Vérification automatique après chaque validation de présence
-  Génération du PDF d'attestation si toutes les présences sont validées
-  Enregistrement en base de données
-  Upload automatique sur S3

## Dépendances installées
- qrcode (génération de QR codes)
- @types/qrcode (types TypeScript)

Le système est entièrement fonctionnel et prêt à être utilisé par les formateurs et apprenants.
2026-01-11 14:01:22 -05:00

475 lines
18 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 } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
// Couleurs par catégorie de menu
const getCategoryColor = (sectionTitle: string) => {
const colors: Record<string, string> = {
"Tableau de bord": "text-blue-600",
"Gestion": "text-green-600",
"Configuration": "text-orange-600",
"Traçabilité": "text-purple-600",
"Analyses": "text-pink-600",
};
return colors[sectionTitle] || "";
};
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" },
]
},
{
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: 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" },
]
},
{
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" },
]
},
];
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');
}
}, [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 ni Traçabilité
if (user?.role === 'formateur') {
return section.title !== 'Configuration' && section.title !== 'Traçabilité' && section.title !== 'Analyses';
}
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 = 3; // Incrémenté pour forcer la réinitialisation après le bug formateur
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 : toutes les sections sont ouvertes par défaut
// Cela garantit que le menu est visible pour tous les rôles
const initial: Record<string, boolean> = {};
menuSections.forEach(section => {
// Ouvrir toutes les sections par défaut
initial[section.title] = true;
});
// 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
const toggleSection = (sectionTitle: string) => {
setOpenSections(prev => {
const newState = {
...prev,
[sectionTitle]: !prev[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-4 py-2 flex items-center justify-between hover:bg-accent/50 transition-colors rounded-md mx-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<h3 className={`text-xs font-semibold uppercase tracking-wider ${getCategoryColor(section.title)}`}>
{section.title}
</h3>
<ChevronDown
className={`h-3 w-3 transition-transform duration-200 ${getCategoryColor(section.title)} ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{isOpen && (
<SidebarMenu className="px-2 py-1">
{section.items.map(item => {
const isActive = location === item.path;
const hasSubItems = 'subItems' in item && item.subItems && item.subItems.length > 0;
const isSubItemActive = hasSubItems && item.subItems.some((sub: any) => location === sub.path);
return (
<div key={item.path}>
<SidebarMenuItem>
<SidebarMenuButton
isActive={isActive || isSubItemActive}
onClick={() => setLocation(item.path)}
tooltip={item.label}
className={`h-10 transition-all font-normal`}
>
<item.icon
className={`h-4 w-4 ${isActive || isSubItemActive ? "text-primary" : getCategoryColor(section.title)}`}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
{hasSubItems && (
<div className="ml-6 mt-1 space-y-1">
{item.subItems.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>
)}
</div>
);
})}
</SidebarMenu>
)}
</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>
</>
);
}