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:
Manus Sandbox
2025-11-25 05:49:13 -05:00
parent 9fffa8c773
commit f5dfb20e9a
10 changed files with 1514 additions and 1 deletions

309
server/importExcel.ts Normal file
View 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);
}

View File

@@ -10,6 +10,7 @@ import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportS
import { TRPCError } from "@trpc/server";
import { sendInscriptionConfirmation, sendGroupEmail } from "./emailService";
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
// Procédure admin uniquement
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
@@ -72,6 +73,68 @@ export const appRouter = router({
await db.deleteFormation(input.id);
return { success: true };
}),
importExcel: adminProcedure.input(z.object({
fileBase64: z.string(),
})).mutation(async ({ input }) => {
try {
// Décoder le fichier base64
const buffer = Buffer.from(input.fileBase64, 'base64');
// Parser le fichier Excel
const { formations, sequences } = parseExcelFile(buffer);
// Valider les données
const validation = validateImportData(formations, sequences);
if (!validation.valid) {
return {
success: false,
formationsCreated: 0,
sequencesCreated: 0,
errors: validation.errors,
warnings: validation.warnings,
};
}
// Importer dans la base de données
const result = await importToDatabase(formations, sequences);
return {
...result,
warnings: [...validation.warnings, ...result.warnings],
};
} catch (error: any) {
return {
success: false,
formationsCreated: 0,
sequencesCreated: 0,
errors: [`Erreur lors du traitement du fichier: ${error.message}`],
warnings: [],
};
}
}),
previewExcel: adminProcedure.input(z.object({
fileBase64: z.string(),
})).mutation(async ({ input }) => {
try {
const buffer = Buffer.from(input.fileBase64, 'base64');
const { formations, sequences } = parseExcelFile(buffer);
const validation = validateImportData(formations, sequences);
return {
formations,
sequences,
validation,
};
} catch (error: any) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Erreur lors de la lecture du fichier: ${error.message}`,
});
}
}),
}),
// ===== APPRENANTS =====