Checkpoint: Ajout de la page de gestion des attestations de formation
Nouvelle fonctionnalité complète pour gérer les attestations : - Schéma de base de données : colonnes modeAttestation et modeEnvoi dans formations, colonnes documentUrl, documentS3Key, uploadedBy, uploadedAt dans attestations - Backend : fichier gestionAttestationsDb.ts, procédures tRPC, fonction sendAttestationEmail - Interface : page AdminGestionAttestations avec sélection de formation, configuration des modes, tableau des apprenants, boutons upload/prévisualisation/envoi/suppression - Navigation : lien dans menu Gestion, route /admin/gestion-attestations
This commit is contained in:
400
client/src/pages/AdminGestionAttestations.tsx
Normal file
400
client/src/pages/AdminGestionAttestations.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
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";
|
||||
|
||||
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);
|
||||
|
||||
// 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}`);
|
||||
},
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
if (!apprenant.attestation?.urlPdf) {
|
||||
toast.error("Aucune attestation disponible pour cet apprenant");
|
||||
return;
|
||||
}
|
||||
|
||||
sendAttestationMutation.mutate({
|
||||
attestationId: apprenant.attestation.id,
|
||||
apprenantEmail: apprenant.apprenant.email,
|
||||
apprenantNom: apprenant.apprenant.nom,
|
||||
apprenantPrenom: apprenant.apprenant.prenom,
|
||||
formationNom: formations?.find(f => f.id === selectedFormationId)?.nom || "",
|
||||
pdfUrl: apprenant.attestation.urlPdf,
|
||||
});
|
||||
};
|
||||
|
||||
// Supprimer un document
|
||||
const handleDeleteDocument = (attestationId: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer ce document ?")) {
|
||||
deleteDocumentMutation.mutate({ attestationId });
|
||||
}
|
||||
};
|
||||
|
||||
// Ouvrir le dialogue d'upload
|
||||
const handleOpenUpload = (inscriptionId: number) => {
|
||||
setSelectedInscriptionId(inscriptionId);
|
||||
setUploadDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">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>
|
||||
|
||||
{/* 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>
|
||||
<CardTitle>Apprenants inscrits</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez les attestations pour chaque apprenant inscrit à cette formation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingApprenants ? (
|
||||
<p className="text-center text-muted-foreground py-8">Chargement...</p>
|
||||
) : apprenants && apprenants.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Séquence</TableHead>
|
||||
<TableHead>Statut attestation</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apprenants.map((item) => {
|
||||
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}>
|
||||
<TableCell>{item.apprenant.nom}</TableCell>
|
||||
<TableCell>{item.apprenant.prenom}</TableCell>
|
||||
<TableCell>{item.apprenant.email}</TableCell>
|
||||
<TableCell>{item.sequence.nom}</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"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenUpload(item.inscription.id)}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
{hasDocument ? "Remplacer" : "Charger"}
|
||||
</Button>
|
||||
)}
|
||||
{hasDocument && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePreview(documentUrl!)}
|
||||
>
|
||||
<Eye className="h-4 w-4 text-blue-600" />
|
||||
</Button>
|
||||
{modeEnvoi === "manuel" && !isSent && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleSendAttestation(item)}
|
||||
disabled={sendAttestationMutation.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
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 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 */}
|
||||
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Charger une attestation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sélectionnez un fichier PDF à uploader pour cet apprenant
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fonctionnalité d'upload à implémenter avec un composant FileUpload similaire à celui des rappels
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setUploadDialogOpen(false)}>
|
||||
Fermer
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user