Checkpoint: Système d'attestations de formation implémenté

 Infrastructure backend :
- Tables attestations et configAttestation en base de données
- Service de génération PDF avec PDFKit (attestationService.ts)
- Génération automatique de PDF avec template personnalisable
- Upload automatique vers S3 avec URLs publiques
- Procédures tRPC pour générer, récupérer et configurer

 Interface utilisateur :
- Page de configuration des attestations (/admin/attestations)
- Personnalisation du texte avec variables dynamiques (nomComplet, nomFormation, dateDebut, dateFin)
- Configuration du signataire (nom et fonction)
- Bouton "Générer" dans la page de validation des présences formateur
- Génération automatique avec ouverture du PDF dans un nouvel onglet

 Fonctionnalités :
- Vérification des doublons (une seule attestation par apprenant/séquence)
- Génération uniquement pour les apprenants marqués comme présents
- PDF professionnel avec détails de la formation et dates
- Stockage permanent sur S3

📋 Améliorations futures possibles :
- Upload de logo et signature personnalisés
- Envoi automatique par email aux apprenants
- Espace apprenant pour télécharger les attestations
This commit is contained in:
Manus
2026-01-07 10:06:29 -05:00
parent cc3badffa3
commit 023711df92
15 changed files with 6691 additions and 2 deletions

View File

@@ -0,0 +1,155 @@
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 { 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";
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) {
toast.error("Erreur lors de l'enregistrement");
console.error(error);
}
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">Chargement...</p>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="container max-w-4xl 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">Configuration des attestations</h1>
<p className="text-muted-foreground">
Personnalisez le contenu et la signature des attestations de formation
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informations du signataire</CardTitle>
<CardDescription>
Ces informations apparaîtront en bas de l'attestation
</CardDescription>
</CardHeader>
<CardContent className="space-y-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>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Texte de l'attestation</CardTitle>
<CardDescription>
Personnalisez le texte principal de l'attestation. Utilisez les variables suivantes :
<ul className="list-disc list-inside mt-2 space-y-1">
<li><code className="bg-muted px-1 py-0.5 rounded">{'{{nomComplet}}'}</code> - Nom complet de l'apprenant</li>
<li><code className="bg-muted px-1 py-0.5 rounded">{'{{nomFormation}}'}</code> - Nom de la formation</li>
<li><code className="bg-muted px-1 py-0.5 rounded">{'{{dateDebut}}'}</code> - Date de début de la formation</li>
<li><code className="bg-muted px-1 py-0.5 rounded">{'{{dateFin}}'}</code> - Date de fin de la formation</li>
</ul>
</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}
className="font-serif"
/>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
if (config) {
setFormData({
nomSignataire: config.nomSignataire || "",
fonctionSignataire: config.fonctionSignataire || "",
texteAttestation: config.texteAttestation || "",
});
}
}}
>
Annuler
</Button>
<Button type="submit" disabled={updateConfig.isPending}>
<Save className="mr-2 h-4 w-4" />
{updateConfig.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</form>
</div>
</DashboardLayout>
);
}

View File

@@ -13,9 +13,48 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { trpc } from "@/lib/trpc";
import { Calendar, FileText, MapPin, Upload, Users } from "lucide-react";
import { Calendar, FileText, MapPin, Upload, Users, Award } from "lucide-react";
import { useParams } from "wouter";
import { toast } from "sonner";
import { useState } from "react";
function AttestationButton({ inscriptionId, present }: { inscriptionId: number; present: boolean }) {
const [isGenerating, setIsGenerating] = useState(false);
const genererAttestation = trpc.attestations.generer.useMutation();
const handleGenerer = async () => {
if (!present) {
toast.error("L'apprenant doit être marqué comme présent pour générer une attestation");
return;
}
setIsGenerating(true);
try {
const result = await genererAttestation.mutateAsync({ inscriptionId });
if (result.success && result.attestation) {
toast.success(result.message || "Attestation générée avec succès");
// Ouvrir le PDF dans un nouvel onglet
window.open(result.attestation.pdfUrl, "_blank");
}
} catch (error: any) {
toast.error(error.message || "Erreur lors de la génération de l'attestation");
} finally {
setIsGenerating(false);
}
};
return (
<Button
size="sm"
variant="outline"
onClick={handleGenerer}
disabled={!present || isGenerating}
>
<Award className="h-4 w-4 mr-1" />
{isGenerating ? "Génération..." : "Générer"}
</Button>
);
}
export default function FormateurSequence() {
const { id } = useParams();
@@ -189,6 +228,7 @@ export default function FormateurSequence() {
<TableHead>Établissement</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-center">Présent</TableHead>
<TableHead className="text-center">Attestation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -224,6 +264,9 @@ export default function FormateurSequence() {
}
/>
</TableCell>
<TableCell className="text-center">
<AttestationButton inscriptionId={apprenant.inscriptionId} present={apprenant.statut === "confirmee"} />
</TableCell>
</TableRow>
))}
</TableBody>