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

@@ -0,0 +1,9 @@
{
"query": "ALTER TABLE parametres ADD COLUMN envoiAutomatiqueAttestations BOOLEAN DEFAULT true NOT NULL;",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute ALTER TABLE parametres ADD COLUMN envoiAutomatiqueAttestations BOOLEAN DEFAULT true NOT NULL;",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 1061
}

View File

@@ -0,0 +1,9 @@
{
"query": "CREATE TABLE historiqueAttestations (\n id INT AUTO_INCREMENT PRIMARY KEY,\n sequenceId INT NOT NULL,\n apprenantId INT NOT NULL,\n dateEnvoi TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n statut ENUM('envoye', 'erreur') NOT NULL,\n messageErreur TEXT NULL,\n urlAttestation VARCHAR(500) NULL,\n createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (sequenceId) REFERENCES sequences(id) ON DELETE CASCADE,\n FOREIGN KEY (apprenantId) REFERENCES apprenants(id) ON DELETE CASCADE\n);",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute CREATE TABLE historiqueAttestations (\n id INT AUTO_INCREMENT PRIMARY KEY,\n sequenceId INT NOT NULL,\n apprenantId INT NOT NULL,\n dateEnvoi TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n statut ENUM('envoye', 'erreur') NOT NULL,\n messageErreur TEXT NULL,\n urlAttestation VARCHAR(500) NULL,\n createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (sequenceId) REFERENCES sequences(id) ON DELETE CASCADE,\n FOREIGN KEY (apprenantId) REFERENCES apprenants(id) ON DELETE CASCADE\n);",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 261
}

View File

