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:
@@ -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<number | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(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<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 (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
@@ -439,52 +550,91 @@ export default function AdminGestionAttestations() {
|
||||
Gérez les attestations pour chaque apprenant inscrit à cette formation
|
||||
</CardDescription>
|
||||
</div>
|
||||
{modeEnvoi === "manuel" && apprenants && apprenants.length > 0 && (
|
||||
<Button
|
||||
onClick={handleSendAll}
|
||||
disabled={sendAllMutation.isPending}
|
||||
variant="default"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{sendAllMutation.isPending ? "Envoi en cours..." : "Tout renvoyer"}
|
||||
</Button>
|
||||
)}
|
||||
<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 && (
|
||||
<Button
|
||||
onClick={handleSendAll}
|
||||
disabled={sendAllMutation.isPending}
|
||||
variant="default"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{sendAllMutation.isPending ? "Envoi en cours..." : "Tout renvoyer"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingApprenants ? (
|
||||
<p className="text-center text-muted-foreground py-8">Chargement...</p>
|
||||
) : apprenants && apprenants.length > 0 ? (
|
||||
) : filteredSequences && filteredSequences.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{/* Regrouper les apprenants par séquence */}
|
||||
{Object.entries(
|
||||
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;
|
||||
}, {})
|
||||
)
|
||||
.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 (
|
||||
<div key={sequenceId} className="border rounded-lg overflow-hidden">
|
||||
{/* 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="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-blue-900">{sequenceName}</h3>
|
||||
<p className="text-sm text-blue-700">{sequenceApprenants.length} apprenant(s)</p>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg text-blue-900">{sequenceName}</h3>
|
||||
{isCompleted && (
|
||||
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-300">
|
||||
Terminée
|
||||
</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>
|
||||
<Badge variant="outline" className="bg-white">
|
||||
{sequenceApprenants.filter(a => a.attestation?.emailEnvoye).length} / {sequenceApprenants.length} envoyé(es)
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -586,7 +736,13 @@ export default function AdminGestionAttestations() {
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user