/** * Service d'export des données en Excel et PDF */ import * as XLSX from 'xlsx'; import { jsPDF } from 'jspdf'; import autoTable from 'jspdf-autotable'; import * as fs from 'fs'; import * as path from 'path'; interface InscriptionExport { nom: string; prenom: string; email: string; codeEtablissement: string | null; fonction: string | null; statut: string; dateInscription: Date; } interface SequenceInfo { formationNom: string; sequenceNom: string; dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; lieu: string; publicCible: string; inscriptions: InscriptionExport[]; } interface ApprenantPresence { nom: string; prenom: string; codeEtablissement: string | null; fonction: string | null; presences?: Array<{ dateFormationId: number; periode: 'matin' | 'apres_midi'; signatureUrl: string | null; }>; } interface FeuillePresenceInfo { formationNom: string; sequenceNom: string; dates: Array<{ id: number; dateDebut: Date; dateFin: Date; ordre: number }>; lieu: string; publicCible: string; formateur?: string; apprenants: ApprenantPresence[]; signaturesFormateurs?: Array<{ dateFormationId: number; periode: 'matin' | 'apres_midi'; signatureUrl: string | null; }>; } /** * Génère un fichier Excel avec la liste des inscrits */ export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer { // Préparer les données const data = sequenceInfo.inscriptions.map(i => ({ 'Nom': i.nom, 'Prénom': i.prenom, 'Email': i.email, 'Code établissement': i.codeEtablissement || '', 'Fonction': i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', 'Statut': i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée', 'Date d\'inscription': i.dateInscription.toLocaleDateString('fr-FR'), })); // Créer le workbook const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Inscrits'); // Ajouter une feuille d'informations const infoData = [ { 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom }, { 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom }, { 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu }, { 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre' }, { 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() }, ]; // Ajouter les dates sequenceInfo.dates.forEach(date => { infoData.push({ 'Information': `Date ${date.ordre}`, 'Valeur': date.dateDebut.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }); }); const wsInfo = XLSX.utils.json_to_sheet(infoData); XLSX.utils.book_append_sheet(wb, wsInfo, 'Informations'); // Générer le buffer return Buffer.from(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })); } /** * Génère un PDF avec la liste des inscrits */ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { const doc = new jsPDF(); // Titre doc.setFontSize(18); doc.text('Liste des inscrits', 14, 20); // Informations de séquence doc.setFontSize(10); let yPos = 35; doc.text(`Formation : ${sequenceInfo.formationNom}`, 14, yPos); yPos += 6; doc.text(`Séquence : ${sequenceInfo.sequenceNom}`, 14, yPos); yPos += 6; doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos); yPos += 6; doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos); yPos += 8; // Dates de formation doc.setFontSize(9); sequenceInfo.dates.forEach(date => { doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`, 14, yPos); yPos += 5; }); // Tableau des inscrits const tableData = sequenceInfo.inscriptions.map(i => [ i.nom, i.prenom, i.email, i.codeEtablissement || '', i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée', ]); autoTable(doc, { startY: yPos + 10, head: [['Nom', 'Prénom', 'Email', 'Code étab.', 'Fonction', 'Statut']], body: tableData, styles: { fontSize: 9 }, headStyles: { fillColor: [37, 99, 235] }, }); 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(); // Charger le logo let logoData: string | null = null; try { // Utiliser un chemin absolu depuis la racine du projet 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); } // Fonction pour générer une page de feuille de présence pour une journée const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => { if (!isFirstPage) { doc.addPage(); } // Ajouter le logo en haut à droite si disponible if (logoData) { doc.addImage(logoData, 'PNG', 160, 10, 40, 15); } // Titre doc.setFontSize(18); doc.text('Feuille de présence', 14, 20); // Informations de séquence doc.setFontSize(10); let yPos = 35; doc.text(`Formation : ${feuilleInfo.formationNom}`, 14, yPos); yPos += 6; doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 14, yPos); yPos += 6; doc.text(`Lieu : ${feuilleInfo.lieu}`, 14, yPos); yPos += 6; // Ajouter le formateur si disponible if (feuilleInfo.formateur) { doc.text(`Formateur : ${feuilleInfo.formateur}`, 14, yPos); yPos += 6; } doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos); yPos += 8; // Date de la journée doc.setFontSize(12); doc.setFont('helvetica', 'bold'); doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`, 14, yPos); doc.setFont('helvetica', 'normal'); yPos += 10; // Tableau de présence avec signatures matin et après-midi const tableData = feuilleInfo.apprenants.map(i => { // Trouver les présences pour cette date (utiliser l'ID de la date, pas l'ordre) const presenceMatin = i.presences?.find(p => p.dateFormationId === dateInfo.id && p.periode === 'matin'); const presenceApresMidi = i.presences?.find(p => p.dateFormationId === dateInfo.id && p.periode === 'apres_midi'); return [ i.nom, i.prenom, i.codeEtablissement || '', i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', presenceMatin?.signatureUrl || '', // Signature matin (sera remplacée par l'image) presenceApresMidi?.signatureUrl || '', // Signature après-midi (sera remplacée par l'image) ]; }); autoTable(doc, { startY: yPos, head: [['Nom', 'Prénom', 'Code étab.', 'Fonction', 'Signature\nMatin', 'Signature\nAprès-midi']], body: tableData, margin: { left: 10, right: 10 }, // Marges réduites pour maximiser la largeur styles: { fontSize: 8, cellPadding: 2, minCellHeight: 15, // Augmenté pour les signatures 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 établissement 3: { cellWidth: 24 }, // Fonction 4: { cellWidth: 40 }, // Signature matin 5: { cellWidth: 40 }, // Signature après-midi }, didDrawCell: (data) => { // Insérer les images de signatures dans les colonnes 4 et 5 if (data.section === 'body' && (data.column.index === 4 || data.column.index === 5)) { const signatureUrl = data.cell.raw as string; if (signatureUrl && signatureUrl.startsWith('/uploads/signatures/')) { try { // Construire le chemin absolu vers le fichier de signature const signaturePath = path.resolve(process.cwd(), signatureUrl.substring(1)); // Enlever le / du début // Vérifier si le fichier existe if (fs.existsSync(signaturePath)) { // Charger l'image const signatureBuffer = fs.readFileSync(signaturePath); const signatureBase64 = `data:image/png;base64,${signatureBuffer.toString('base64')}`; // Insérer l'image dans la cellule const cellX = data.cell.x + 2; const cellY = data.cell.y + 2; const cellWidth = data.cell.width - 4; const cellHeight = data.cell.height - 4; doc.addImage(signatureBase64, 'PNG', cellX, cellY, cellWidth, cellHeight); } } catch (error) { console.error('Erreur lors du chargement de la signature:', error); } } } }, }); // Ajouter deux champs de signature pour le formateur (Matin et Après-midi) const finalY = (doc as any).lastAutoTable.finalY || yPos + 50; const signatureY = finalY + 20; doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.text('Signatures du formateur :', 14, signatureY); doc.setFont('helvetica', 'normal'); // Récupérer les signatures formateurs pour cette date const signatureMatin = feuilleInfo.signaturesFormateurs?.find( s => s.dateFormationId === dateInfo.id && s.periode === 'matin' ); const signatureApresMidi = feuilleInfo.signaturesFormateurs?.find( s => s.dateFormationId === dateInfo.id && s.periode === 'apres_midi' ); // Signature Matin doc.setFontSize(9); doc.text('Matin :', 14, signatureY + 8); if (signatureMatin?.signatureUrl) { try { const signaturePath = path.resolve(process.cwd(), signatureMatin.signatureUrl.substring(1)); if (fs.existsSync(signaturePath)) { const signatureBuffer = fs.readFileSync(signaturePath); const signatureBase64 = `data:image/png;base64,${signatureBuffer.toString('base64')}`; doc.addImage(signatureBase64, 'PNG', 30, signatureY + 2, 60, 15); } else { doc.line(30, signatureY + 8, 90, signatureY + 8); } } catch (error) { console.error('Erreur lors du chargement de la signature formateur matin:', error); doc.line(30, signatureY + 8, 90, signatureY + 8); } } else { doc.line(30, signatureY + 8, 90, signatureY + 8); } // Signature Après-midi doc.text('Après-midi :', 110, signatureY + 8); if (signatureApresMidi?.signatureUrl) { try { const signaturePath = path.resolve(process.cwd(), signatureApresMidi.signatureUrl.substring(1)); if (fs.existsSync(signaturePath)) { const signatureBuffer = fs.readFileSync(signaturePath); const signatureBase64 = `data:image/png;base64,${signatureBuffer.toString('base64')}`; doc.addImage(signatureBase64, 'PNG', 140, signatureY + 2, 60, 15); } else { doc.line(140, signatureY + 8, 200, signatureY + 8); } } catch (error) { console.error('Erreur lors du chargement de la signature formateur après-midi:', error); doc.line(140, signatureY + 8, 200, signatureY + 8); } } else { doc.line(140, signatureY + 8, 200, signatureY + 8); } }; // Générer une page pour chaque date feuilleInfo.dates.forEach((date, index) => { generatePageForDate(date, index === 0); }); return Buffer.from(doc.output('arraybuffer')); }