Files
formation-manager-itinova/server/exportService.ts

531 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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'));
}