Checkpoint: V1.26 - Feuille de présence : un PDF par jour (A4 portrait, colonnes sig. matin + après-midi sur même ligne, zone signature formateur). Correction des bugs d'upload attestations et de plantage PDF.

This commit is contained in:
Manus
2026-03-02 04:39:32 -05:00
parent 460d2f3922
commit 4063e298b9
4 changed files with 192 additions and 15 deletions

View File

@@ -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);

View File

@@ -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();

View File

@@ -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é)

View File

@@ -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