Checkpoint: 1) Filtre automatique des lignes parasites (Ventilation cout, Infogérance, Sécurité, Applicatifs PA, etc.) lors du chargement depuis localStorage — purge et sauvegarde la version nettoyée. 2) Édition inline dans la vue Établissements : les cellules bleues (saisie manuelle) sont cliquables, affichent un input numérique autoFocus, sauvegardent via Enter/onBlur, annulent via Escape. Les overrides sont stockés dans opexState.montantsManuelEtab et intégrés dans le recalcul dynamique. Les cellules avec override saisi sont en gras. Les cellules vides affichent 'cliquer' en bleu clair.

This commit is contained in:
Manus
2026-06-10 07:55:49 +00:00
parent eb236f4b8f
commit a0508893f1

View File

@@ -114,6 +114,8 @@ interface OpexAnneeState {
validated: boolean;
savedAt?: string;
validatedAt?: string;
// Overrides manuels par établissement : { codeEtab: { libellePoste: montant } }
montantsManuelEtab?: Record<string, Record<string, number>>;
}
// ─── 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<LigneOpex | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// Édition inline établissements (colonnes bleues = saisie manuelle)
// clé = "codeEtab|libellePoste"
const [inlineEditKey, setInlineEditKey] = useState<string | null>(null);
const [inlineEditVal, setInlineEditVal] = useState<string>('');
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<string, number> = {};
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 (
<td key={ligne.id} className="px-1 py-1 border border-blue-400 bg-blue-50 min-w-[90px]">
<input
autoFocus
type="number"
min={0}
step={1}
value={inlineEditVal}
placeholder="0"
onChange={e => 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' }}
/>
</td>
);
}
return (
<td
key={ligne.id}
className={`px-2 py-2 text-right tabular-nums ${
onClick={() => 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) + ' '
: <span className="text-muted-foreground">—</span>}
: isManuel && !isValidated
? <span className="text-blue-300 text-[10px]">cliquer</span>
: <span className="text-muted-foreground">—</span>}
</td>
);
})}