diff --git a/client/src/App.tsx b/client/src/App.tsx index 095879c..93550cc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ; +} + function App() { return ( - - - - - - + + + + + + + + + diff --git a/client/src/components/AppSidebar.tsx b/client/src/components/AppSidebar.tsx index 2072442..83b69a2 100644 --- a/client/src/components/AppSidebar.tsx +++ b/client/src/components/AppSidebar.tsx @@ -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>(() => 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) { {/* Bouton Imports & Données */} 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" > @@ -403,8 +403,6 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) { )} - {/* Modale d'import */} - setShowImport(false)} /> 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 ${ diff --git a/client/src/components/ImportModal.tsx b/client/src/components/ImportModal.tsx index 5b1686e..118f117 100644 --- a/client/src/components/ImportModal.tsx +++ b/client/src/components/ImportModal.tsx @@ -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(); 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); diff --git a/client/src/contexts/ImportModalContext.tsx b/client/src/contexts/ImportModalContext.tsx new file mode 100644 index 0000000..b99f4d1 --- /dev/null +++ b/client/src/contexts/ImportModalContext.tsx @@ -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({ + open: false, + openModal: () => {}, + closeModal: () => {}, +}); + +export function ImportModalProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + return ( + setOpen(true), closeModal: () => setOpen(false) }}> + {children} + + ); +} + +export function useImportModal() { + return useContext(ImportModalContext); +}