diff --git a/client/public/itinova-logo.png b/client/public/itinova-logo.png new file mode 100644 index 0000000..8325caa Binary files /dev/null and b/client/public/itinova-logo.png differ diff --git a/server/exportService.ts b/server/exportService.ts index bc373b1..b63e6b6 100644 --- a/server/exportService.ts +++ b/server/exportService.ts @@ -5,6 +5,8 @@ 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; @@ -38,6 +40,7 @@ interface FeuillePresenceInfo { dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; lieu: string; publicCible: string; + formateur?: string; apprenants: ApprenantPresence[]; } @@ -57,62 +60,43 @@ export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer { })); // Créer le workbook + const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); - - // Créer la feuille avec les informations de séquence + XLSX.utils.book_append_sheet(wb, ws, 'Inscrits'); + + // Ajouter une feuille d'informations const infoData = [ - ['Formation', sequenceInfo.formationNom], - ['Séquence', sequenceInfo.sequenceNom], - ['Lieu', sequenceInfo.lieu], - ['Public cible', sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : 'Autre'], - ['Nombre de dates', sequenceInfo.dates.length.toString()], - [], - ['DATES DE FORMATION'], + { '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([ - `Date ${date.ordre}`, - `Du ${date.dateDebut.toLocaleDateString('fr-FR', { + 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' - })} au ${date.dateFin.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}` - ]); + }) + }); }); - infoData.push([]); - infoData.push(['Nombre d\'inscrits', sequenceInfo.inscriptions.length.toString()]); - infoData.push([]); - - // Créer la feuille principale - const ws = XLSX.utils.aoa_to_sheet(infoData); - - // Ajouter les données des inscrits - XLSX.utils.sheet_add_json(ws, data, { origin: -1 }); - - // Ajouter la feuille au workbook - XLSX.utils.book_append_sheet(wb, ws, 'Inscrits'); + const wsInfo = XLSX.utils.json_to_sheet(infoData); + XLSX.utils.book_append_sheet(wb, wsInfo, 'Informations'); // Générer le buffer - const excelBuffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); - - return excelBuffer; + return Buffer.from(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })); } /** - * Génère un fichier PDF avec la liste des inscrits + * Génère un PDF avec la liste des inscrits */ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { const doc = new jsPDF(); @@ -130,9 +114,7 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { 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' : 'Autre'}`, 14, yPos); - yPos += 6; - doc.text(`Nombre de dates : ${sequenceInfo.dates.length}`, 14, yPos); + 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 @@ -149,10 +131,6 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { yPos += 5; }); - yPos += 3; - doc.setFontSize(10); - doc.text(`Nombre d'inscrits : ${sequenceInfo.inscriptions.length}`, 14, yPos); - // Tableau des inscrits const tableData = sequenceInfo.inscriptions.map(i => [ i.nom, @@ -175,31 +153,61 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { } /** - * Génère une feuille de présence PDF + * 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); + } - // Titre - doc.setFontSize(18); - doc.text('Feuille de présence', 14, 20); + // 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(); + } - // 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; - doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : 'Autre'}`, 14, yPos); - yPos += 8; + // Ajouter le logo en haut à droite si disponible + if (logoData) { + doc.addImage(logoData, 'PNG', 160, 10, 40, 15); + } - // Dates de formation - doc.setFontSize(9); - feuilleInfo.dates.forEach(date => { - doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + // 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(undefined, 'bold'); + doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', @@ -207,27 +215,46 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe hour: '2-digit', minute: '2-digit' })}`, 14, yPos); - yPos += 5; - }); + doc.setFont(undefined, 'normal'); + yPos += 10; - // Tableau de présence - const tableData = feuilleInfo.apprenants.map(i => [ - i.nom, - i.prenom, - i.codeEtablissement, - i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', - '', // Colonne signature - ]); + // Tableau de présence avec signatures matin et après-midi + const tableData = feuilleInfo.apprenants.map(i => [ + i.nom, + i.prenom, + i.codeEtablissement, + i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', + '', // Signature matin + '', // Signature après-midi + ]); - autoTable(doc, { - startY: yPos + 10, - head: [['Nom', 'Prénom', 'Code établissement', 'Fonction', 'Signature']], - body: tableData, - styles: { fontSize: 10, cellPadding: 5 }, - headStyles: { fillColor: [37, 99, 235] }, - columnStyles: { - 4: { cellWidth: 40 }, // Largeur pour la colonne signature - }, + 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: 10, + }, + 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 + }, + }); + }; + + // Générer une page pour chaque date + feuilleInfo.dates.forEach((date, index) => { + generatePageForDate(date, index === 0); }); return Buffer.from(doc.output('arraybuffer')); diff --git a/server/routers.ts b/server/routers.ts index e1a29f1..cd9a6af 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -544,6 +544,15 @@ export const appRouter = router({ const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); const dates = await db.getDatesBySequence(input.sequenceId); + // Récupérer le formateur si défini + let formateurNom: string | undefined = undefined; + if (sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + if (formateur) { + formateurNom = formateur.nom; + } + } + const data = inscriptions .filter(i => i.inscription.statut === 'confirmee' && i.apprenant) .map(i => ({ @@ -563,6 +572,7 @@ export const appRouter = router({ })), lieu: sequence.lieu, publicCible: sequence.publicCible, + formateur: formateurNom, apprenants: data, }); diff --git a/todo.md b/todo.md index 4b3014e..02629fe 100644 --- a/todo.md +++ b/todo.md @@ -354,3 +354,12 @@ - [x] Corriger le schéma Zod pour accepter null (formateurId: z.number().nullable().optional()) - [x] Modifier FormateurCombobox pour stocker l'ID au lieu du nom - [x] Tester les modifications dans la liste et dans le dialog de modification + +## Améliorations des feuilles de présence + +- [x] Copier le logo Itinova dans le dossier public du projet +- [x] Ajouter le logo Itinova en haut à droite de chaque page de feuille de présence +- [x] Ajouter le nom du formateur dans l'en-tête de la feuille de présence +- [x] Modifier le tableau pour avoir deux signatures par apprenant et par journée (matin et après-midi) +- [x] Mettre à jour l'interface FeuillePresenceInfo pour inclure le formateur +- [x] Tester la génération des feuilles de présence avec toutes les modifications