diff --git a/client/src/data_opex.json b/client/src/data_opex.json index 54ec9d2..aba31a5 100644 --- a/client/src/data_opex.json +++ b/client/src/data_opex.json @@ -3355,5 +3355,43 @@ "meta": { "nb_postes": 35, "nb_etablissements": 67 + }, + "tendances_categories": { + "App HEP": { + "montant_n1": 51000, + "montant_n": 98000, + "variation_pct": 92.2, + "tendance": "hausse" + }, + "App SMR": { + "montant_n1": 11000, + "montant_n": 14500, + "variation_pct": 31.8, + "tendance": "hausse" + }, + "App global": { + "montant_n1": 400750, + "montant_n": 504980, + "variation_pct": 26.0, + "tendance": "hausse" + }, + "Infogérance": { + "montant_n1": 275020, + "montant_n": 377198, + "variation_pct": 37.2, + "tendance": "hausse" + }, + "Sécurité": { + "montant_n1": 144400, + "montant_n": 229400, + "variation_pct": 58.9, + "tendance": "hausse" + }, + "Téléphonie": { + "montant_n1": 62000, + "montant_n": 33600, + "variation_pct": -45.8, + "tendance": "baisse" + } } } \ No newline at end of file diff --git a/client/src/data_opex_2025.json b/client/src/data_opex_2025.json new file mode 100644 index 0000000..5cc386c --- /dev/null +++ b/client/src/data_opex_2025.json @@ -0,0 +1,493 @@ +{ + "annee": 2025, + "total_global": 1017082.0, + "postes": [ + { + "col_idx": 5, + "fournisseur": "AGEVAL", + "libelle": "SAAS AGEVAL Gestion de la qualité", + "detail": "Gestion qualité (procédures,FEI...)", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 30000.0 + }, + { + "col_idx": 6, + "fournisseur": "OCTIME", + "libelle": "SAAS OCTIME Gestion de temps", + "detail": "GTA", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 46000.0 + }, + { + "col_idx": 7, + "fournisseur": "LUCCA", + "libelle": "SAAS FIGGO Gestion congés", + "detail": "Gestion des congés Directions", + "facturation": "Annuelle", + "mode_ventilation": "Manuel", + "categorie": "App global", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 2400.0 + }, + { + "col_idx": 8, + "fournisseur": "LUCCA", + "libelle": "SAAS CLEEMY Gestion frais professionnels", + "detail": "Gestion des notes de frais directions", + "facturation": "Annuelle", + "mode_ventilation": "Manuel", + "categorie": "App global", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 1700.0 + }, + { + "col_idx": 9, + "fournisseur": "ISI-DSI", + "libelle": "SAAS ISI-APP Gestion de parc", + "detail": "Outil de pilotage SI du parc + Connexion AD + Module Santé", + "facturation": "Mensuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Infogérance", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 14500.0 + }, + { + "col_idx": 10, + "fournisseur": "DATA LEGAL DRIVE", + "libelle": "SAAS DLD Gestion de la Conformité RGPD", + "detail": "RGPD", + "facturation": "Mensuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 5000.0 + }, + { + "col_idx": 11, + "fournisseur": "DOCUSIGN", + "libelle": "SAAS Gestion signature électronique", + "detail": "DE électroniques", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 11200.0 + }, + { + "col_idx": 12, + "fournisseur": "MICROSOFT", + "libelle": "SAAS Licences M365", + "detail": "Licences pack office (outlook, excel, word...) + sauvegardes onde drive + intranet + sharepoint", + "facturation": "Mensuelle", + "mode_ventilation": "Manuel", + "categorie": "Infogérance", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 141000.0 + }, + { + "col_idx": 13, + "fournisseur": "DIMO", + "libelle": "Redevance SXS Gestion trésorerie SAGE", + "detail": "Communications bancaires", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 16000.0 + }, + { + "col_idx": 14, + "fournisseur": "LIFEN", + "libelle": "SAAS LIFEN Plateforme E-Santé Envois", + "detail": "SMR / outil de d'envoi vers le DMP et la MSS connecté à la GAP et au DPI + envoi des courriers papiers", + "facturation": "Mensuelle", + "mode_ventilation": "Manuel", + "categorie": "App SMR", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 11000.0 + }, + { + "col_idx": 15, + "fournisseur": "LIFEN", + "libelle": "SAAS LIFEN Plateforme E-Santé Réceptions", + "detail": "SMR / outil d'intégration dans le DPI depuis le DMP et/ou la MSS", + "facturation": "Mensuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "New", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 3500.0 + }, + { + "col_idx": 16, + "fournisseur": "NOVRH", + "libelle": "NOVRH", + "detail": "SIRH (année pleine)", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 232750.0 + }, + { + "col_idx": 17, + "fournisseur": "DARKTRACE", + "libelle": "SAAS Protection mails DARKTRACE", + "detail": "Sécurité", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "New", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 25000.0 + }, + { + "col_idx": 18, + "fournisseur": "IMAGO", + "libelle": "SAAS DUI Pole HEP", + "detail": "Outil métier", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App HEP", + "type": "SAAS", + "compte": "613511", + "budget_n1": null, + "montant_previsionnel_2025": 51000.0 + }, + { + "col_idx": 19, + "fournisseur": "BOUYGUES + MY TELECOM", + "libelle": "Solution VoIP Trunks + Liens + Canaux", + "detail": "Télphonie centralisée VOIP (20 ets)", + "facturation": "Mensuelle", + "mode_ventilation": "Manuel", + "categorie": "Téléphonie", + "type": "Téléphonie VoIP", + "compte": "626200", + "budget_n1": null, + "montant_previsionnel_2025": 62000.0 + }, + { + "col_idx": 20, + "fournisseur": "FREE PRO DATA CENTER", + "libelle": "Infrastructures Data Center + Liens Internet", + "detail": "DataCenter", + "facturation": "Mensuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Infogérance", + "type": "Loc. Infra Data Center", + "compte": "613512", + "budget_n1": null, + "montant_previsionnel_2025": 53000.0 + }, + { + "col_idx": 21, + "fournisseur": "FREE PRO LIENS FIBRES", + "libelle": "Liens internet Siège Data Center", + "detail": "DataCenter", + "facturation": "Mensuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Infogérance", + "type": "Liens Internet", + "compte": "626203", + "budget_n1": null, + "montant_previsionnel_2025": 14000.0 + }, + { + "col_idx": 22, + "fournisseur": "LASOTEL", + "libelle": "Abonnements Liaisons internet siège et Data Center", + "detail": "DataCenter", + "facturation": "Mensuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Infogérance", + "type": "Liens Internet", + "compte": "626203", + "budget_n1": null, + "montant_previsionnel_2025": 0.0 + }, + { + "col_idx": 23, + "fournisseur": "OCTIME", + "libelle": "Prestation TMA réglementaire", + "detail": "Gestion du temps module TMA", + "facturation": "Au fil de l'eau", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "Prestation", + "compte": "628400", + "budget_n1": null, + "montant_previsionnel_2025": 3500.0 + }, + { + "col_idx": 24, + "fournisseur": "ISO-RA", + "libelle": "Crédit jours technicien", + "detail": "Intervention technicien externe sur 5 ets", + "facturation": "Au fil de l'eau", + "mode_ventilation": "Manuel", + "categorie": "Infogérance", + "type": "Prestation", + "compte": "628400", + "budget_n1": null, + "montant_previsionnel_2025": 16000.0 + }, + { + "col_idx": 25, + "fournisseur": "CEGI", + "libelle": "Maintenance applicatifs", + "detail": "Compta-first, paye-fisrt, GAP-first...", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 52200.0 + }, + { + "col_idx": 26, + "fournisseur": "ARCHE-MC2 (ex SOLWARE)", + "libelle": "Maintenance D3i +PSI (Résilié)", + "detail": "Outil métier pôle HEP", + "facturation": "Annuelle", + "mode_ventilation": "Manuel", + "categorie": "App HEP", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 0.0 + }, + { + "col_idx": 27, + "fournisseur": "OSTENDI", + "libelle": "Maintenance OSIDOC (A RESILIER)", + "detail": "Gestion des contrats", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "App global", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 0.0 + }, + { + "col_idx": 28, + "fournisseur": "SOFT4EUROPE", + "libelle": "Maintenance FLEXICLOUD", + "detail": "Connexions aux serveurs du DataCenter", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Infogérance", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 3000.0 + }, + { + "col_idx": 29, + "fournisseur": "TEAM VIEWER", + "libelle": "Abonnement Solution de télémaintenance", + "detail": "Prise de main distante pour équipe IT", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Infogérance", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 520.0 + }, + { + "col_idx": 30, + "fournisseur": "ISO-RA", + "libelle": "Maintenance WATCHGUARD", + "detail": "Sécurité /Pare-feux renouvellement des pare-feux tous les 5 ans", + "facturation": "Au fil de l'eau", + "mode_ventilation": "Prorata classe 6", + "categorie": "Sécurité", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 55000.0 + }, + { + "col_idx": 31, + "fournisseur": "ISO-RA", + "libelle": "Maintenance EPDR WG", + "detail": "Sécurité / Anti-virus", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Sécurité", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 29400.0 + }, + { + "col_idx": 32, + "fournisseur": "ISO-RA", + "libelle": "Maintenance VEEAM", + "detail": "DataCenter / Sauvegardes machines + Sauvegardes 0365", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Infogérance", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 21000.0 + }, + { + "col_idx": 33, + "fournisseur": "DARKTRACE", + "libelle": "Maintenance DARKTRACE", + "detail": "Sécurité / Inteligence artificielle surveillance réseau", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Sécurité", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 36000.0 + }, + { + "col_idx": 34, + "fournisseur": "DARKTRACE", + "libelle": "Maintenance DARKTRACE ANTIGENA", + "detail": "Sécurité / Inteligence artificielle surveillance réseau", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Sécurité", + "type": "Maintenance", + "compte": "615610", + "budget_n1": null, + "montant_previsionnel_2025": 24000.0 + }, + { + "col_idx": 35, + "fournisseur": "3069UDA", + "libelle": "Budget Petit matériel et Autres dépenses", + "detail": "Divers matériels", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "Infogérance", + "type": "Petits matériels", + "compte": null, + "budget_n1": null, + "montant_previsionnel_2025": 12000.0 + }, + { + "col_idx": 36, + "fournisseur": "One System", + "libelle": "Abo 4G", + "detail": "Abo 4G siège et Concha", + "facturation": "MEnsuelle", + "mode_ventilation": "Siège + Concha", + "categorie": "New", + "type": "Liens internet", + "compte": null, + "budget_n1": null, + "montant_previsionnel_2025": 7512.0 + }, + { + "col_idx": 37, + "fournisseur": "GAMMEO", + "libelle": "Maintenance GAMMEO", + "detail": "GMAO", + "facturation": "Annuelle", + "mode_ventilation": "Prorata classe 6", + "categorie": "New", + "type": "Maintenance", + "compte": null, + "budget_n1": null, + "montant_previsionnel_2025": 36900.0 + } + ], + "etablissements": [], + "categories_totaux": { + "App HEP": 51000, + "App SMR": 11000, + "App global": 400750, + "Infogérance": 275020, + "New": 72912, + "Sécurité": 144400, + "Téléphonie": 62000 + }, + "tendances_categories": { + "App HEP": { + "montant_n1": null, + "montant_n": 51000, + "variation_pct": null, + "tendance": "stable" + }, + "App SMR": { + "montant_n1": null, + "montant_n": 11000, + "variation_pct": null, + "tendance": "stable" + }, + "App global": { + "montant_n1": null, + "montant_n": 400750, + "variation_pct": null, + "tendance": "stable" + }, + "Infogérance": { + "montant_n1": null, + "montant_n": 275020, + "variation_pct": null, + "tendance": "stable" + }, + "New": { + "montant_n1": null, + "montant_n": 72912, + "variation_pct": null, + "tendance": "stable" + }, + "Sécurité": { + "montant_n1": null, + "montant_n": 144400, + "variation_pct": null, + "tendance": "stable" + }, + "Téléphonie": { + "montant_n1": null, + "montant_n": 62000, + "variation_pct": null, + "tendance": "stable" + } + }, + "meta": { + "source": "Prévi OPEX 2025 v1", + "nb_postes": 33 + } +} \ No newline at end of file diff --git a/client/src/pages/DsiOpex.tsx b/client/src/pages/DsiOpex.tsx index 43b8a76..31c34f6 100644 --- a/client/src/pages/DsiOpex.tsx +++ b/client/src/pages/DsiOpex.tsx @@ -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; - meta: { nb_postes: number; nb_etablissements: number }; + tendances_categories?: Record; + 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() { )}

- {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 && ( · Enregistré le {new Date(opexState.savedAt).toLocaleDateString('fr-FR')} à {new Date(opexState.savedAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} )} @@ -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 (

-
- - {cat} +
+
+ + {cat} +
+ {/* Flèche de tendance */} + {tendance && tendance !== 'stable' && tendance !== 'new' && variationPct !== null && variationPct !== undefined && ( +
+ {tendance === 'hausse' + ? + : } + {variationPct > 0 ? '+' : ''}{variationPct}% +
+ )} + {tendance === 'stable' && variationPct !== null && ( +
+ ≈ stable +
+ )} + {tendance === 'new' && ( +
+ Nouveau +
+ )}

{formatEuros(montant)}

-

{pct}% du total

+
+

{pct}% du total

+ {montantN1 !== null && montantN1 !== undefined && montantN1 > 0 && ( +

N-1 : {formatEuros(montantN1)}

+ )} +
); })} @@ -977,7 +1033,7 @@ export default function DsiOpex() { Base répartition - {opexData.postes.slice(0, showDetails ? 8 : 4).map(p => ( + {opexData2026.postes.slice(0, showDetails ? 8 : 4).map(p => ( {p.libelle.split(' ').slice(0, 3).join(' ')} @@ -999,7 +1055,7 @@ export default function DsiOpex() { ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(etab.base_repartition) + ' €' : '—'} - {opexData.postes.slice(0, showDetails ? 8 : 4).map(p => ( + {opexData2026.postes.slice(0, showDetails ? 8 : 4).map(p => ( {etab.montants[p.libelle] > 0 ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(etab.montants[p.libelle])) + ' €'