Checkpoint: Ajout de la fonctionnalité d'import Excel pour créer des formations et séquences en masse. Inclut le template Excel, la logique backend de parsing et validation, l'interface frontend avec prévisualisation, et la documentation utilisateur complète.
This commit is contained in:
309
server/importExcel.ts
Normal file
309
server/importExcel.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getDb } from './db';
|
||||
import { formations, sequences, datesFormation } from '../drizzle/schema';
|
||||
|
||||
export interface FormationImport {
|
||||
nom: string;
|
||||
description: string;
|
||||
publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre';
|
||||
}
|
||||
|
||||
export interface SequenceImport {
|
||||
formationNom: string;
|
||||
nom: string;
|
||||
lieu: string;
|
||||
publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre';
|
||||
capaciteMax: number;
|
||||
dateBlocage: string;
|
||||
statut: 'ouverte' | 'bloquee' | 'terminee';
|
||||
dates: Array<{ debut: string; fin: string }>;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
formationsCreated: number;
|
||||
sequencesCreated: number;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse un fichier Excel et retourne les données structurées
|
||||
*/
|
||||
export function parseExcelFile(buffer: Buffer): { formations: FormationImport[]; sequences: SequenceImport[] } {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
|
||||
const formations: FormationImport[] = [];
|
||||
const sequences: SequenceImport[] = [];
|
||||
|
||||
// Parser la feuille "Formations"
|
||||
if (workbook.SheetNames.includes('Formations')) {
|
||||
const sheet = workbook.Sheets['Formations'];
|
||||
const data = XLSX.utils.sheet_to_json<any>(sheet, { header: 1 });
|
||||
|
||||
// Ignorer la première ligne (en-têtes) et les lignes vides
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
if (!row || row.length === 0 || !row[0]) continue;
|
||||
|
||||
// Arrêter si on atteint les instructions
|
||||
if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break;
|
||||
|
||||
formations.push({
|
||||
nom: String(row[0] || '').trim(),
|
||||
description: String(row[1] || '').trim(),
|
||||
publicCible: normalizePublicCible(String(row[2] || '').trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parser la feuille "Séquences"
|
||||
if (workbook.SheetNames.includes('Séquences')) {
|
||||
const sheet = workbook.Sheets['Séquences'];
|
||||
const data = XLSX.utils.sheet_to_json<any>(sheet, { header: 1 });
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
if (!row || row.length === 0 || !row[0]) continue;
|
||||
|
||||
// Arrêter si on atteint les instructions
|
||||
if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break;
|
||||
|
||||
const dates: Array<{ debut: string; fin: string }> = [];
|
||||
|
||||
// Parser les 4 dates possibles (colonnes 7-14)
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const debutIdx = 7 + (d * 2);
|
||||
const finIdx = 8 + (d * 2);
|
||||
|
||||
if (row[debutIdx] && row[finIdx]) {
|
||||
dates.push({
|
||||
debut: parseExcelDate(row[debutIdx]),
|
||||
fin: parseExcelDate(row[finIdx]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sequences.push({
|
||||
formationNom: String(row[0] || '').trim(),
|
||||
nom: String(row[1] || '').trim(),
|
||||
lieu: String(row[2] || '').trim(),
|
||||
publicCible: normalizePublicCible(String(row[3] || '').trim()),
|
||||
capaciteMax: parseInt(String(row[4] || '12')),
|
||||
dateBlocage: parseExcelDate(row[5]),
|
||||
statut: normalizeStatut(String(row[6] || '').trim()),
|
||||
dates,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { formations, sequences };
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les données importées
|
||||
*/
|
||||
export function validateImportData(
|
||||
formations: FormationImport[],
|
||||
sequences: SequenceImport[]
|
||||
): { valid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Valider les formations
|
||||
formations.forEach((formation, index) => {
|
||||
if (!formation.nom) {
|
||||
errors.push(`Formation ligne ${index + 2}: Le nom est obligatoire`);
|
||||
}
|
||||
if (!formation.description) {
|
||||
errors.push(`Formation ligne ${index + 2}: La description est obligatoire`);
|
||||
}
|
||||
if (!['directeur', 'chef_service', 'tous', 'autre'].includes(formation.publicCible)) {
|
||||
errors.push(`Formation ligne ${index + 2}: Public cible invalide (${formation.publicCible})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Créer un set des noms de formations pour validation des séquences
|
||||
const formationNames = new Set(formations.map(f => f.nom.toLowerCase()));
|
||||
|
||||
// Valider les séquences
|
||||
sequences.forEach((sequence, index) => {
|
||||
if (!sequence.formationNom) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Le nom de la formation est obligatoire`);
|
||||
} else if (!formationNames.has(sequence.formationNom.toLowerCase())) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Formation "${sequence.formationNom}" non trouvée dans la feuille Formations`);
|
||||
}
|
||||
|
||||
if (!sequence.nom) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Le nom est obligatoire`);
|
||||
}
|
||||
if (!sequence.lieu) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Le lieu est obligatoire`);
|
||||
}
|
||||
if (!['directeur', 'chef_service', 'tous', 'autre'].includes(sequence.publicCible)) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Public cible invalide`);
|
||||
}
|
||||
if (!sequence.dateBlocage) {
|
||||
errors.push(`Séquence ligne ${index + 2}: La date de blocage est obligatoire`);
|
||||
}
|
||||
if (!['ouverte', 'bloquee', 'terminee'].includes(sequence.statut)) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Statut invalide`);
|
||||
}
|
||||
if (sequence.dates.length === 0) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Au moins une date est obligatoire`);
|
||||
}
|
||||
if (sequence.dates.length > 4) {
|
||||
warnings.push(`Séquence ligne ${index + 2}: Maximum 4 dates autorisées, les dates supplémentaires seront ignorées`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe les données dans la base de données
|
||||
*/
|
||||
export async function importToDatabase(
|
||||
formations: FormationImport[],
|
||||
sequences: SequenceImport[]
|
||||
): Promise<ImportResult> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
return {
|
||||
success: false,
|
||||
formationsCreated: 0,
|
||||
sequencesCreated: 0,
|
||||
errors: ['Connexion à la base de données impossible'],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let formationsCreated = 0;
|
||||
let sequencesCreated = 0;
|
||||
|
||||
try {
|
||||
// Map pour stocker les IDs des formations créées
|
||||
const formationIdMap = new Map<string, number>();
|
||||
|
||||
// Importer les formations
|
||||
for (const formation of formations) {
|
||||
try {
|
||||
const result = await db.insert(formations).values({
|
||||
nom: formation.nom,
|
||||
description: formation.description,
|
||||
publicCible: formation.publicCible,
|
||||
lienInscription: generateUniqueLien(),
|
||||
});
|
||||
|
||||
const formationId = Number(result.insertId);
|
||||
formationIdMap.set(formation.nom.toLowerCase(), formationId);
|
||||
formationsCreated++;
|
||||
} catch (error: any) {
|
||||
errors.push(`Erreur lors de la création de la formation "${formation.nom}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Importer les séquences
|
||||
for (const sequence of sequences) {
|
||||
try {
|
||||
const formationId = formationIdMap.get(sequence.formationNom.toLowerCase());
|
||||
if (!formationId) {
|
||||
errors.push(`Formation "${sequence.formationNom}" non trouvée pour la séquence "${sequence.nom}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await db.insert(sequences).values({
|
||||
formationId,
|
||||
nom: sequence.nom,
|
||||
lieu: sequence.lieu,
|
||||
publicCible: sequence.publicCible,
|
||||
capaciteMax: sequence.capaciteMax,
|
||||
dateBlocage: new Date(sequence.dateBlocage),
|
||||
statut: sequence.statut,
|
||||
});
|
||||
|
||||
const sequenceId = Number(result.insertId);
|
||||
|
||||
// Insérer les dates de formation
|
||||
for (const date of sequence.dates.slice(0, 4)) {
|
||||
await db.insert(datesFormation).values({
|
||||
sequenceId,
|
||||
dateDebut: new Date(date.debut),
|
||||
dateFin: new Date(date.fin),
|
||||
});
|
||||
}
|
||||
|
||||
sequencesCreated++;
|
||||
} catch (error: any) {
|
||||
errors.push(`Erreur lors de la création de la séquence "${sequence.nom}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
formationsCreated,
|
||||
sequencesCreated,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
formationsCreated,
|
||||
sequencesCreated,
|
||||
errors: [`Erreur générale: ${error.message}`],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise le public cible
|
||||
*/
|
||||
function normalizePublicCible(value: string): 'directeur' | 'chef_service' | 'tous' | 'autre' {
|
||||
const normalized = value.toLowerCase().trim();
|
||||
if (normalized === 'directeur') return 'directeur';
|
||||
if (normalized === 'chef_service' || normalized === 'chef de service') return 'chef_service';
|
||||
if (normalized === 'tous') return 'tous';
|
||||
return 'autre';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise le statut
|
||||
*/
|
||||
function normalizeStatut(value: string): 'ouverte' | 'bloquee' | 'terminee' {
|
||||
const normalized = value.toLowerCase().trim();
|
||||
if (normalized === 'ouverte') return 'ouverte';
|
||||
if (normalized === 'bloquee' || normalized === 'bloquée') return 'bloquee';
|
||||
if (normalized === 'terminee' || normalized === 'terminée') return 'terminee';
|
||||
return 'ouverte';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse une date Excel (peut être un nombre de série Excel ou une chaîne)
|
||||
*/
|
||||
function parseExcelDate(value: any): string {
|
||||
if (!value) return '';
|
||||
|
||||
// Si c'est un nombre (date Excel)
|
||||
if (typeof value === 'number') {
|
||||
const date = XLSX.SSF.parse_date_code(value);
|
||||
return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')} ${String(date.H || 0).padStart(2, '0')}:${String(date.M || 0).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Si c'est déjà une chaîne
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un lien unique pour une formation
|
||||
*/
|
||||
function generateUniqueLien(): string {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
Reference in New Issue
Block a user