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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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é)
|
||||
|
||||
8
todo.md
8
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
|
||||
|
||||
Reference in New Issue
Block a user