Checkpoint: Phase 1 : Système d'attestations de formation automatiques

 Infrastructure backend complète :
- Tables attestations et configAttestation en base de données
- Service de génération PDF (attestationService.ts) avec PDFKit
- Procédures tRPC pour générer, récupérer et configurer les attestations
- Stockage S3 des PDF générés

 Interface d'administration :
- Page de configuration des attestations (/admin/attestations)
- Personnalisation du texte avec variables dynamiques
- Champs pour signataire (nom et fonction)
- Placeholders pour logo et signature (à implémenter en phase 2)

📋 À faire en Phase 2 :
- Upload des images (logo/signature) vers S3
- Génération automatique après validation de présence
- Envoi automatique par email aux apprenants
- Espace apprenant pour télécharger les attestations
This commit is contained in:
Manus
2026-01-07 06:35:12 -05:00
parent 29b35fceaa
commit 2eb352206f
14 changed files with 3098 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
import { useState, useEffect } from "react";
import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Upload, FileText, Save } from "lucide-react";
export default function AdminAttestations() {
const { data: config, isLoading, refetch } = trpc.attestations.getConfig.useQuery();
const updateConfig = trpc.attestations.updateConfig.useMutation();
const [formData, setFormData] = useState({
nomSignataire: "",
fonctionSignataire: "",
texteAttestation: "",
});
// Mettre à jour le formulaire quand les données sont chargées
useEffect(() => {
if (config) {
setFormData({
nomSignataire: config.nomSignataire || "",
fonctionSignataire: config.fonctionSignataire || "",
texteAttestation: config.texteAttestation || "",
});
}
}, [config]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateConfig.mutateAsync(formData);
toast.success("Configuration enregistrée avec succès");
refetch();
} catch (error: any) {
toast.error("Erreur lors de l'enregistrement : " + error.message);
}
};
if (isLoading) {
return (
<DashboardLayout>
<div className="text-center py-8">Chargement...</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Configuration des Attestations</h1>
<p className="text-muted-foreground">
Personnalisez le template des attestations de formation
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Logo et signature */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Logo
</CardTitle>
<CardDescription>
Logo affiché en haut de l'attestation
</CardDescription>
</CardHeader>
<CardContent>
{config?.logoUrl ? (
<div className="space-y-4">
<img
src={config.logoUrl}
alt="Logo"
className="max-w-full h-auto border rounded"
/>
<Button type="button" variant="outline" size="sm">
Changer le logo
</Button>
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Aucun logo configuré
</p>
<Button type="button" variant="outline" size="sm">
Uploader un logo
</Button>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Signature
</CardTitle>
<CardDescription>
Signature affichée en bas de l'attestation
</CardDescription>
</CardHeader>
<CardContent>
{config?.signatureUrl ? (
<div className="space-y-4">
<img
src={config.signatureUrl}
alt="Signature"
className="max-w-full h-auto border rounded"
/>
<Button type="button" variant="outline" size="sm">
Changer la signature
</Button>
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<FileText className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Aucune signature configurée
</p>
<Button type="button" variant="outline" size="sm">
Uploader une signature
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{/* Informations du signataire */}
<Card>
<CardHeader>
<CardTitle>Informations du signataire</CardTitle>
<CardDescription>
Nom et fonction affichés en bas de l'attestation
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="nomSignataire">Nom du signataire</Label>
<Input
id="nomSignataire"
value={formData.nomSignataire}
onChange={(e) =>
setFormData({ ...formData, nomSignataire: e.target.value })
}
placeholder="Ex: Jean Dupont"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fonctionSignataire">Fonction du signataire</Label>
<Input
id="fonctionSignataire"
value={formData.fonctionSignataire}
onChange={(e) =>
setFormData({ ...formData, fonctionSignataire: e.target.value })
}
placeholder="Ex: Directeur de la formation"
/>
</div>
</div>
</CardContent>
</Card>
{/* Texte de l'attestation */}
<Card>
<CardHeader>
<CardTitle>Texte de l'attestation</CardTitle>
<CardDescription>
Personnalisez le texte principal de l'attestation. Variables disponibles :
<code className="ml-2 text-xs bg-muted px-2 py-1 rounded">
{"{"}{"{"} nomComplet {"}"}{"}"}
</code>
<code className="ml-2 text-xs bg-muted px-2 py-1 rounded">
{"{"}{"{"} nomFormation {"}"}{"}"}
</code>
<code className="ml-2 text-xs bg-muted px-2 py-1 rounded">
{"{"}{"{"} dateDebut {"}"}{"}"}
</code>
<code className="ml-2 text-xs bg-muted px-2 py-1 rounded">
{"{"}{"{"} dateFin {"}"}{"}"}
</code>
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={formData.texteAttestation}
onChange={(e) =>
setFormData({ ...formData, texteAttestation: e.target.value })
}
placeholder="Nous attestons que {{nomComplet}} a suivi avec assiduité la formation &quot;{{nomFormation}}&quot; organisée du {{dateDebut}} au {{dateFin}}."
rows={6}
/>
</CardContent>
</Card>
{/* Bouton de sauvegarde */}
<div className="flex justify-end">
<Button type="submit" disabled={updateConfig.isPending}>
<Save className="h-4 w-4 mr-2" />
{updateConfig.isPending ? "Enregistrement..." : "Enregistrer la configuration"}
</Button>
</div>
</form>
</div>
</DashboardLayout>
);
}