Checkpoint: Ajout du support XLSX natif via la librairie SheetJS (xlsx). Un helper fileToRows() unifie la lecture CSV et XLSX : pour XLSX, lecture via arrayBuffer + XLSX.read + sheet_to_json ; pour CSV, détection du séparateur et split classique. Le parsing ISI-APP fonctionne désormais identiquement sur les deux formats. Les métadonnées d'import incluent le format détecté (isi-app ou classique).

This commit is contained in:
Manus
2026-06-04 15:10:48 +00:00
parent b917aeaa56
commit af5d4fd703
3 changed files with 101 additions and 10 deletions

View File

@@ -2,6 +2,7 @@
// Design: Corporate Modernism — Itinova Budget SI // Design: Corporate Modernism — Itinova Budget SI
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import * as XLSX from 'xlsx';
import { import {
Upload, Upload,
X, X,
@@ -21,6 +22,25 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
// ─── Helper : lecture fichier → tableau de lignes (CSV ou XLSX) ────────────────
async function fileToRows(file: File): Promise<string[][]> {
const isXlsx = file.name.match(/\.(xlsx|xls)$/i);
if (isXlsx) {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
const ws = wb.Sheets[wb.SheetNames[0]];
// sheet_to_json avec header:1 retourne un tableau de tableaux
const rows = XLSX.utils.sheet_to_json<string[]>(ws, { header: 1, defval: '' });
return rows.map(r => r.map(c => String(c ?? '').trim()));
} else {
// CSV : détection du séparateur
const text = await file.text();
const lines = text.split('\n').filter(l => l.trim());
const sep = lines[0].includes(';') ? ';' : ',';
return lines.map(l => l.split(sep).map(c => c.trim().replace(/"/g, '').replace(/[\r\n]/g, '')));
}
}
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
export interface Etablissement { export interface Etablissement {
@@ -274,17 +294,15 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
return; return;
} }
const text = await file.text(); // Lecture unifiée CSV + XLSX via SheetJS
const lines = text.split('\n').filter(l => l.trim()); const rows = await fileToRows(file);
if (lines.length < 2) { if (rows.length < 2) {
setInventaireStatus('error'); setInventaireStatus('error');
setInventaireMsg('Fichier vide ou format invalide (moins de 2 lignes)'); setInventaireMsg('Fichier vide ou format invalide (moins de 2 lignes)');
return; return;
} }
// Détection du séparateur const rawHeaders = rows[0].map(h => h.toLowerCase().replace(/[\r\n]/g, ''));
const sep = lines[0].includes(';') ? ';' : ',';
const rawHeaders = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, '').replace(/[\r\n]/g, ''));
// ── Format ISI-APP : extraction par libellé et entité ────────────────── // ── Format ISI-APP : extraction par libellé et entité ──────────────────
// Colonne "libellé" : contient le code poste (ex: 1083MAS-F4833) // Colonne "libellé" : contient le code poste (ex: 1083MAS-F4833)
@@ -344,8 +362,7 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
// Extraction des établissements uniques depuis l'inventaire // Extraction des établissements uniques depuis l'inventaire
const etabMap = new Map<string, { nom: string; groupe?: string; ville?: string }>(); const etabMap = new Map<string, { nom: string; groupe?: string; ville?: string }>();
for (const line of lines.slice(1)) { for (const cols of rows.slice(1)) {
const cols = line.split(sep).map(c => c.trim().replace(/"/g, '').replace(/[\r\n]/g, ''));
let code = ''; let code = '';
let nom = ''; let nom = '';
@@ -412,11 +429,12 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
saveEtablissements(merged); saveEtablissements(merged);
// Enregistrement des métadonnées du fichier importé // Enregistrement des métadonnées du fichier importé
const nbLignes = rows.length - 1;
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, nbLignes,
nbEtablissements: etabMap.size, nbEtablissements: etabMap.size,
nbFixes: useIsiFormat ? nbFixes : null, nbFixes: useIsiFormat ? nbFixes : null,
nbPortables: useIsiFormat ? nbPortables : null, nbPortables: useIsiFormat ? nbPortables : null,
@@ -424,7 +442,7 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
})); }));
const details = [ const details = [
`${lines.length - 1} poste${lines.length > 2 ? 's' : ''}`, `${nbLignes} poste${nbLignes > 1 ? 's' : ''}`,
`${etabMap.size} établissement${etabMap.size > 1 ? 's' : ''} détectés`, `${etabMap.size} établissement${etabMap.size > 1 ? 's' : ''} détectés`,
nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null, nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null,
nbMisAJour > 0 ? `${nbMisAJour} mis à jour` : null, nbMisAJour > 0 ? `${nbMisAJour} mis à jour` : null,

View File

@@ -62,6 +62,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"wouter": "^3.3.5", "wouter": "^3.3.5",
"xlsx": "^0.18.5",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {

72
pnpm-lock.yaml generated
View File

@@ -166,6 +166,9 @@ importers:
wouter: wouter:
specifier: ^3.3.5 specifier: ^3.3.5
version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1) version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1)
xlsx:
specifier: ^0.18.5
version: 0.18.5
zod: zod:
specifier: ^4.1.12 specifier: ^4.1.12
version: 4.1.12 version: 4.1.12
@@ -1786,6 +1789,10 @@ packages:
add@2.0.6: add@2.0.6:
resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==} resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==}
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
aria-hidden@1.2.6: aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1848,6 +1855,10 @@ packages:
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chai@5.3.3: chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1893,6 +1904,10 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1938,6 +1953,11 @@ packages:
cose-base@2.2.0: cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2316,6 +2336,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fraction.js@4.3.7: fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@@ -3179,6 +3203,10 @@ packages:
space-separated-tokens@2.0.2: space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -3517,11 +3545,24 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
hasBin: true hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wouter@3.7.1: wouter@3.7.1:
resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==} resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==}
peerDependencies: peerDependencies:
react: '>=16.8.0' react: '>=16.8.0'
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -5027,6 +5068,8 @@ snapshots:
add@2.0.6: {} add@2.0.6: {}
adler-32@1.3.1: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -5102,6 +5145,11 @@ snapshots:
ccount@2.0.1: {} ccount@2.0.1: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chai@5.3.3: chai@5.3.3:
dependencies: dependencies:
assertion-error: 2.0.1 assertion-error: 2.0.1
@@ -5154,6 +5202,8 @@ snapshots:
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
codepage@1.15.0: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -5188,6 +5238,8 @@ snapshots:
dependencies: dependencies:
layout-base: 2.0.1 layout-base: 2.0.1
crc-32@1.2.2: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.1.3: {} csstype@3.1.3: {}
@@ -5622,6 +5674,8 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
frac@1.1.2: {}
fraction.js@4.3.7: {} fraction.js@4.3.7: {}
framer-motion@12.23.22(react-dom@19.2.1(react@19.2.1))(react@19.2.1): framer-motion@12.23.22(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
@@ -6837,6 +6891,10 @@ snapshots:
space-separated-tokens@2.0.2: {} space-separated-tokens@2.0.2: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
stackback@0.0.2: {} stackback@0.0.2: {}
statuses@2.0.1: {} statuses@2.0.1: {}
@@ -7176,6 +7234,10 @@ snapshots:
siginfo: 2.0.0 siginfo: 2.0.0
stackback: 0.0.2 stackback: 0.0.2
wmf@1.0.2: {}
word@0.3.0: {}
wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1): wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1):
dependencies: dependencies:
mitt: 3.0.1 mitt: 3.0.1
@@ -7183,6 +7245,16 @@ snapshots:
regexparam: 3.0.0 regexparam: 3.0.0
use-sync-external-store: 1.6.0(react@19.2.1) use-sync-external-store: 1.6.0(react@19.2.1)
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
yallist@3.1.1: {} yallist@3.1.1: {}
yallist@5.0.0: {} yallist@5.0.0: {}