From cfeb5196dba0b23246691fa51d7918a66e8dec3e Mon Sep 17 00:00:00 2001 From: Manus Date: Thu, 11 Jun 2026 07:24:34 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Corrections=20des=20deux=20lacune?= =?UTF-8?q?s=20identifi=C3=A9es=20dans=20l'onglet=20"Cl=C3=A9s=20de=20r?= =?UTF-8?q?=C3=A9partition"=20de=20DSI=20OPEX=20:=201.=20Loading/error=20s?= =?UTF-8?q?tate=20:=20ClesRepartitionView=20re=C3=A7oit=20d=C3=A9sormais?= =?UTF-8?q?=20isLoadingBases=20et=20isErrorBases=20depuis=20la=20requ?= =?UTF-8?q?=C3=AAte=20trpc.opex.getBasesRepartition.useQuery().=20Affiche?= =?UTF-8?q?=20un=20spinner=20pendant=20le=20chargement=20et=20un=20message?= =?UTF-8?q?=20d'erreur=20si=20la=20requ=C3=AAte=20=C3=A9choue=20(au=20lieu?= =?UTF-8?q?=20d'un=20tableau=20vide=20trompeur).=202.=20Import=20fichier?= =?UTF-8?q?=20am=C3=A9lior=C3=A9=20:=20d=C3=A9tection=20intelligente=20de?= =?UTF-8?q?=20la=20feuille=20(mots-cl=C3=A9s=20OPEX/DSI/r=C3=A9partition/b?= =?UTF-8?q?ase,=20insensible=20aux=20accents),=20recherche=20de=20l'en-t?= =?UTF-8?q?=C3=AAte=20dans=20les=2015=20premi=C3=A8res=20lignes,=20normali?= =?UTF-8?q?sation=20des=20accents=20pour=20la=20d=C3=A9tection=20des=20col?= =?UTF-8?q?onnes,=20message=20d'erreur=20d=C3=A9taill=C3=A9=20avec=20les?= =?UTF-8?q?=20colonnes=20d=C3=A9tect=C3=A9es,=20toast=20informatif=20si=20?= =?UTF-8?q?plusieurs=20feuilles=20pr=C3=A9sentes.=2014=20tests=20Vitest=20?= =?UTF-8?q?passants.=200=20erreur=20TypeScript.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/DsiOpex.tsx | 111 ++++++++++++++++++++++++++++++----- todo.md | 21 ++++--- 2 files changed, 109 insertions(+), 23 deletions(-) diff --git a/client/src/pages/DsiOpex.tsx b/client/src/pages/DsiOpex.tsx index a62592e..b2affaf 100644 --- a/client/src/pages/DsiOpex.tsx +++ b/client/src/pages/DsiOpex.tsx @@ -389,11 +389,13 @@ interface BaseRow { interface ClesRepartitionViewProps { annee: number; basesRepartitionRaw: BaseRow[]; + isLoadingBases: boolean; + isErrorBases: boolean; setBaseRepartition: ReturnType; importBasesRepartition: ReturnType; } -function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, importBasesRepartition }: ClesRepartitionViewProps) { +function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErrorBases, setBaseRepartition, importBasesRepartition }: ClesRepartitionViewProps) { const anneeBase = annee - 2; // La base de répartition correspond à l'année OPEX - 2 const [editingCell, setEditingCell] = useState<{ code: string; col: 'base' | 'hep' } | null>(null); const [editValue, setEditValue] = useState(''); @@ -439,33 +441,91 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, i const XLSX = await import('xlsx'); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); - const ws = wb.Sheets[wb.SheetNames[0]]; + + // ── Sélection intelligente de la feuille ────────────────────────────── + // Priorité : feuille dont le nom contient OPEX, DSI, répartition ou base + // (insensible à la casse et aux accents). Sinon, première feuille. + const SHEET_KEYWORDS = ['opex', 'dsi', 'repartition', 'répartition', 'base']; + const selectedSheetName = + wb.SheetNames.find(name => { + const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + return SHEET_KEYWORDS.some(kw => lower.includes(kw)); + }) ?? wb.SheetNames[0]; + + const ws = wb.Sheets[selectedSheetName]; + + // Informer l'utilisateur de la feuille utilisée si le classeur en contient plusieurs + if (wb.SheetNames.length > 1) { + toast.info(`Feuille utilisée : « ${selectedSheetName} »`, { + description: `${wb.SheetNames.length} feuilles disponibles dans le classeur.`, + }); + } + const data: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][]; - // Trouver la ligne d'en-tête + + // ── Détection de la ligne d'en-tête ────────────────────────────────── + // Cherche dans les 15 premières lignes une ligne contenant un code établissement let headerIdx = -1; - for (let i = 0; i < Math.min(data.length, 10); i++) { - const row = data[i].map(c => String(c).toLowerCase()); - if (row.some(c => c.includes('code') || c.includes('établissement') || c.includes('etab'))) { + for (let i = 0; i < Math.min(data.length, 15); i++) { + const row = data[i].map(c => String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')); + if (row.some(c => + c.includes('code') || + c.includes('etablissement') || + c.includes('etab') || + c.includes('structure') + )) { headerIdx = i; break; } } - if (headerIdx === -1) { toast.error('Format non reconnu', { description: 'Impossible de trouver la ligne d\'en-tête (code établissement)' }); return; } - const headers = data[headerIdx].map(c => String(c).toLowerCase()); + if (headerIdx === -1) { + toast.error('Format non reconnu', { + description: `Impossible de trouver la ligne d'en-tête dans la feuille « ${selectedSheetName} ». Colonnes attendues : code établissement, base de répartition.`, + }); + return; + } + + // ── Détection des colonnes ──────────────────────────────────────────── + const headers = data[headerIdx].map(c => + String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + ); const codeIdx = headers.findIndex(h => h.includes('code')); - const nomIdx = headers.findIndex(h => h.includes('nom') || h.includes('établissement') || h.includes('etab')); - const baseIdx = headers.findIndex(h => (h.includes('base') || h.includes('répartition') || h.includes('repartition')) && !h.includes('hep')); + const nomIdx = headers.findIndex(h => + h.includes('nom') || h.includes('etablissement') || h.includes('etab') || h.includes('libelle') + ); + // Colonne base standard : contient 'base' ou 'repartition' mais PAS 'hep' + const baseIdx = headers.findIndex(h => + (h.includes('base') || h.includes('repartition')) && !h.includes('hep') + ); + // Colonne base HEP : contient 'hep' const hepIdx = headers.findIndex(h => h.includes('hep')); - if (codeIdx === -1 || baseIdx === -1) { toast.error('Colonnes manquantes', { description: 'Le fichier doit contenir au minimum les colonnes : code établissement, base de répartition' }); return; } + + if (codeIdx === -1 || baseIdx === -1) { + toast.error('Colonnes manquantes', { + description: `Le fichier doit contenir au minimum les colonnes : code établissement, base de répartition. Colonnes détectées : ${headers.filter(Boolean).join(', ') || '(aucune)'}`, + }); + return; + } + + // ── Construction des lignes à importer ─────────────────────────────── const importRows = data.slice(headerIdx + 1) .filter(row => row[codeIdx] && String(row[codeIdx]).trim()) .map(row => ({ etablissementCode: String(row[codeIdx]).trim(), etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null, - baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.]/g, '')) || 0, - baseRepartitionHep: hepIdx >= 0 ? parseFloat(String(row[hepIdx]).replace(/[^0-9.]/g, '')) || 0 : 0, + baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0, + baseRepartitionHep: hepIdx >= 0 + ? parseFloat(String(row[hepIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0 + : 0, })); - if (importRows.length === 0) { toast.error('Aucune ligne importée', { description: 'Le fichier ne contient aucune ligne de données valide' }); return; } + + if (importRows.length === 0) { + toast.error('Aucune ligne importée', { + description: 'Le fichier ne contient aucune ligne de données valide après la ligne d\'en-tête.', + }); + return; + } + importBasesRepartition.mutate({ annee, rows: importRows }); } catch (e) { toast.error('Erreur lecture fichier', { description: String(e) }); @@ -478,6 +538,25 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, i return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(n) + ' €'; } + if (isLoadingBases) { + return ( +
+ +

