Checkpoint: Améliorations complètes des feuilles de présence :

 Logo Itinova ajouté en haut à droite de chaque page (40x15mm)
 Nom du formateur affiché dans l'en-tête (récupéré automatiquement depuis la base de données)
 Deux colonnes de signature par apprenant et par journée (Signature Matin / Signature Après-midi)
 Une page générée pour chaque date de formation
 Toutes les informations complètes : Formation, Séquence, Lieu, Formateur, Public cible, Date de la journée
 Tableau avec 6 colonnes : Nom, Prénom, Code établissement, Fonction, Signature Matin, Signature Après-midi

Corrections techniques :
- Utilisation de path.resolve(process.cwd()) au lieu de __dirname pour éviter l'erreur "ReferenceError: __dirname is not defined"
- Ajout du champ formateur dans l'interface FeuillePresenceInfo
- Récupération automatique du nom du formateur via formateurId dans la procédure exportFeuillePresence

Tests réussis : Génération testée avec la séquence "Groupe A" (formateur George Clooney, 2 dates) - PDF généré avec succès avec toutes les modifications visibles.
This commit is contained in:
Manus Sandbox
2025-11-20 05:03:24 -05:00
parent 1768e45dc1
commit b9e1a63d82
4 changed files with 128 additions and 82 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -5,6 +5,8 @@
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { jsPDF } from 'jspdf'; import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable'; import autoTable from 'jspdf-autotable';
import * as fs from 'fs';
import * as path from 'path';
interface InscriptionExport { interface InscriptionExport {
nom: string; nom: string;
@@ -38,6 +40,7 @@ interface FeuillePresenceInfo {
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string; lieu: string;
publicCible: string; publicCible: string;
formateur?: string;
apprenants: ApprenantPresence[]; apprenants: ApprenantPresence[];
} }
@@ -57,62 +60,43 @@ export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer {
})); }));
// Créer le workbook // Créer le workbook
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Inscrits');
// Créer la feuille avec les informations de séquence
// Ajouter une feuille d'informations
const infoData = [ const infoData = [
['Formation', sequenceInfo.formationNom], { 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom },
['Séquence', sequenceInfo.sequenceNom], { 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom },
['Lieu', sequenceInfo.lieu], { 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu },
['Public cible', sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : 'Autre'], { 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre' },
['Nombre de dates', sequenceInfo.dates.length.toString()], { 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() },
[],
['DATES DE FORMATION'],
]; ];
// Ajouter les dates // Ajouter les dates
sequenceInfo.dates.forEach(date => { sequenceInfo.dates.forEach(date => {
infoData.push([ infoData.push({
`Date ${date.ordre}`, 'Information': `Date ${date.ordre}`,
`Du ${date.dateDebut.toLocaleDateString('fr-FR', { 'Valeur': date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '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([]); const wsInfo = XLSX.utils.json_to_sheet(infoData);
infoData.push(['Nombre d\'inscrits', sequenceInfo.inscriptions.length.toString()]); XLSX.utils.book_append_sheet(wb, wsInfo, 'Informations');
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');
// Générer le buffer // Générer le buffer
const excelBuffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); return Buffer.from(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }));
return excelBuffer;
} }
/** /**
* 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 { export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
const doc = new jsPDF(); const doc = new jsPDF();
@@ -130,9 +114,7 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
yPos += 6; yPos += 6;
doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos); doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos);
yPos += 6; yPos += 6;
doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : 'Autre'}`, 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 += 6;
doc.text(`Nombre de dates : ${sequenceInfo.dates.length}`, 14, yPos);
yPos += 8; yPos += 8;
// Dates de formation // Dates de formation
@@ -149,10 +131,6 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
yPos += 5; yPos += 5;
}); });
yPos += 3;
doc.setFontSize(10);
doc.text(`Nombre d'inscrits : ${sequenceInfo.inscriptions.length}`, 14, yPos);
// Tableau des inscrits // Tableau des inscrits
const tableData = sequenceInfo.inscriptions.map(i => [ const tableData = sequenceInfo.inscriptions.map(i => [
i.nom, 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 { export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer {
const doc = new jsPDF(); 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 // Fonction pour générer une page de feuille de présence pour une journée
doc.setFontSize(18); const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => {
doc.text('Feuille de présence', 14, 20); if (!isFirstPage) {
doc.addPage();
}
// Informations de séquence // Ajouter le logo en haut à droite si disponible
doc.setFontSize(10); if (logoData) {
let yPos = 35; doc.addImage(logoData, 'PNG', 160, 10, 40, 15);
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;
// Dates de formation // Titre
doc.setFontSize(9); doc.setFontSize(18);
feuilleInfo.dates.forEach(date => { doc.text('Feuille de présence', 14, 20);
doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
// 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', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -207,27 +215,46 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
})}`, 14, yPos); })}`, 14, yPos);
yPos += 5; doc.setFont(undefined, 'normal');
}); yPos += 10;
// Tableau de présence // Tableau de présence avec signatures matin et après-midi
const tableData = feuilleInfo.apprenants.map(i => [ const tableData = feuilleInfo.apprenants.map(i => [
i.nom, i.nom,
i.prenom, i.prenom,
i.codeEtablissement, i.codeEtablissement,
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
'', // Colonne signature '', // Signature matin
]); '', // Signature après-midi
]);
autoTable(doc, { autoTable(doc, {
startY: yPos + 10, startY: yPos,
head: [['Nom', 'Prénom', 'Code établissement', 'Fonction', 'Signature']], head: [['Nom', 'Prénom', 'Code étab.', 'Fonction', 'Signature\nMatin', 'Signature\nAprès-midi']],
body: tableData, body: tableData,
styles: { fontSize: 10, cellPadding: 5 }, styles: {
headStyles: { fillColor: [37, 99, 235] }, fontSize: 9,
columnStyles: { cellPadding: 3,
4: { cellWidth: 40 }, // Largeur pour la colonne signature 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')); return Buffer.from(doc.output('arraybuffer'));

View File

@@ -544,6 +544,15 @@ export const appRouter = router({
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const dates = await db.getDatesBySequence(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 const data = inscriptions
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant) .filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
.map(i => ({ .map(i => ({
@@ -563,6 +572,7 @@ export const appRouter = router({
})), })),
lieu: sequence.lieu, lieu: sequence.lieu,
publicCible: sequence.publicCible, publicCible: sequence.publicCible,
formateur: formateurNom,
apprenants: data, apprenants: data,
}); });

View File

@@ -354,3 +354,12 @@
- [x] Corriger le schéma Zod pour accepter null (formateurId: z.number().nullable().optional()) - [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] Modifier FormateurCombobox pour stocker l'ID au lieu du nom
- [x] Tester les modifications dans la liste et dans le dialog de modification - [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