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:
@@ -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>
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
<ImportModalProvider>
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
<GlobalModals />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</ImportModalProvider>
|
||||
</AnneeProvider>
|
||||
</ParametresProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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);
|
||||
|
||||
30
client/src/contexts/ImportModalContext.tsx
Normal file
30
client/src/contexts/ImportModalContext.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user