From 5c9b6637c47510ae05e9a77ff5443096441d258c Mon Sep 17 00:00:00 2001 From: Manus Sandbox Date: Wed, 12 Nov 2025 09:57:58 -0500 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Refonte=20majeure=20:=20transform?= =?UTF-8?q?ation=20du=20syst=C3=A8me=20de=20sessions=20en=20s=C3=A9quences?= =?UTF-8?q?=20avec=20support=20de=20jusqu'=C3=A0=204=20dates=20par=20s?= =?UTF-8?q?=C3=A9quence.=20L'inscription=20=C3=A0=20une=20s=C3=A9quence=20?= =?UTF-8?q?inscrit=20automatiquement=20l'apprenant=20=C3=A0=20toutes=20les?= =?UTF-8?q?=20dates.=20Mise=20=C3=A0=20jour=20compl=C3=A8te=20du=20backend?= =?UTF-8?q?=20(sch=C3=A9ma=20DB,=20proc=C3=A9dures=20tRPC,=20emails,=20exp?= =?UTF-8?q?orts)=20et=20du=20frontend=20(AdminSequences,=20AdminSequenceIn?= =?UTF-8?q?scrits,=20Inscription,=20Admin).=20Toutes=20les=20fonctionnalit?= =?UTF-8?q?=C3=A9s=20existantes=20(filtres,=20statistiques,=20exports=20PD?= =?UTF-8?q?F/Excel,=20emails=20automatiques)=20sont=20pr=C3=A9serv=C3=A9es?= =?UTF-8?q?=20et=20adapt=C3=A9es=20au=20nouveau=20mod=C3=A8le.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 8 +- client/src/components/DashboardLayout.tsx | 2 +- client/src/pages/Admin.tsx | 14 +- client/src/pages/AdminSequenceInscrits.tsx | 512 +++++++++++++++ client/src/pages/AdminSequences.tsx | 731 +++++++++++++++++++++ client/src/pages/AdminSessionInscrits.tsx | 452 ------------- client/src/pages/AdminSessions.tsx | 504 -------------- client/src/pages/ComponentShowcase.tsx | 2 +- client/src/pages/Home.tsx | 12 +- client/src/pages/Inscription.tsx | 66 +- drizzle/0003_steady_mystique.sql | 32 + drizzle/meta/0003_snapshot.json | 485 ++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 57 +- migrate-to-sequences.sh | 21 + server/db.ts | 284 ++++---- server/emailService.ts | 139 ++-- server/exportService.ts | 170 +++-- server/routers.ts | 343 ++++++---- todo.md | 14 + 20 files changed, 2445 insertions(+), 1410 deletions(-) create mode 100644 client/src/pages/AdminSequenceInscrits.tsx create mode 100644 client/src/pages/AdminSequences.tsx delete mode 100644 client/src/pages/AdminSessionInscrits.tsx delete mode 100644 client/src/pages/AdminSessions.tsx create mode 100644 drizzle/0003_steady_mystique.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100755 migrate-to-sequences.sh diff --git a/client/src/App.tsx b/client/src/App.tsx index f8d07b7..de2ba01 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,9 +7,9 @@ import { ThemeProvider } from "./contexts/ThemeContext"; import Home from "./pages/Home"; import Admin from "./pages/Admin"; import AdminFormations from "./pages/AdminFormations"; -import AdminSessions from "./pages/AdminSessions"; +import AdminSequences from "./pages/AdminSequences"; import AdminApprenants from "./pages/AdminApprenants"; -import AdminSessionInscrits from "./pages/AdminSessionInscrits"; +import AdminSequenceInscrits from "./pages/AdminSequenceInscrits"; import Inscription from "./pages/Inscription"; function Router() { @@ -19,9 +19,9 @@ function Router() { - + - + {/* Final fallback route */} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 790419e..2cce05c 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -30,7 +30,7 @@ import { Button } from "./ui/button"; const menuItems = [ { icon: LayoutDashboard, label: "Tableau de bord", path: "/admin" }, { icon: GraduationCap, label: "Formations", path: "/admin/formations" }, - { icon: Calendar, label: "Sessions", path: "/admin/sessions" }, + { icon: Calendar, label: "Séquences", path: "/admin/sequences" }, { icon: Users, label: "Apprenants", path: "/admin/apprenants" }, ]; diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx index 5b45a36..614a545 100644 --- a/client/src/pages/Admin.tsx +++ b/client/src/pages/Admin.tsx @@ -8,7 +8,7 @@ import { useMemo } from "react"; export default function Admin() { const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery(); - const { data: sessions, isLoading: loadingSessions } = trpc.sessions.list.useQuery(); + const { data: sequences, isLoading: loadingSessions } = trpc.sequences.list.useQuery(); const { data: apprenants, isLoading: loadingApprenants } = trpc.apprenants.list.useQuery(); // Statistiques par fonction @@ -34,8 +34,8 @@ export default function Admin() { loading: loadingFormations, }, { - title: "Sessions ouvertes", - value: sessions?.filter(s => s.statut === "ouverte").length || 0, + title: "Séquences ouvertes", + value: sequences?.filter(s => s.statut === "ouverte").length || 0, icon: , loading: loadingSessions, }, @@ -46,8 +46,8 @@ export default function Admin() { loading: loadingApprenants, }, { - title: "Sessions terminées", - value: sessions?.filter(s => s.statut === "terminee").length || 0, + title: "Séquences terminées", + value: sequences?.filter(s => s.statut === "terminee").length || 0, icon: , loading: loadingSessions, }, @@ -132,7 +132,7 @@ export default function Admin() { Bienvenue dans l'espace d'administration - Gérez vos formations, sessions et apprenants depuis le menu de navigation. + Gérez vos formations, sequences et apprenants depuis le menu de navigation. @@ -140,7 +140,7 @@ export default function Admin() {