@@ -30,6 +30,7 @@ import AdminRappelsStats from "./pages/admin/AdminRappelsStats";
import AdminFormateurs from "./pages/admin/AdminFormateurs"; import AdminFormateurs from "./pages/admin/AdminFormateurs";
import AdminNotifications from "./pages/admin/AdminNotifications"; import AdminNotifications from "./pages/admin/AdminNotifications";
import AdminAttestations from "./pages/admin/AdminAttestations"; import AdminAttestations from "./pages/admin/AdminAttestations";
import HistoriqueAttestations from "./pages/HistoriqueAttestations";
import AdminParametres from "./pages/AdminParametres"; import AdminParametres from "./pages/AdminParametres";
import QuestionnaireReponse from "./pages/QuestionnaireReponse"; import QuestionnaireReponse from "./pages/QuestionnaireReponse";
import Inscription from "./pages/Inscription"; import Inscription from "./pages/Inscription";
@@ -56,6 +57,7 @@ function Router() {
<Route path={"/admin/formateurs"} component={AdminFormateurs} /> <Route path={"/admin/formateurs"} component={AdminFormateurs} />
<Route path={"/admin/notifications"} component={AdminNotifications} /> <Route path={"/admin/notifications"} component={AdminNotifications} />
<Route path={"/admin/attestations"} component={AdminAttestations} /> <Route path={"/admin/attestations"} component={AdminAttestations} />
<Route path={"/admin/historique-attestations"} component={HistoriqueAttestations} />
<Route path={"/admin/email-templates"} component={AdminEmailTemplates} /> <Route path={"/admin/email-templates"} component={AdminEmailTemplates} />
<Route path={"/admin/email-config"} component={AdminEmailConfig} /> <Route path={"/admin/email-config"} component={AdminEmailConfig} />
<Route path={"/admin/parametres"} component={AdminParametres} /> <Route path={"/admin/parametres"} component={AdminParametres} />

View File

@@ -77,6 +77,7 @@ const menuSections = [
{ icon: Mail, label: "Notifications", path: "/admin/notifications" }, { icon: Mail, label: "Notifications", path: "/admin/notifications" },
{ icon: TrendingUp, label: "Suivi Questionnaires", path: "/admin/questionnaires/suivi" }, { icon: TrendingUp, label: "Suivi Questionnaires", path: "/admin/questionnaires/suivi" },
{ icon: Bell, label: "Rappels", path: "/admin/rappels/historique" }, { icon: Bell, label: "Rappels", path: "/admin/rappels/historique" },
{ icon: FileText, label: "Historique Attestations", path: "/admin/historique-attestations" },
] ]
}, },
{ {

View File

@@ -19,6 +19,7 @@ export default function AdminParametres() {
const [delaiExpirationQR, setDelaiExpirationQR] = useState(30); const [delaiExpirationQR, setDelaiExpirationQR] = useState(30);
const [dureeValiditeToken, setDureeValiditeToken] = useState(24); const [dureeValiditeToken, setDureeValiditeToken] = useState(24);
const [notificationsActives, setNotificationsActives] = useState(true); const [notificationsActives, setNotificationsActives] = useState(true);
const [envoiAutomatiqueAttestations, setEnvoiAutomatiqueAttestations] = useState(true);
const [testUrlStatus, setTestUrlStatus] = useState<{ loading: boolean; result?: { accessible: boolean; message: string } }>({ loading: false }); const [testUrlStatus, setTestUrlStatus] = useState<{ loading: boolean; result?: { accessible: boolean; message: string } }>({ loading: false });
// Initialiser les valeurs quand les données sont chargées // Initialiser les valeurs quand les données sont chargées
@@ -28,6 +29,7 @@ export default function AdminParametres() {
setDelaiExpirationQR(parametres.delaiExpirationQR); setDelaiExpirationQR(parametres.delaiExpirationQR);
setDureeValiditeToken(parametres.dureeValiditeToken); setDureeValiditeToken(parametres.dureeValiditeToken);
setNotificationsActives(parametres.notificationsActives); setNotificationsActives(parametres.notificationsActives);
setEnvoiAutomatiqueAttestations(parametres.envoiAutomatiqueAttestations);
} }
}, [parametres]); }, [parametres]);
@@ -72,6 +74,7 @@ export default function AdminParametres() {
delaiExpirationQR, delaiExpirationQR,
dureeValiditeToken, dureeValiditeToken,
notificationsActives, notificationsActives,
envoiAutomatiqueAttestations,
}); });
}; };
@@ -221,6 +224,23 @@ export default function AdminParametres() {
/> />
</div> </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"> <div className="flex items-center gap-2 pt-4">
<Button <Button
type="submit" 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 { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileText, Save } from "lucide-react"; import { FileText, Save, Eye } from "lucide-react";
import ImageUpload from "@/components/ImageUpload"; import ImageUpload from "@/components/ImageUpload";
export default function AdminAttestations() { export default function AdminAttestations() {
const { data: config, isLoading, refetch } = trpc.attestations.getConfig.useQuery(); const { data: config, isLoading, refetch } = trpc.attestations.getConfig.useQuery();
const updateConfig = trpc.attestations.updateConfig.useMutation(); const updateConfig = trpc.attestations.updateConfig.useMutation();
const previsualiser = trpc.attestations.previsualiser.useMutation();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
nomSignataire: "", nomSignataire: "",
@@ -162,31 +164,71 @@ export default function AdminAttestations() {
</CardContent> </CardContent>
</Card> </Card>
<div className="flex justify-end gap-3"> <div className="flex justify-between items-center">
<Button <Button
type="button" type="button"
variant="outline" variant="secondary"
onClick={() => { onClick={async () => {
if (config) { try {
setFormData({ const result = await previsualiser.mutateAsync();
nomSignataire: config.nomSignataire || "", setPreviewUrl(result.pdfUrl);
fonctionSignataire: config.fonctionSignataire || "", toast.success("Prévisualisation générée");
texteAttestation: config.texteAttestation || "", } catch (error) {
logoUrl: config.logoUrl || "", toast.error("Erreur lors de la génération de la prévisualisation");
logoS3Key: config.logoS3Key || "", console.error(error);
signatureUrl: config.signatureUrl || "",
signatureS3Key: config.signatureS3Key || "",
});
} }
}} }}
disabled={previsualiser.isPending}
> >
Annuler <Eye className="mr-2 h-4 w-4" />
</Button> {previsualiser.isPending ? "Génération..." : "Prévisualiser le modèle"}
<Button type="submit" disabled={updateConfig.isPending}>
<Save className="mr-2 h-4 w-4" />
{updateConfig.isPending ? "Enregistrement..." : "Enregistrer"}
</Button> </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> </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> </form>
</div> </div>
</DashboardLayout> </DashboardLayout>

View File

@@ -485,6 +485,8 @@ export const parametres = mysqlTable("parametres", {
dureeValiditeToken: int("dureeValiditeToken").default(24).notNull(), dureeValiditeToken: int("dureeValiditeToken").default(24).notNull(),
/** Activer ou désactiver les notifications automatiques */ /** Activer ou désactiver les notifications automatiques */
notificationsActives: boolean("notificationsActives").default(true).notNull(), notificationsActives: boolean("notificationsActives").default(true).notNull(),
/** Activer ou désactiver l'envoi automatique des attestations de formation */
envoiAutomatiqueAttestations: boolean("envoiAutomatiqueAttestations").default(true).notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}); });
@@ -512,3 +514,21 @@ export const historiqueParametres = mysqlTable("historiqueParametres", {
export type HistoriqueParametre = typeof historiqueParametres.$inferSelect; export type HistoriqueParametre = typeof historiqueParametres.$inferSelect;
export type InsertHistoriqueParametre = typeof historiqueParametres.$inferInsert; export type InsertHistoriqueParametre = typeof historiqueParametres.$inferInsert;
/**
* Table de l'historique des envois d'attestations de formation
* Permet de tracer tous les envois d'attestations
*/
export const historiqueAttestations = mysqlTable("historiqueAttestations", {
id: int("id").autoincrement().primaryKey(),
sequenceId: int("sequenceId").notNull(),
apprenantId: int("apprenantId").notNull(),
dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
statut: mysqlEnum("statut", ["envoye", "erreur"]).notNull(),
messageErreur: text("messageErreur"),
urlAttestation: varchar("urlAttestation", { length: 500 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type HistoriqueAttestation = typeof historiqueAttestations.$inferSelect;
export type InsertHistoriqueAttestation = typeof historiqueAttestations.$inferInsert;

View File

@@ -294,3 +294,137 @@ export async function updateConfigAttestation(data: {
await db.insert(configAttestation).values(data); await db.insert(configAttestation).values(data);
} }
} }
/**
* Génère une prévisualisation du modèle d'attestation avec des données fictives
*/
export async function genererPreviewAttestation(): Promise<{ pdfUrl: string }> {
const db = await getDb();
if (!db) {
throw new Error("Base de données non disponible");
}
// Récupérer la configuration de l'attestation
const config = await getOrCreateConfigAttestation();
if (!config) {
throw new Error("Configuration d'attestation introuvable");
}
// Données fictives pour la prévisualisation
const donneesFictives = {
apprenant: {
nom: "Dupont",
prenom: "Jean",
email: "jean.dupont@example.com",
},
formation: {
nom: "Formation Manager Itinova",
nbJours: 5,
},
sequence: {
nom: "Groupe A",
lieu: "Paris",
},
dates: [
{ date: new Date("2026-02-10") },
{ date: new Date("2026-02-11") },
{ date: new Date("2026-02-12") },
{ date: new Date("2026-02-13") },
{ date: new Date("2026-02-14") },
],
};
// Créer le PDF
const doc = new PDFDocument({ size: "A4", margin: 50 });
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk));
// En-tête avec logo si disponible
if (config.logoUrl) {
try {
const response = await fetch(config.logoUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
doc.image(buffer, 50, 45, { width: 100 });
} catch (error) {
console.error("Erreur lors du chargement du logo:", error);
}
}
// Titre
doc
.fontSize(24)
.font("Helvetica-Bold")
.text("ATTESTATION DE FORMATION", 0, 150, { align: "center" });
doc.moveDown(2);
// Texte de l'attestation avec remplacement des variables
let texte = config.texteAttestation || "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.";
const dateDebut = format(donneesFictives.dates[0].date, "d MMMM yyyy", { locale: fr });
const dateFin = format(donneesFictives.dates[donneesFictives.dates.length - 1].date, "d MMMM yyyy", { locale: fr });
texte = texte
.replace(/\{\{nomComplet\}\}/g, `${donneesFictives.apprenant.prenom} ${donneesFictives.apprenant.nom}`)
.replace(/\{\{nomFormation\}\}/g, donneesFictives.formation.nom)
.replace(/\{\{dateDebut\}\}/g, dateDebut)
.replace(/\{\{dateFin\}\}/g, dateFin)
.replace(/\{\{lieu\}\}/g, donneesFictives.sequence.lieu)
.replace(/\{\{nbJours\}\}/g, donneesFictives.formation.nbJours.toString());
doc
.fontSize(12)
.font("Helvetica")
.text(texte, { align: "justify", lineGap: 5 });
doc.moveDown(2);
// Dates de formation
doc.fontSize(10).font("Helvetica-Bold").text("Dates de formation :");
doc.font("Helvetica");
donneesFictives.dates.forEach((d) => {
doc.text(`${format(d.date, "EEEE d MMMM yyyy", { locale: fr })}`, { indent: 20 });
});
doc.moveDown(2);
// Signature
doc.fontSize(10).font("Helvetica").text(`Fait à ${donneesFictives.sequence.lieu}, le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`);
doc.moveDown(1);
if (config.signatureUrl) {
try {
const response = await fetch(config.signatureUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
doc.image(buffer, doc.x, doc.y, { width: 150 });
doc.moveDown(3);
} catch (error) {
console.error("Erreur lors du chargement de la signature:", error);
}
}
if (config.nomSignataire) {
doc.font("Helvetica-Bold").text(config.nomSignataire);
}
if (config.fonctionSignataire) {
doc.font("Helvetica").text(config.fonctionSignataire);
}
// Finaliser le PDF
doc.end();
await new Promise((resolve) => doc.on("end", resolve));
const pdfBuffer = Buffer.concat(chunks);
// Uploader sur S3
const randomSuffix = crypto.randomBytes(8).toString("hex");
const s3Key = `attestations/preview/preview-${randomSuffix}.pdf`;
const { url: pdfUrl } = await storagePut(s3Key, pdfBuffer, "application/pdf");
return { pdfUrl };
}

93
server/attestationsDb.ts Normal file
View File

@@ -0,0 +1,93 @@
import { desc, eq } from "drizzle-orm";
import { getDb } from "./db";
import { historiqueAttestations, sequences, apprenants, formations } from "../drizzle/schema";
/**
* Enregistrer un envoi d'attestation dans l'historique
*/
export async function enregistrerEnvoiAttestation(data: {
sequenceId: number;
apprenantId: number;
statut: "envoye" | "erreur";
messageErreur?: string;
urlAttestation?: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.insert(historiqueAttestations).values({
sequenceId: data.sequenceId,
apprenantId: data.apprenantId,
statut: data.statut,
messageErreur: data.messageErreur || null,
urlAttestation: data.urlAttestation || null,
});
}
/**
* Récupérer l'historique des envois d'attestations
* Avec les informations de la séquence, formation et apprenant
*/
export async function getHistoriqueAttestations() {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
id: historiqueAttestations.id,
dateEnvoi: historiqueAttestations.dateEnvoi,
statut: historiqueAttestations.statut,
messageErreur: historiqueAttestations.messageErreur,
urlAttestation: historiqueAttestations.urlAttestation,
sequence: {
id: sequences.id,
nom: sequences.nom,
},
formation: {
id: formations.id,
nom: formations.nom,
},
apprenant: {
id: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
email: apprenants.email,
},
})
.from(historiqueAttestations)
.innerJoin(sequences, eq(historiqueAttestations.sequenceId, sequences.id))
.innerJoin(formations, eq(sequences.formationId, formations.id))
.innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id))
.orderBy(desc(historiqueAttestations.dateEnvoi));
return results;
}
/**
* Récupérer l'historique des attestations pour une séquence spécifique
*/
export async function getHistoriqueAttestationsBySequence(sequenceId: number) {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
id: historiqueAttestations.id,
dateEnvoi: historiqueAttestations.dateEnvoi,
statut: historiqueAttestations.statut,
messageErreur: historiqueAttestations.messageErreur,
urlAttestation: historiqueAttestations.urlAttestation,
apprenant: {
id: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
email: apprenants.email,
},
})
.from(historiqueAttestations)
.innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id))
.where(eq(historiqueAttestations.sequenceId, sequenceId))
.orderBy(desc(historiqueAttestations.dateEnvoi));
return results;
}

