From 2ebc3dfa996ec3c862f058da3346ced35b2c2675 Mon Sep 17 00:00:00 2001 From: Manus Date: Sun, 25 Jan 2026 11:29:16 -0500 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Am=C3=A9liorations=20de=20la=20pa?= =?UTF-8?q?ge=20de=20gestion=20des=20attestations=20:=20-=20Affichage=20de?= =?UTF-8?q?=20la=20date=20de=20d=C3=A9but=20dans=20l'en-t=C3=AAte=20de=20c?= =?UTF-8?q?haque=20s=C3=A9quence=20-=20Tri=20chronologique=20des=20s=C3=A9?= =?UTF-8?q?quences=20par=20date=20de=20d=C3=A9but=20-=20Filtre=20fonctionn?= =?UTF-8?q?el=20:=20Toutes=20les=20s=C3=A9quences=20/=20S=C3=A9quences=20t?= =?UTF-8?q?ermin=C3=A9es=20/=20S=C3=A9quences=20=C3=A0=20venir=20-=20Badge?= =?UTF-8?q?=20de=20statut=20visuel=20(Termin=C3=A9e=20en=20vert,=20=C3=80?= =?UTF-8?q?=20venir=20en=20orange)=20-=20Compteur=20d'envois=20par=20s?= =?UTF-8?q?=C3=A9quence=20-=20Bouton=20"Tout=20renvoyer"=20pour=20envoi=20?= =?UTF-8?q?group=C3=A9=20par=20s=C3=A9quence=20-=20Correction=20robuste=20?= =?UTF-8?q?de=20l'affichage=20des=20apprenants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/AdminGestionAttestations.tsx | 232 +++++++++++++++--- 1 file changed, 194 insertions(+), 38 deletions(-) diff --git a/client/src/pages/AdminGestionAttestations.tsx b/client/src/pages/AdminGestionAttestations.tsx index c3b5bd6..ff1c707 100644 --- a/client/src/pages/AdminGestionAttestations.tsx +++ b/client/src/pages/AdminGestionAttestations.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { trpc } from "@/lib/trpc"; import DashboardLayout from "@/components/DashboardLayout"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -9,7 +9,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; -import { Eye, Send, Upload, Trash2, FileText } from "lucide-react"; +import { Eye, Send, Upload, Trash2, FileText, Calendar, Filter } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; // Composant de dialogue d'upload d'attestation @@ -171,10 +171,14 @@ export default function AdminGestionAttestations() { const [selectedInscriptionId, setSelectedInscriptionId] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [filterStatus, setFilterStatus] = useState<"all" | "completed" | "upcoming">("all"); // Récupérer la liste des formations const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery(); + // Récupérer toutes les séquences pour obtenir les dates + const { data: allSequences } = trpc.sequences.list.useQuery(); + // Récupérer la configuration de la formation sélectionnée const { data: formationConfig, refetch: refetchConfig } = trpc.gestionAttestations.getFormationConfig.useQuery( { formationId: selectedFormationId! }, @@ -188,6 +192,8 @@ export default function AdminGestionAttestations() { { enabled: !!selectedFormationId } ); + // Les dates sont déjà incluses dans les séquences + // Mutation pour mettre à jour la configuration const updateConfigMutation = trpc.gestionAttestations.updateFormationConfig.useMutation({ onSuccess: () => { @@ -283,7 +289,6 @@ export default function AdminGestionAttestations() { }); }; - // Supprimer un document const handleDeleteDocument = (attestationId: number) => { if (confirm("Êtes-vous sûr de vouloir supprimer ce document ?")) { deleteDocumentMutation.mutate({ attestationId }); @@ -310,12 +315,118 @@ export default function AdminGestionAttestations() { } }; + // Envoyer toutes les attestations d'une séquence + const handleSendSequence = (sequenceId: number, sequenceApprenants: any[]) => { + const attestationsReady = sequenceApprenants.filter(item => { + const hasDocument = item.attestation && (item.attestation.documentUrl || item.attestation.urlPdf); + return hasDocument && !item.attestation.emailEnvoye; + }); + + if (attestationsReady.length === 0) { + toast.error("Aucune attestation prête à envoyer pour cette séquence"); + return; + } + + if (confirm(`Êtes-vous sûr de vouloir envoyer ${attestationsReady.length} attestation(s) pour cette séquence ?`)) { + // Envoyer chaque attestation de la séquence + let successCount = 0; + let errorCount = 0; + + const sendPromises = attestationsReady.map(item => { + const pdfUrl = item.attestation.documentUrl || item.attestation.urlPdf; + return sendAttestationMutation.mutateAsync({ + attestationId: item.attestation.id, + sequenceId: item.sequence.id, + apprenantId: item.apprenant.id, + apprenantEmail: item.apprenant.email, + apprenantNom: item.apprenant.nom, + apprenantPrenom: item.apprenant.prenom, + formationNom: formations?.find(f => f.id === selectedFormationId)?.nom || "", + pdfUrl: pdfUrl, + }).then(() => { + successCount++; + }).catch(() => { + errorCount++; + }); + }); + + Promise.all(sendPromises).finally(() => { + if (successCount > 0) { + toast.success(`${successCount} attestation(s) envoyée(s) avec succès${errorCount > 0 ? `, ${errorCount} échec(s)` : ''}`); + } + refetchApprenants(); + }); + } + }; + // Ouvrir le dialogue d'upload const handleOpenUpload = (inscriptionId: number) => { setSelectedInscriptionId(inscriptionId); setUploadDialogOpen(true); }; + // Calculer les informations de séquence avec dates et tri + const sequencesWithDates = useMemo(() => { + if (!apprenants || !allSequences) return []; + + // Regrouper par séquence + const grouped = apprenants.reduce((acc: Record, item) => { + const sequenceId = item.sequence.id.toString(); + if (!acc[sequenceId]) acc[sequenceId] = []; + acc[sequenceId].push(item); + return acc; + }, {}); + + // Ajouter les dates et calculer le statut + const today = new Date(); + today.setHours(0, 0, 0, 0); + + return Object.entries(grouped).map(([sequenceId, sequenceApprenants]) => { + const sequenceIdNum = parseInt(sequenceId); + const sequence = allSequences.find(s => s.id === sequenceIdNum); + const dates = sequence?.dates || []; + + // Trouver la première et dernière date + const sortedDates = dates + .map(d => new Date(d.dateDebut)) + .sort((a, b) => a.getTime() - b.getTime()); + + const sortedEndDates = dates + .map(d => new Date(d.dateFin)) + .sort((a, b) => a.getTime() - b.getTime()); + + const firstDate = sortedDates.length > 0 ? sortedDates[0] : null; + const lastDate = sortedEndDates.length > 0 ? sortedEndDates[sortedEndDates.length - 1] : null; + + // Déterminer si la séquence est terminée + const isCompleted = lastDate ? lastDate < today : false; + + return { + sequenceId: sequenceIdNum, + sequenceName: sequenceApprenants[0]?.sequence?.nom || 'Séquence inconnue', + apprenants: sequenceApprenants, + firstDate, + lastDate, + isCompleted, + }; + }) + .sort((a, b) => { + // Tri chronologique par première date + if (!a.firstDate && !b.firstDate) return a.sequenceId - b.sequenceId; + if (!a.firstDate) return 1; + if (!b.firstDate) return -1; + return a.firstDate.getTime() - b.firstDate.getTime(); + }); + }, [apprenants, allSequences]); + + // Filtrer les séquences selon le filtre sélectionné + const filteredSequences = useMemo(() => { + if (filterStatus === "all") return sequencesWithDates; + if (filterStatus === "completed") return sequencesWithDates.filter(s => s.isCompleted); + if (filterStatus === "upcoming") return sequencesWithDates.filter(s => !s.isCompleted); + return sequencesWithDates; + }, [sequencesWithDates, filterStatus]); + return (
@@ -439,52 +550,91 @@ export default function AdminGestionAttestations() { Gérez les attestations pour chaque apprenant inscrit à cette formation
- {modeEnvoi === "manuel" && apprenants && apprenants.length > 0 && ( - - )} +
+ {/* Filtre */} + + + {/* Bouton envoi global */} + {modeEnvoi === "manuel" && apprenants && apprenants.length > 0 && ( + + )} +
{loadingApprenants ? (

Chargement...

- ) : apprenants && apprenants.length > 0 ? ( + ) : filteredSequences && filteredSequences.length > 0 ? (
- {/* Regrouper les apprenants par séquence */} - {Object.entries( - apprenants.reduce((acc: Record, item) => { - const sequenceId = item.sequence.id.toString(); - if (!acc[sequenceId]) acc[sequenceId] = []; - acc[sequenceId].push(item); - return acc; - }, {}) - ) - .sort((a, b) => { - // Trier par ID de séquence (ordre de création) - const idA = parseInt(a[0]); - const idB = parseInt(b[0]); - return idA - idB; - }) - .map(([sequenceId, sequenceApprenants]) => { - const sequenceName = sequenceApprenants[0]?.sequence?.nom || 'Séquence inconnue'; + {filteredSequences.map(({ sequenceId, sequenceName, apprenants: sequenceApprenants, firstDate, isCompleted }) => { + const attestationsEnvoyees = sequenceApprenants.filter(a => a.attestation?.emailEnvoye).length; + const attestationsPretesNonEnvoyees = sequenceApprenants.filter(a => { + const hasDocument = a.attestation && (a.attestation.documentUrl || a.attestation.urlPdf); + return hasDocument && !a.attestation.emailEnvoye; + }).length; + return (
{/* En-tête de séquence */}
-
-

{sequenceName}

-

{sequenceApprenants.length} apprenant(s)

+
+
+

{sequenceName}

+ {isCompleted && ( + + Terminée + + )} + {!isCompleted && ( + + À venir + + )} +
+
+

{sequenceApprenants.length} apprenant(s)

+ {firstDate && ( +
+ + Début : {firstDate.toLocaleDateString('fr-FR')} +
+ )} +
+
+
+ + {attestationsEnvoyees} / {sequenceApprenants.length} envoyé(es) + + {modeEnvoi === "manuel" && attestationsPretesNonEnvoyees > 0 && ( + + )}
- - {sequenceApprenants.filter(a => a.attestation?.emailEnvoye).length} / {sequenceApprenants.length} envoyé(es) -
@@ -586,7 +736,13 @@ export default function AdminGestionAttestations() { ) : (
-

Aucun apprenant inscrit pour cette formation

+

+ {filterStatus === "all" + ? "Aucun apprenant inscrit pour cette formation" + : filterStatus === "completed" + ? "Aucune séquence terminée" + : "Aucune séquence à venir"} +

)}