Checkpoint: Import inventaire ISI-APP enrichi : le fichier CSV est maintenant parsé pour extraire automatiquement les établissements uniques (code + nom) et les enregistrer dans budgetsi_etablissements. Fusion intelligente avec les établissements existants (sans écrasement). Message de résultat détaillé (nb lignes, nb établissements détectés, nb nouveaux/mis à jour). Accepte de nombreuses variantes de nommage de colonnes (code_etab, code_etablissement, nom_etab, etablissement…). Message d'erreur explicite avec affichage des en-têtes détectés si les colonnes sont introuvables.
This commit is contained in:
@@ -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<string, { nom: string; groupe?: string; ville?: string }>();
|
||||
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) {
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-semibold mb-1">Inventaire des postes (ISI-APP)</p>
|
||||
<p className="text-xs text-blue-700">
|
||||
Importez l'export de l'inventaire depuis ISI-APP au format Excel (.xlsx) ou CSV (.csv).
|
||||
Les colonnes attendues sont : <strong>Code établissement, Nom, Type (fixe/portable), Date achat, Modèle</strong>.
|
||||
Importez l'export de l'inventaire depuis ISI-APP au format CSV (.csv).
|
||||
Colonnes <strong>obligatoires</strong> : <strong>code établissement</strong> et <strong>nom établissement</strong> (variantes acceptées : <em>code_etab, code_etablissement, nom_etab, etablissement…</em>).
|
||||
Colonnes optionnelles : type, date_achat, modele, fabricant, groupe, ville.
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1.5 font-medium">
|
||||
✦ La liste des établissements sera automatiquement créée/mise à jour à partir de cet import.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user