Chargement des bases de répartition…

+
+ ); + } + + if (isErrorBases) { + return ( +
+ +

Erreur lors du chargement des bases de répartition

+

Vérifiez votre connexion et rechargez la page.

+
+ ); + } + return (
{/* Bandeau d'information */} @@ -646,7 +725,7 @@ export default function DsiOpex() { const { data: montantsEtabRaw, isLoading: loadingMontants } = trpc.opex.getMontantsEtab.useQuery({ annee }); const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee }); // Bases de répartition depuis la BDD (charges classe 6 par établissement) - const { data: basesRepartitionRaw } = trpc.opex.getBasesRepartition.useQuery({ annee }); + const { data: basesRepartitionRaw, isLoading: isLoadingBases, isError: isErrorBases } = trpc.opex.getBasesRepartition.useQuery({ annee }); // ── Mutations tRPC ───────────────────────────────────────────────────────── const upsertPoste = trpc.opex.upsertPoste.useMutation({ @@ -1731,6 +1810,8 @@ export default function DsiOpex() { diff --git a/todo.md b/todo.md index 3551afb..4ad568f 100644 --- a/todo.md +++ b/todo.md @@ -43,11 +43,16 @@ ## Onglet Clés de répartition dans DSI OPEX -- [ ] Analyser le fichier source pour extraire les colonnes base_repartition et base_repartition_hep -- [ ] Ajouter colonne base_repartition_hep dans la table opex_bases_repartition + migration -- [ ] Routes tRPC : opex.getBasesRepartition (mise à jour), opex.setBaseRepartition (mise à jour avec hep), opex.importBasesRepartition -- [ ] Seeder les données base_repartition_hep 2026 depuis le fichier source -- [ ] Créer l'onglet "Clés de répartition" dans DsiOpex avec tableau éditable (base standard + /HEP) -- [ ] Import fichier Excel/CSV dans l'onglet Clés de répartition -- [ ] Afficher l'info "Année de la base = année OPEX - 2" dans l'onglet -- [ ] Brancher le calcul du poste "DUI pôle HEP" sur base_repartition_hep +- [x] Analyser le fichier source pour extraire les colonnes base_repartition et base_repartition_hep +- [x] Ajouter colonne base_repartition_hep dans la table opex_bases_repartition + migration +- [x] Routes tRPC : opex.getBasesRepartition (mise à jour), opex.setBaseRepartition (mise à jour avec hep), opex.importBasesRepartition +- [x] Seeder les données base_repartition_hep 2026 depuis le fichier source +- [x] Créer l'onglet "Clés de répartition" dans DsiOpex avec tableau éditable (base standard + /HEP) +- [x] Import fichier Excel/CSV dans l'onglet Clés de répartition +- [x] Afficher l'info "Année de la base = année OPEX - 2" dans l'onglet +- [x] Brancher le calcul du poste "DUI pôle HEP" sur base_repartition_hep + +## Corrections de lacunes (post-checkpoint 841ff1bd) + +- [x] Ajouter un vrai loading/error state pour l'onglet « Clés de répartition » basé sur la requête `opex.getBasesRepartition` +- [x] Rendre l'import des clés de répartition compatible avec le classeur source réel : détection intelligente de la bonne feuille (mots-clés OPEX/DSI/répartition/base), normalisation des accents, validation des colonnes attendues avec message d'erreur détaillé