View File

@@ -35,6 +35,7 @@ export async function updateParametres(
delaiExpirationQR: number; delaiExpirationQR: number;
dureeValiditeToken: number; dureeValiditeToken: number;
notificationsActives: boolean; notificationsActives: boolean;
envoiAutomatiqueAttestations: boolean;
}>, }>,
userId: number, userId: number,
userName: string userName: string

View File

@@ -1478,6 +1478,7 @@ export const appRouter = router({
delaiExpirationQR: z.number().int().positive().optional(), delaiExpirationQR: z.number().int().positive().optional(),
dureeValiditeToken: z.number().int().positive().optional(), dureeValiditeToken: z.number().int().positive().optional(),
notificationsActives: z.boolean().optional(), notificationsActives: z.boolean().optional(),
envoiAutomatiqueAttestations: z.boolean().optional(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { updateParametres } = await import("./parametresDb"); const { updateParametres } = await import("./parametresDb");
@@ -2092,6 +2093,30 @@ export const appRouter = router({
await updateConfigAttestation(input); await updateConfigAttestation(input);
return { success: true }; return { success: true };
}), }),
// Récupérer l'historique des envois d'attestations
historique: adminProcedure
.query(async () => {
const { getHistoriqueAttestations } = await import("./attestationsDb");
return getHistoriqueAttestations();
}),
// Récupérer l'historique pour une séquence spécifique
historiqueBySequence: adminProcedure
.input(z.object({
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getHistoriqueAttestationsBySequence } = await import("./attestationsDb");
return getHistoriqueAttestationsBySequence(input.sequenceId);
}),
// Générer une prévisualisation du modèle d'attestation
previsualiser: adminProcedure
.mutation(async () => {
const { genererPreviewAttestation } = await import("./attestationService");
return genererPreviewAttestation();
}),
}), }),
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) ===== // ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====

