From 4063e298b98b2a9a4c2657185ff0208cca0b8152 Mon Sep 17 00:00:00 2001 From: Manus Date: Mon, 2 Mar 2026 04:39:32 -0500 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20V1.26=20-=20Feuille=20de=20pr?= =?UTF-8?q?=C3=A9sence=20:=20un=20PDF=20par=20jour=20(A4=20portrait,=20col?= =?UTF-8?q?onnes=20sig.=20matin=20+=20apr=C3=A8s-midi=20sur=20m=C3=AAme=20?= =?UTF-8?q?ligne,=20zone=20signature=20formateur).=20Correction=20des=20bu?= =?UTF-8?q?gs=20d'upload=20attestations=20et=20de=20plantage=20PDF.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/AdminSequenceInscrits.tsx | 27 ++-- server/exportService.ts | 160 +++++++++++++++++++++ server/routers.ts | 12 +- todo.md | 8 ++ 4 files changed, 192 insertions(+), 15 deletions(-) diff --git a/client/src/pages/AdminSequenceInscrits.tsx b/client/src/pages/AdminSequenceInscrits.tsx index 77bdc24..bc73daa 100644 --- a/client/src/pages/AdminSequenceInscrits.tsx +++ b/client/src/pages/AdminSequenceInscrits.tsx @@ -132,17 +132,24 @@ export default function AdminSequenceInscrits() { }); const exportFeuilleMutation = trpc.inscriptions.exportFeuillePresence.useMutation({ - onSuccess: (result) => { - const blob = new Blob([Uint8Array.from(atob(result.buffer), c => c.charCodeAt(0))], { - type: 'application/pdf' + onSuccess: (results) => { + // Télécharger chaque PDF avec un petit décalage pour éviter les blocages navigateur + results.forEach(({ buffer, filename }, index) => { + setTimeout(() => { + const blob = new Blob([Uint8Array.from(atob(buffer), c => c.charCodeAt(0))], { + type: 'application/pdf' + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }, index * 500); // 500ms de décalage entre chaque téléchargement }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = result.filename; - a.click(); - window.URL.revokeObjectURL(url); - toast.success("Feuille de présence téléchargée"); + toast.success(`${results.length} feuille(s) de présence téléchargée(s) (une par jour)`); }, onError: (error) => { toast.error("Erreur lors de l'export : " + error.message); diff --git a/server/exportService.ts b/server/exportService.ts index 8a88599..b3173cc 100644 --- a/server/exportService.ts +++ b/server/exportService.ts @@ -162,8 +162,168 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { return Buffer.from(doc.output('arraybuffer')); } +/** + * Génère un PDF de feuille de présence pour UN SEUL jour (sections matin + après-midi + signatures formateur) + * Retourne un Buffer PDF + */ +export function generateFeuillePresenceJour( + feuilleInfo: FeuillePresenceInfo, + dateInfo: { id: number; dateDebut: Date; dateFin: Date; ordre: number } +): Buffer { + // Format A4 portrait : largeur utile ~190mm (210 - 10*2) + const doc = new jsPDF({ orientation: 'portrait', format: 'a4' }); + + // Charger le logo + let logoData: string | null = null; + try { + const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png'); + const logoBuffer = fs.readFileSync(logoPath); + logoData = `data:image/png;base64,${logoBuffer.toString('base64')}`; + } catch (error) { + console.warn('Logo Itinova non trouvé, génération sans logo:', error); + } + + const publicCibleLabel = + feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : + feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : + feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'; + + const fonctionLabel = (f: string | null) => + f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre'; + + const loadSignatureBase64 = (signatureUrl: string | null): string | null => { + if (!signatureUrl) return null; + try { + const p = path.resolve(process.cwd(), signatureUrl.startsWith('/') ? signatureUrl.substring(1) : signatureUrl); + if (fs.existsSync(p)) { + return `data:image/png;base64,${fs.readFileSync(p).toString('base64')}`; + } + } catch { /* ignore */ } + return null; + }; + + const dateLabel = dateInfo.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + }); + + // ── En-tête ────────────────────────────────────────────────────────────── + if (logoData) doc.addImage(logoData, 'PNG', 160, 8, 40, 15); + + doc.setFontSize(16); + doc.setFont('helvetica', 'bold'); + doc.text('Feuille de présence', 10, 18); + doc.setFont('helvetica', 'normal'); + + doc.setFontSize(9); + let yPos = 28; + doc.text(`Formation : ${feuilleInfo.formationNom}`, 10, yPos); yPos += 5; + doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 10, yPos); yPos += 5; + doc.text(`Lieu : ${feuilleInfo.lieu}`, 10, yPos); yPos += 5; + if (feuilleInfo.formateur) { doc.text(`Formateur : ${feuilleInfo.formateur}`, 10, yPos); yPos += 5; } + doc.text(`Public cible : ${publicCibleLabel}`, 10, yPos); yPos += 5; + + doc.setFontSize(11); + doc.setFont('helvetica', 'bold'); + doc.text(`Jour ${dateInfo.ordre} – ${dateLabel}`, 10, yPos + 3); + doc.setFont('helvetica', 'normal'); + yPos += 10; + + // ── Tableau unique avec colonnes Signature Matin + Signature Après-midi ── + const tableData = feuilleInfo.apprenants.map(apprenant => { + const presenceMatin = apprenant.presences?.find( + p => p.dateFormationId === dateInfo.id && p.periode === 'matin' + ); + const presenceApresMidi = apprenant.presences?.find( + p => p.dateFormationId === dateInfo.id && p.periode === 'apres_midi' + ); + return [ + apprenant.nom, + apprenant.prenom, + apprenant.codeEtablissement || '', + fonctionLabel(apprenant.fonction), + presenceMatin?.signatureUrl || '', // col 4 : sig matin + presenceApresMidi?.signatureUrl || '', // col 5 : sig après-midi + ]; + }); + + autoTable(doc, { + startY: yPos, + head: [['Nom', 'Prénom', 'Code étab.', 'Fonction', 'Signature\nMatin', 'Signature\nAprès-midi']], + body: tableData, + margin: { left: 10, right: 10 }, + styles: { fontSize: 8, cellPadding: 2, minCellHeight: 18, overflow: 'linebreak' }, + headStyles: { fillColor: [37, 99, 235], halign: 'center', fontSize: 8 }, + columnStyles: { + 0: { cellWidth: 28 }, // Nom + 1: { cellWidth: 28 }, // Prénom + 2: { cellWidth: 20 }, // Code étab. + 3: { cellWidth: 24 }, // Fonction + 4: { cellWidth: 45 }, // Signature Matin + 5: { cellWidth: 45 }, // Signature Après-midi + }, + didDrawCell: (data) => { + if (data.section === 'body' && (data.column.index === 4 || data.column.index === 5)) { + const sig = loadSignatureBase64(data.cell.raw as string); + if (sig) { + doc.addImage(sig, 'PNG', data.cell.x + 2, data.cell.y + 2, data.cell.width - 4, data.cell.height - 4); + } + } + }, + }); + + // ── Signatures formateur (Matin et Après-midi sur la même ligne) ────────── + const finalY = (doc as any).lastAutoTable.finalY || yPos + 50; + const sigY = finalY + 10; + + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.text('Signatures du formateur :', 10, sigY); + doc.setFont('helvetica', 'normal'); + + // Matin (gauche) + doc.text('Matin :', 10, sigY + 8); + const sigMatinFormateur = feuilleInfo.signaturesFormateurs?.find( + s => s.dateFormationId === dateInfo.id && s.periode === 'matin' + ); + const sigMatinBase64 = loadSignatureBase64(sigMatinFormateur?.signatureUrl ?? null); + if (sigMatinBase64) { + doc.addImage(sigMatinBase64, 'PNG', 30, sigY + 2, 60, 14); + } else { + doc.line(30, sigY + 8, 90, sigY + 8); + } + + // Après-midi (droite) + doc.text('Après-midi :', 110, sigY + 8); + const sigApresMidiFormateur = feuilleInfo.signaturesFormateurs?.find( + s => s.dateFormationId === dateInfo.id && s.periode === 'apres_midi' + ); + const sigApresMidiBase64 = loadSignatureBase64(sigApresMidiFormateur?.signatureUrl ?? null); + if (sigApresMidiBase64) { + doc.addImage(sigApresMidiBase64, 'PNG', 140, sigY + 2, 60, 14); + } else { + doc.line(140, sigY + 8, 200, sigY + 8); + } + + return Buffer.from(doc.output('arraybuffer')); +} + +/** + * Génère un tableau de { ordre, filename, buffer } – un PDF par jour + */ +export function generateFeuillePresenceParJour( + feuilleInfo: FeuillePresenceInfo +): Array<{ ordre: number; filename: string; buffer: Buffer }> { + return feuilleInfo.dates.map(dateInfo => { + const buffer = generateFeuillePresenceJour(feuilleInfo, dateInfo); + const dateStr = dateInfo.dateDebut.toLocaleDateString('fr-FR').replace(/\//g, '-'); + const filename = `feuille_presence_jour${dateInfo.ordre}_${dateStr}.pdf`; + return { ordre: dateInfo.ordre, filename, buffer }; + }); +} + /** * Génère une feuille de présence PDF avec signatures matin/après-midi pour chaque journée + * @deprecated Utiliser generateFeuillePresenceParJour à la place */ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer { const doc = new jsPDF(); diff --git a/server/routers.ts b/server/routers.ts index 3580333..22b815f 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -11,7 +11,7 @@ import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportS import { TRPCError } from "@trpc/server"; import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService"; import { logNotification } from "./notificationLogsDb"; -import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; +import { generateExcelExport, generatePDFExport, generateFeuillePresenceParJour } from "./exportService"; import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; import crypto from "crypto"; import bcrypt from "bcryptjs"; @@ -1232,7 +1232,7 @@ export const appRouter = router({ }; }); - const buffer = await generateFeuillePresence({ + const pdfsParJour = generateFeuillePresenceParJour({ formationNom: formation.nom, sequenceNom: sequence.nom, dates: dates.map(d => ({ @@ -1252,10 +1252,12 @@ export const appRouter = router({ })), }); - return { + // Retourner un tableau de PDFs (un par jour) + return pdfsParJour.map(({ ordre, filename, buffer }) => ({ + ordre, buffer: buffer.toString('base64'), - filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`, - }; + filename, + })); }), // Ajout manuel d'un inscrit par l'admin (sans vérifications de blocage/capacité) diff --git a/todo.md b/todo.md index 97f066d..d3010f2 100644 --- a/todo.md +++ b/todo.md @@ -1708,3 +1708,11 @@ - [x] Corriger les largeurs de colonnes du tableau PDF (180mm total dans 190mm utiles) - [x] Tester sur TEST - [x] Déployer sur PROD + +## Amélioration - Feuille de présence : un PDF par jour (matin + après-midi + signature formateur) + +- [x] Refactoriser exportService.ts pour générer un PDF par jour (A4 portrait, colonnes sig. matin + après-midi) +- [x] Adapter le router pour retourner plusieurs PDFs (un par jour) +- [x] Adapter le frontend pour télécharger les PDFs par jour (décalage 500ms entre chaque) +- [x] Tester sur TEST +- [x] Déployer sur PROD