From ade7471956a5315c7f5e045a31f57efea5bd27f0 Mon Sep 17 00:00:00 2001 From: Manus Date: Thu, 4 Jun 2026 14:28:26 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Import=20inventaire=20ISI-APP=20e?= =?UTF-8?q?nrichi=20:=20le=20fichier=20CSV=20est=20maintenant=20pars=C3=A9?= =?UTF-8?q?=20pour=20extraire=20automatiquement=20les=20=C3=A9tablissement?= =?UTF-8?q?s=20uniques=20(code=20+=20nom)=20et=20les=20enregistrer=20dans?= =?UTF-8?q?=20budgetsi=5Fetablissements.=20Fusion=20intelligente=20avec=20?= =?UTF-8?q?les=20=C3=A9tablissements=20existants=20(sans=20=C3=A9crasement?= =?UTF-8?q?).=20Message=20de=20r=C3=A9sultat=20d=C3=A9taill=C3=A9=20(nb=20?= =?UTF-8?q?lignes,=20nb=20=C3=A9tablissements=20d=C3=A9tect=C3=A9s,=20nb?= =?UTF-8?q?=20nouveaux/mis=20=C3=A0=20jour).=20Accepte=20de=20nombreuses?= =?UTF-8?q?=20variantes=20de=20nommage=20de=20colonnes=20(code=5Fetab,=20c?= =?UTF-8?q?ode=5Fetablissement,=20nom=5Fetab,=20etablissement=E2=80=A6).?= =?UTF-8?q?=20Message=20d'erreur=20explicite=20avec=20affichage=20des=20en?= =?UTF-8?q?-t=C3=AAtes=20d=C3=A9tect=C3=A9s=20si=20les=20colonnes=20sont?= =?UTF-8?q?=20introuvables.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/ImportModal.tsx | 136 ++++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 8 deletions(-) diff --git a/client/src/components/ImportModal.tsx b/client/src/components/ImportModal.tsx index de4d7b1..68e6814 100644 --- a/client/src/components/ImportModal.tsx +++ b/client/src/components/ImportModal.tsx @@ -155,26 +155,142 @@ export function ImportModal({ open, onClose }: ImportModalProps) { if (!open) return null; // ── Inventaire ISI-APP ────────────────────────────────────────────────────── + // Colonnes attendues dans l'export ISI-APP : + // code_etab (ou code_etablissement, code etab…) — obligatoire + // nom_etab (ou nom_etablissement, libelle etab, etablissement…) — obligatoire + // type (fixe / portable / bureau / laptop…) + // date_achat, modele, fabricant — optionnels const handleInventaireFile = async (file: File) => { try { - // Validation basique du format if (!file.name.match(/\.(xlsx|xls|csv)$/i)) { setInventaireStatus('error'); setInventaireMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv'); return; } - // Stocker le nom du fichier importé + + const text = await file.text(); + const lines = text.split('\n').filter(l => l.trim()); + if (lines.length < 2) { + setInventaireStatus('error'); + setInventaireMsg('Fichier vide ou format invalide (moins de 2 lignes)'); + return; + } + + // Détection du séparateur + const sep = lines[0].includes(';') ? ';' : ','; + const rawHeaders = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, '').replace(/[\r\n]/g, '')); + + // Recherche des colonnes code et nom établissement + // Accepte de nombreuses variantes de nommage ISI-APP + const codeIdx = rawHeaders.findIndex(h => + h === 'code' || + h.includes('code_etab') || + h.includes('code etab') || + h.includes('code_etablissement') || + h.includes('code etablissement') || + h.includes('codeétab') || + h.includes('codeétablissement') || + (h.startsWith('code') && h.length < 20) + ); + const nomIdx = rawHeaders.findIndex(h => + h === 'nom' || + h.includes('nom_etab') || + h.includes('nom etab') || + h.includes('nom_etablissement') || + h.includes('nom etablissement') || + h.includes('libelle_etab') || + h.includes('libelle etab') || + h.includes('établissement') || + h.includes('etablissement') || + h.includes('nométab') || + (h.startsWith('nom') && h.length < 20) + ); + const groupeIdx = rawHeaders.findIndex(h => + h.includes('groupe') || h.includes('association') || h.includes('entite') || h.includes('entité') + ); + const villeIdx = rawHeaders.findIndex(h => + h.includes('ville') || h.includes('commune') || h.includes('localite') || h.includes('localité') + ); + + if (codeIdx === -1 || nomIdx === -1) { + setInventaireStatus('error'); + setInventaireMsg( + `Colonnes établissement introuvables. En-têtes détectés : ${rawHeaders.slice(0, 8).join(', ')}…\n` + + 'Attendu : une colonne contenant "code" et une contenant "nom" ou "etablissement".' + ); + return; + } + + // Extraction des établissements uniques depuis l'inventaire + const etabMap = new Map(); + for (const line of lines.slice(1)) { + const cols = line.split(sep).map(c => c.trim().replace(/"/g, '').replace(/[\r\n]/g, '')); + const code = cols[codeIdx]?.trim(); + const nom = cols[nomIdx]?.trim(); + if (!code || !nom) continue; + if (!etabMap.has(code)) { + etabMap.set(code, { + nom, + groupe: groupeIdx >= 0 ? cols[groupeIdx]?.trim() || undefined : undefined, + ville: villeIdx >= 0 ? cols[villeIdx]?.trim() || undefined : undefined, + }); + } + } + + if (etabMap.size === 0) { + setInventaireStatus('error'); + setInventaireMsg('Aucun établissement extrait. Vérifiez que les colonnes code/nom contiennent des données.'); + return; + } + + // Fusion avec les établissements déjà enregistrés (sans écraser les données existantes) + const existing = loadEtablissements(); + const existingMap = new Map(existing.map(e => [e.code, e])); + let nbNouveaux = 0; + let nbMisAJour = 0; + + etabMap.forEach((info, code) => { + if (existingMap.has(code)) { + // Mise à jour du nom si vide, conservation du reste + const ex = existingMap.get(code)!; + if (!ex.nom && info.nom) { + existingMap.set(code, { ...ex, nom: info.nom }); + nbMisAJour++; + } + } else { + existingMap.set(code, { code, nom: info.nom, groupe: info.groupe, ville: info.ville, actif: true }); + nbNouveaux++; + } + }); + + const merged = Array.from(existingMap.values()); + saveEtablissements(merged); + + // Enregistrement des métadonnées du fichier importé localStorage.setItem('budgetsi_inventaire_import', JSON.stringify({ filename: file.name, date: new Date().toISOString(), size: file.size, + nbLignes: lines.length - 1, + nbEtablissements: etabMap.size, })); + + const details = [ + `${lines.length - 1} ligne${lines.length > 2 ? 's' : ''} de postes`, + `${etabMap.size} établissement${etabMap.size > 1 ? 's' : ''} détectés`, + nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null, + nbMisAJour > 0 ? `${nbMisAJour} mis à jour` : null, + ].filter(Boolean).join(' — '); + setInventaireStatus('success'); - setInventaireMsg(`Fichier "${file.name}" importé avec succès`); - toast.success('Inventaire importé', { description: `Fichier ${file.name} enregistré` }); - } catch { + setInventaireMsg(`"${file.name}" importé — ${details}`); + toast.success('Inventaire importé', { + description: `${etabMap.size} établissements extraits et enregistrés dans la liste.`, + }); + } catch (err) { + console.error('Import inventaire:', err); setInventaireStatus('error'); - setInventaireMsg('Erreur lors de l\'import du fichier'); + setInventaireMsg('Erreur lors de la lecture du fichier. Vérifiez le format CSV.'); } }; @@ -346,8 +462,12 @@ export function ImportModal({ open, onClose }: ImportModalProps) {

Inventaire des postes (ISI-APP)

- Importez l'export de l'inventaire depuis ISI-APP au format Excel (.xlsx) ou CSV (.csv). - Les colonnes attendues sont : Code établissement, Nom, Type (fixe/portable), Date achat, Modèle. + Importez l'export de l'inventaire depuis ISI-APP au format CSV (.csv). + Colonnes obligatoires : code établissement et nom établissement (variantes acceptées : code_etab, code_etablissement, nom_etab, etablissement…). + Colonnes optionnelles : type, date_achat, modele, fabricant, groupe, ville. +

+

+ ✦ La liste des établissements sera automatiquement créée/mise à jour à partir de cet import.