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 { 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,52 +550,91 @@ 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>
|
||||||
{modeEnvoi === "manuel" && apprenants && apprenants.length > 0 && (
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
{/* Filtre */}
|
||||||
onClick={handleSendAll}
|
<Select value={filterStatus} onValueChange={(value: any) => setFilterStatus(value)}>
|
||||||
disabled={sendAllMutation.isPending}
|
<SelectTrigger className="w-[200px]">
|
||||||
variant="default"
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
>
|
<SelectValue />
|
||||||
<Send className="h-4 w-4 mr-2" />
|
</SelectTrigger>
|
||||||
{sendAllMutation.isPending ? "Envoi en cours..." : "Tout renvoyer"}
|
<SelectContent>
|
||||||
</Button>
|
<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>
|
</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">
|
||||||
<h3 className="font-semibold text-lg text-blue-900">{sequenceName}</h3>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-blue-700">{sequenceApprenants.length} apprenant(s)</p>
|
<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>
|
</div>
|
||||||
<Badge variant="outline" className="bg-white">
|
|
||||||
{sequenceApprenants.filter(a => a.attestation?.emailEnvoye).length} / {sequenceApprenants.length} envoyé(es)
|
|
||||||
</Badge>
|
|
||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user