Fonctionnalités principales :

  • Créer et gérer les formations avec liens d'inscription uniques
  • -
  • Organiser les sessions avec limitation à 12 participants
  • +
  • Organiser les sequences avec limitation à 12 participants
  • Gérer les inscriptions avec blocage automatique à J-15
  • Exporter les listes d'inscrits en PDF ou Excel
  • Générer des invitations Outlook et envoyer des emails automatiques
  • diff --git a/client/src/pages/AdminSequenceInscrits.tsx b/client/src/pages/AdminSequenceInscrits.tsx new file mode 100644 index 0000000..ce745a6 --- /dev/null +++ b/client/src/pages/AdminSequenceInscrits.tsx @@ -0,0 +1,512 @@ +import DashboardLayout from "@/components/DashboardLayout"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { trpc } from "@/lib/trpc"; +import { ArrowLeft, Download, Mail, Search } from "lucide-react"; +import { useLocation, useRoute } from "wouter"; +import { useState, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "sonner"; + +export default function AdminSequenceInscrits() { + const [, setLocation] = useLocation(); + const [, params] = useRoute("/admin/sequences/:id/inscrits"); + const sequenceId = params?.id ? parseInt(params.id) : 0; + + // États pour les filtres + const [searchTerm, setSearchTerm] = useState(""); + const [filterStatut, setFilterStatut] = useState("all"); + const [filterEtablissement, setFilterEtablissement] = useState("all"); + const [filterFonction, setFilterFonction] = useState("all"); + const [sortBy, setSortBy] = useState<"date" | "nom">("date"); + + const utils = trpc.useUtils(); + const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId }); + const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySequence.useQuery({ sequenceId }); + const { data: formations } = trpc.formations.list.useQuery(); + + const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({ + onSuccess: () => { + utils.inscriptions.listBySequence.invalidate({ sequenceId }); + toast.success("Statut mis à jour avec succès"); + }, + onError: (error) => { + toast.error("Erreur lors de la mise à jour : " + error.message); + }, + }); + + const sendEmailsMutation = trpc.inscriptions.sendGroupEmail.useMutation({ + onSuccess: (result) => { + toast.success(`Emails envoyés : ${result.sent} réussis, ${result.failed} échecs`); + }, + onError: (error) => { + toast.error("Erreur lors de l'envoi : " + error.message); + }, + }); + + const handleStatutChange = (inscriptionId: number, newStatut: string) => { + updateStatutMutation.mutate({ + id: inscriptionId, + statut: newStatut as "confirmee" | "liste_attente" | "annulee", + }); + }; + + const exportExcelMutation = trpc.inscriptions.exportExcel.useMutation({ + onSuccess: (result) => { + const blob = new Blob([Uint8Array.from(atob(result.buffer), c => c.charCodeAt(0))], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.filename; + a.click(); + window.URL.revokeObjectURL(url); + toast.success("Export Excel téléchargé"); + }, + onError: (error) => { + toast.error("Erreur lors de l'export : " + error.message); + }, + }); + + const exportPDFMutation = trpc.inscriptions.exportPDF.useMutation({ + onSuccess: (result) => { + const blob = new Blob([Uint8Array.from(atob(result.buffer), c => c.charCodeAt(0))], { + type: 'application/pdf' + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.filename; + a.click(); + window.URL.revokeObjectURL(url); + toast.success("Export PDF téléchargé"); + }, + onError: (error) => { + toast.error("Erreur lors de l'export : " + error.message); + }, + }); + + const exportFeuilleMutation = trpc.inscriptions.exportFeuillePresence.useMutation({ + onSuccess: (result) => { + const blob = new Blob([Uint8Array.from(atob(result.buffer), c => c.charCodeAt(0))], { + type: 'application/pdf' + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.filename; + a.click(); + window.URL.revokeObjectURL(url); + toast.success("Feuille de présence téléchargée"); + }, + onError: (error) => { + toast.error("Erreur lors de l'export : " + error.message); + }, + }); + + const handleExportExcel = () => { + exportExcelMutation.mutate({ sequenceId }); + }; + + const handleExportPDF = () => { + exportPDFMutation.mutate({ sequenceId }); + }; + + const handleExportFeuille = () => { + exportFeuilleMutation.mutate({ sequenceId }); + }; + + const handleSendEmails = (type: "teaser" | "rappel") => { + if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${type === "teaser" ? "teaser" : "de rappel J-7"} à tous les inscrits confirmés ?`)) { + sendEmailsMutation.mutate({ sequenceId, type }); + } + }; + + // Filtrage et tri + const filteredAndSortedInscriptions = useMemo(() => { + if (!inscriptions) return []; + + let filtered = inscriptions.filter((item) => { + if (!item.apprenant) return false; + + const matchSearch = + item.apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) || + item.apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) || + item.apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchStatut = filterStatut === "all" || item.inscription.statut === filterStatut; + const matchEtablissement = filterEtablissement === "all" || item.apprenant.codeEtablissement === filterEtablissement; + const matchFonction = filterFonction === "all" || item.apprenant.fonction === filterFonction; + + return matchSearch && matchStatut && matchEtablissement && matchFonction; + }); + + filtered.sort((a, b) => { + if (sortBy === "date") { + return new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime(); + } else { + return (a.apprenant?.nom || "").localeCompare(b.apprenant?.nom || ""); + } + }); + + return filtered; + }, [inscriptions, searchTerm, filterStatut, filterEtablissement, filterFonction, sortBy]); + + const uniqueEtablissements = useMemo(() => { + if (!inscriptions) return []; + return Array.from(new Set(inscriptions.map((i) => i.apprenant?.codeEtablissement).filter(Boolean))); + }, [inscriptions]); + + const getFormationName = () => { + if (!sequence || !formations) return "N/A"; + return formations.find((f) => f.id === sequence.formationId)?.nom || "N/A"; + }; + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const getStatutBadge = (statut: string) => { + const colors = { + confirmee: "bg-green-100 text-green-800", + liste_attente: "bg-orange-100 text-orange-800", + annulee: "bg-gray-100 text-gray-800", + }; + const labels = { + confirmee: "Confirmée", + liste_attente: "Liste d'attente", + annulee: "Annulée", + }; + return { + color: colors[statut as keyof typeof colors] || colors.confirmee, + label: labels[statut as keyof typeof labels] || statut, + }; + }; + + const getFonctionLabel = (fonction: string) => { + const labels = { + directeur: "Directeur", + chef_service: "Chef de service", + autre: "Autre", + }; + return labels[fonction as keyof typeof labels] || fonction; + }; + + if (loadingSequence || loadingInscriptions) { + return ( + +
    + + +
    +
    + ); + } + + if (!sequence) { + return ( + +
    + + + Séquence introuvable + La séquence demandée n'existe pas. + + + + + +
    +
    + ); + } + + const nbConfirmes = inscriptions?.filter((i) => i.inscription.statut === "confirmee").length || 0; + const nbListeAttente = inscriptions?.filter((i) => i.inscription.statut === "liste_attente").length || 0; + + return ( + +
    +
    +
    + +

    Inscrits à la séquence

    +

    + {getFormationName()} - {sequence.nom} +

    +
    +
    + + {/* Informations de la séquence */} + + + Informations de la séquence + + +
    +
    +

    Formation

    +

    {getFormationName()}

    +
    +
    +

    Nom de la séquence

    +

    {sequence.nom}

    +
    +
    +

    Lieu

    +

    {sequence.lieu}

    +
    +
    +

    Capacité

    +

    + {nbConfirmes} / {sequence.capaciteMax} inscrits confirmés +

    +
    +
    + +
    +

    Dates de formation

    +
    + {sequence.dates.map((date: any) => ( +
    + Date {date.ordre}: + + Du {formatDate(date.dateDebut)} au {formatDate(date.dateFin)} + +
    + ))} +
    +
    +
    +
    + + {/* Actions */} + + + Actions + Envoi d'emails et exports + + +
    + + + + + +
    +
    +
    + + {/* Filtres */} + + + + + Filtres et recherche + + + +
    +
    + + setSearchTerm(e.target.value)} + /> +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +

    + {filteredAndSortedInscriptions.length} inscription(s) trouvée(s) sur {inscriptions?.length || 0} au total +

    +
    +
    + + {/* Liste des inscrits */} + + + Liste des inscrits + + {nbConfirmes} confirmé(s), {nbListeAttente} en liste d'attente + + + + {filteredAndSortedInscriptions.length === 0 ? ( +
    + Aucune inscription trouvée +
    + ) : ( +
    + + + + Nom + Prénom + Email + Code étab. + Fonction + Date inscription + Statut + Actions + + + + {filteredAndSortedInscriptions.map((item) => { + if (!item.apprenant) return null; + const statutInfo = getStatutBadge(item.inscription.statut); + + return ( + + {item.apprenant.nom} + {item.apprenant.prenom} + {item.apprenant.email} + {item.apprenant.codeEtablissement} + {getFonctionLabel(item.apprenant.fonction)} + + {formatDate(item.inscription.dateInscription)} + + + + {statutInfo.label} + + + + + + + ); + })} + +
    +
    + )} +
    +
    +
    +
    + ); +} diff --git a/client/src/pages/AdminSequences.tsx b/client/src/pages/AdminSequences.tsx new file mode 100644 index 0000000..dd6d672 --- /dev/null +++ b/client/src/pages/AdminSequences.tsx @@ -0,0 +1,731 @@ +import { useState, useMemo } from "react"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { trpc } from "@/lib/trpc"; +import { Calendar, Edit, Eye, Plus, Trash2, X } from "lucide-react"; +import { toast } from "sonner"; +import { useLocation } from "wouter"; +import { format } from "date-fns"; + +interface DateFormation { + dateDebut: string; + dateFin: string; + ordre: number; +} + +export default function AdminSequences() { + const [, setLocation] = useLocation(); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [editingSequence, setEditingSequence] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [filterLieu, setFilterLieu] = useState("all"); + const [filterStatut, setFilterStatut] = useState("all"); + const [sortBy, setSortBy] = useState<"date" | "lieu" | "capacite">("date"); + + const [formData, setFormData] = useState({ + formationId: "", + nom: "", + lieu: "", + capaciteMax: "12", + dateBlocage: "", + statut: "ouverte" as "ouverte" | "bloquee" | "terminee", + dates: [ + { dateDebut: "", dateFin: "", ordre: 1 } + ] as DateFormation[], + }); + + const utils = trpc.useUtils(); + const { data: formations } = trpc.formations.list.useQuery(); + const { data: sequences, isLoading } = trpc.sequences.list.useQuery(); + + const createMutation = trpc.sequences.create.useMutation({ + onSuccess: () => { + toast.success("Séquence créée avec succès"); + setIsCreateOpen(false); + resetForm(); + utils.sequences.list.invalidate(); + }, + onError: (error) => { + toast.error(`Erreur : ${error.message}`); + }, + }); + + const updateMutation = trpc.sequences.update.useMutation({ + onSuccess: () => { + toast.success("Séquence modifiée avec succès"); + setIsEditOpen(false); + setEditingSequence(null); + utils.sequences.list.invalidate(); + }, + onError: (error) => { + toast.error(`Erreur : ${error.message}`); + }, + }); + + const deleteMutation = trpc.sequences.delete.useMutation({ + onSuccess: () => { + toast.success("Séquence supprimée avec succès"); + utils.sequences.list.invalidate(); + }, + onError: (error) => { + toast.error(`Erreur : ${error.message}`); + }, + }); + + const resetForm = () => { + setFormData({ + formationId: "", + nom: "", + lieu: "", + capaciteMax: "12", + dateBlocage: "", + statut: "ouverte", + dates: [{ dateDebut: "", dateFin: "", ordre: 1 }], + }); + }; + + const handleEdit = (sequence: any) => { + setEditingSequence(sequence); + setFormData({ + formationId: sequence.formationId.toString(), + nom: sequence.nom, + lieu: sequence.lieu, + capaciteMax: sequence.capaciteMax.toString(), + dateBlocage: format(new Date(sequence.dateBlocage), "yyyy-MM-dd'T'HH:mm"), + statut: sequence.statut, + dates: sequence.dates.map((d: any) => ({ + dateDebut: format(new Date(d.dateDebut), "yyyy-MM-dd'T'HH:mm"), + dateFin: format(new Date(d.dateFin), "yyyy-MM-dd'T'HH:mm"), + ordre: d.ordre, + })), + }); + setIsEditOpen(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.dates.length) { + toast.error("Veuillez ajouter au moins une date de formation"); + return; + } + + const data = { + formationId: parseInt(formData.formationId), + nom: formData.nom, + lieu: formData.lieu, + capaciteMax: parseInt(formData.capaciteMax), + dateBlocage: formData.dateBlocage, + statut: formData.statut, + dates: formData.dates, + }; + + if (editingSequence) { + updateMutation.mutate({ id: editingSequence.id, ...data }); + } else { + createMutation.mutate(data); + } + }; + + const handleDelete = (id: number) => { + if (confirm("Êtes-vous sûr de vouloir supprimer cette séquence ?")) { + deleteMutation.mutate({ id }); + } + }; + + const addDate = () => { + if (formData.dates.length >= 4) { + toast.error("Maximum 4 dates par séquence"); + return; + } + setFormData({ + ...formData, + dates: [...formData.dates, { dateDebut: "", dateFin: "", ordre: formData.dates.length + 1 }], + }); + }; + + const removeDate = (index: number) => { + if (formData.dates.length <= 1) { + toast.error("Au moins une date est requise"); + return; + } + const newDates = formData.dates.filter((_, i) => i !== index); + // Réordonner les dates + newDates.forEach((d, i) => d.ordre = i + 1); + setFormData({ ...formData, dates: newDates }); + }; + + const updateDate = (index: number, field: "dateDebut" | "dateFin", value: string) => { + const newDates = [...formData.dates]; + newDates[index][field] = value; + setFormData({ ...formData, dates: newDates }); + }; + + // Filtrage et tri + const filteredAndSortedSequences = useMemo(() => { + if (!sequences) return []; + + let filtered = sequences.filter((seq) => { + const matchSearch = seq.nom.toLowerCase().includes(searchTerm.toLowerCase()); + const matchLieu = filterLieu === "all" || seq.lieu === filterLieu; + const matchStatut = filterStatut === "all" || seq.statut === filterStatut; + return matchSearch && matchLieu && matchStatut; + }); + + filtered.sort((a, b) => { + if (sortBy === "date") { + const dateA = a.dates[0] ? new Date(a.dates[0].dateDebut).getTime() : 0; + const dateB = b.dates[0] ? new Date(b.dates[0].dateDebut).getTime() : 0; + return dateA - dateB; + } else if (sortBy === "lieu") { + return a.lieu.localeCompare(b.lieu); + } else { + return b.capaciteMax - a.capaciteMax; + } + }); + + return filtered; + }, [sequences, searchTerm, filterLieu, filterStatut, sortBy]); + + const uniqueLieux = useMemo(() => { + if (!sequences) return []; + return Array.from(new Set(sequences.map((s) => s.lieu))); + }, [sequences]); + + const getFormationName = (formationId: number) => { + return formations?.find((f) => f.id === formationId)?.nom || "N/A"; + }; + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const getStatutBadge = (statut: string) => { + const colors = { + ouverte: "bg-green-100 text-green-800", + bloquee: "bg-orange-100 text-orange-800", + terminee: "bg-gray-100 text-gray-800", + }; + return colors[statut as keyof typeof colors] || colors.ouverte; + }; + + return ( + +
    +
    +
    +

    Gestion des Séquences

    +

    + Créez et gérez les séquences de formation avec leurs dates +

    +
    + + + + + + + Créer une nouvelle séquence + + Remplissez les informations de la séquence et ajoutez jusqu'à 4 dates de formation + + +
    +
    + + +
    + +
    + + setFormData({ ...formData, nom: e.target.value })} + placeholder="Ex: Séquence 1 - Février 2026" + required + /> +
    + +
    +
    + + setFormData({ ...formData, lieu: e.target.value })} + placeholder="Ex: Salle de formation A" + required + /> +
    + +
    + + setFormData({ ...formData, capaciteMax: e.target.value })} + required + /> +
    +
    + +
    +
    + + setFormData({ ...formData, dateBlocage: e.target.value })} + required + /> +
    + +
    + + +
    +
    + +
    +
    + + +
    + + {formData.dates.map((date, index) => ( + + +
    + Date {date.ordre} + {formData.dates.length > 1 && ( + + )} +
    +
    + +
    +
    + + updateDate(index, "dateDebut", e.target.value)} + required + /> +
    +
    + + updateDate(index, "dateFin", e.target.value)} + required + /> +
    +
    +
    +
    + ))} +
    + +
    + + +
    +
    +
    +
    +
    + + {/* Filtres */} + + + Filtres et recherche + + +
    +
    + + setSearchTerm(e.target.value)} + /> +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +

    + {filteredAndSortedSequences.length} séquence(s) trouvée(s) +

    +
    +
    + + {/* Liste des séquences */} + + + + + Liste des séquences + + + {sequences?.length || 0} séquence(s) au total + + + + {isLoading ? ( +
    Chargement...
    + ) : filteredAndSortedSequences.length === 0 ? ( +
    + Aucune séquence trouvée +
    + ) : ( +
    + + + + Formation + Nom + Dates + Lieu + Capacité + Statut + Actions + + + + {filteredAndSortedSequences.map((sequence) => ( + + {getFormationName(sequence.formationId)} + {sequence.nom} + +
    + {sequence.dates.map((date: any) => ( +
    + Date {date.ordre}:{" "} + {formatDate(date.dateDebut)} +
    + ))} +
    +
    + {sequence.lieu} + {sequence.capaciteMax} + + + {sequence.statut} + + + +
    + + + +
    +
    +
    + ))} +
    +
    +
    + )} +
    +
    + + {/* Dialog d'édition */} + + + + Modifier la séquence + + Modifiez les informations de la séquence et ses dates + + +
    +
    + + +
    + +
    + + setFormData({ ...formData, nom: e.target.value })} + required + /> +
    + +
    +
    + + setFormData({ ...formData, lieu: e.target.value })} + required + /> +
    + +
    + + setFormData({ ...formData, capaciteMax: e.target.value })} + required + /> +
    +
    + +
    +
    + + setFormData({ ...formData, dateBlocage: e.target.value })} + required + /> +
    + +
    + + +
    +
    + +
    +
    + + +
    + + {formData.dates.map((date, index) => ( + + +
    + Date {date.ordre} + {formData.dates.length > 1 && ( + + )} +
    +
    + +
    +
    + + updateDate(index, "dateDebut", e.target.value)} + required + /> +
    +
    + + updateDate(index, "dateFin", e.target.value)} + required + /> +
    +
    +
    +
    + ))} +
    + +
    + + +
    +
    +
    +
    +
    +
    + ); +} diff --git a/client/src/pages/AdminSessionInscrits.tsx b/client/src/pages/AdminSessionInscrits.tsx deleted file mode 100644 index fcdc043..0000000 --- a/client/src/pages/AdminSessionInscrits.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import DashboardLayout from "@/components/DashboardLayout"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { trpc } from "@/lib/trpc"; -import { ArrowLeft, Download, Mail, Search, SlidersHorizontal } from "lucide-react"; -import { useLocation, useRoute } from "wouter"; -import { useState, useMemo } from "react"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; -import { toast } from "sonner"; - -export default function AdminSessionInscrits() { - const [, setLocation] = useLocation(); - const [, params] = useRoute("/admin/sessions/:id/inscrits"); - const sessionId = params?.id ? parseInt(params.id) : 0; - - // États pour les filtres - const [searchTerm, setSearchTerm] = useState(""); - const [filterStatut, setFilterStatut] = useState("all"); - const [filterEtablissement, setFilterEtablissement] = useState("all"); - const [filterFonction, setFilterFonction] = useState("all"); - const [sortBy, setSortBy] = useState<"date" | "nom">("date"); - - const utils = trpc.useUtils(); - const { data: session, isLoading: loadingSession } = trpc.sessions.getById.useQuery({ id: sessionId }); - const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySession.useQuery({ sessionId }); - const { data: formations } = trpc.formations.list.useQuery(); - - const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({ - onSuccess: () => { - utils.inscriptions.listBySession.invalidate({ sessionId }); - toast.success("Statut mis à jour avec succès"); - }, - onError: (error) => { - toast.error("Erreur lors de la mise à jour : " + error.message); - }, - }); - - const sendEmailsMutation = trpc.inscriptions.sendGroupEmails.useMutation({ - onSuccess: (result) => { - toast.success(`Emails envoyés : ${result.sent} réussis, ${result.failed} échecs`); - }, - onError: (error) => { - toast.error("Erreur lors de l'envoi : " + error.message); - }, - }); - - const handleStatutChange = (inscriptionId: number, newStatut: string) => { - updateStatutMutation.mutate({ - id: inscriptionId, - statut: newStatut as "confirmee" | "liste_attente" | "annulee", - }); - }; - - const exportExcelMutation = trpc.inscriptions.exportExcel.useMutation({ - onSuccess: (result) => { - const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `inscrits-session-${sessionId}.xlsx`; - a.click(); - window.URL.revokeObjectURL(url); - toast.success("Export Excel téléchargé"); - }, - onError: (error) => { - toast.error("Erreur lors de l'export : " + error.message); - }, - }); - - const exportPDFMutation = trpc.inscriptions.exportPDF.useMutation({ - onSuccess: (result) => { - const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { - type: 'application/pdf' - }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `inscrits-session-${sessionId}.pdf`; - a.click(); - window.URL.revokeObjectURL(url); - toast.success("Export PDF téléchargé"); - }, - onError: (error) => { - toast.error("Erreur lors de l'export : " + error.message); - }, - }); - - const exportFeuilleMutation = trpc.inscriptions.exportFeuillePresence.useMutation({ - onSuccess: (result) => { - const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { - type: 'application/pdf' - }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `feuille-presence-session-${sessionId}.pdf`; - a.click(); - window.URL.revokeObjectURL(url); - toast.success("Feuille de présence téléchargée"); - }, - onError: (error) => { - toast.error("Erreur lors de la génération : " + error.message); - }, - }); - - const handleExportExcel = () => { - exportExcelMutation.mutate({ sessionId }); - }; - - const handleExportPDF = () => { - exportPDFMutation.mutate({ sessionId }); - }; - - const handleGenerateFeuillePresence = () => { - exportFeuilleMutation.mutate({ sessionId }); - }; - - const getFormationName = (formationId: number) => { - return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue"; - }; - - // Extraire les codes établissement uniques - const uniqueEtablissements = useMemo(() => { - if (!inscriptions) return []; - const codes = inscriptions - .filter(i => i.apprenant) - .map(i => i.apprenant!.codeEtablissement); - return Array.from(new Set(codes)).sort(); - }, [inscriptions]); - - // Filtrer et trier les inscriptions - const filteredAndSortedInscriptions = useMemo(() => { - if (!inscriptions) return []; - - let filtered = inscriptions.filter(inscription => { - // Filtre par recherche (nom, prénom, email) - const matchesSearch = searchTerm === "" || - inscription.apprenant?.nom.toLowerCase().includes(searchTerm.toLowerCase()) || - inscription.apprenant?.prenom.toLowerCase().includes(searchTerm.toLowerCase()) || - inscription.apprenant?.email.toLowerCase().includes(searchTerm.toLowerCase()); - - // Filtre par statut - const matchesStatut = filterStatut === "all" || inscription.inscription.statut === filterStatut; - - // Filtre par établissement - const matchesEtablissement = filterEtablissement === "all" || - inscription.apprenant?.codeEtablissement === filterEtablissement; - - // Filtre par fonction - const matchesFonction = filterFonction === "all" || - inscription.apprenant?.fonction === filterFonction; - - return matchesSearch && matchesStatut && matchesEtablissement && matchesFonction; - }); - - // Tri - filtered.sort((a, b) => { - if (sortBy === "date") { - return new Date(b.inscription.dateInscription).getTime() - new Date(a.inscription.dateInscription).getTime(); - } else if (sortBy === "nom") { - return (a.apprenant?.nom || "").localeCompare(b.apprenant?.nom || ""); - } - return 0; - }); - - return filtered; - }, [inscriptions, searchTerm, filterStatut, filterEtablissement, filterFonction, sortBy]); - - const getStatutBadge = (statut: string) => { - const styles = { - confirmee: "bg-green-100 text-green-800", - liste_attente: "bg-orange-100 text-orange-800", - annulee: "bg-gray-100 text-gray-800", - }; - const labels = { - confirmee: "Confirmée", - liste_attente: "Liste d'attente", - annulee: "Annulée", - }; - return ( - - {labels[statut as keyof typeof labels]} - - ); - }; - - const inscritsConfirmes = inscriptions?.filter(i => i.inscription.statut === "confirmee") || []; - const listeAttente = inscriptions?.filter(i => i.inscription.statut === "liste_attente") || []; - - return ( - -
    -
    - -
    -

    Inscrits à la session

    - {session && ( -

    - {getFormationName(session.formationId)} - {session.nom} -

    - )}
    -
    - - {/* Filtres et recherche */} - - - - - Filtres et recherche - - - -
    - {/* Recherche */} -
    - -
    - - setSearchTerm(e.target.value)} - className="pl-8" - /> -
    -
    - - {/* Filtre par statut */} -
    - - -
    - - {/* Filtre par établissement */} -
    - - -
    - - {/* Filtre par fonction */} -
    - - -
    - - {/* Tri */} -
    - - -
    -
    - - {/* Compteur de résultats */} -
    - {filteredAndSortedInscriptions.length} inscrit(s) trouvé(s) sur {inscriptions?.length || 0} total -
    -
    -
    - - {loadingSession || loadingInscriptions ? ( -
    - - -
    - ) : session ? ( - <> -
    - - - Places confirmées - - -
    - {inscritsConfirmes.length} / {session.capaciteMax} -
    -

    - {session.capaciteMax - inscritsConfirmes.length} places restantes -

    -
    -
    - - - - Liste d'attente - - -
    {listeAttente.length}
    -

    - En attente de places -

    -
    -
    - - - - Total inscriptions - - -
    {inscriptions?.length || 0}
    -

    - Toutes statuts confondus -

    -
    -
    -
    - -
    - - - - - -
    - - - - Liste des inscrits - - Gérez les inscriptions et leurs statuts - - - - {filteredAndSortedInscriptions && filteredAndSortedInscriptions.length > 0 ? ( - - - - Nom - Prénom - Email - Code établissement - Date d'inscription - Statut - Actions - - - - {filteredAndSortedInscriptions.map((item) => ( - - {item.apprenant?.nom} - {item.apprenant?.prenom} - {item.apprenant?.email} - {item.apprenant?.codeEtablissement} - - {new Date(item.inscription.dateInscription).toLocaleDateString('fr-FR')} - - {getStatutBadge(item.inscription.statut)} - - - - - ))} - -
    - ) : ( -
    - Aucune inscription pour cette session. -
    - )} -
    -
    - - ) : ( -
    - Session introuvable. -
    - )} -
    -
    - ); -} diff --git a/client/src/pages/AdminSessions.tsx b/client/src/pages/AdminSessions.tsx deleted file mode 100644 index 4cab606..0000000 --- a/client/src/pages/AdminSessions.tsx +++ /dev/null @@ -1,504 +0,0 @@ -import DashboardLayout from "@/components/DashboardLayout"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { trpc } from "@/lib/trpc"; -import { Plus, Pencil, Trash2, Users as UsersIcon, Search, SlidersHorizontal } from "lucide-react"; -import { useState, useMemo } from "react"; -import { toast } from "sonner"; -import { Skeleton } from "@/components/ui/skeleton"; -import { format } from "date-fns"; -import { fr } from "date-fns/locale"; -import { useLocation } from "wouter"; - -export default function AdminSessions() { - const [, setLocation] = useLocation(); - const [open, setOpen] = useState(false); - const [editingId, setEditingId] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - const [filterLieu, setFilterLieu] = useState("all"); - const [filterStatut, setFilterStatut] = useState("all"); - const [sortBy, setSortBy] = useState<"date" | "lieu" | "places">("date"); - const [formData, setFormData] = useState({ - formationId: "", - nom: "", - dateDebut: "", - dateFin: "", - lieu: "", - capaciteMax: "12", - dateBlocage: "", - statut: "ouverte" as "ouverte" | "bloquee" | "terminee", - }); - - const utils = trpc.useUtils(); - const { data: sessions, isLoading } = trpc.sessions.list.useQuery(); - const { data: formations } = trpc.formations.list.useQuery(); - - const createMutation = trpc.sessions.create.useMutation({ - onSuccess: () => { - utils.sessions.list.invalidate(); - toast.success("Session créée avec succès"); - setOpen(false); - resetForm(); - }, - onError: (error) => { - toast.error("Erreur lors de la création : " + error.message); - }, - }); - - const updateMutation = trpc.sessions.update.useMutation({ - onSuccess: () => { - utils.sessions.list.invalidate(); - toast.success("Session mise à jour avec succès"); - setOpen(false); - resetForm(); - }, - onError: (error) => { - toast.error("Erreur lors de la mise à jour : " + error.message); - }, - }); - - const deleteMutation = trpc.sessions.delete.useMutation({ - onSuccess: () => { - utils.sessions.list.invalidate(); - toast.success("Session supprimée avec succès"); - }, - onError: (error) => { - toast.error("Erreur lors de la suppression : " + error.message); - }, - }); - - const resetForm = () => { - setFormData({ - formationId: "", - nom: "", - dateDebut: "", - dateFin: "", - lieu: "", - capaciteMax: "12", - dateBlocage: "", - statut: "ouverte", - }); - setEditingId(null); - }; - - const handleEdit = (session: any) => { - setFormData({ - formationId: session.formationId.toString(), - nom: session.nom, - dateDebut: format(new Date(session.dateDebut), "yyyy-MM-dd'T'HH:mm"), - dateFin: format(new Date(session.dateFin), "yyyy-MM-dd'T'HH:mm"), - lieu: session.lieu, - capaciteMax: session.capaciteMax.toString(), - dateBlocage: format(new Date(session.dateBlocage), "yyyy-MM-dd'T'HH:mm"), - statut: session.statut, - }); - setEditingId(session.id); - setOpen(true); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const data = { - formationId: parseInt(formData.formationId), - nom: formData.nom, - dateDebut: formData.dateDebut, - dateFin: formData.dateFin, - lieu: formData.lieu, - capaciteMax: parseInt(formData.capaciteMax), - dateBlocage: formData.dateBlocage, - statut: formData.statut, - }; - - if (editingId) { - updateMutation.mutate({ id: editingId, ...data }); - } else { - createMutation.mutate(data); - } - }; - - const handleDelete = (id: number) => { - if (confirm("Êtes-vous sûr de vouloir supprimer cette session ?")) { - deleteMutation.mutate({ id }); - } - }; - - const getFormationName = (formationId: number) => { - return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue"; - }; - - // Extraire les lieux uniques pour le filtre - const uniqueLieux = useMemo(() => { - if (!sessions) return []; - const lieux = sessions.map(s => s.lieu); - return Array.from(new Set(lieux)).sort(); - }, [sessions]); - - // Filtrer et trier les sessions - const filteredAndSortedSessions = useMemo(() => { - if (!sessions) return []; - - let filtered = sessions.filter(session => { - // Filtre par recherche (nom de session ou formation) - const matchesSearch = searchTerm === "" || - session.nom.toLowerCase().includes(searchTerm.toLowerCase()) || - getFormationName(session.formationId).toLowerCase().includes(searchTerm.toLowerCase()); - - // Filtre par lieu - const matchesLieu = filterLieu === "all" || session.lieu === filterLieu; - - // Filtre par statut - const matchesStatut = filterStatut === "all" || session.statut === filterStatut; - - return matchesSearch && matchesLieu && matchesStatut; - }); - - // Tri - filtered.sort((a, b) => { - if (sortBy === "date") { - return new Date(a.dateDebut).getTime() - new Date(b.dateDebut).getTime(); - } else if (sortBy === "lieu") { - return a.lieu.localeCompare(b.lieu); - } else if (sortBy === "places") { - return a.capaciteMax - b.capaciteMax; - } - return 0; - }); - - return filtered; - }, [sessions, searchTerm, filterLieu, filterStatut, sortBy, formations]); - - const formatDate = (date: Date | string) => { - return format(new Date(date), "dd/MM/yyyy HH:mm", { locale: fr }); - }; - - const getStatutBadge = (statut: string) => { - const styles = { - ouverte: "bg-green-100 text-green-800", - bloquee: "bg-orange-100 text-orange-800", - terminee: "bg-gray-100 text-gray-800", - }; - const labels = { - ouverte: "Ouverte", - bloquee: "Bloquée", - terminee: "Terminée", - }; - return ( - - {labels[statut as keyof typeof labels]} - - ); - }; - - return ( - -
    -
    -
    -
    -

    Sessions

    -

    - Gérez les sessions de formation -

    -
    - { - setOpen(isOpen); - if (!isOpen) resetForm(); - }}> - - - - -
    - - - {editingId ? "Modifier la session" : "Nouvelle session"} - - - Créez une session avec dates, lieu et capacité - - -
    -
    - - -
    -
    - - setFormData({ ...formData, nom: e.target.value })} - placeholder="Session 1 - Février 2026" - required - /> -
    -
    -
    - - setFormData({ ...formData, dateDebut: e.target.value })} - required - /> -
    -
    - - setFormData({ ...formData, dateFin: e.target.value })} - required - /> -
    -
    -
    - - setFormData({ ...formData, lieu: e.target.value })} - placeholder="Salle de formation A, Bâtiment principal" - required - /> -
    -
    -
    - - setFormData({ ...formData, capaciteMax: e.target.value })} - required - /> -
    -
    - - setFormData({ ...formData, dateBlocage: e.target.value })} - required - /> -
    -
    -
    - - -
    -
    - - - - -
    -
    -
    -
    - - {/* Filtres et recherche */} - - - - - Filtres et recherche - - - -
    - {/* Recherche */} -
    - -
    - - setSearchTerm(e.target.value)} - className="pl-8" - /> -
    -
    - - {/* Filtre par lieu */} -
    - - -
    - - {/* Filtre par statut */} -
    - - -
    - - {/* Tri */} -
    - - -
    -
    - - {/* Compteur de résultats */} -
    - {filteredAndSortedSessions.length} session(s) trouvée(s) -
    -
    -
    -
    - - - - Liste des sessions - - Gérez vos sessions et consultez les inscriptions - - - - {isLoading ? ( -
    - - - -
    - ) : filteredAndSortedSessions && filteredAndSortedSessions.length > 0 ? ( -
    - - - - Formation - Nom - Date début - Date fin - Lieu - Capacité - Statut - Actions - - - - {filteredAndSortedSessions.map((session) => ( - - {getFormationName(session.formationId)} - {session.nom} - {formatDate(session.dateDebut)} - {formatDate(session.dateFin)} - {session.lieu} - {session.capaciteMax} - {getStatutBadge(session.statut)} - -
    - - - -
    -
    -
    - ))} -
    -
    -
    - ) : ( -
    - Aucune session pour le moment. Créez-en une pour commencer. -
    - )} -
    -
    -
    -
    - ); -} diff --git a/client/src/pages/ComponentShowcase.tsx b/client/src/pages/ComponentShowcase.tsx index b7725c2..603659d 100644 --- a/client/src/pages/ComponentShowcase.tsx +++ b/client/src/pages/ComponentShowcase.tsx @@ -881,7 +881,7 @@ export default function ComponentsShowcase() { Error - Your session has expired. Please log in again. + Your sequence has expired. Please log in again. diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index de63228..aba88e1 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -31,7 +31,7 @@ export default function Home() { { icon: , title: "Sessions planifiées", - description: "Organisez des sessions avec dates, lieux et capacité maximale de 12 participants. Blocage automatique à J-15.", + description: "Organisez des sequences avec dates, lieux et capacité maximale de 12 participants. Blocage automatique à J-15.", }, { icon: , @@ -80,7 +80,7 @@ export default function Home() {

    Solution complète pour gérer l'organisation interne des formations destinées à environ 150 apprenants. - Inscriptions, sessions, communications automatisées et exports simplifiés. + Inscriptions, sequences, communications automatisées et exports simplifiés.

    {isAuthenticated && user?.role === 'admin' && ( @@ -132,7 +132,7 @@ export default function Home() { -

    Créez des formations et des sessions avec toutes les informations nécessaires (dates, lieu, capacité). Chaque formation dispose d'un lien unique d'inscription à partager avec les apprenants.

    +

    Créez des formations et des sequences avec toutes les informations nécessaires (dates, lieu, capacité). Chaque formation dispose d'un lien unique d'inscription à partager avec les apprenants.

    Gérez les inscriptions, envoyez des emails groupés (teaser, rappels), et exportez les listes d'inscrits en Excel ou PDF pour vos besoins administratifs.

    @@ -145,7 +145,7 @@ export default function Home() { -

    Cliquez sur le lien d'inscription fourni par la DRH, consultez les sessions disponibles avec les dates et lieux, puis inscrivez-vous en quelques clics.

    +

    Cliquez sur le lien d'inscription fourni par la DRH, consultez les sequences disponibles avec les dates et lieux, puis inscrivez-vous en quelques clics.

    Recevez immédiatement un email de confirmation avec une invitation Outlook (.ics) qui bloque automatiquement votre agenda. Un rappel vous sera envoyé 7 jours avant la formation.

    @@ -158,8 +158,8 @@ export default function Home() { -

    Le système applique automatiquement toutes les contraintes : capacité maximale de 12 participants par session, prévention des doubles inscriptions, et gestion de la liste d'attente.

    -

    Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la session pour garantir la stabilité des groupes.

    +

    Le système applique automatiquement toutes les contraintes : capacité maximale de 12 participants par sequence, prévention des doubles inscriptions, et gestion de la liste d'attente.

    +

    Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la sequence pour garantir la stabilité des groupes.

    diff --git a/client/src/pages/Inscription.tsx b/client/src/pages/Inscription.tsx index 4dd1558..73c78a1 100644 --- a/client/src/pages/Inscription.tsx +++ b/client/src/pages/Inscription.tsx @@ -24,12 +24,12 @@ export default function Inscription() { codeEtablissement: "", fonction: "autre" as "directeur" | "chef_service" | "autre", }); - const [selectedSessionId, setSelectedSessionId] = useState(null); + const [selectedSequenceId, setSelectedSequenceId] = useState(null); const [inscriptionSuccess, setInscriptionSuccess] = useState(false); const [inscriptionStatut, setInscriptionStatut] = useState(""); const { data: formation, isLoading: loadingFormation } = trpc.formations.getByLien.useQuery({ lien }); - const { data: sessions, isLoading: loadingSessions } = trpc.sessions.listByFormation.useQuery( + const { data: sequences, isLoading: loadingSessions } = trpc.sequences.listByFormation.useQuery( { formationId: formation?.id || 0 }, { enabled: !!formation?.id } ); @@ -39,7 +39,7 @@ export default function Inscription() { onSuccess: (data) => { setInscriptionSuccess(true); setInscriptionStatut(data.statut); - utils.sessions.listByFormation.invalidate({ formationId: formation?.id || 0 }); + utils.sequences.listByFormation.invalidate({ formationId: formation?.id || 0 }); utils.apprenants.list.invalidate(); // Invalider la liste des apprenants if (data.statut === "confirmee") { toast.success("Inscription confirmée avec succès !"); @@ -57,8 +57,8 @@ export default function Inscription() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!selectedSessionId) { - toast.error("Veuillez sélectionner une session"); + if (!selectedSequenceId) { + toast.error("Veuillez sélectionner une sequence"); return; } @@ -81,32 +81,32 @@ export default function Inscription() { apprenantId = newApprenant.id; } - // Inscrire l'apprenant à la session + // Inscrire l'apprenant à la sequence await inscriptionMutation.mutateAsync({ apprenantId, - sessionId: selectedSessionId, + sequenceId: selectedSequenceId, }); } catch (error: any) { console.error("Erreur lors de l'inscription:", error); } }; - // Calculer les places disponibles pour chaque session + // Calculer les places disponibles pour chaque sequence const sessionsAvecPlaces = useMemo(() => { - if (!sessions) return []; + if (!sequences) return []; - return sessions.map(session => { + return sequences.map(sequence => { const now = new Date(); - const dateBlocage = new Date(session.dateBlocage); - const isBloquee = session.statut === 'bloquee' || now >= dateBlocage; + const dateBlocage = new Date(sequence.dateBlocage); + const isBloquee = sequence.statut === 'bloquee' || now >= dateBlocage; return { - ...session, + ...sequence, isBloquee, - placesRestantes: session.capaciteMax, + placesRestantes: sequence.capaciteMax, }; }); - }, [sessions]); + }, [sequences]); const formatDate = (date: Date | string) => { return format(new Date(date), "EEEE d MMMM yyyy 'à' HH:mm", { locale: fr }); @@ -202,49 +202,55 @@ export default function Inscription() { Sessions disponibles - Sélectionnez une session pour vous inscrire (capacité : 12 participants maximum) + Sélectionnez une sequence pour vous inscrire (capacité : 12 participants maximum) {sessionsAvecPlaces && sessionsAvecPlaces.length > 0 ? (
    - {sessionsAvecPlaces.map((session) => ( + {sessionsAvecPlaces.map((sequence) => (
    !session.isBloquee && setSelectedSessionId(session.id)} + onClick={() => !sequence.isBloquee && setSelectedSequenceId(sequence.id)} >
    -

    {session.nom}

    +

    {sequence.nom}

    - {formatDate(session.dateDebut)} +
    + {sequence.dates.map((date: any) => ( +
    + Date {date.ordre}: {formatDate(date.dateDebut)} +
    + ))} +
    - {session.lieu} + {sequence.lieu}
    - {session.placesRestantes} places disponibles + {sequence.placesRestantes} places disponibles
    - {session.isBloquee ? ( + {sequence.isBloquee ? ( Fermée - ) : session.placesRestantes === 0 ? ( + ) : sequence.placesRestantes === 0 ? ( Complète @@ -262,14 +268,14 @@ export default function Inscription() {
    ) : (
    - Aucune session disponible pour le moment. + Aucune sequence disponible pour le moment.
    )} {/* Formulaire d'inscription */} - {selectedSessionId && ( + {selectedSequenceId && ( Vos informations @@ -338,7 +344,7 @@ export default function Inscription() {

    - Important : Les inscriptions seront bloquées 15 jours avant le début de la session. + Important : Les inscriptions seront bloquées 15 jours avant le début de la sequence. Après cette date, aucune modification ne sera possible.

    diff --git a/drizzle/0003_steady_mystique.sql b/drizzle/0003_steady_mystique.sql new file mode 100644 index 0000000..aa8cab5 --- /dev/null +++ b/drizzle/0003_steady_mystique.sql @@ -0,0 +1,32 @@ +CREATE TABLE `datesFormation` ( + `id` int AUTO_INCREMENT NOT NULL, + `sequenceId` int NOT NULL, + `dateDebut` datetime NOT NULL, + `dateFin` datetime NOT NULL, + `ordre` int NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `datesFormation_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `sequences` ( + `id` int AUTO_INCREMENT NOT NULL, + `formationId` int NOT NULL, + `nom` varchar(255) NOT NULL, + `lieu` varchar(500) NOT NULL, + `capaciteMax` int NOT NULL DEFAULT 12, + `dateBlocage` datetime NOT NULL, + `statut` enum('ouverte','bloquee','terminee') NOT NULL DEFAULT 'ouverte', + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `sequences_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +DROP TABLE `sessions`;--> statement-breakpoint +ALTER TABLE `inscriptions` DROP INDEX `inscriptions_apprenantId_sessionId_unique`;--> statement-breakpoint +ALTER TABLE `inscriptions` MODIFY COLUMN `statut` enum('confirmee','liste_attente','annulee') NOT NULL;--> statement-breakpoint +ALTER TABLE `inscriptions` ADD `sequenceId` int NOT NULL;--> statement-breakpoint +ALTER TABLE `inscriptions` DROP COLUMN `sessionId`;--> statement-breakpoint +ALTER TABLE `inscriptions` DROP COLUMN `invitationEnvoyee`;--> statement-breakpoint +ALTER TABLE `inscriptions` DROP COLUMN `emailConfirmationEnvoye`;--> statement-breakpoint +ALTER TABLE `inscriptions` DROP COLUMN `emailTeaserEnvoye`;--> statement-breakpoint +ALTER TABLE `inscriptions` DROP COLUMN `emailRappelEnvoye`; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..8253787 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,485 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "0352a8eb-52dd-4dee-814f-96378a8fce64", + "prevId": "c625e855-6929-4091-a755-d300ea81b53a", + "tables": { + "apprenants": { + "name": "apprenants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prenom": { + "name": "prenom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeEtablissement": { + "name": "codeEtablissement", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fonction": { + "name": "fonction", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "apprenants_id": { + "name": "apprenants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "apprenants_email_unique": { + "name": "apprenants_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "datesFormation": { + "name": "datesFormation", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateDebut": { + "name": "dateDebut", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateFin": { + "name": "dateFin", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ordre": { + "name": "ordre", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "datesFormation_id": { + "name": "datesFormation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "formations": { + "name": "formations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lienUnique": { + "name": "lienUnique", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formations_id": { + "name": "formations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "formations_lienUnique_unique": { + "name": "formations_lienUnique_unique", + "columns": [ + "lienUnique" + ] + } + }, + "checkConstraint": {} + }, + "inscriptions": { + "name": "inscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('confirmee','liste_attente','annulee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateInscription": { + "name": "dateInscription", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inscriptions_id": { + "name": "inscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sequences": { + "name": "sequences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "formationId": { + "name": "formationId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lieu": { + "name": "lieu", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capaciteMax": { + "name": "capaciteMax", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "dateBlocage": { + "name": "dateBlocage", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouverte','bloquee','terminee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouverte'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sequences_id": { + "name": "sequences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0e92372..58852a1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1762957612797, "tag": "0002_fuzzy_sugar_man", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1762958719297, + "tag": "0003_steady_mystique", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 131af6a..6e00a80 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,4 +1,4 @@ -import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, boolean, datetime, unique } from "drizzle-orm/mysql-core"; +import { boolean, datetime, int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core"; /** * Core user table backing auth flow. @@ -60,56 +60,61 @@ export type Apprenant = typeof apprenants.$inferSelect; export type InsertApprenant = typeof apprenants.$inferInsert; /** - * Table des sessions de formation + * Table des séquences de formation + * Une séquence peut contenir jusqu'à 4 dates de formation */ -export const sessions = mysqlTable("sessions", { +export const sequences = mysqlTable("sequences", { id: int("id").autoincrement().primaryKey(), formationId: int("formationId").notNull(), nom: varchar("nom", { length: 255 }).notNull(), - /** Date de début de la session */ - dateDebut: datetime("dateDebut").notNull(), - /** Date de fin de la session */ - dateFin: datetime("dateFin").notNull(), /** Lieu de la formation */ lieu: varchar("lieu", { length: 500 }).notNull(), /** Capacité maximale (12 par défaut) */ capaciteMax: int("capaciteMax").default(12).notNull(), - /** Date de blocage des inscriptions (J-15) */ + /** Date de blocage des inscriptions (J-15 avant la première date) */ dateBlocage: datetime("dateBlocage").notNull(), - /** Statut de la session */ + /** Statut de la séquence */ statut: mysqlEnum("statut", ["ouverte", "bloquee", "terminee"]).default("ouverte").notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); -export type Session = typeof sessions.$inferSelect; -export type InsertSession = typeof sessions.$inferInsert; +export type Sequence = typeof sequences.$inferSelect; +export type InsertSequence = typeof sequences.$inferInsert; + +/** + * Table des dates de formation pour chaque séquence + * Une séquence peut avoir jusqu'à 4 dates + */ +export const datesFormation = mysqlTable("datesFormation", { + id: int("id").autoincrement().primaryKey(), + sequenceId: int("sequenceId").notNull(), + /** Date de début de cette journée de formation */ + dateDebut: datetime("dateDebut").notNull(), + /** Date de fin de cette journée de formation */ + dateFin: datetime("dateFin").notNull(), + /** Ordre de la date dans la séquence (1 à 4) */ + ordre: int("ordre").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type DateFormation = typeof datesFormation.$inferSelect; +export type InsertDateFormation = typeof datesFormation.$inferInsert; /** * Table des inscriptions + * L'inscription à une séquence inscrit automatiquement à toutes ses dates */ export const inscriptions = mysqlTable("inscriptions", { id: int("id").autoincrement().primaryKey(), apprenantId: int("apprenantId").notNull(), - sessionId: int("sessionId").notNull(), + sequenceId: int("sequenceId").notNull(), /** Statut de l'inscription */ - statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).default("confirmee").notNull(), - /** Date d'inscription */ + statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).notNull(), dateInscription: timestamp("dateInscription").defaultNow().notNull(), - /** Invitation Outlook envoyée */ - invitationEnvoyee: boolean("invitationEnvoyee").default(false).notNull(), - /** Email de confirmation envoyé */ - emailConfirmationEnvoye: boolean("emailConfirmationEnvoye").default(false).notNull(), - /** Email teaser envoyé */ - emailTeaserEnvoye: boolean("emailTeaserEnvoye").default(false).notNull(), - /** Email rappel J-7 envoyé */ - emailRappelEnvoye: boolean("emailRappelEnvoye").default(false).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - // Contrainte d'unicité pour éviter les doubles inscriptions - uniqueInscription: unique().on(table.apprenantId, table.sessionId), -})); +}); export type Inscription = typeof inscriptions.$inferSelect; export type InsertInscription = typeof inscriptions.$inferInsert; diff --git a/migrate-to-sequences.sh b/migrate-to-sequences.sh new file mode 100755 index 0000000..844f6b2 --- /dev/null +++ b/migrate-to-sequences.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Script pour remplacer "session" par "sequence" dans les fichiers frontend + +cd /home/ubuntu/formation-manager-itinova + +# Remplacements dans les fichiers TypeScript/TSX +find client/src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \ + -e 's/\bsessions\b/sequences/g' \ + -e 's/\bsession\b/sequence/g' \ + -e 's/\bSession\b/Sequence/g' \ + -e 's/\bSESSION\b/SEQUENCE/g' \ + -e 's/sessionId/sequenceId/g' \ + -e 's/SessionId/SequenceId/g' \ + -e 's/sessionNom/sequenceNom/g' \ + -e 's/SessionNom/SequenceNom/g' \ + -e 's/AdminSequences/AdminSequences/g' \ + -e 's/AdminSequenceInscrits/AdminSequenceInscrits/g' \ + {} \; + +echo "Migration terminée !" diff --git a/server/db.ts b/server/db.ts index 46eff02..2894292 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,16 +1,22 @@ -import { eq, and, sql, lt, gt } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import { InsertUser, users, formations, apprenants, - sessions, + sequences, + datesFormation, inscriptions, InsertFormation, InsertApprenant, - InsertSession, - InsertInscription + InsertSequence, + InsertDateFormation, + InsertInscription, + Sequence, + DateFormation, + Apprenant, + Formation } from "../drizzle/schema"; import { ENV } from './_core/env'; @@ -100,230 +106,256 @@ export async function getUserByOpenId(openId: string) { return result.length > 0 ? result[0] : undefined; } -// ===== FORMATIONS ===== +// ==================== FORMATIONS ==================== -export async function getAllFormations() { +export async function createFormation(data: InsertFormation) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(formations).values(data); + return result; +} + +export async function getFormations() { const db = await getDb(); if (!db) return []; - return db.select().from(formations).orderBy(formations.createdAt); + + return await db.select().from(formations); } export async function getFormationById(id: number) { const db = await getDb(); if (!db) return undefined; + const result = await db.select().from(formations).where(eq(formations.id, id)).limit(1); - return result[0]; + return result.length > 0 ? result[0] : undefined; } export async function getFormationByLien(lien: string) { const db = await getDb(); if (!db) return undefined; + const result = await db.select().from(formations).where(eq(formations.lienUnique, lien)).limit(1); - return result[0]; -} - -export async function createFormation(data: InsertFormation) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - const result = await db.insert(formations).values(data); - return result; + return result.length > 0 ? result[0] : undefined; } export async function updateFormation(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database not available"); + await db.update(formations).set(data).where(eq(formations.id, id)); } export async function deleteFormation(id: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); + await db.delete(formations).where(eq(formations.id, id)); } -// ===== APPRENANTS ===== +// ==================== SÉQUENCES ==================== -export async function getAllApprenants() { +export async function createSequence(data: InsertSequence) { const db = await getDb(); - if (!db) return []; - return db.select().from(apprenants).orderBy(apprenants.nom, apprenants.prenom); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(sequences).values(data); + return result; } -export async function getApprenantById(id: number) { +export async function getSequences() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(sequences); +} + +export async function getSequenceById(id: number): Promise { const db = await getDb(); if (!db) return undefined; + + const result = await db.select().from(sequences).where(eq(sequences.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function getSequencesByFormation(formationId: number) { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(sequences).where(eq(sequences.formationId, formationId)); +} + +export async function updateSequence(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(sequences).set(data).where(eq(sequences.id, id)); +} + +export async function deleteSequence(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(sequences).where(eq(sequences.id, id)); +} + +// ==================== DATES DE FORMATION ==================== + +export async function createDateFormation(data: InsertDateFormation) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(datesFormation).values(data); + return result; +} + +export async function getDatesBySequence(sequenceId: number): Promise { + const db = await getDb(); + if (!db) return []; + + const results = await db.select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, sequenceId)) + .orderBy(datesFormation.ordre); + + return results; +} + +export async function deleteDatesBySequence(sequenceId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(datesFormation).where(eq(datesFormation.sequenceId, sequenceId)); +} + +// ==================== APPRENANTS ==================== + +export async function createApprenant(data: InsertApprenant) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(apprenants).values(data); + return result; +} + +export async function getApprenants() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(apprenants); +} + +export async function getApprenantById(id: number): Promise { + const db = await getDb(); + if (!db) return undefined; + const result = await db.select().from(apprenants).where(eq(apprenants.id, id)).limit(1); - return result[0]; + return result.length > 0 ? result[0] : undefined; } export async function getApprenantByEmail(email: string) { const db = await getDb(); if (!db) return undefined; + const result = await db.select().from(apprenants).where(eq(apprenants.email, email)).limit(1); - return result[0]; -} - -export async function createApprenant(data: InsertApprenant) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - const result = await db.insert(apprenants).values(data); - return result; + return result.length > 0 ? result[0] : undefined; } export async function updateApprenant(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database not available"); + await db.update(apprenants).set(data).where(eq(apprenants.id, id)); } export async function deleteApprenant(id: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); + await db.delete(apprenants).where(eq(apprenants.id, id)); } -// ===== SESSIONS ===== +// ==================== INSCRIPTIONS ==================== -export async function getAllSessions() { - const db = await getDb(); - if (!db) return []; - return db.select().from(sessions).orderBy(sessions.dateDebut); -} - -export async function getSessionsByFormation(formationId: number) { - const db = await getDb(); - if (!db) return []; - return db.select().from(sessions).where(eq(sessions.formationId, formationId)).orderBy(sessions.dateDebut); -} - -export async function getSessionById(id: number) { - const db = await getDb(); - if (!db) return undefined; - const result = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1); - return result[0]; -} - -export async function createSession(data: InsertSession) { +export async function createInscription(data: InsertInscription) { const db = await getDb(); if (!db) throw new Error("Database not available"); - const result = await db.insert(sessions).values(data); + + const result = await db.insert(inscriptions).values(data); return result; } -export async function updateSession(id: number, data: Partial) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - await db.update(sessions).set(data).where(eq(sessions.id, id)); -} - -export async function deleteSession(id: number) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - await db.delete(sessions).where(eq(sessions.id, id)); -} - -// ===== INSCRIPTIONS ===== - -export async function getInscriptionsBySession(sessionId: number) { +export async function getInscriptionsBySequence(sequenceId: number) { const db = await getDb(); if (!db) return []; - const result = await db - .select({ - inscription: inscriptions, - apprenant: apprenants, - }) - .from(inscriptions) - .leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) - .where(eq(inscriptions.sessionId, sessionId)) - .orderBy(inscriptions.dateInscription); + const results = await db.select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where(eq(inscriptions.sequenceId, sequenceId)); - return result; + return results; } export async function getInscriptionsByApprenant(apprenantId: number) { const db = await getDb(); if (!db) return []; - const result = await db - .select({ - inscription: inscriptions, - session: sessions, - formation: formations, - }) - .from(inscriptions) - .leftJoin(sessions, eq(inscriptions.sessionId, sessions.id)) - .leftJoin(formations, eq(sessions.formationId, formations.id)) - .where(eq(inscriptions.apprenantId, apprenantId)) - .orderBy(inscriptions.dateInscription); + const results = await db.select({ + inscription: inscriptions, + sequence: sequences, + }) + .from(inscriptions) + .leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .where(eq(inscriptions.apprenantId, apprenantId)); - return result; + return results; } -export async function getInscriptionByApprenantAndSession(apprenantId: number, sessionId: number) { +export async function checkExistingInscription(apprenantId: number, sequenceId: number) { const db = await getDb(); if (!db) return undefined; - const result = await db - .select() + const result = await db.select() .from(inscriptions) .where(and( eq(inscriptions.apprenantId, apprenantId), - eq(inscriptions.sessionId, sessionId) + eq(inscriptions.sequenceId, sequenceId) )) .limit(1); - return result[0]; + return result.length > 0 ? result[0] : undefined; } -export async function countInscriptionsConfirmees(sessionId: number) { +export async function countInscriptionsBySequence(sequenceId: number, statut?: string) { const db = await getDb(); if (!db) return 0; - const result = await db - .select({ count: sql`count(*)` }) + const conditions = [eq(inscriptions.sequenceId, sequenceId)]; + if (statut) { + conditions.push(eq(inscriptions.statut, statut as any)); + } + + const result = await db.select({ count: sql`count(*)` }) .from(inscriptions) - .where(and( - eq(inscriptions.sessionId, sessionId), - eq(inscriptions.statut, "confirmee") - )); + .where(and(...conditions)); return result[0]?.count || 0; } -export async function createInscription(data: InsertInscription) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - const result = await db.insert(inscriptions).values(data); - return result; -} - export async function updateInscription(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database not available"); + await db.update(inscriptions).set(data).where(eq(inscriptions.id, id)); } export async function deleteInscription(id: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); + await db.delete(inscriptions).where(eq(inscriptions.id, id)); } - -export async function getSessionsAvecInscriptions(formationId: number) { - const db = await getDb(); - if (!db) return []; - - const result = await db - .select({ - session: sessions, - nbInscrits: sql`count(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 END)`, - }) - .from(sessions) - .leftJoin(inscriptions, eq(sessions.id, inscriptions.sessionId)) - .where(eq(sessions.formationId, formationId)) - .groupBy(sessions.id) - .orderBy(sessions.dateDebut); - - return result; -} diff --git a/server/emailService.ts b/server/emailService.ts index 64cfe8b..7f4a05a 100644 --- a/server/emailService.ts +++ b/server/emailService.ts @@ -67,6 +67,7 @@ function getEmailTemplate(content: string): string { .footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; } .button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; } .info-box { background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; } + .date-item { margin: 10px 0; padding: 10px; background-color: white; border-radius: 4px; } @@ -88,7 +89,7 @@ function getEmailTemplate(content: string): string { } /** - * Envoie un email de confirmation d'inscription avec invitation Outlook + * Envoie un email de confirmation d'inscription avec invitations Outlook pour toutes les dates */ export async function sendInscriptionConfirmation(params: { apprenantEmail: string; @@ -96,9 +97,8 @@ export async function sendInscriptionConfirmation(params: { apprenantPrenom: string; apprenantFonction: string; formationNom: string; - sessionNom: string; - dateDebut: Date; - dateFin: Date; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; lieu: string; statut: 'confirmee' | 'liste_attente'; }): Promise { @@ -106,6 +106,29 @@ export async function sendInscriptionConfirmation(params: { const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre'; + // Générer la liste des dates + const datesHTML = params.dates.map(date => ` +
    + Date ${date.ordre} :
    + Du ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}
    + au ${date.dateFin.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
    + `).join(''); + const content = `

    Confirmation d'inscription

    Bonjour ${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom},

    @@ -118,49 +141,36 @@ export async function sendInscriptionConfirmation(params: {

    Détails de la formation

    Formation : ${params.formationNom}

    -

    Session : ${params.sessionNom}

    -

    Date de début : ${params.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}

    -

    Date de fin : ${params.dateFin.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}

    +

    Séquence : ${params.sequenceNom}

    Lieu : ${params.lieu}

    +

    Dates de formation (${params.dates.length} séance${params.dates.length > 1 ? 's' : ''}) :

    + ${datesHTML}
    ${isConfirmed - ? `

    Une invitation Outlook est jointe à cet email. Merci de l'ajouter à votre calendrier pour bloquer votre agenda.

    -

    Important : Les inscriptions seront bloquées 15 jours avant le début de la session. Après cette date, aucune modification ne sera possible.

    ` + ? `

    Des invitations Outlook sont jointes à cet email pour chaque date de formation. Merci de les ajouter à votre calendrier pour bloquer votre agenda.

    +

    Important : Les inscriptions seront bloquées 15 jours avant le début de la séquence. Après cette date, aucune modification ne sera possible.

    ` : '

    Nous vous contacterons dès qu\'une place se libère.

    ' }

    À bientôt pour cette formation !

    `; - const attachments = isConfirmed ? [{ - filename: 'invitation.ics', + // Générer une invitation ICS pour chaque date + const attachments = isConfirmed ? params.dates.map((date, index) => ({ + filename: `invitation_date_${date.ordre}.ics`, content: generateFormationICS({ formationNom: params.formationNom, - sessionNom: params.sessionNom, - dateDebut: params.dateDebut, - dateFin: params.dateFin, + sessionNom: `${params.sequenceNom} - Date ${date.ordre}`, + dateDebut: date.dateDebut, + dateFin: date.dateFin, lieu: params.lieu, apprenantNom: params.apprenantNom, apprenantPrenom: params.apprenantPrenom, apprenantEmail: params.apprenantEmail, }), contentType: 'text/calendar', - }] : undefined; + })) : undefined; return sendEmail({ to: params.apprenantEmail, @@ -173,7 +183,7 @@ export async function sendInscriptionConfirmation(params: { } /** - * Envoie un email teaser pour une session + * Envoie un email teaser pour une séquence */ export async function sendTeaserEmail(params: { apprenantEmail: string; @@ -181,13 +191,24 @@ export async function sendTeaserEmail(params: { apprenantNom: string; apprenantFonction: string; formationNom: string; - sessionNom: string; - dateDebut: Date; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; }): Promise { const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + const datesHTML = params.dates.map(date => ` +
    + Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + })} +
    + `).join(''); + const content = `

    Votre formation approche !

    Bonjour ${salutation},

    @@ -195,13 +216,9 @@ export async function sendTeaserEmail(params: {

    Nous sommes ravis de vous accueillir prochainement pour la formation ${params.formationNom}.

    -

    Session : ${params.sessionNom}

    -

    Date : ${params.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric' - })}

    +

    Séquence : ${params.sequenceNom}

    +

    Dates :

    + ${datesHTML}

    Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.

    @@ -225,14 +242,28 @@ export async function sendRappelJ7Email(params: { apprenantNom: string; apprenantFonction: string; formationNom: string; - sessionNom: string; - dateDebut: Date; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; lieu: string; }): Promise { const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + const datesHTML = params.dates.map(date => ` +
    + Date ${date.ordre} :
    + ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
    + `).join(''); + const content = `

    Rappel : Votre formation dans 7 jours

    Bonjour ${salutation},

    @@ -241,16 +272,10 @@ export async function sendRappelJ7Email(params: {

    Informations pratiques

    -

    Session : ${params.sessionNom}

    -

    Date : ${params.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}

    +

    Séquence : ${params.sequenceNom}

    Lieu : ${params.lieu}

    +

    Dates :

    + ${datesHTML}

    Merci de vous présenter à l'heure indiquée.

    @@ -268,14 +293,14 @@ export async function sendRappelJ7Email(params: { } /** - * Envoie des emails groupés à tous les inscrits d'une session + * Envoie des emails groupés à tous les inscrits d'une séquence */ export async function sendGroupEmail(params: { recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>; formationNom: string; - sessionNom: string; + sequenceNom: string; type: 'teaser' | 'rappel'; - dateDebut: Date; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; lieu?: string; }): Promise<{ sent: number; failed: number }> { let sent = 0; @@ -290,8 +315,8 @@ export async function sendGroupEmail(params: { apprenantNom: recipient.nom, apprenantFonction: recipient.fonction, formationNom: params.formationNom, - sessionNom: params.sessionNom, - dateDebut: params.dateDebut, + sequenceNom: params.sequenceNom, + dates: params.dates, }); } else { await sendRappelJ7Email({ @@ -300,8 +325,8 @@ export async function sendGroupEmail(params: { apprenantNom: recipient.nom, apprenantFonction: recipient.fonction, formationNom: params.formationNom, - sessionNom: params.sessionNom, - dateDebut: params.dateDebut, + sequenceNom: params.sequenceNom, + dates: params.dates, lieu: params.lieu || '', }); } diff --git a/server/exportService.ts b/server/exportService.ts index a576d74..cf55d3a 100644 --- a/server/exportService.ts +++ b/server/exportService.ts @@ -16,23 +16,35 @@ interface InscriptionExport { dateInscription: Date; } -interface SessionInfo { +interface SequenceInfo { formationNom: string; - sessionNom: string; - dateDebut: Date; - dateFin: Date; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; lieu: string; + inscriptions: InscriptionExport[]; +} + +interface ApprenantPresence { + nom: string; + prenom: string; + codeEtablissement: string; + fonction: string; +} + +interface FeuillePresenceInfo { + formationNom: string; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; + lieu: string; + apprenants: ApprenantPresence[]; } /** * Génère un fichier Excel avec la liste des inscrits */ -export function generateExcelExport( - sessionInfo: SessionInfo, - inscriptions: InscriptionExport[] -): Buffer { +export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer { // Préparer les données - const data = inscriptions.map(i => ({ + const data = sequenceInfo.inscriptions.map(i => ({ 'Nom': i.nom, 'Prénom': i.prenom, 'Email': i.email, @@ -45,31 +57,42 @@ export function generateExcelExport( // Créer le workbook const wb = XLSX.utils.book_new(); - // Créer la feuille avec les informations de session + // Créer la feuille avec les informations de séquence const infoData = [ - ['Formation', sessionInfo.formationNom], - ['Session', sessionInfo.sessionNom], - ['Date de début', sessionInfo.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })], - ['Date de fin', sessionInfo.dateFin.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })], - ['Lieu', sessionInfo.lieu], - ['Nombre d\'inscrits', inscriptions.length.toString()], + ['Formation', sequenceInfo.formationNom], + ['Séquence', sequenceInfo.sequenceNom], + ['Lieu', sequenceInfo.lieu], + ['Nombre de dates', sequenceInfo.dates.length.toString()], [], + ['DATES DE FORMATION'], ]; + // Ajouter les dates + sequenceInfo.dates.forEach(date => { + infoData.push([ + `Date ${date.ordre}`, + `Du ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} au ${date.dateFin.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}` + ]); + }); + + infoData.push([]); + infoData.push(['Nombre d\'inscrits', sequenceInfo.inscriptions.length.toString()]); + infoData.push([]); + // Créer la feuille principale const ws = XLSX.utils.aoa_to_sheet(infoData); @@ -88,38 +111,45 @@ export function generateExcelExport( /** * Génère un fichier PDF avec la liste des inscrits */ -export function generatePDFExport( - sessionInfo: SessionInfo, - inscriptions: InscriptionExport[] -): Buffer { +export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { const doc = new jsPDF(); // Titre doc.setFontSize(18); doc.text('Liste des inscrits', 14, 20); - // Informations de session + // Informations de séquence doc.setFontSize(10); let yPos = 35; - doc.text(`Formation : ${sessionInfo.formationNom}`, 14, yPos); + doc.text(`Formation : ${sequenceInfo.formationNom}`, 14, yPos); yPos += 6; - doc.text(`Session : ${sessionInfo.sessionNom}`, 14, yPos); + doc.text(`Séquence : ${sequenceInfo.sequenceNom}`, 14, yPos); yPos += 6; - doc.text(`Date : ${sessionInfo.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}`, 14, yPos); + doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos); yPos += 6; - doc.text(`Lieu : ${sessionInfo.lieu}`, 14, yPos); - yPos += 6; - doc.text(`Nombre d'inscrits : ${inscriptions.length}`, 14, yPos); + doc.text(`Nombre de dates : ${sequenceInfo.dates.length}`, 14, yPos); + yPos += 8; + + // Dates de formation + doc.setFontSize(9); + sequenceInfo.dates.forEach(date => { + doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}`, 14, yPos); + yPos += 5; + }); + + yPos += 3; + doc.setFontSize(10); + doc.text(`Nombre d'inscrits : ${sequenceInfo.inscriptions.length}`, 14, yPos); // Tableau des inscrits - const tableData = inscriptions.map(i => [ + const tableData = sequenceInfo.inscriptions.map(i => [ i.nom, i.prenom, i.email, @@ -142,37 +172,39 @@ export function generatePDFExport( /** * Génère une feuille de présence PDF */ -export function generateFeuillePresence( - sessionInfo: SessionInfo, - inscriptions: InscriptionExport[] -): Buffer { +export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer { const doc = new jsPDF(); // Titre doc.setFontSize(18); doc.text('Feuille de présence', 14, 20); - // Informations de session + // Informations de séquence doc.setFontSize(10); let yPos = 35; - doc.text(`Formation : ${sessionInfo.formationNom}`, 14, yPos); + doc.text(`Formation : ${feuilleInfo.formationNom}`, 14, yPos); yPos += 6; - doc.text(`Session : ${sessionInfo.sessionNom}`, 14, yPos); + doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 14, yPos); yPos += 6; - doc.text(`Date : ${sessionInfo.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}`, 14, yPos); - yPos += 6; - doc.text(`Lieu : ${sessionInfo.lieu}`, 14, yPos); + doc.text(`Lieu : ${feuilleInfo.lieu}`, 14, yPos); + yPos += 8; - // Tableau de présence (uniquement les inscrits confirmés) - const inscritsConfirmes = inscriptions.filter(i => i.statut === 'confirmee'); - const tableData = inscritsConfirmes.map(i => [ + // Dates de formation + doc.setFontSize(9); + feuilleInfo.dates.forEach(date => { + doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}`, 14, yPos); + yPos += 5; + }); + + // Tableau de présence + const tableData = feuilleInfo.apprenants.map(i => [ i.nom, i.prenom, i.codeEtablissement, diff --git a/server/routers.ts b/server/routers.ts index bbe902e..2e85150 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -32,7 +32,7 @@ export const appRouter = router({ // ===== FORMATIONS ===== formations: router({ list: adminProcedure.query(async () => { - return db.getAllFormations(); + return db.getFormations(); }), getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { @@ -74,7 +74,7 @@ export const appRouter = router({ // ===== APPRENANTS ===== apprenants: router({ list: adminProcedure.query(async () => { - return db.getAllApprenants(); + return db.getApprenants(); }), getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { @@ -115,75 +115,133 @@ export const appRouter = router({ }), }), - // ===== SESSIONS ===== - sessions: router({ + // ===== SÉQUENCES ===== + sequences: router({ list: adminProcedure.query(async () => { - return db.getAllSessions(); + const seqs = await db.getSequences(); + // Récupérer les dates pour chaque séquence + const sequencesAvecDates = await Promise.all( + seqs.map(async (seq) => { + const dates = await db.getDatesBySequence(seq.id); + return { ...seq, dates }; + }) + ); + return sequencesAvecDates; }), listByFormation: publicProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => { - return db.getSessionsByFormation(input.formationId); - }), - - listAvecInscriptions: adminProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => { - return db.getSessionsAvecInscriptions(input.formationId); + const seqs = await db.getSequencesByFormation(input.formationId); + // Récupérer les dates et le nombre d'inscrits pour chaque séquence + const sequencesAvecDates = await Promise.all( + seqs.map(async (seq) => { + const dates = await db.getDatesBySequence(seq.id); + const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee'); + return { ...seq, dates, nbInscrits }; + }) + ); + return sequencesAvecDates; }), getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { - return db.getSessionById(input.id); + const seq = await db.getSequenceById(input.id); + if (!seq) return null; + const dates = await db.getDatesBySequence(input.id); + return { ...seq, dates }; }), create: adminProcedure.input(z.object({ formationId: z.number(), nom: z.string().min(1), - dateDebut: z.string(), - dateFin: z.string(), lieu: z.string().min(1), capaciteMax: z.number().default(12), dateBlocage: z.string(), statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(), + dates: z.array(z.object({ + dateDebut: z.string(), + dateFin: z.string(), + ordre: z.number(), + })).min(1).max(4), })).mutation(async ({ input }) => { - await db.createSession({ - ...input, - dateDebut: new Date(input.dateDebut), - dateFin: new Date(input.dateFin), - dateBlocage: new Date(input.dateBlocage), + const { dates, ...sequenceData } = input; + + // Créer la séquence + const result = await db.createSequence({ + ...sequenceData, + dateBlocage: new Date(sequenceData.dateBlocage), }); - return { success: true }; + + // Récupérer l'ID de la séquence créée + const sequenceId = Number(result[0].insertId); + + // Créer les dates de formation + for (const date of dates) { + await db.createDateFormation({ + sequenceId, + dateDebut: new Date(date.dateDebut), + dateFin: new Date(date.dateFin), + ordre: date.ordre, + }); + } + + return { success: true, sequenceId }; }), update: adminProcedure.input(z.object({ id: z.number(), formationId: z.number().optional(), nom: z.string().min(1).optional(), - dateDebut: z.string().optional(), - dateFin: z.string().optional(), lieu: z.string().min(1).optional(), capaciteMax: z.number().optional(), dateBlocage: z.string().optional(), statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(), + dates: z.array(z.object({ + dateDebut: z.string(), + dateFin: z.string(), + ordre: z.number(), + })).min(1).max(4).optional(), })).mutation(async ({ input }) => { - const { id, ...data } = input; - const updateData: any = { ...data }; + const { id, dates, ...sequenceData } = input; - if (data.dateDebut) updateData.dateDebut = new Date(data.dateDebut); - if (data.dateFin) updateData.dateFin = new Date(data.dateFin); - if (data.dateBlocage) updateData.dateBlocage = new Date(data.dateBlocage); + // Mettre à jour la séquence + const updateData: any = { ...sequenceData }; + if (sequenceData.dateBlocage) { + updateData.dateBlocage = new Date(sequenceData.dateBlocage); + } + + await db.updateSequence(id, updateData); + + // Si des dates sont fournies, les mettre à jour + if (dates) { + // Supprimer les anciennes dates + await db.deleteDatesBySequence(id); + + // Créer les nouvelles dates + for (const date of dates) { + await db.createDateFormation({ + sequenceId: id, + dateDebut: new Date(date.dateDebut), + dateFin: new Date(date.dateFin), + ordre: date.ordre, + }); + } + } - await db.updateSession(id, updateData); return { success: true }; }), delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { - await db.deleteSession(input.id); + // Supprimer d'abord les dates associées + await db.deleteDatesBySequence(input.id); + // Puis supprimer la séquence + await db.deleteSequence(input.id); return { success: true }; }), }), // ===== INSCRIPTIONS ===== inscriptions: router({ - listBySession: adminProcedure.input(z.object({ sessionId: z.number() })).query(async ({ input }) => { - return db.getInscriptionsBySession(input.sessionId); + listBySequence: adminProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => { + return db.getInscriptionsBySequence(input.sequenceId); }), listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => { @@ -192,65 +250,72 @@ export const appRouter = router({ checkExisting: publicProcedure.input(z.object({ apprenantId: z.number(), - sessionId: z.number(), + sequenceId: z.number(), })).query(async ({ input }) => { - return db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId); + return db.checkExistingInscription(input.apprenantId, input.sequenceId); }), inscrire: publicProcedure.input(z.object({ apprenantId: z.number(), - sessionId: z.number(), + sequenceId: z.number(), })).mutation(async ({ input }) => { - // Vérifier si la session existe - const session = await db.getSessionById(input.sessionId); - if (!session) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' }); + // Vérifier si la séquence existe + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } - // Vérifier si la session est bloquée + // Vérifier si la séquence est bloquée const now = new Date(); - if (session.statut === 'bloquee' || now >= new Date(session.dateBlocage)) { + if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) { throw new TRPCError({ code: 'FORBIDDEN', - message: 'Les inscriptions sont fermées pour cette session (J-15 dépassé)' + message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)' }); } - // Vérifier si l'apprenant existe déjà une inscription - const existing = await db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId); + // Vérifier si l'apprenant a déjà une inscription + const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId); if (existing) { throw new TRPCError({ code: 'CONFLICT', - message: 'Vous êtes déjà inscrit à cette session' + message: 'Vous êtes déjà inscrit à cette séquence' }); } // Vérifier la capacité - const nbInscrits = await db.countInscriptionsConfirmees(input.sessionId); - const statut = nbInscrits >= session.capaciteMax ? 'liste_attente' : 'confirmee'; + const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); + const statut = nbInscrits >= sequence.capaciteMax ? 'liste_attente' : 'confirmee'; await db.createInscription({ apprenantId: input.apprenantId, - sessionId: input.sessionId, + sequenceId: input.sequenceId, statut, }); - // Envoyer l'email de confirmation avec invitation Outlook - const inscriptionSession = await db.getSessionById(input.sessionId); + // Envoyer l'email de confirmation avec invitations Outlook pour toutes les dates + const inscriptionSequence = await db.getSequenceById(input.sequenceId); const inscriptionApprenant = await db.getApprenantById(input.apprenantId); - const inscriptionFormation = inscriptionSession ? await db.getFormationById(inscriptionSession.formationId) : null; + const inscriptionFormation = inscriptionSequence ? await db.getFormationById(inscriptionSequence.formationId) : null; + const dates = inscriptionSequence ? await db.getDatesBySequence(inscriptionSequence.id) : []; - if (inscriptionSession && inscriptionApprenant && inscriptionFormation) { + if (inscriptionSequence && inscriptionApprenant && inscriptionFormation && dates.length > 0) { + // Utiliser la première date pour l'email principal + const premiereDate = dates[0]; + await sendInscriptionConfirmation({ apprenantEmail: inscriptionApprenant.email, apprenantNom: inscriptionApprenant.nom, apprenantPrenom: inscriptionApprenant.prenom, apprenantFonction: inscriptionApprenant.fonction, formationNom: inscriptionFormation.nom, - sessionNom: inscriptionSession.nom, - dateDebut: new Date(inscriptionSession.dateDebut), - dateFin: new Date(inscriptionSession.dateFin), - lieu: inscriptionSession.lieu, + sequenceNom: inscriptionSequence.nom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: inscriptionSequence.lieu, statut, }); } @@ -260,25 +325,25 @@ export const appRouter = router({ desinscrire: publicProcedure.input(z.object({ apprenantId: z.number(), - sessionId: z.number(), + sequenceId: z.number(), })).mutation(async ({ input }) => { - // Vérifier si la session existe - const session = await db.getSessionById(input.sessionId); - if (!session) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' }); + // Vérifier si la séquence existe + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } - // Vérifier si la session est bloquée + // Vérifier si la séquence est bloquée const now = new Date(); - if (session.statut === 'bloquee' || now >= new Date(session.dateBlocage)) { + if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) { throw new TRPCError({ code: 'FORBIDDEN', - message: 'Les désinscriptions sont fermées pour cette session (J-15 dépassé)' + message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)' }); } // Trouver l'inscription - const inscription = await db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId); + const inscription = await db.checkExistingInscription(input.apprenantId, input.sequenceId); if (!inscription) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' }); } @@ -296,21 +361,26 @@ export const appRouter = router({ return { success: true }; }), - sendGroupEmails: adminProcedure.input(z.object({ - sessionId: z.number(), + sendGroupEmail: adminProcedure.input(z.object({ + sequenceId: z.number(), type: z.enum(['teaser', 'rappel']), })).mutation(async ({ input }) => { - const session = await db.getSessionById(input.sessionId); - if (!session) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' }); + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } - const formation = await db.getFormationById(session.formationId); + const formation = await db.getFormationById(sequence.formationId); if (!formation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); } - const inscriptions = await db.getInscriptionsBySession(input.sessionId); + const dates = await db.getDatesBySequence(sequence.id); + if (dates.length === 0) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' }); + } + + const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); const recipients = inscriptions .filter(i => i.inscription.statut === 'confirmee' && i.apprenant) .map(i => ({ @@ -323,23 +393,29 @@ export const appRouter = router({ const result = await sendGroupEmail({ recipients, formationNom: formation.nom, - sessionNom: session.nom, + sequenceNom: sequence.nom, type: input.type, - dateDebut: new Date(session.dateDebut), - lieu: session.lieu, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: sequence.lieu, }); return result; }), - exportExcel: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => { - const session = await db.getSessionById(input.sessionId); - if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' }); + exportExcel: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); - const formation = await db.getFormationById(session.formationId); + const formation = await db.getFormationById(sequence.formationId); if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); - const inscriptions = await db.getInscriptionsBySession(input.sessionId); + const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); + const dates = await db.getDatesBySequence(input.sequenceId); + const data = inscriptions .filter(i => i.apprenant) .map(i => ({ @@ -349,31 +425,37 @@ export const appRouter = router({ codeEtablissement: i.apprenant!.codeEtablissement, fonction: i.apprenant!.fonction, statut: i.inscription.statut, - dateInscription: new Date(i.inscription.dateInscription), + dateInscription: i.inscription.dateInscription, })); - const buffer = generateExcelExport( - { - formationNom: formation.nom, - sessionNom: session.nom, - dateDebut: new Date(session.dateDebut), - dateFin: new Date(session.dateFin), - lieu: session.lieu, - }, - data - ); + const buffer = await generateExcelExport({ + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: sequence.lieu, + inscriptions: data, + }); - return { data: buffer.toString('base64') }; + return { + buffer: buffer.toString('base64'), + filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`, + }; }), - exportPDF: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => { - const session = await db.getSessionById(input.sessionId); - if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' }); + exportPDF: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); - const formation = await db.getFormationById(session.formationId); + const formation = await db.getFormationById(sequence.formationId); if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); - const inscriptions = await db.getInscriptionsBySession(input.sessionId); + const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); + const dates = await db.getDatesBySequence(input.sequenceId); + const data = inscriptions .filter(i => i.apprenant) .map(i => ({ @@ -383,55 +465,62 @@ export const appRouter = router({ codeEtablissement: i.apprenant!.codeEtablissement, fonction: i.apprenant!.fonction, statut: i.inscription.statut, - dateInscription: new Date(i.inscription.dateInscription), + dateInscription: i.inscription.dateInscription, })); - const buffer = generatePDFExport( - { - formationNom: formation.nom, - sessionNom: session.nom, - dateDebut: new Date(session.dateDebut), - dateFin: new Date(session.dateFin), - lieu: session.lieu, - }, - data - ); + const buffer = await generatePDFExport({ + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: sequence.lieu, + inscriptions: data, + }); - return { data: buffer.toString('base64') }; + return { + buffer: buffer.toString('base64'), + filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`, + }; }), - exportFeuillePresence: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => { - const session = await db.getSessionById(input.sessionId); - if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' }); + exportFeuillePresence: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); - const formation = await db.getFormationById(session.formationId); + const formation = await db.getFormationById(sequence.formationId); if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); - const inscriptions = await db.getInscriptionsBySession(input.sessionId); + const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); + const dates = await db.getDatesBySequence(input.sequenceId); + const data = inscriptions - .filter(i => i.apprenant) + .filter(i => i.inscription.statut === 'confirmee' && i.apprenant) .map(i => ({ nom: i.apprenant!.nom, prenom: i.apprenant!.prenom, - email: i.apprenant!.email, codeEtablissement: i.apprenant!.codeEtablissement, fonction: i.apprenant!.fonction, - statut: i.inscription.statut, - dateInscription: new Date(i.inscription.dateInscription), })); - const buffer = generateFeuillePresence( - { - formationNom: formation.nom, - sessionNom: session.nom, - dateDebut: new Date(session.dateDebut), - dateFin: new Date(session.dateFin), - lieu: session.lieu, - }, - data - ); + const buffer = await generateFeuillePresence({ + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: sequence.lieu, + apprenants: data, + }); - return { data: buffer.toString('base64') }; + return { + buffer: buffer.toString('base64'), + filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`, + }; }), }), }); diff --git a/todo.md b/todo.md index 6901242..7d1d6c9 100644 --- a/todo.md +++ b/todo.md @@ -82,3 +82,17 @@ - [x] Créer des statistiques de répartition par fonction sur le tableau de bord - [x] Ajouter des graphiques de visualisation des données - [x] Personnaliser les templates d'emails avec la fonction de l'apprenant + +## Refonte : Sessions → Séquences avec dates multiples + +- [x] Renommer "Session" en "Séquence" dans le schéma de base de données +- [x] Créer une table pour gérer jusqu'à 4 dates par séquence +- [x] Mettre à jour toutes les procédures tRPC pour utiliser "séquence" +- [x] Renommer toutes les interfaces et pages (AdminSessions → AdminSequences) +- [x] Adapter le formulaire de création/édition pour gérer 4 dates +- [x] Mettre à jour la page d'inscription pour afficher toutes les dates d'une séquence +- [x] Modifier la logique d'inscription pour inscrire à toutes les dates +- [x] Mettre à jour les exports PDF/Excel avec toutes les dates +- [x] Adapter les emails pour inclure toutes les dates de la séquence +- [x] Mettre à jour les invitations Outlook pour créer 4 événements si nécessaire +- [x] Renommer partout dans l'interface utilisateur (labels, titres, descriptions)