Checkpoint: OPEX DSI : flèches de tendance colorées (rouge=hausse, orange=stable, vert=baisse) sur chaque vignette de catégorie, avec montant N-1 affiché. Données 2025 intégrées depuis le fichier Excel. Support multi-années 2025/2026 avec données sources distinctes.

This commit is contained in:
Manus
2026-06-04 08:03:28 +00:00
parent 2304724ba6
commit eac06d1ca2
3 changed files with 604 additions and 17 deletions

View File

@@ -4,6 +4,7 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import {
TrendingDown,
TrendingUp,
Search,
ChevronDown,
ChevronUp,
@@ -29,6 +30,7 @@ import { AppSidebar } from '../components/AppSidebar';
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
import { useAnnee, getOpexStorageKey } from '../contexts/AnneeContext';
import opexRaw from '../data_opex.json';
import opex2025Raw from '../data_opex_2025.json';
import { formatEuros } from '../lib/format';
import {
AlertDialog,
@@ -63,7 +65,8 @@ interface Poste {
type: string | null;
compte: string | null;
budget_n1: number | null;
montant_previsionnel_2026: number | null;
montant_previsionnel_2026?: number | null;
montant_previsionnel_2025?: number | null;
}
interface Etablissement {
@@ -74,13 +77,21 @@ interface Etablissement {
total: number;
}
interface TendanceCategorie {
montant_n1: number | null;
montant_n: number;
variation_pct: number | null;
tendance: 'hausse' | 'baisse' | 'stable' | 'new';
}
interface OpexData {
annee: number;
total_global: number;
postes: Poste[];
etablissements: Etablissement[];
categories_totaux: Record<string, number>;
meta: { nb_postes: number; nb_etablissements: number };
tendances_categories?: Record<string, TendanceCategorie>;
meta: { nb_postes: number; nb_etablissements?: number };
}
// Ligne persistée (peut être source ou ajoutée par l'utilisateur)
@@ -107,7 +118,13 @@ interface OpexAnneeState {
// ─── Constantes ───────────────────────────────────────────────────────────────
const opexData = opexRaw as OpexData;
const opexData2026 = opexRaw as OpexData;
const opexData2025 = opex2025Raw as OpexData;
function getOpexDataForAnnee(annee: number): OpexData {
if (annee === 2025) return opexData2025;
return opexData2026; // 2026 et autres années
}
const CATEGORIES_FIXES = ['App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR'];
@@ -132,8 +149,9 @@ function formatNum(v: number | null | undefined): string {
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
}
function buildLignesFromSource(): LigneOpex[] {
return opexData.postes.map(p => ({
function buildLignesFromSource(annee?: number): LigneOpex[] {
const src = getOpexDataForAnnee(annee ?? 2026);
return src.postes.map(p => ({
id: `src-${p.col_idx}`,
libelle: p.libelle,
fournisseur: p.fournisseur ?? '',
@@ -143,7 +161,7 @@ function buildLignesFromSource(): LigneOpex[] {
compte: p.compte ?? '',
detail: p.detail ?? '',
budget_n1: p.budget_n1 ?? 0,
montant: p.montant_previsionnel_2026 ?? 0,
montant: (annee === 2025 ? p.montant_previsionnel_2025 : p.montant_previsionnel_2026) ?? 0,
isCustom: false,
}));
}
@@ -153,11 +171,14 @@ function loadOpexState(annee: number): OpexAnneeState {
const raw = localStorage.getItem(getOpexStorageKey(annee));
if (raw) {
const parsed = JSON.parse(raw) as OpexAnneeState;
// Compatibilité ascendante : si l'ancien format (montants dict) est détecté
if (parsed.lignes && Array.isArray(parsed.lignes)) return parsed;
}
} catch { /* ignore */ }
return { lignes: buildLignesFromSource(), validated: false };
// Données sources disponibles pour 2025 et 2026 uniquement
if (annee === 2025 || annee === 2026) {
return { lignes: buildLignesFromSource(annee), validated: false };
}
return { lignes: [], validated: false };
}
function saveOpexState(annee: number, state: OpexAnneeState): void {
@@ -426,7 +447,7 @@ export default function DsiOpex() {
// Réinitialiser aux valeurs du JSON source
const handleReset = useCallback(() => {
if (isValidated) return;
const newState: OpexAnneeState = { lignes: buildLignesFromSource(), validated: false };
const newState: OpexAnneeState = { lignes: buildLignesFromSource(annee), validated: false };
setOpexState(newState);
saveOpexState(annee, newState);
setIsDirty(false);
@@ -551,7 +572,8 @@ export default function DsiOpex() {
// Établissements (vue par établissement)
const filteredEtabs = useMemo(() => {
return opexData.etablissements.filter(e => {
const srcData = getOpexDataForAnnee(annee);
return srcData.etablissements.filter(e => {
if (search && !e.nom.toLowerCase().includes(search.toLowerCase()) &&
!e.code.toLowerCase().includes(search.toLowerCase())) return false;
return true;
@@ -592,7 +614,7 @@ export default function DsiOpex() {
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
{opexState.lignes.length} postes de charges · {opexData.meta.nb_etablissements} établissements
{opexState.lignes.length} postes de charges · {getOpexDataForAnnee(annee).meta.nb_etablissements ?? opexData2026.meta.nb_etablissements} établissements
{opexState.savedAt && !isValidated && (
<span className="ml-2 text-xs">· Enregistré le {new Date(opexState.savedAt).toLocaleDateString('fr-FR')} à {new Date(opexState.savedAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>
)}
@@ -675,16 +697,50 @@ export default function DsiOpex() {
const montant = totalParCategorie[cat] || 0;
const pct = totalGlobal > 0 ? (montant / totalGlobal * 100).toFixed(1) : '0';
const colors = getCatColors(cat);
// Tendance depuis les données sources de l'année courante
const srcData = getOpexDataForAnnee(annee);
const tendanceInfo: TendanceCategorie | undefined = srcData.tendances_categories?.[cat];
const tendance = tendanceInfo?.tendance;
const variationPct = tendanceInfo?.variation_pct;
const montantN1 = tendanceInfo?.montant_n1;
return (
<div key={cat} className="bg-card border border-border rounded-xl p-4 min-w-[140px] flex-1">
<div className="flex items-center gap-2 mb-1">
<BarChart3 className={`w-4 h-4 ${colors.icon}`} />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium truncate">{cat}</span>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<BarChart3 className={`w-4 h-4 ${colors.icon}`} />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium truncate">{cat}</span>
</div>
{/* Flèche de tendance */}
{tendance && tendance !== 'stable' && tendance !== 'new' && variationPct !== null && variationPct !== undefined && (
<div className={`flex items-center gap-0.5 text-xs font-semibold px-1.5 py-0.5 rounded-full ${
tendance === 'hausse' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
}`}>
{tendance === 'hausse'
? <TrendingUp className="w-3 h-3" />
: <TrendingDown className="w-3 h-3" />}
<span>{variationPct > 0 ? '+' : ''}{variationPct}%</span>
</div>
)}
{tendance === 'stable' && variationPct !== null && (
<div className="flex items-center gap-0.5 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-600">
<span> stable</span>
</div>
)}
{tendance === 'new' && (
<div className="flex items-center gap-0.5 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-600">
<span>Nouveau</span>
</div>
)}
</div>
<p className={`text-xl font-bold tabular-nums ${colors.kpi}`} style={{ fontFamily: 'Sora, sans-serif' }}>
{formatEuros(montant)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{pct}% du total</p>
<div className="flex items-center justify-between mt-0.5">
<p className="text-xs text-muted-foreground">{pct}% du total</p>
{montantN1 !== null && montantN1 !== undefined && montantN1 > 0 && (
<p className="text-xs text-muted-foreground">N-1 : {formatEuros(montantN1)}</p>
)}
</div>
</div>
);
})}
@@ -977,7 +1033,7 @@ export default function DsiOpex() {
<SortBtn col="nom" label="Établissement" />
</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">Base répartition</th>
{opexData.postes.slice(0, showDetails ? 8 : 4).map(p => (
{opexData2026.postes.slice(0, showDetails ? 8 : 4).map(p => (
<th key={p.libelle} className="text-right px-3 py-3 font-medium text-muted-foreground text-xs max-w-24 hidden xl:table-cell">
<span className="block truncate max-w-20" title={p.libelle}>{p.libelle.split(' ').slice(0, 3).join(' ')}</span>
</th>
@@ -999,7 +1055,7 @@ export default function DsiOpex() {
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(etab.base_repartition) + ' €'
: '—'}
</td>
{opexData.postes.slice(0, showDetails ? 8 : 4).map(p => (
{opexData2026.postes.slice(0, showDetails ? 8 : 4).map(p => (
<td key={p.libelle} className="px-3 py-2.5 text-right text-xs tabular-nums text-muted-foreground hidden xl:table-cell">
{etab.montants[p.libelle] > 0
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(etab.montants[p.libelle])) + ' €'