diff --git a/client/src/components/ImportModal.tsx b/client/src/components/ImportModal.tsx index 118f117..60ebbf4 100644 --- a/client/src/components/ImportModal.tsx +++ b/client/src/components/ImportModal.tsx @@ -2,6 +2,7 @@ // Design: Corporate Modernism — Itinova Budget SI import { useState, useRef, useEffect } from 'react'; +import * as XLSX from 'xlsx'; import { Upload, X, @@ -21,6 +22,25 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; +// ─── Helper : lecture fichier → tableau de lignes (CSV ou XLSX) ──────────────── +async function fileToRows(file: File): Promise { + 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(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 ──────────────────────────────────────────────────────────────────── export interface Etablissement { @@ -274,17 +294,15 @@ export function ImportModal({ open, onClose }: ImportModalProps) { return; } - const text = await file.text(); - const lines = text.split('\n').filter(l => l.trim()); - if (lines.length < 2) { + // Lecture unifiée CSV + XLSX via SheetJS + const rows = await fileToRows(file); + if (rows.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, '')); + const rawHeaders = rows[0].map(h => h.toLowerCase().replace(/[\r\n]/g, '')); // ── Format ISI-APP : extraction par libellé et entité ────────────────── // 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 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, '')); + for (const cols of rows.slice(1)) { let code = ''; let nom = ''; @@ -412,11 +429,12 @@ export function ImportModal({ open, onClose }: ImportModalProps) { saveEtablissements(merged); // Enregistrement des métadonnées du fichier importé + const nbLignes = rows.length - 1; localStorage.setItem('budgetsi_inventaire_import', JSON.stringify({ filename: file.name, date: new Date().toISOString(), size: file.size, - nbLignes: lines.length - 1, + nbLignes, nbEtablissements: etabMap.size, nbFixes: useIsiFormat ? nbFixes : null, nbPortables: useIsiFormat ? nbPortables : null, @@ -424,7 +442,7 @@ export function ImportModal({ open, onClose }: ImportModalProps) { })); 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`, nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null, nbMisAJour > 0 ? `${nbMisAJour} mis à jour` : null, diff --git a/package.json b/package.json index 8ea2f9f..5ec288a 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", "wouter": "^3.3.5", + "xlsx": "^0.18.5", "zod": "^4.1.12" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82db073..700f005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: wouter: specifier: ^3.3.5 version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zod: specifier: ^4.1.12 version: 4.1.12 @@ -1786,6 +1789,10 @@ packages: add@2.0.6: 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: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1848,6 +1855,10 @@ packages: ccount@2.0.1: 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: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1893,6 +1904,10 @@ packages: react: ^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: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1938,6 +1953,11 @@ packages: cose-base@2.2.0: 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: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2316,6 +2336,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -3179,6 +3203,10 @@ packages: space-separated-tokens@2.0.2: 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: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3517,11 +3545,24 @@ packages: engines: {node: '>=8'} 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: resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==} peerDependencies: 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: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -5027,6 +5068,8 @@ snapshots: add@2.0.6: {} + adler-32@1.3.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -5102,6 +5145,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -5154,6 +5202,8 @@ snapshots: - '@types/react' - '@types/react-dom' + codepage@1.15.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -5188,6 +5238,8 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@1.2.2: {} + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -5622,6 +5674,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + fraction.js@4.3.7: {} 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: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stackback@0.0.2: {} statuses@2.0.1: {} @@ -7176,6 +7234,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wmf@1.0.2: {} + + word@0.3.0: {} + wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1): dependencies: mitt: 3.0.1 @@ -7183,6 +7245,16 @@ snapshots: regexparam: 3.0.0 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@5.0.0: {}