22
todo.md
View File

@@ -725,3 +725,25 @@
- [x] Vérifier les noms de colonnes dans la table rappels - [x] Vérifier les noms de colonnes dans la table rappels
- [x] Corriger le schéma Drizzle pour correspondre à la base de données - [x] Corriger le schéma Drizzle pour correspondre à la base de données
- [x] Tester les requêtes sur la page Rappels - [x] Tester les requêtes sur la page Rappels
## Améliorations attestations de formation
### Base de données
- [x] Ajouter le paramètre envoiAutomatiqueAttestations dans la table parametres
- [x] Créer la table historiqueAttestations pour tracer les envois
### Backend
- [x] Créer les fonctions de gestion du paramètre d'envoi automatique
- [x] Créer les procédures tRPC pour l'historique des attestations
- [x] Créer la procédure de prévisualisation du PDF
### Frontend
- [x] Ajouter le toggle d'envoi automatique dans AdminParametres
- [x] Ajouter la prévisualisation PDF dans AdminAttestations
- [x] Créer la page HistoriqueAttestations dans Traçabilité
- [x] Ajouter le lien dans le menu de navigation
### Tes### Tests
- [x] Tester le paramètre d'envoi automatique
- [x] Tester la prévisualisation du modèle
- [x] Tester la page d'historique