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

- Ajout du paramètre d'envoi automatique des attestations dans la page Paramètres
- Ajout de la prévisualisation du modèle d'attestation avec génération PDF
- Création de la page Historique des attestations dans la section Traçabilité
- Création de la table historiqueAttestations pour tracer les envois
- Ajout des procédures tRPC pour l'historique et la prévisualisation
This commit is contained in:
Manus
2026-01-12 13:54:18 -05:00
parent 61f28781f0
commit ce64c72f00
13 changed files with 563 additions and 19 deletions

View File

@@ -19,6 +19,7 @@ export default function AdminParametres() {
const [delaiExpirationQR, setDelaiExpirationQR] = useState(30);
const [dureeValiditeToken, setDureeValiditeToken] = useState(24);
const [notificationsActives, setNotificationsActives] = useState(true);
const [envoiAutomatiqueAttestations, setEnvoiAutomatiqueAttestations] = useState(true);
const [testUrlStatus, setTestUrlStatus] = useState<{ loading: boolean; result?: { accessible: boolean; message: string } }>({ loading: false });
// Initialiser les valeurs quand les données sont chargées
@@ -28,6 +29,7 @@ export default function AdminParametres() {
setDelaiExpirationQR(parametres.delaiExpirationQR);
setDureeValiditeToken(parametres.dureeValiditeToken);
setNotificationsActives(parametres.notificationsActives);
setEnvoiAutomatiqueAttestations(parametres.envoiAutomatiqueAttestations);
}
}, [parametres]);
@@ -72,6 +74,7 @@ export default function AdminParametres() {
delaiExpirationQR,
dureeValiditeToken,
notificationsActives,
envoiAutomatiqueAttestations,
});
};
@@ -221,6 +224,23 @@ export default function AdminParametres() {
/>
</div>
{/* Envoi automatique des attestations */}
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="envoiAutomatiqueAttestations" className="text-base">
Envoi automatique des attestations
</Label>
<p className="text-sm text-muted-foreground">
Envoyer automatiquement les attestations de formation aux apprenants à la fin de chaque séquence
</p>
</div>
<Switch
id="envoiAutomatiqueAttestations"
checked={envoiAutomatiqueAttestations}
onCheckedChange={setEnvoiAutomatiqueAttestations}
/>
</div>
<div className="flex items-center gap-2 pt-4">
<Button
type="submit"

View File

@@ -0,0 +1,166 @@
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader2, FileText, CheckCircle2, XCircle, ExternalLink } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
export default function HistoriqueAttestations() {
const { data: historique, isLoading } = trpc.attestations.historique.useQuery();
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="container max-w-7xl py-8">
<div className="flex items-center gap-3 mb-6">
<FileText className="h-8 w-8 text-primary" />
<div>
<h1 className="text-3xl font-bold">Historique des attestations</h1>
<p className="text-muted-foreground">
Suivi de tous les envois d'attestations de formation
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Envois d'attestations</CardTitle>
<CardDescription>
Liste complète des attestations envoyées avec leur statut
</CardDescription>
</CardHeader>
<CardContent>
{!historique || historique.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Aucun envoi d'attestation enregistré</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date d'envoi</TableHead>
<TableHead>Formation</TableHead>
<TableHead>Séquence</TableHead>
<TableHead>Apprenant</TableHead>
<TableHead>Email</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historique.map((item) => (
<TableRow key={item.id}>
<TableCell className="whitespace-nowrap">
{format(new Date(item.dateEnvoi), "dd/MM/yyyy HH:mm", { locale: fr })}
</TableCell>
<TableCell className="font-medium">
{item.formation.nom}
</TableCell>
<TableCell>{item.sequence.nom}</TableCell>
<TableCell>
{item.apprenant.prenom} {item.apprenant.nom}
</TableCell>
<TableCell className="text-muted-foreground">
{item.apprenant.email}
</TableCell>
<TableCell>
{item.statut === "envoye" ? (
<Badge variant="default" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
Envoyé
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Erreur
</Badge>
)}
</TableCell>
<TableCell>
{item.urlAttestation && (
<Button
variant="ghost"
size="sm"
asChild
>
<a
href={item.urlAttestation}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
<ExternalLink className="h-4 w-4" />
Voir PDF
</a>
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Statistiques */}
{historique && historique.length > 0 && (
<div className="grid gap-4 md:grid-cols-3 mt-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total envoyé
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{historique.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Envois réussis
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{historique.filter((h) => h.statut === "envoye").length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Erreurs
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{historique.filter((h) => h.statut === "erreur").length}
</div>
</CardContent>
</Card>
</div>
)}
</div>
</DashboardLayout>
);
}

View File

@@ -7,12 +7,14 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { FileText, Save } from "lucide-react";
import { FileText, Save, Eye } from "lucide-react";
import ImageUpload from "@/components/ImageUpload";
export default function AdminAttestations() {
const { data: config, isLoading, refetch } = trpc.attestations.getConfig.useQuery();
const updateConfig = trpc.attestations.updateConfig.useMutation();
const previsualiser = trpc.attestations.previsualiser.useMutation();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [formData, setFormData] = useState({
nomSignataire: "",
@@ -162,31 +164,71 @@ export default function AdminAttestations() {
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<div className="flex justify-between items-center">
<Button
type="button"
variant="outline"
onClick={() => {
if (config) {
setFormData({
nomSignataire: config.nomSignataire || "",
fonctionSignataire: config.fonctionSignataire || "",
texteAttestation: config.texteAttestation || "",
logoUrl: config.logoUrl || "",
logoS3Key: config.logoS3Key || "",
signatureUrl: config.signatureUrl || "",
signatureS3Key: config.signatureS3Key || "",
});
variant="secondary"
onClick={async () => {
try {
const result = await previsualiser.mutateAsync();
setPreviewUrl(result.pdfUrl);
toast.success("Prévisualisation générée");
} catch (error) {
toast.error("Erreur lors de la génération de la prévisualisation");
console.error(error);
}
}}
disabled={previsualiser.isPending}
>
Annuler
</Button>
<Button type="submit" disabled={updateConfig.isPending}>
<Save className="mr-2 h-4 w-4" />
{updateConfig.isPending ? "Enregistrement..." : "Enregistrer"}
<Eye className="mr-2 h-4 w-4" />
{previsualiser.isPending ? "Génération..." : "Prévisualiser le modèle"}
</Button>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
if (config) {
setFormData({
nomSignataire: config.nomSignataire || "",
fonctionSignataire: config.fonctionSignataire || "",
texteAttestation: config.texteAttestation || "",
logoUrl: config.logoUrl || "",
logoS3Key: config.logoS3Key || "",
signatureUrl: config.signatureUrl || "",
signatureS3Key: config.signatureS3Key || "",
});
}
}}
>
Annuler
</Button>
<Button type="submit" disabled={updateConfig.isPending}>
<Save className="mr-2 h-4 w-4" />
{updateConfig.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</div>
{/* Prévisualisation PDF */}
{previewUrl && (
<Card>
<CardHeader>
<CardTitle>Prévisualisation du modèle</CardTitle>
<CardDescription>
Aperçu du PDF d'attestation avec des données fictives
</CardDescription>
</CardHeader>
<CardContent>
<iframe
src={previewUrl}
className="w-full h-[800px] border rounded"
title="Prévisualisation de l'attestation"
/>
</CardContent>
</Card>
)}
</form>
</div>
</DashboardLayout>