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:
@@ -29,6 +29,7 @@ import AdminRappelsHistorique from "./pages/admin/AdminRappelsHistorique";
|
||||
import AdminRappelsStats from "./pages/admin/AdminRappelsStats";
|
||||
import AdminFormateurs from "./pages/admin/AdminFormateurs";
|
||||
import AdminNotifications from "./pages/admin/AdminNotifications";
|
||||
import AdminAttestations from "./pages/admin/AdminAttestations";
|
||||
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
|
||||
import Inscription from "./pages/Inscription";
|
||||
import Login from "./pages/Login";
|
||||
@@ -53,6 +54,7 @@ function Router() {
|
||||
<Route path={"/admin/users"} component={AdminUsers} />
|
||||
<Route path={"/admin/formateurs"} component={AdminFormateurs} />
|
||||
<Route path={"/admin/notifications"} component={AdminNotifications} />
|
||||
<Route path={"/admin/attestations"} component={AdminAttestations} />
|
||||
<Route path={"/admin/email-templates"} component={AdminEmailTemplates} />
|
||||
<Route path={"/admin/email-config"} component={AdminEmailConfig} />
|
||||
<Route path={"/admin/calendrier"} component={AdminCalendrier} />
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User } from "lucide-react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User, FileText } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
@@ -57,6 +57,7 @@ const menuSections = [
|
||||
{ icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" },
|
||||
{ icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" },
|
||||
{ icon: Mail, label: "Notifications", path: "/admin/notifications" },
|
||||
{ icon: FileText, label: "Attestations", path: "/admin/attestations" },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
155
client/src/pages/admin/AdminAttestations.tsx
Normal file
155
client/src/pages/admin/AdminAttestations.tsx
Normal 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 "{{nomFormation}}" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user