Files
formation-manager-itinova/client/src/pages/AdminGestionAttestations.tsx
Manus b32487aa4d Checkpoint: Améliorations de la gestion des attestations :
- Affichage de la date de début dans l'en-tête de chaque séquence
- Bouton d'envoi groupé par séquence
- Filtre séquences terminées/à venir
2026-01-25 09:16:22 -05:00

708 lines
30 KiB
TypeScript

import { useState } from "react";
import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
// Composant de dialogue d'upload d'attestation
function UploadAttestationDialog({
open,
onOpenChange,
inscriptionId,
onSuccess,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
inscriptionId: number | null;
onSuccess: () => void;
}) {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [fileData, setFileData] = useState<{
nomFichier: string;
urlFichier: string;
s3Key: string;
typeFichier: string;
tailleFichier: number;
} | null>(null);
const uploadMutation = trpc.gestionAttestations.uploadDocument.useMutation({
onSuccess: () => {
onSuccess();
setFileData(null);
},
onError: (error) => {
toast.error(`Erreur lors de l'upload : ${error.message}`);
setUploading(false);
},
});
const handleUpload = () => {
if (!fileData || !inscriptionId) return;
setUploading(true);
uploadMutation.mutate({
inscriptionId,
documentUrl: fileData.urlFichier,
documentS3Key: fileData.s3Key,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Charger une attestation</DialogTitle>
<DialogDescription>
Sélectionnez un fichier PDF à uploader pour cet apprenant
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Fichier PDF</Label>
<input
type="file"
accept=".pdf"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.type !== 'application/pdf') {
toast.error('Seuls les fichiers PDF sont acceptés');
return;
}
if (file.size > 10 * 1024 * 1024) {
toast.error('Le fichier ne doit pas dépasser 10 Mo');
return;
}
setUploading(true);
setUploadProgress(0);
try {
const formData = new FormData();
formData.append('file', file);
// Simuler la progression de l'upload
const progressInterval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 90) return prev;
return prev + 10;
});
}, 200);
const response = await fetch('/api/upload-file', {
method: 'POST',
body: formData,
});
clearInterval(progressInterval);
setUploadProgress(100);
if (!response.ok) {
throw new Error('Erreur lors de l\'upload');
}
const data = await response.json();
setFileData({
nomFichier: file.name,
urlFichier: data.url,
s3Key: data.key,
typeFichier: file.type,
tailleFichier: file.size,
});
toast.success('Fichier uploadé avec succès');
} catch (error) {
toast.error('Erreur lors de l\'upload du fichier');
setUploadProgress(0);
} finally {
setUploading(false);
}
}}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-primary-foreground hover:file:bg-primary/90"
/>
{uploading && (
<div className="mt-4">
<div className="flex justify-between text-sm text-muted-foreground mb-1">
<span>Upload en cours...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{fileData && !uploading && (
<p className="text-sm text-muted-foreground mt-2">
Fichier sélectionné : {fileData.nomFichier}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={uploading}>
Annuler
</Button>
<Button onClick={handleUpload} disabled={!fileData || uploading}>
{uploading ? 'Chargement...' : 'Valider'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function AdminGestionAttestations() {
const [selectedFormationId, setSelectedFormationId] = useState<number | null>(null);
const [modeAttestation, setModeAttestation] = useState<"auto" | "manuel">("auto");
const [modeEnvoi, setModeEnvoi] = useState<"auto" | "manuel">("manuel");
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [selectedInscriptionId, setSelectedInscriptionId] = useState<number | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [filtreStatut, setFiltreStatut] = useState<"toutes" | "terminees" | "a_venir">("toutes");
// Récupérer la liste des formations
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery();
// Récupérer la configuration de la formation sélectionnée
const { data: formationConfig, refetch: refetchConfig } = trpc.gestionAttestations.getFormationConfig.useQuery(
{ formationId: selectedFormationId! },
{ enabled: !!selectedFormationId }
);
// Récupérer les apprenants avec leur statut d'attestation
const { data: apprenants, isLoading: loadingApprenants, refetch: refetchApprenants } =
trpc.gestionAttestations.getApprenantsWithStatus.useQuery(
{ formationId: selectedFormationId! },
{ enabled: !!selectedFormationId }
);
// Mutation pour mettre à jour la configuration
const updateConfigMutation = trpc.gestionAttestations.updateFormationConfig.useMutation({
onSuccess: () => {
toast.success("Configuration enregistrée avec succès");
refetchConfig();
},
onError: (error) => {
toast.error(`Erreur : ${error.message}`);
},
});
// Mutation pour envoyer une attestation
const sendAttestationMutation = trpc.gestionAttestations.sendAttestation.useMutation({
onSuccess: () => {
toast.success("Attestation envoyée avec succès");
refetchApprenants();
},
onError: (error) => {
toast.error(`Erreur lors de l'envoi : ${error.message}`);
},
});
// Mutation pour supprimer un document
const deleteDocumentMutation = trpc.gestionAttestations.deleteDocument.useMutation({
onSuccess: () => {
toast.success("Document supprimé avec succès");
refetchApprenants();
},
onError: (error) => {
toast.error(`Erreur lors de la suppression : ${error.message}`);
},
});
// Mutation pour envoyer toutes les attestations
const sendAllMutation = trpc.gestionAttestations.sendAllAttestations.useMutation({
onSuccess: (result) => {
toast.success(`${result.succes} attestation(s) envoyée(s) avec succès${result.echecs > 0 ? `, ${result.echecs} échec(s)` : ''}`);
refetchApprenants();
},
onError: (error) => {
toast.error(`Erreur lors de l'envoi en masse : ${error.message}`);
},
});
// Charger la configuration quand une formation est sélectionnée
const handleFormationChange = (formationId: string) => {
const id = parseInt(formationId);
setSelectedFormationId(id);
// Charger la configuration existante
const config = formations?.find(f => f.id === id);
if (config) {
setModeAttestation((config as any).modeAttestation || "auto");
setModeEnvoi((config as any).modeEnvoi || "manuel");
}
};
// Enregistrer la configuration
const handleSaveConfig = () => {
if (!selectedFormationId) return;
updateConfigMutation.mutate({
formationId: selectedFormationId,
modeAttestation,
modeEnvoi,
});
};
// Prévisualiser un document
const handlePreview = (url: string) => {
setPreviewUrl(url);
setPreviewDialogOpen(true);
};
// Envoyer une attestation
const handleSendAttestation = (apprenant: any) => {
const pdfUrl = apprenant.attestation?.documentUrl || apprenant.attestation?.urlPdf;
if (!pdfUrl) {
toast.error("Aucune attestation disponible pour cet apprenant");
return;
}
sendAttestationMutation.mutate({
attestationId: apprenant.attestation.id,
sequenceId: apprenant.sequence.id,
apprenantId: apprenant.apprenant.id,
apprenantEmail: apprenant.apprenant.email,
apprenantNom: apprenant.apprenant.nom,
apprenantPrenom: apprenant.apprenant.prenom,
formationNom: formations?.find(f => f.id === selectedFormationId)?.nom || "",
pdfUrl: pdfUrl,
});
};
// Supprimer un document
const handleDeleteDocument = (attestationId: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer ce document ?")) {
deleteDocumentMutation.mutate({ attestationId });
}
};
// Envoyer toutes les attestations
const handleSendAll = () => {
if (!selectedFormationId) return;
// Compter le nombre d'attestations prêtes à envoyer
const attestationsReady = apprenants?.filter(item => {
const hasDocument = item.attestation && (item.attestation.urlPdf || item.attestation.documentUrl);
return hasDocument;
}).length || 0;
if (attestationsReady === 0) {
toast.error("Aucune attestation prête à envoyer");
return;
}
if (confirm(`Êtes-vous sûr de vouloir envoyer ${attestationsReady} attestation(s) ?`)) {
sendAllMutation.mutate({ formationId: selectedFormationId });
}
};
// Ouvrir le dialogue d'upload
const handleOpenUpload = (inscriptionId: number) => {
setSelectedInscriptionId(inscriptionId);
setUploadDialogOpen(true);
};
return (
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="page-title">Gestion des attestations de formation</h1>
<p className="text-muted-foreground">
Configurez le mode de génération et d'envoi des attestations pour chaque formation
</p>
</div>
{/* Sélection de la formation */}
<Card>
<CardHeader>
<CardTitle>Sélectionner une formation</CardTitle>
<CardDescription>
Choisissez la formation pour laquelle vous souhaitez gérer les attestations
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={selectedFormationId?.toString() || ""}
onValueChange={handleFormationChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sélectionner une formation" />
</SelectTrigger>
<SelectContent>
{formations?.map((formation) => (
<SelectItem key={formation.id} value={formation.id.toString()}>
{formation.nom}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Filtre par statut */}
{selectedFormationId && (
<Card>
<CardHeader>
<CardTitle>Filtrer les séquences</CardTitle>
<CardDescription>
Afficher uniquement les séquences terminées ou à venir
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup value={filtreStatut} onValueChange={(value: "toutes" | "terminees" | "a_venir") => setFiltreStatut(value)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="toutes" id="toutes" />
<Label htmlFor="toutes" className="font-normal cursor-pointer">
Toutes les séquences
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="terminees" id="terminees" />
<Label htmlFor="terminees" className="font-normal cursor-pointer">
Séquences terminées
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="a_venir" id="a_venir" />
<Label htmlFor="a_venir" className="font-normal cursor-pointer">
Séquences à venir
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
)}
{/* Configuration */}
{selectedFormationId && (
<Card>
<CardHeader>
<CardTitle>Configuration de la formation</CardTitle>
<CardDescription>
Définissez comment les attestations seront générées et envoyées
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Mode de génération */}
<div className="space-y-3">
<Label className="text-base font-semibold">Mode de génération des attestations</Label>
<RadioGroup value={modeAttestation} onValueChange={(value: "auto" | "manuel") => setModeAttestation(value)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto" id="auto" />
<Label htmlFor="auto" className="font-normal cursor-pointer">
<div>
<div className="font-medium">Génération automatique</div>
<div className="text-sm text-muted-foreground">
Les attestations sont générées automatiquement à partir du modèle configuré
</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="manuel" id="manuel" />
<Label htmlFor="manuel" className="font-normal cursor-pointer">
<div>
<div className="font-medium">Import manuel</div>
<div className="text-sm text-muted-foreground">
Vous uploadez un document PDF pour chaque apprenant
</div>
</div>
</Label>
</div>
</RadioGroup>
</div>
{/* Mode d'envoi */}
<div className="space-y-3">
<Label className="text-base font-semibold">Mode d'envoi des attestations</Label>
<RadioGroup value={modeEnvoi} onValueChange={(value: "auto" | "manuel") => setModeEnvoi(value)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto" id="envoi-auto" />
<Label htmlFor="envoi-auto" className="font-normal cursor-pointer">
<div>
<div className="font-medium">Envoi automatique</div>
<div className="text-sm text-muted-foreground">
Les attestations sont envoyées automatiquement par email aux apprenants
</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="manuel" id="envoi-manuel" />
<Label htmlFor="envoi-manuel" className="font-normal cursor-pointer">
<div>
<div className="font-medium">Envoi manuel</div>
<div className="text-sm text-muted-foreground">
Vous décidez quand envoyer chaque attestation via un bouton
</div>
</div>
</Label>
</div>
</RadioGroup>
</div>
<Button onClick={handleSaveConfig} disabled={updateConfigMutation.isPending}>
{updateConfigMutation.isPending ? "Enregistrement..." : "Enregistrer la configuration"}
</Button>
</CardContent>
</Card>
)}
{/* Liste des apprenants */}
{selectedFormationId && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Apprenants inscrits</CardTitle>
<CardDescription>
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>
</CardHeader>
<CardContent>
{loadingApprenants ? (
<p className="text-center text-muted-foreground py-8">Chargement...</p>
) : apprenants && apprenants.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;
})
.filter(([sequenceId, sequenceApprenants]) => {
// Filtrer selon le statut sélectionné
if (filtreStatut === "toutes") return true;
const premiereDate = sequenceApprenants[0]?.sequence?.premiereDate;
if (!premiereDate) return true; // Afficher les séquences sans date
const now = new Date();
const sequenceDate = new Date(premiereDate);
if (filtreStatut === "terminees") {
return sequenceDate < now;
} else if (filtreStatut === "a_venir") {
return sequenceDate >= now;
}
return true;
})
.map(([sequenceId, sequenceApprenants]) => {
const sequenceName = sequenceApprenants[0]?.sequence?.nom || 'Séquence inconnue';
const premiereDate = sequenceApprenants[0]?.sequence?.premiereDate;
const dateFormatted = premiereDate ? new Date(premiereDate).toLocaleDateString('fr-FR') : null;
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}
{dateFormatted && <span className="text-blue-600 ml-2">- {dateFormatted}</span>}
</h3>
<p className="text-sm text-blue-700">{sequenceApprenants.length} apprenant(s)</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="bg-white">
{sequenceApprenants.filter(a => a.attestation?.emailEnvoye).length} / {sequenceApprenants.length} envoyé(es)
</Badge>
<Button
size="sm"
variant="default"
className="bg-blue-600 hover:bg-blue-700"
onClick={() => {
const inscriptionIds = sequenceApprenants
.filter(a => a.attestation?.documentUrl && !a.attestation?.emailEnvoye)
.map(a => a.inscription.id);
if (inscriptionIds.length === 0) {
toast.info("Aucune attestation à envoyer pour cette séquence");
return;
}
if (confirm(`Envoyer ${inscriptionIds.length} attestation(s) pour cette séquence ?`)) {
inscriptionIds.forEach(id => {
sendAttestationMutation.mutate({ inscriptionId: id });
});
}
}}
disabled={sendAttestationMutation.isPending}
>
<Send className="h-4 w-4 mr-1" />
Envoyer tout
</Button>
</div>
</div>
</div>
{/* Tableau des apprenants de cette séquence */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Prénom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Statut attestation</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sequenceApprenants.map((item, index) => {
const hasAttestation = !!item.attestation;
const hasDocument = hasAttestation && (item.attestation.urlPdf || item.attestation.documentUrl);
const isSent = hasAttestation && item.attestation.emailEnvoye;
const documentUrl = item.attestation?.documentUrl || item.attestation?.urlPdf;
return (
<TableRow key={item.inscription.id} className={index % 2 === 1 ? "bg-blue-50/30" : ""}>
<TableCell>{item.apprenant.nom}</TableCell>
<TableCell>{item.apprenant.prenom}</TableCell>
<TableCell>{item.apprenant.email}</TableCell>
<TableCell>
{!hasDocument && (
<Badge variant="outline" className="bg-gray-50">
Aucune attestation
</Badge>
)}
{hasDocument && !isSent && (
<Badge variant="outline" className="bg-orange-50 text-orange-700 border-orange-300">
Prête à envoyer
</Badge>
)}
{hasDocument && isSent && (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-300">
Envoyée
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{modeAttestation === "manuel" && (
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => handleOpenUpload(item.inscription.id)}
>
<Upload className="h-4 w-4 mr-1" />
{hasDocument ? "Remplacer" : "Charger"}
</Button>
)}
{hasDocument && (
<>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => handlePreview(documentUrl!)}
>
<Eye className="h-4 w-4" />
</Button>
{modeEnvoi === "manuel" && (
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleSendAttestation(item)}
disabled={sendAttestationMutation.isPending}
>
<Send className="h-4 w-4 mr-1" />
{isSent ? "Renvoyer" : "Envoyer"}
</Button>
)}
{modeAttestation === "manuel" && (
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteDocument(item.attestation!.id)}
disabled={deleteDocumentMutation.isPending}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
)}
</>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
})}
</div>
) : (
<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>
</div>
)}
</CardContent>
</Card>
)}
{/* Dialog d'upload */}
<UploadAttestationDialog
open={uploadDialogOpen}
onOpenChange={setUploadDialogOpen}
inscriptionId={selectedInscriptionId}
onSuccess={() => {
refetchApprenants();
setUploadDialogOpen(false);
toast.success("Attestation chargée avec succès");
}}
/>
{/* Dialog de prévisualisation */}
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent className="max-w-4xl h-[80vh]">
<DialogHeader>
<DialogTitle>Prévisualisation de l'attestation</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden">
{previewUrl && (
<iframe
src={previewUrl}
className="w-full h-full border-0"
title="Prévisualisation de l'attestation"
/>
)}
</div>
</DialogContent>
</Dialog>
</div>
</DashboardLayout>
);
}