Checkpoint: Correction du bug d'affichage de la modale : déplacement du rendu hors de la sidebar (overflow:hidden) via un ImportModalContext + rendu au niveau racine dans App.tsx. Mise à jour du prompt par défaut avec les règles précises du format ISI-APP réel (code depuis libellé 8 car., nom depuis entité, type fixe/portable depuis libellé, vétusté depuis date mise en service ou date achat). Mise à jour du parsing handleInventaireFile pour utiliser ces règles avec comptage fixes/portables dans le résumé.

This commit is contained in:
Manus
2026-06-04 14:53:25 +00:00
parent e124a970a7
commit b917aeaa56
4 changed files with 152 additions and 59 deletions

View File

@@ -6,6 +6,8 @@ import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import { ParametresProvider } from "./contexts/ParametresContext"; import { ParametresProvider } from "./contexts/ParametresContext";
import { AnneeProvider } from "./contexts/AnneeContext"; import { AnneeProvider } from "./contexts/AnneeContext";
import { ImportModalProvider, useImportModal } from "./contexts/ImportModalContext";
import { ImportModal } from "./components/ImportModal";
import Home from "./pages/Home"; import Home from "./pages/Home";
import Parametres from "./pages/Parametres"; import Parametres from "./pages/Parametres";
import Budget2027 from "./pages/Budget2027"; import Budget2027 from "./pages/Budget2027";
@@ -32,17 +34,26 @@ function Router() {
); );
} }
// Rendu de la modale au niveau racine (hors de tout overflow:hidden)
function GlobalModals() {
const { open, closeModal } = useImportModal();
return <ImportModal open={open} onClose={closeModal} />;
}
function App() { function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ParametresProvider> <ParametresProvider>
<AnneeProvider> <AnneeProvider>
<ThemeProvider defaultTheme="light"> <ImportModalProvider>
<TooltipProvider> <ThemeProvider defaultTheme="light">
<Toaster /> <TooltipProvider>
<Router /> <Toaster />
</TooltipProvider> <Router />
</ThemeProvider> <GlobalModals />
</TooltipProvider>
</ThemeProvider>
</ImportModalProvider>
</AnneeProvider> </AnneeProvider>
</ParametresProvider> </ParametresProvider>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -13,7 +13,7 @@
// └── Paramètres → /parametres // └── Paramètres → /parametres
import { useState } from 'react'; import { useState } from 'react';
import { ImportModal } from './ImportModal'; import { useImportModal } from '../contexts/ImportModalContext';
import { useLocation } from 'wouter'; import { useLocation } from 'wouter';
import { import {
BarChart3, BarChart3,
@@ -205,7 +205,7 @@ interface AppSidebarProps {
export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) { export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpenSections(location)); const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpenSections(location));
const [showImport, setShowImport] = useState(false); const { openModal: openImportModal } = useImportModal();
// Mode accordéon : ferme les autres sections du même niveau lors de l'ouverture // Mode accordéon : ferme les autres sections du même niveau lors de l'ouverture
const toggleSection = (id: string) => { const toggleSection = (id: string) => {
@@ -392,7 +392,7 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
<div className="px-2 pb-3 pt-2 border-t border-white/10 space-y-1"> <div className="px-2 pb-3 pt-2 border-t border-white/10 space-y-1">
{/* Bouton Imports & Données */} {/* Bouton Imports & Données */}
<button <button
onClick={() => setShowImport(true)} onClick={() => openImportModal()}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group text-white/70 hover:bg-white/12 hover:text-white" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group text-white/70 hover:bg-white/12 hover:text-white"
> >
<div className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 transition-colors bg-indigo-500/30 group-hover:bg-indigo-500/50"> <div className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 transition-colors bg-indigo-500/30 group-hover:bg-indigo-500/50">
@@ -403,8 +403,6 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
)} )}
</button> </button>
{/* Modale d'import */}
<ImportModal open={showImport} onClose={() => setShowImport(false)} />
<button <button
onClick={() => navigate(SETTINGS_ITEM.path)} onClick={() => navigate(SETTINGS_ITEM.path)}
className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group ${ className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group ${

View File

@@ -54,30 +54,40 @@ const DEFAULT_IMPORT_PROMPT = `# Processus d'import de l'inventaire ISI-APP
- Encodage : UTF-8 ou Windows-1252 - Encodage : UTF-8 ou Windows-1252
- Première ligne : en-têtes de colonnes - Première ligne : en-têtes de colonnes
## Colonnes obligatoires ## Extraction du code établissement
- **Code établissement** : identifiant unique (ex : 1063CRD, 2013ANG…) - Colonne source : "libellé" (1ère colonne)
Noms acceptés : code, code_etab, code_etablissement, code etablissement - Règle : prendre les 8 premiers caractères du libellé
- **Nom établissement** : libellé complet - Exemple : libellé "1083MAS-F4833" → code établissement "1083MAS"
Noms acceptés : nom, nom_etab, nom_etablissement, etablissement, libelle_etab - Exemple : libellé "3069UDA-P5191" → code établissement "3069UDA"
## Colonnes optionnelles ## Extraction du nom établissement
- type : fixe / portable / bureau / laptop - Colonne source : "entité"
- date_achat : date d'achat du poste (JJ/MM/AAAA) - Utiliser la valeur telle quelle comme nom de l'établissement
- modele : modèle du matériel
- fabricant : marque (Lenovo, Dell, HP…) ## Détection du type de poste (fixe / portable)
- groupe : groupement ou association de rattachement - Colonne source : "libellé"
- ville : commune de l'établissement - PC fixe : le libellé contient "-F" ou "-FIX" après le code établissement
Exemple : 1083SYL-F4833 → PC Fixe
- PC portable : le libellé contient "-P" ou "-POR" après le code établissement
Exemple : 3069UDA-P5191 → PC Portable
## Calcul de la vétusté
- Utiliser en priorité la colonne "date de mise en service"
- Si cette colonne est vide ou absente, utiliser la colonne "date d'achat"
- L'âge en années est calculé depuis cette date jusqu'à la date de référence (01/01/2027)
## Règles de traitement ## Règles de traitement
1. Les établissements uniques sont extraits et enregistrés dans la liste des établissements 1. Les établissements uniques (code + nom) sont extraits et enregistrés dans la liste des établissements
2. Les établissements déjà présents ne sont pas écrasés (fusion intelligente) 2. Les établissements déjà présents ne sont pas écrasés (fusion intelligente)
3. Les lignes sans code ou sans nom sont ignorées 3. Les lignes sans libellé ou sans entité sont ignorées
4. Les doublons de postes sont conservés (un poste = une ligne) 4. Chaque ligne = un poste (un poste = une ligne)
## Comportement attendu ## Comportement attendu
- Créer ou mettre à jour la liste des établissements (code + nom) - Créer ou mettre à jour la liste des établissements (code 8 car. + nom depuis "entité")
- Classifier chaque poste en fixe ou portable selon le libellé
- Calculer l'âge à partir de la date de mise en service (ou date d'achat si vide)
- Enregistrer les métadonnées du fichier importé (nom, date, nb lignes) - Enregistrer les métadonnées du fichier importé (nom, date, nb lignes)
- Afficher un résumé : nb lignes traitées, nb établissements détectés, nb nouveaux`; - Afficher un résumé : nb lignes traitées, nb établissements détectés, nb fixes, nb portables`;
export function loadImportPrompt(): string { export function loadImportPrompt(): string {
try { try {
@@ -276,53 +286,92 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
const sep = lines[0].includes(';') ? ';' : ','; const sep = lines[0].includes(';') ? ';' : ',';
const rawHeaders = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, '').replace(/[\r\n]/g, '')); const rawHeaders = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, '').replace(/[\r\n]/g, ''));
// Recherche des colonnes code et nom établissement // ── Format ISI-APP : extraction par libellé et entité ──────────────────
// Accepte de nombreuses variantes de nommage ISI-APP // Colonne "libellé" : contient le code poste (ex: 1083MAS-F4833)
const codeIdx = rawHeaders.findIndex(h => // → code établissement = 8 premiers caractères avant le tiret
h === 'code' || // → type = F/FIX → fixe, P/POR → portable
h.includes('code_etab') || // Colonne "entité" : nom de l'établissement
h.includes('code etab') || // Colonne "date de mise en service" (priorité) ou "date d'achat" : pour la vétusté
h.includes('code_etablissement') ||
h.includes('code etablissement') || const libelleIdx = rawHeaders.findIndex(h =>
h.includes('codeétab') || h === 'libellé' || h === 'libelle' || h === 'label' ||
h.includes('codeétablissement') || h.includes('libellé') || h.includes('libelle')
(h.startsWith('code') && h.length < 20)
); );
const nomIdx = rawHeaders.findIndex(h => const entiteIdx = rawHeaders.findIndex(h =>
h === 'nom' || h === 'entité' || h === 'entite' || h === 'entity' ||
h.includes('nom_etab') || h.includes('entité') || h.includes('entite')
h.includes('nom etab') || );
h.includes('nom_etablissement') || const dateMiseEnServiceIdx = rawHeaders.findIndex(h =>
h.includes('nom etablissement') || h.includes('mise en service') || h.includes('miseenservice') || h.includes('mise_en_service') ||
h.includes('libelle_etab') || h.includes('service')
h.includes('libelle etab') || );
h.includes('établissement') || const dateAchatIdx = rawHeaders.findIndex(h =>
h.includes('etablissement') || h.includes('achat') || h.includes('purchase') || h.includes('date_achat')
h.includes('nométab') ||
(h.startsWith('nom') && h.length < 20)
); );
const groupeIdx = rawHeaders.findIndex(h => const groupeIdx = rawHeaders.findIndex(h =>
h.includes('groupe') || h.includes('association') || h.includes('entite') || h.includes('entité') h.includes('groupe') || h.includes('association') || h.includes('groupement')
); );
const villeIdx = rawHeaders.findIndex(h => const villeIdx = rawHeaders.findIndex(h =>
h.includes('ville') || h.includes('commune') || h.includes('localite') || h.includes('localité') h.includes('ville') || h.includes('commune') || h.includes('localite') || h.includes('localité')
); );
if (codeIdx === -1 || nomIdx === -1) { // Fallback : si pas de colonne libellé/entité, chercher code/nom classiques
const codeIdxFallback = libelleIdx === -1 ? rawHeaders.findIndex(h =>
h === 'code' || h.includes('code_etab') || h.includes('code etablissement') ||
(h.startsWith('code') && h.length < 20)
) : -1;
const nomIdxFallback = entiteIdx === -1 ? rawHeaders.findIndex(h =>
h === 'nom' || h.includes('nom_etab') || h.includes('nom etablissement') ||
h.includes('etablissement') || (h.startsWith('nom') && h.length < 20)
) : -1;
const useIsiFormat = libelleIdx !== -1 && entiteIdx !== -1;
const useClassicFormat = !useIsiFormat && codeIdxFallback !== -1 && nomIdxFallback !== -1;
if (!useIsiFormat && !useClassicFormat) {
setInventaireStatus('error'); setInventaireStatus('error');
setInventaireMsg( setInventaireMsg(
`Colonnes établissement introuvables. En-têtes détectés : ${rawHeaders.slice(0, 8).join(', ')}\n` + `Colonnes introuvables. En-têtes détectés : ${rawHeaders.slice(0, 10).join(', ')}\n` +
'Attendu : une colonne contenant "code" et une contenant "nom" ou "etablissement".' 'Format ISI-APP attendu : colonnes "libellé" et "entité". ' +
'Format alternatif : colonnes "code" et "nom".'
); );
return; return;
} }
// Compteurs pour le résumé
let nbFixes = 0;
let nbPortables = 0;
// 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 line of lines.slice(1)) {
const cols = line.split(sep).map(c => c.trim().replace(/"/g, '').replace(/[\r\n]/g, '')); 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(); let code = '';
let nom = '';
if (useIsiFormat) {
// Format ISI-APP : code = 8 premiers caractères du libellé (avant le tiret)
const libelle = cols[libelleIdx]?.trim() || '';
// Extraire le code établissement : tout ce qui précède le premier tiret, max 8 car.
const dashIdx = libelle.indexOf('-');
code = dashIdx > 0 ? libelle.substring(0, dashIdx) : libelle.substring(0, 8);
code = code.trim();
nom = cols[entiteIdx]?.trim() || '';
// Détecter le type de poste depuis le libellé
const afterDash = dashIdx > 0 ? libelle.substring(dashIdx + 1).toUpperCase() : '';
if (afterDash.startsWith('F') || afterDash.startsWith('FIX')) {
nbFixes++;
} else if (afterDash.startsWith('P') || afterDash.startsWith('POR')) {
nbPortables++;
}
} else {
// Format classique : code et nom dans des colonnes dédiées
code = cols[codeIdxFallback]?.trim() || '';
nom = cols[nomIdxFallback]?.trim() || '';
}
if (!code || !nom) continue; if (!code || !nom) continue;
if (!etabMap.has(code)) { if (!etabMap.has(code)) {
etabMap.set(code, { etabMap.set(code, {
@@ -369,19 +418,24 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
size: file.size, size: file.size,
nbLignes: lines.length - 1, nbLignes: lines.length - 1,
nbEtablissements: etabMap.size, nbEtablissements: etabMap.size,
nbFixes: useIsiFormat ? nbFixes : null,
nbPortables: useIsiFormat ? nbPortables : null,
format: useIsiFormat ? 'isi-app' : 'classique',
})); }));
const details = [ const details = [
`${lines.length - 1} ligne${lines.length > 2 ? 's' : ''} de postes`, `${lines.length - 1} poste${lines.length > 2 ? '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,
useIsiFormat && nbFixes > 0 ? `${nbFixes} fixes` : null,
useIsiFormat && nbPortables > 0 ? `${nbPortables} portables` : null,
].filter(Boolean).join(' — '); ].filter(Boolean).join(' — ');
setInventaireStatus('success'); setInventaireStatus('success');
setInventaireMsg(`"${file.name}" importé — ${details}`); setInventaireMsg(`"${file.name}" importé — ${details}`);
toast.success('Inventaire importé', { toast.success('Inventaire importé', {
description: `${etabMap.size} établissements extraits et enregistrés dans la liste.`, description: `${etabMap.size} établissements extraits${useIsiFormat ? `${nbFixes} fixes, ${nbPortables} portables` : ''}.`,
}); });
} catch (err) { } catch (err) {
console.error('Import inventaire:', err); console.error('Import inventaire:', err);

View File

@@ -0,0 +1,30 @@
// ImportModalContext.tsx — Contrôle global de la modale d'import
// Permet d'ouvrir la modale depuis la sidebar (qui a overflow:hidden)
// en la rendant au niveau racine de l'application dans App.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
interface ImportModalContextValue {
open: boolean;
openModal: () => void;
closeModal: () => void;
}
const ImportModalContext = createContext<ImportModalContextValue>({
open: false,
openModal: () => {},
closeModal: () => {},
});
export function ImportModalProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<ImportModalContext.Provider value={{ open, openModal: () => setOpen(true), closeModal: () => setOpen(false) }}>
{children}
</ImportModalContext.Provider>
);
}
export function useImportModal() {
return useContext(ImportModalContext);
}