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;
|
if (!open) return null;
|
||||||
|
|
||||||
// ── Inventaire ISI-APP ──────────────────────────────────────────────────────
|
// ── 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) => {
|
const handleInventaireFile = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
// Validation basique du format
|
|
||||||
if (!file.name.match(/\.(xlsx|xls|csv)$/i)) {
|
if (!file.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||||
setInventaireStatus('error');
|
setInventaireStatus('error');
|
||||||
setInventaireMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv');
|
setInventaireMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv');
|
||||||
return;
|
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({
|
localStorage.setItem('budgetsi_inventaire_import', JSON.stringify({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
size: file.size,
|
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');
|
setInventaireStatus('success');
|
||||||
setInventaireMsg(`Fichier "${file.name}" importé avec succès`);
|
setInventaireMsg(`"${file.name}" importé — ${details}`);
|
||||||
toast.success('Inventaire importé', { description: `Fichier ${file.name} enregistré` });
|
toast.success('Inventaire importé', {
|
||||||
} catch {
|
description: `${etabMap.size} établissements extraits et enregistrés dans la liste.`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import inventaire:', err);
|
||||||
setInventaireStatus('error');
|
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">
|
<div className="text-sm text-blue-800">
|
||||||
<p className="font-semibold mb-1">Inventaire des postes (ISI-APP)</p>
|
<p className="font-semibold mb-1">Inventaire des postes (ISI-APP)</p>
|
||||||
<p className="text-xs text-blue-700">
|
<p className="text-xs text-blue-700">
|
||||||
Importez l'export de l'inventaire depuis ISI-APP au format Excel (.xlsx) ou CSV (.csv).
|
Importez l'export de l'inventaire depuis ISI-APP au format CSV (.csv).
|
||||||
Les colonnes attendues sont : <strong>Code établissement, Nom, Type (fixe/portable), Date achat, Modèle</strong>.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user