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 { ParametresProvider } from "./contexts/ParametresContext";
import { AnneeProvider } from "./contexts/AnneeContext";
import { ImportModalProvider, useImportModal } from "./contexts/ImportModalContext";
import { ImportModal } from "./components/ImportModal";
import Home from "./pages/Home";
import Parametres from "./pages/Parametres";
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() {
return (
<ErrorBoundary>
<ParametresProvider>
<AnneeProvider>
<ImportModalProvider>
<ThemeProvider defaultTheme="light">
<TooltipProvider>
<Toaster />
<Router />
<GlobalModals />
</TooltipProvider>
</ThemeProvider>
</ImportModalProvider>
</AnneeProvider>
</ParametresProvider>
</ErrorBoundary>

View File

@@ -13,7 +13,7 @@
// └── Paramètres → /parametres
import { useState } from 'react';
import { ImportModal } from './ImportModal';
import { useImportModal } from '../contexts/ImportModalContext';
import { useLocation } from 'wouter';
import {
BarChart3,
@@ -205,7 +205,7 @@ interface AppSidebarProps {
export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
const [location, navigate] = useLocation();
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
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">
{/* Bouton Imports & Données */}
<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"
>
<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>
{/* Modale d'import */}
<ImportModal open={showImport} onClose={() => setShowImport(false)} />
<button
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 ${

View File

@@ -54,30 +54,40 @@ const DEFAULT_IMPORT_PROMPT = `# Processus d'import de l'inventaire ISI-APP
- Encodage : UTF-8 ou Windows-1252
- Première ligne : en-têtes de colonnes
## Colonnes obligatoires
- **Code établissement** : identifiant unique (ex : 1063CRD, 2013ANG…)
Noms acceptés : code, code_etab, code_etablissement, code etablissement
- **Nom établissement** : libellé complet
Noms acceptés : nom, nom_etab, nom_etablissement, etablissement, libelle_etab
## Extraction du code établissement
- Colonne source : "libellé" (1ère colonne)
- Règle : prendre les 8 premiers caractères du libellé
- Exemple : libellé "1083MAS-F4833" → code établissement "1083MAS"
- Exemple : libellé "3069UDA-P5191" → code établissement "3069UDA"
## Colonnes optionnelles
- type : fixe / portable / bureau / laptop
- date_achat : date d'achat du poste (JJ/MM/AAAA)
- modele : modèle du matériel
- fabricant : marque (Lenovo, Dell, HP…)
- groupe : groupement ou association de rattachement
- ville : commune de l'établissement
## Extraction du nom établissement
- Colonne source : "entité"
- Utiliser la valeur telle quelle comme nom de l'établissement
## Détection du type de poste (fixe / portable)
- Colonne source : "libellé"
- 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
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)
3. Les lignes sans code ou sans nom sont ignorées
4. Les doublons de postes sont conservés (un poste = une ligne)
3. Les lignes sans libellé ou sans entité sont ignorées
4. Chaque ligne = un poste (un poste = une ligne)
## 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)
- 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 {
try {
@@ -276,53 +286,92 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
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)
// ── Format ISI-APP : extraction par libellé et entité ──────────────────
// Colonne "libellé" : contient le code poste (ex: 1083MAS-F4833)
// → code établissement = 8 premiers caractères avant le tiret
// → type = F/FIX → fixe, P/POR → portable
// Colonne "entité" : nom de l'établissement
// Colonne "date de mise en service" (priorité) ou "date d'achat" : pour la vétusté
const libelleIdx = rawHeaders.findIndex(h =>
h === 'libellé' || h === 'libelle' || h === 'label' ||
h.includes('libellé') || h.includes('libelle')
);
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 entiteIdx = rawHeaders.findIndex(h =>
h === 'entité' || h === 'entite' || h === 'entity' ||
h.includes('entité') || h.includes('entite')
);
const dateMiseEnServiceIdx = rawHeaders.findIndex(h =>
h.includes('mise en service') || h.includes('miseenservice') || h.includes('mise_en_service') ||
h.includes('service')
);
const dateAchatIdx = rawHeaders.findIndex(h =>
h.includes('achat') || h.includes('purchase') || h.includes('date_achat')
);
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 =>
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');
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".'
`Colonnes introuvables. En-têtes détectés : ${rawHeaders.slice(0, 10).join(', ')}\n` +
'Format ISI-APP attendu : colonnes "libellé" et "entité". ' +
'Format alternatif : colonnes "code" et "nom".'
);
return;
}
// Compteurs pour le résumé
let nbFixes = 0;
let nbPortables = 0;
// 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();
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 (!etabMap.has(code)) {
etabMap.set(code, {
@@ -369,19 +418,24 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
size: file.size,
nbLignes: lines.length - 1,
nbEtablissements: etabMap.size,
nbFixes: useIsiFormat ? nbFixes : null,
nbPortables: useIsiFormat ? nbPortables : null,
format: useIsiFormat ? 'isi-app' : 'classique',
}));
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`,
nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null,
nbMisAJour > 0 ? `${nbMisAJour} mis à jour` : null,
useIsiFormat && nbFixes > 0 ? `${nbFixes} fixes` : null,
useIsiFormat && nbPortables > 0 ? `${nbPortables} portables` : null,
].filter(Boolean).join(' — ');
setInventaireStatus('success');
setInventaireMsg(`"${file.name}" importé — ${details}`);
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) {
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);
}