Checkpoint: v4 : Import Excel (.xlsx) et CSV natif pour établissements et contacts, pages Mes Solutions Numériques et Solutions Logicielles avec accordéons, CGU réaffichée à chaque session, moteur de recherche multicritères corrigé. 33 tests passés, 0 erreur TypeScript.

This commit is contained in:
Manus
2026-04-16 12:30:59 -04:00
parent a86806fbb7
commit 9aad377c49
4 changed files with 111 additions and 15 deletions

View File

@@ -23,6 +23,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { useState, useMemo, Fragment, useRef, useCallback } from "react"; import { useState, useMemo, Fragment, useRef, useCallback } from "react";
import * as XLSX from "xlsx";
import { toast } from "sonner"; import { toast } from "sonner";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -863,14 +864,25 @@ function ImportEtablissementsPanel() {
setDone(0); setDone(0);
}, []); }, []);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const isXlsx = file.name.endsWith(".xlsx") || file.name.endsWith(".xls");
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (ev) => parseCSV(ev.target?.result as string); if (isXlsx) {
reader.readAsText(file, "UTF-8"); reader.onload = (ev) => {
const data = new Uint8Array(ev.target?.result as ArrayBuffer);
const wb = XLSX.read(data, { type: "array" });
const ws = wb.Sheets[wb.SheetNames[0]];
const csv = XLSX.utils.sheet_to_csv(ws, { FS: ";" });
parseCSV(csv);
};
reader.readAsArrayBuffer(file);
} else {
reader.onload = (ev) => parseCSV(ev.target?.result as string);
reader.readAsText(file, "UTF-8");
}
}; };
const handleImport = async () => { const handleImport = async () => {
setImporting(true); setImporting(true);
let count = 0; let count = 0;
@@ -939,7 +951,7 @@ function ImportEtablissementsPanel() {
<FileText size={32} className="mx-auto text-muted-foreground/40 mb-2" /> <FileText size={32} className="mx-auto text-muted-foreground/40 mb-2" />
<p className="text-sm font-medium text-foreground">Cliquez pour sélectionner un fichier CSV</p> <p className="text-sm font-medium text-foreground">Cliquez pour sélectionner un fichier CSV</p>
<p className="text-xs text-muted-foreground mt-1">Séparateur : point-virgule, virgule ou tabulation</p> <p className="text-xs text-muted-foreground mt-1">Séparateur : point-virgule, virgule ou tabulation</p>
<input ref={fileRef} type="file" accept=".csv,.txt" className="hidden" onChange={handleFile} /> <input ref={fileRef} type="file" accept=".csv,.txt,.xlsx,.xls" className="hidden" onChange={handleFile} />
</div> </div>
{/* Colonnes attendues */} {/* Colonnes attendues */}
@@ -1078,11 +1090,22 @@ function ImportContactsPanel() {
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const isXlsx = file.name.endsWith(".xlsx") || file.name.endsWith(".xls");
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (ev) => parseCSV(ev.target?.result as string); if (isXlsx) {
reader.readAsText(file, "UTF-8"); reader.onload = (ev) => {
const data = new Uint8Array(ev.target?.result as ArrayBuffer);
const wb = XLSX.read(data, { type: "array" });
const ws = wb.Sheets[wb.SheetNames[0]];
const csv = XLSX.utils.sheet_to_csv(ws, { FS: ";" });
parseCSV(csv);
};
reader.readAsArrayBuffer(file);
} else {
reader.onload = (ev) => parseCSV(ev.target?.result as string);
reader.readAsText(file, "UTF-8");
}
}; };
const handleImport = async () => { const handleImport = async () => {
setImporting(true); setImporting(true);
let count = 0; let count = 0;
@@ -1150,7 +1173,7 @@ function ImportContactsPanel() {
<FileText size={32} className="mx-auto text-muted-foreground/40 mb-2" /> <FileText size={32} className="mx-auto text-muted-foreground/40 mb-2" />
<p className="text-sm font-medium text-foreground">Cliquez pour sélectionner un fichier CSV</p> <p className="text-sm font-medium text-foreground">Cliquez pour sélectionner un fichier CSV</p>
<p className="text-xs text-muted-foreground mt-1">Séparateur : point-virgule, virgule ou tabulation</p> <p className="text-xs text-muted-foreground mt-1">Séparateur : point-virgule, virgule ou tabulation</p>
<input ref={fileRef} type="file" accept=".csv,.txt" className="hidden" onChange={handleFile} /> <input ref={fileRef} type="file" accept=".csv,.txt,.xlsx,.xls" className="hidden" onChange={handleFile} />
</div> </div>
{/* Colonnes attendues */} {/* Colonnes attendues */}

View File

@@ -77,6 +77,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

@@ -208,6 +208,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
@@ -2389,6 +2392,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'}
@@ -2465,6 +2472,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'}
@@ -2510,6 +2521,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'}
@@ -2563,6 +2578,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'}
@@ -3059,6 +3079,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==}
@@ -3972,6 +3996,10 @@ packages:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
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==}
@@ -4317,11 +4345,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==}
@@ -6757,6 +6798,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
@@ -6840,6 +6883,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
@@ -6892,6 +6940,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
@@ -6932,6 +6982,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: {}
@@ -7419,6 +7471,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):
@@ -8681,6 +8735,10 @@ snapshots:
sqlstring@2.3.3: {} sqlstring@2.3.3: {}
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: {}
@@ -9026,6 +9084,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
@@ -9033,6 +9095,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: {}

12
todo.md
View File

@@ -64,9 +64,9 @@
## Évolution v4 ## Évolution v4
- [ ] CGU : afficher à chaque connexion (réinitialiser à chaque login, pas seulement à la fermeture du navigateur) - [x] CGU : afficher à chaque connexion (réinitialiser à chaque login, pas seulement à la fermeture du navigateur)
- [ ] Moteur de recherche : corriger les filtres multicritères qui ne fonctionnent pas - [x] Moteur de recherche : corriger les filtres multicritères qui ne fonctionnent pas
- [ ] Page "Mes Solutions Numériques" : liste de toutes mes solutions avec les établissements les utilisant (accordéon) - [x] Page "Mes Solutions Numériques" : liste de toutes mes solutions avec les établissements les utilisant (accordéon)
- [ ] Page "Solutions Logicielles" : liste globale des solutions avec établissements équipés (accordéon), accessible depuis la sidebar - [x] Page "Solutions Logicielles" : liste globale des solutions avec établissements équipés (accordéon), accessible depuis la sidebar
- [ ] Admin : onglet import établissements (CSV/Excel) - [x] Admin : onglet import établissements (CSV/Excel)
- [ ] Admin : onglet import contacts (CSV/Excel) - [x] Admin : onglet import contacts (CSV/Excel)