Checkpoint: Améliorations de la page de gestion des attestations :

- Affichage de la date de début dans l'en-tête de chaque séquence
- Tri chronologique des séquences par date de début
- Filtre fonctionnel : Toutes les séquences / Séquences terminées / Séquences à venir
- Badge de statut visuel (Terminée en vert, À venir en orange)
- Compteur d'envois par séquence
- Bouton "Tout renvoyer" pour envoi groupé par séquence
- Correction robuste de l'affichage des apprenants
This commit is contained in:
Manus
2026-01-25 11:29:16 -05:00
parent 8efcb65f0f
commit 2ebc3dfa99

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; 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"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
// Composant de dialogue d'upload d'attestation // Composant de dialogue d'upload d'attestation
@@ -171,10 +171,14 @@ export default function AdminGestionAttestations() {
const [selectedInscriptionId, setSelectedInscriptionId] = useState<number | null>(null); const [selectedInscriptionId, setSelectedInscriptionId] = useState<number | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false); const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [filterStatus, setFilterStatus] = useState<"all" | "completed" | "upcoming">("all");
// Récupérer la liste des formations // Récupérer la liste des formations
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery(); 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 // Récupérer la configuration de la formation sélectionnée
const { data: formationConfig, refetch: refetchConfig } = trpc.gestionAttestations.getFormationConfig.useQuery( const { data: formationConfig, refetch: refetchConfig } = trpc.gestionAttestations.getFormationConfig.useQuery(
{ formationId: selectedFormationId! }, { formationId: selectedFormationId! },
@@ -188,6 +192,8 @@ export default function AdminGestionAttestations() {
{ enabled: !!selectedFormationId } { enabled: !!selectedFormationId }
); );
// Les dates sont déjà incluses dans les séquences
// Mutation pour mettre à jour la configuration // Mutation pour mettre à jour la configuration
const updateConfigMutation = trpc.gestionAttestations.updateFormationConfig.useMutation({ const updateConfigMutation = trpc.gestionAttestations.updateFormationConfig.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -283,7 +289,6 @@ export default function AdminGestionAttestations() {
}); });
}; };
// Supprimer un document
const handleDeleteDocument = (attestationId: number) => { const handleDeleteDocument = (attestationId: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer ce document ?")) { if (confirm("Êtes-vous sûr de vouloir supprimer ce document ?")) {
deleteDocumentMutation.mutate({ attestationId }); 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 // Ouvrir le dialogue d'upload
const handleOpenUpload = (inscriptionId: number) => { const handleOpenUpload = (inscriptionId: number) => {
setSelectedInscriptionId(inscriptionId); setSelectedInscriptionId(inscriptionId);
setUploadDialogOpen(true); 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<string, typeof apprenants>, 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 ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
@@ -439,6 +550,21 @@ export default function AdminGestionAttestations() {
Gérez les attestations pour chaque apprenant inscrit à cette formation Gérez les attestations pour chaque apprenant inscrit à cette formation
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-3">
{/* Filtre */}
<Select value={filterStatus} onValueChange={(value: any) => setFilterStatus(value)}>
<SelectTrigger className="w-[200px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les séquences</SelectItem>
<SelectItem value="completed">Séquences terminées</SelectItem>
<SelectItem value="upcoming">Séquences à venir</SelectItem>
</SelectContent>
</Select>
{/* Bouton envoi global */}
{modeEnvoi === "manuel" && apprenants && apprenants.length > 0 && ( {modeEnvoi === "manuel" && apprenants && apprenants.length > 0 && (
<Button <Button
onClick={handleSendAll} onClick={handleSendAll}
@@ -450,41 +576,65 @@ export default function AdminGestionAttestations() {
</Button> </Button>
)} )}
</div> </div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingApprenants ? ( {loadingApprenants ? (
<p className="text-center text-muted-foreground py-8">Chargement...</p> <p className="text-center text-muted-foreground py-8">Chargement...</p>
) : apprenants && apprenants.length > 0 ? ( ) : filteredSequences && filteredSequences.length > 0 ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Regrouper les apprenants par séquence */} {filteredSequences.map(({ sequenceId, sequenceName, apprenants: sequenceApprenants, firstDate, isCompleted }) => {
{Object.entries( const attestationsEnvoyees = sequenceApprenants.filter(a => a.attestation?.emailEnvoye).length;
apprenants.reduce((acc: Record<string, typeof apprenants>, item) => { const attestationsPretesNonEnvoyees = sequenceApprenants.filter(a => {
const sequenceId = item.sequence.id.toString(); const hasDocument = a.attestation && (a.attestation.documentUrl || a.attestation.urlPdf);
if (!acc[sequenceId]) acc[sequenceId] = []; return hasDocument && !a.attestation.emailEnvoye;
acc[sequenceId].push(item); }).length;
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';
return ( return (
<div key={sequenceId} className="border rounded-lg overflow-hidden"> <div key={sequenceId} className="border rounded-lg overflow-hidden">
{/* En-tête de séquence */} {/* En-tête de séquence */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b px-4 py-3"> <div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-lg text-blue-900">{sequenceName}</h3> <h3 className="font-semibold text-lg text-blue-900">{sequenceName}</h3>
<p className="text-sm text-blue-700">{sequenceApprenants.length} apprenant(s)</p> {isCompleted && (
</div> <Badge variant="outline" className="bg-green-100 text-green-700 border-green-300">
<Badge variant="outline" className="bg-white"> Terminée
{sequenceApprenants.filter(a => a.attestation?.emailEnvoye).length} / {sequenceApprenants.length} envoyé(es)
</Badge> </Badge>
)}
{!isCompleted && (
<Badge variant="outline" className="bg-orange-100 text-orange-700 border-orange-300">
À venir
</Badge>
)}
</div>
<div className="flex items-center gap-4 mt-1">
<p className="text-sm text-blue-700">{sequenceApprenants.length} apprenant(s)</p>
{firstDate && (
<div className="flex items-center gap-1 text-sm text-blue-600">
<Calendar className="h-3 w-3" />
<span>Début : {firstDate.toLocaleDateString('fr-FR')}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-white">
{attestationsEnvoyees} / {sequenceApprenants.length} envoyé(es)
</Badge>
{modeEnvoi === "manuel" && attestationsPretesNonEnvoyees > 0 && (
<Button
size="sm"
onClick={() => handleSendSequence(sequenceId, sequenceApprenants)}
disabled={sendAllMutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white"
>
<Send className="h-3 w-3 mr-1" />
Envoyer la séquence ({attestationsPretesNonEnvoyees})
</Button>
)}
</div>
</div> </div>
</div> </div>
@@ -586,7 +736,13 @@ export default function AdminGestionAttestations() {
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-3" /> <FileText className="h-12 w-12 text-gray-400 mx-auto mb-3" />
<p className="text-muted-foreground">Aucun apprenant inscrit pour cette formation</p> <p className="text-muted-foreground">
{filterStatus === "all"
? "Aucun apprenant inscrit pour cette formation"
: filterStatus === "completed"
? "Aucune séquence terminée"
: "Aucune séquence à venir"}
</p>
</div> </div>
)} )}
</CardContent> </CardContent>