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:
@@ -30,6 +30,7 @@ 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 HistoriqueAttestations from "./pages/HistoriqueAttestations";
|
||||
import AdminParametres from "./pages/AdminParametres";
|
||||
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
|
||||
import Inscription from "./pages/Inscription";
|
||||
@@ -56,6 +57,7 @@ function Router() {
|
||||
<Route path={"/admin/formateurs"} component={AdminFormateurs} />
|
||||
<Route path={"/admin/notifications"} component={AdminNotifications} />
|
||||
<Route path={"/admin/attestations"} component={AdminAttestations} />
|
||||
<Route path={"/admin/historique-attestations"} component={HistoriqueAttestations} />
|
||||
<Route path={"/admin/email-templates"} component={AdminEmailTemplates} />
|
||||
<Route path={"/admin/email-config"} component={AdminEmailConfig} />
|
||||
<Route path={"/admin/parametres"} component={AdminParametres} />
|
||||
|
||||
@@ -77,6 +77,7 @@ const menuSections = [
|
||||
{ icon: Mail, label: "Notifications", path: "/admin/notifications" },
|
||||
{ icon: TrendingUp, label: "Suivi Questionnaires", path: "/admin/questionnaires/suivi" },
|
||||
{ icon: Bell, label: "Rappels", path: "/admin/rappels/historique" },
|
||||
{ icon: FileText, label: "Historique Attestations", path: "/admin/historique-attestations" },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
166
client/src/pages/HistoriqueAttestations.tsx
Normal file
166
client/src/pages/HistoriqueAttestations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user