From a0508893f19fb54a369b07b22793f089b1d95ca8 Mon Sep 17 00:00:00 2001 From: Manus Date: Wed, 10 Jun 2026 07:55:49 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=201)=20Filtre=20automatique=20des?= =?UTF-8?q?=20lignes=20parasites=20(Ventilation=20cout,=20Infog=C3=A9rance?= =?UTF-8?q?,=20S=C3=A9curit=C3=A9,=20Applicatifs=20PA,=20etc.)=20lors=20du?= =?UTF-8?q?=20chargement=20depuis=20localStorage=20=E2=80=94=20purge=20et?= =?UTF-8?q?=20sauvegarde=20la=20version=20nettoy=C3=A9e.=202)=20=C3=89diti?= =?UTF-8?q?on=20inline=20dans=20la=20vue=20=C3=89tablissements=20:=20les?= =?UTF-8?q?=20cellules=20bleues=20(saisie=20manuelle)=20sont=20cliquables,?= =?UTF-8?q?=20affichent=20un=20input=20num=C3=A9rique=20autoFocus,=20sauve?= =?UTF-8?q?gardent=20via=20Enter/onBlur,=20annulent=20via=20Escape.=20Les?= =?UTF-8?q?=20overrides=20sont=20stock=C3=A9s=20dans=20opexState.montantsM?= =?UTF-8?q?anuelEtab=20et=20int=C3=A9gr=C3=A9s=20dans=20le=20recalcul=20dy?= =?UTF-8?q?namique.=20Les=20cellules=20avec=20override=20saisi=20sont=20en?= =?UTF-8?q?=20gras.=20Les=20cellules=20vides=20affichent=20'cliquer'=20en?= =?UTF-8?q?=20bleu=20clair.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/DsiOpex.tsx | 124 +++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 11 deletions(-) diff --git a/client/src/pages/DsiOpex.tsx b/client/src/pages/DsiOpex.tsx index 9f4f3fd..a8f5483 100644 --- a/client/src/pages/DsiOpex.tsx +++ b/client/src/pages/DsiOpex.tsx @@ -114,6 +114,8 @@ interface OpexAnneeState { validated: boolean; savedAt?: string; validatedAt?: string; + // Overrides manuels par établissement : { codeEtab: { libellePoste: montant } } + montantsManuelEtab?: Record>; } // ─── Constantes ─────────────────────────────────────────────────────────────── @@ -166,12 +168,37 @@ function buildLignesFromSource(annee?: number): LigneOpex[] { })); } +// Libellés de synthèse/catégorie qui ne sont pas de vrais postes de charges +// (ils proviennent d'anciennes versions du JSON et doivent être filtrés) +const LIBELLES_PARASITES = new Set([ + 'Ventilation cout', 'Ventilation coût', + 'Infogérance', 'Sécurité', 'Téléphonie', + 'App global', 'App HEP', 'App SMR', + 'Applicatifs communs', 'Applicatifs PA', 'Applicatifs HEP', 'Applicatifs SMR', + 'Nouveautés', 'TOTAL', +]); + +function isLigneParasite(ligne: LigneOpex): boolean { + const lib = ligne.libelle.trim(); + return LIBELLES_PARASITES.has(lib) || /^(total|ventilation|répartition)/i.test(lib); +} + function loadOpexState(annee: number): OpexAnneeState { try { const raw = localStorage.getItem(getOpexStorageKey(annee)); if (raw) { const parsed = JSON.parse(raw) as OpexAnneeState; - if (parsed.lignes && Array.isArray(parsed.lignes)) return parsed; + if (parsed.lignes && Array.isArray(parsed.lignes)) { + // Filtrer les lignes parasites (libellés = noms de catégories ou de synthèse) + const lignesFiltrees = parsed.lignes.filter(l => !isLigneParasite(l)); + if (lignesFiltrees.length !== parsed.lignes.length) { + // Purger et sauvegarder la version nettoyée + const cleaned = { ...parsed, lignes: lignesFiltrees }; + try { localStorage.setItem(getOpexStorageKey(annee), JSON.stringify(cleaned)); } catch { /* ignore */ } + return cleaned; + } + return parsed; + } } } catch { /* ignore */ } // Données sources disponibles pour 2025 et 2026 uniquement @@ -405,6 +432,38 @@ export default function DsiOpex() { const [editingLigne, setEditingLigne] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); + // Édition inline établissements (colonnes bleues = saisie manuelle) + // clé = "codeEtab|libellePoste" + const [inlineEditKey, setInlineEditKey] = useState(null); + const [inlineEditVal, setInlineEditVal] = useState(''); + + const handleInlineEditStart = useCallback((codeEtab: string, libelle: string, currentVal: number, validated: boolean) => { + if (validated) return; + setInlineEditKey(`${codeEtab}|${libelle}`); + setInlineEditVal(currentVal === 0 ? '' : String(currentVal)); + }, []); + + const handleInlineEditCommit = useCallback((codeEtab: string, libelle: string) => { + const v = parseFloat(inlineEditVal); + const montant = isNaN(v) || v < 0 ? 0 : Math.round(v); + const newOverrides = { + ...(opexState.montantsManuelEtab ?? {}), + [codeEtab]: { + ...((opexState.montantsManuelEtab ?? {})[codeEtab] ?? {}), + [libelle]: montant, + }, + }; + const newState = { ...opexState, montantsManuelEtab: newOverrides, savedAt: new Date().toISOString() }; + setOpexState(newState); + saveOpexState(annee, newState); + setInlineEditKey(null); + setIsDirty(false); + }, [inlineEditVal, opexState, annee]); + + const handleInlineEditCancel = useCallback(() => { + setInlineEditKey(null); + }, []); + // Recharger quand l'année change useEffect(() => { setOpexState(loadOpexState(annee)); @@ -605,19 +664,29 @@ export default function DsiOpex() { const totalBase = srcData.etablissements.reduce((s, e) => s + e.base_repartition, 0); if (totalBase === 0) return srcData.etablissements; - // Total des postes éditables (source de vérité) - const totalPostes = opexState.lignes.reduce((s, l) => s + l.montant, 0); + const overrides = opexState.montantsManuelEtab ?? {}; return srcData.etablissements.map(etab => { const ratio = etab.base_repartition / totalBase; - const totalEtab = Math.round(totalPostes * ratio); + const etabOverrides = overrides[etab.code] ?? {}; - // Ventiler chaque poste éditable au prorata + // Ventiler chaque poste : override manuel si présent, sinon prorata const montantsRecalcules: Record = {}; + let totalEtab = 0; for (const ligne of opexState.lignes) { - if (ligne.montant > 0) { - montantsRecalcules[ligne.libelle] = Math.round(ligne.montant * ratio); + const srcPoste = srcData.postes.find(p => p.libelle === ligne.libelle); + const isManuel = srcPoste?.mode_ventilation === 'Manuel' || ligne.isCustom; + let montant: number; + if (isManuel && etabOverrides[ligne.libelle] !== undefined) { + // Override saisi manuellement par l'utilisateur + montant = etabOverrides[ligne.libelle]; + } else if (ligne.montant > 0) { + montant = Math.round(ligne.montant * ratio); + } else { + montant = 0; } + if (montant > 0) montantsRecalcules[ligne.libelle] = montant; + totalEtab += montant; } return { @@ -626,7 +695,7 @@ export default function DsiOpex() { total: totalEtab, }; }); - }, [opexState.lignes, annee]); + }, [opexState.lignes, opexState.montantsManuelEtab, annee]); const filteredEtabs = useMemo(() => { return etablissementsRecalcules.filter(e => { @@ -1225,18 +1294,51 @@ export default function DsiOpex() { const srcPoste = getOpexDataForAnnee(annee).postes.find(p => p.libelle === ligne.libelle); const isManuel = srcPoste?.mode_ventilation === 'Manuel' || ligne.isCustom; const montant = etab.montants[ligne.libelle] ?? 0; + const cellKey = `${etab.code}|${ligne.libelle}`; + const isEditing = inlineEditKey === cellKey; + const hasOverride = (opexState.montantsManuelEtab ?? {})[etab.code]?.[ligne.libelle] !== undefined; + + if (isManuel && isEditing) { + return ( + + setInlineEditVal(e.target.value)} + onBlur={() => handleInlineEditCommit(etab.code, ligne.libelle)} + onKeyDown={e => { + if (e.key === 'Enter') handleInlineEditCommit(etab.code, ligne.libelle); + if (e.key === 'Escape') handleInlineEditCancel(); + }} + className="w-full text-right text-xs tabular-nums px-1 py-0.5 border-0 bg-transparent focus:outline-none text-blue-800 font-medium" + style={{ minWidth: '70px' }} + /> + + ); + } + return ( isManuel && handleInlineEditStart(etab.code, ligne.libelle, montant, isValidated)} + className={`px-2 py-2 text-right tabular-nums transition-colors ${ isManuel - ? 'border border-blue-200 bg-blue-50/30 text-blue-800' + ? `border border-blue-200 bg-blue-50/30 text-blue-800 ${ + !isValidated ? 'cursor-pointer hover:bg-blue-100/60 hover:border-blue-400' : '' + } ${hasOverride ? 'font-semibold' : ''}` : 'border border-emerald-200 bg-emerald-50/20 text-emerald-800' }`} + title={isManuel && !isValidated ? 'Cliquer pour saisir le montant' : undefined} > {montant > 0 ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(montant) + ' €' - : } + : isManuel && !isValidated + ? cliquer + : } ); })}