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:
BIN
client/public/itinova-logo.png
Normal file
BIN
client/public/itinova-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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();
|
||||||
|
|
||||||
// Titre
|
// Charger le logo
|
||||||
doc.setFontSize(18);
|
let logoData: string | null = null;
|
||||||
doc.text('Feuille de présence', 14, 20);
|
|
||||||
|
|
||||||
// Informations de séquence
|
try {
|
||||||
doc.setFontSize(10);
|
// Utiliser un chemin absolu depuis la racine du projet
|
||||||
let yPos = 35;
|
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
|
||||||
doc.text(`Formation : ${feuilleInfo.formationNom}`, 14, yPos);
|
const logoBuffer = fs.readFileSync(logoPath);
|
||||||
yPos += 6;
|
logoData = `data:image/png;base64,${logoBuffer.toString('base64')}`;
|
||||||
doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 14, yPos);
|
} catch (error) {
|
||||||
yPos += 6;
|
console.warn('Logo Itinova non trouvé, génération sans logo:', error);
|
||||||
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
|
// Fonction pour générer une page de feuille de présence pour une journée
|
||||||
doc.setFontSize(9);
|
const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => {
|
||||||
feuilleInfo.dates.forEach(date => {
|
if (!isFirstPage) {
|
||||||
doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
|
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(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'));
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
9
todo.md
9
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user