Modifications principales : - Ajout des champs typeUtilisateur et formateurId dans la table presences pour distinguer formateurs et apprenants - Détection automatique du rôle (formateur/apprenant) via l'email lors du scan du QR code - Interface de signature tactile réutilisée pour les formateurs - Intégration des signatures tactiles formateur dans la génération PDF de la feuille de présence - QR code dans l'espace formateur accessible pour l'émargement - Tests unitaires validés avec succès Le système permet maintenant aux formateurs d'émarger (matin et après-midi) via le même QR code que les apprenants, avec détection automatique du rôle et intégration des signatures dans le PDF.
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
/**
|
|
* 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;
|
|
typeUtilisateur?: 'apprenant' | 'formateur';
|
|
}>;
|
|
}
|
|
|
|
interface FormateurPresence {
|
|
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[];
|
|
presencesFormateur?: FormateurPresence[];
|
|
}
|
|
|
|
/**
|
|
* 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 une feuille de présence PDF avec signatures matin/après-midi pour chaque journée
|
|
*/
|
|
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,
|
|
styles: {
|
|
fontSize: 9,
|
|
cellPadding: 3,
|
|
minCellHeight: 15, // Augmenté pour les signatures
|
|
},
|
|
headStyles: {
|
|
fillColor: [37, 99, 235],
|
|
halign: 'center',
|
|
},
|
|
columnStyles: {
|
|
0: { cellWidth: 30 }, // Nom
|
|
1: { cellWidth: 30 }, // Prénom
|
|
2: { cellWidth: 25 }, // Code établissement
|
|
3: { cellWidth: 30 }, // Fonction
|
|
4: { cellWidth: 35 }, // Signature matin
|
|
5: { cellWidth: 35 }, // 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');
|
|
|
|
// Trouver les signatures du formateur pour cette date
|
|
const presenceFormateurMatin = feuilleInfo.presencesFormateur?.find(
|
|
p => p.dateFormationId === dateInfo.id && p.periode === 'matin'
|
|
);
|
|
const presenceFormateurApresMidi = feuilleInfo.presencesFormateur?.find(
|
|
p => p.dateFormationId === dateInfo.id && p.periode === 'apres_midi'
|
|
);
|
|
|
|
// Signature Matin
|
|
doc.setFontSize(9);
|
|
doc.text('Matin :', 14, signatureY + 8);
|
|
|
|
if (presenceFormateurMatin?.signatureUrl) {
|
|
try {
|
|
const signaturePath = path.resolve(process.cwd(), presenceFormateurMatin.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, 55, 10);
|
|
} 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 (presenceFormateurApresMidi?.signatureUrl) {
|
|
try {
|
|
const signaturePath = path.resolve(process.cwd(), presenceFormateurApresMidi.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, 55, 10);
|
|
} 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'));
|
|
}
|