Checkpoint: Page OPEX DSI complète avec tableau de 35 postes de charges, KPIs, filtres par catégorie, vue par poste et par établissement, sélecteur d'année 2026/2027, et graphique de répartition par catégorie.
This commit is contained in:
3359
client/src/data_opex.json
Normal file
3359
client/src/data_opex.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,569 @@
|
|||||||
// DsiOpex.tsx — OPEX DSI (Charges établissement)
|
// DsiOpex.tsx — OPEX DSI : Charges annuelles du Système d'Information
|
||||||
import { useState } from 'react';
|
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||||
|
// Tableau de charges par établissement, sélection d'année, vue prévisionnel / réel
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
TrendingDown,
|
||||||
|
Search,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Info,
|
||||||
|
BarChart3,
|
||||||
|
Building2,
|
||||||
|
Euro,
|
||||||
|
Filter,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
ArrowUpDown,
|
||||||
|
} from 'lucide-react';
|
||||||
import { AppSidebar } from '../components/AppSidebar';
|
import { AppSidebar } from '../components/AppSidebar';
|
||||||
import { ComingSoon } from '../components/ComingSoon';
|
import opexRaw from '../data_opex.json';
|
||||||
import { TrendingDown } from 'lucide-react';
|
import { formatEuros } from '../lib/format';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Poste {
|
||||||
|
col_idx: number;
|
||||||
|
fournisseur: string | null;
|
||||||
|
libelle: string;
|
||||||
|
detail: string | null;
|
||||||
|
facturation: string | null;
|
||||||
|
mode_ventilation: string | null;
|
||||||
|
categorie: string | null;
|
||||||
|
type: string | null;
|
||||||
|
compte: string | null;
|
||||||
|
budget_n1: number | null;
|
||||||
|
montant_previsionnel_2026: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Etablissement {
|
||||||
|
code: string;
|
||||||
|
nom: string;
|
||||||
|
base_repartition: number;
|
||||||
|
montants: Record<string, number>;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpexData {
|
||||||
|
annee: number;
|
||||||
|
total_global: number;
|
||||||
|
postes: Poste[];
|
||||||
|
etablissements: Etablissement[];
|
||||||
|
categories_totaux: Record<string, number>;
|
||||||
|
meta: { nb_postes: number; nb_etablissements: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const opexData = opexRaw as OpexData;
|
||||||
|
|
||||||
|
// Catégories disponibles
|
||||||
|
const CATEGORIES = ['Toutes', 'App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR'];
|
||||||
|
|
||||||
|
const CATEGORIE_COLORS: Record<string, string> = {
|
||||||
|
'App global': 'bg-blue-100 text-blue-700',
|
||||||
|
'Infogérance': 'bg-purple-100 text-purple-700',
|
||||||
|
'Sécurité': 'bg-red-100 text-red-700',
|
||||||
|
'Téléphonie': 'bg-green-100 text-green-700',
|
||||||
|
'App HEP': 'bg-orange-100 text-orange-700',
|
||||||
|
'App SMR': 'bg-teal-100 text-teal-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatNum(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined || v === 0) return '—';
|
||||||
|
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumShort(v: number): string {
|
||||||
|
if (v === 0) return '—';
|
||||||
|
if (v >= 1000) return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(Math.round(v)) + ' €';
|
||||||
|
return Math.round(v) + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'etablissements' | 'postes';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
export default function DsiOpex() {
|
export default function DsiOpex() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [selectedYear, setSelectedYear] = useState<2026 | 2027>(2026);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('postes');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedCategorie, setSelectedCategorie] = useState('Toutes');
|
||||||
|
const [sortCol, setSortCol] = useState<string | null>(null);
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
|
const [expandedPoste, setExpandedPoste] = useState<string | null>(null);
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
|
||||||
|
// Filtrer les postes par catégorie
|
||||||
|
const filteredPostes = useMemo(() => {
|
||||||
|
return opexData.postes.filter(p => {
|
||||||
|
if (selectedCategorie !== 'Toutes' && p.categorie !== selectedCategorie) return false;
|
||||||
|
if (search && !p.libelle.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
|
!(p.fournisseur || '').toLowerCase().includes(search.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [selectedCategorie, search]);
|
||||||
|
|
||||||
|
// Filtrer les établissements
|
||||||
|
const filteredEtabs = useMemo(() => {
|
||||||
|
return opexData.etablissements.filter(e => {
|
||||||
|
if (search && !e.nom.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
|
!e.code.toLowerCase().includes(search.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Totaux par catégorie (postes filtrés)
|
||||||
|
const totalParCategorie = useMemo(() => {
|
||||||
|
const totaux: Record<string, number> = {};
|
||||||
|
for (const poste of opexData.postes) {
|
||||||
|
const cat = poste.categorie || 'Autre';
|
||||||
|
const montant = poste.montant_previsionnel_2026 || 0;
|
||||||
|
totaux[cat] = (totaux[cat] || 0) + montant;
|
||||||
|
}
|
||||||
|
return totaux;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalFiltre = useMemo(() => {
|
||||||
|
return filteredPostes.reduce((sum, p) => sum + (p.montant_previsionnel_2026 || 0), 0);
|
||||||
|
}, [filteredPostes]);
|
||||||
|
|
||||||
|
const handleSort = (col: string) => {
|
||||||
|
if (sortCol === col) {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortCol(col);
|
||||||
|
setSortDir('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trier les postes
|
||||||
|
const sortedPostes = useMemo(() => {
|
||||||
|
const arr = [...filteredPostes];
|
||||||
|
if (sortCol === 'montant') {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const va = a.montant_previsionnel_2026 || 0;
|
||||||
|
const vb = b.montant_previsionnel_2026 || 0;
|
||||||
|
return sortDir === 'asc' ? va - vb : vb - va;
|
||||||
|
});
|
||||||
|
} else if (sortCol === 'libelle') {
|
||||||
|
arr.sort((a, b) => sortDir === 'asc'
|
||||||
|
? a.libelle.localeCompare(b.libelle)
|
||||||
|
: b.libelle.localeCompare(a.libelle));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [filteredPostes, sortCol, sortDir]);
|
||||||
|
|
||||||
|
// Trier les établissements
|
||||||
|
const sortedEtabs = useMemo(() => {
|
||||||
|
const arr = [...filteredEtabs];
|
||||||
|
if (sortCol === 'total') {
|
||||||
|
arr.sort((a, b) => sortDir === 'asc' ? a.total - b.total : b.total - a.total);
|
||||||
|
} else if (sortCol === 'nom') {
|
||||||
|
arr.sort((a, b) => sortDir === 'asc'
|
||||||
|
? a.nom.localeCompare(b.nom)
|
||||||
|
: b.nom.localeCompare(a.nom));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [filteredEtabs, sortCol, sortDir]);
|
||||||
|
|
||||||
|
const SortBtn = ({ col, label }: { col: string; label: string }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort(col)}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground transition-colors group"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ArrowUpDown className={`w-3 h-3 ${sortCol === col ? 'text-primary' : 'text-muted-foreground/50 group-hover:text-muted-foreground'}`} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
<AppSidebar collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} />
|
<AppSidebar />
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
{/* En-tête */}
|
||||||
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
OPEX — Charges établissement
|
OPEX DSI — Charges du Système d'Information
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">Budget de fonctionnement du Système d'Information</p>
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{opexData.meta.nb_postes} postes de charges · {opexData.meta.nb_etablissements} établissements
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Sélecteur d'année */}
|
||||||
|
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||||
|
{([2026, 2027] as const).map(year => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
onClick={() => setSelectedYear(year)}
|
||||||
|
className={`px-4 py-1.5 rounded-md text-sm font-semibold transition-all ${
|
||||||
|
selectedYear === year
|
||||||
|
? 'bg-primary text-white shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ComingSoon
|
|
||||||
title="OPEX DSI"
|
{/* KPIs */}
|
||||||
subtitle="Le tableau de bord des charges SI par établissement sera disponible prochainement."
|
<div className="px-6 py-4 border-b border-border bg-muted/20 flex-shrink-0">
|
||||||
icon={TrendingDown}
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Euro className="w-4 h-4 text-orange-500" />
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Total OPEX {selectedYear}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-orange-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
{formatEuros(opexData.total_global)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Prévisionnel</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<TrendingDown className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Infogérance</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-purple-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
{formatEuros(totalParCategorie['Infogérance'] || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{((totalParCategorie['Infogérance'] || 0) / opexData.total_global * 100).toFixed(1)}% du total</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<BarChart3 className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Applicatifs</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-blue-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
{formatEuros((totalParCategorie['App global'] || 0) + (totalParCategorie['App HEP'] || 0) + (totalParCategorie['App SMR'] || 0))}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Global + HEP + SMR</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Building2 className="w-4 h-4 text-primary" />
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Sécurité</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-red-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
{formatEuros(totalParCategorie['Sécurité'] || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{((totalParCategorie['Sécurité'] || 0) / opexData.total_global * 100).toFixed(1)}% du total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre d'outils */}
|
||||||
|
<div className="px-6 py-3 border-b border-border bg-background flex-shrink-0 flex flex-wrap items-center gap-3">
|
||||||
|
{/* Recherche */}
|
||||||
|
<div className="relative flex-1 min-w-48 max-w-72">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={viewMode === 'postes' ? 'Rechercher un poste...' : 'Rechercher un établissement...'}
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtre catégorie (vue postes) */}
|
||||||
|
{viewMode === 'postes' && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setSelectedCategorie(cat)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-all ${
|
||||||
|
selectedCategorie === cat
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toggle vue */}
|
||||||
|
<div className="ml-auto flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setViewMode('postes'); setSortCol(null); }}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||||
|
viewMode === 'postes' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-3.5 h-3.5" />
|
||||||
|
Par poste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setViewMode('etablissements'); setSortCol(null); }}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||||
|
viewMode === 'etablissements' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Building2 className="w-3.5 h-3.5" />
|
||||||
|
Par établissement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle détails */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(d => !d)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||||
|
{showDetails ? 'Masquer détails' : 'Voir détails'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu principal */}
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4">
|
||||||
|
|
||||||
|
{/* === VUE PAR POSTE === */}
|
||||||
|
{viewMode === 'postes' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* En-tête total filtré */}
|
||||||
|
{selectedCategorie !== 'Toutes' && (
|
||||||
|
<div className="flex items-center justify-between mb-3 px-1">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{sortedPostes.length} poste{sortedPostes.length > 1 ? 's' : ''} — catégorie <strong>{selectedCategorie}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
Sous-total : <span className="text-primary">{formatEuros(totalFiltre)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tableau des postes */}
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/40">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground w-8">#</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground">
|
||||||
|
<SortBtn col="libelle" label="Poste de charge" />
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden md:table-cell">Catégorie</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">Type</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden xl:table-cell">Facturation</th>
|
||||||
|
{showDetails && <th className="text-left px-4 py-3 font-medium text-muted-foreground hidden xl:table-cell">Compte</th>}
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Budget N-1</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground">
|
||||||
|
<SortBtn col="montant" label={`Prév. ${selectedYear}`} />
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground w-24">Répartition</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedPostes.map((poste, idx) => {
|
||||||
|
const montant = poste.montant_previsionnel_2026 || 0;
|
||||||
|
const pct = opexData.total_global > 0 ? (montant / opexData.total_global) * 100 : 0;
|
||||||
|
const isExpanded = expandedPoste === poste.libelle;
|
||||||
|
const catColor = CATEGORIE_COLORS[poste.categorie || ''] || 'bg-gray-100 text-gray-600';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={poste.libelle}
|
||||||
|
className={`border-b border-border/50 hover:bg-muted/30 transition-colors cursor-pointer ${isExpanded ? 'bg-muted/20' : ''}`}
|
||||||
|
onClick={() => setExpandedPoste(isExpanded ? null : poste.libelle)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">{idx + 1}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded ? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" /> : <ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground leading-tight">{poste.libelle}</p>
|
||||||
|
{poste.fournisseur && poste.fournisseur !== poste.libelle && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{poste.fournisseur}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden md:table-cell">
|
||||||
|
{poste.categorie && (
|
||||||
|
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${catColor}`}>
|
||||||
|
{poste.categorie}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground hidden lg:table-cell">{poste.type || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground hidden xl:table-cell">{poste.facturation || '—'}</td>
|
||||||
|
{showDetails && <td className="px-4 py-3 text-xs font-mono text-muted-foreground hidden xl:table-cell">{poste.compte || '—'}</td>}
|
||||||
|
<td className="px-4 py-3 text-right text-sm text-muted-foreground tabular-nums">
|
||||||
|
{formatNum(poste.budget_n1)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-semibold tabular-nums">
|
||||||
|
<span className={montant > 0 ? 'text-foreground' : 'text-muted-foreground'}>
|
||||||
|
{formatNum(montant)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<div className="w-16 bg-muted rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${Math.min(100, pct * 5)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums w-10 text-right">
|
||||||
|
{pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && showDetails && poste.detail && (
|
||||||
|
<tr key={`${poste.libelle}-detail`} className="bg-blue-50/50 border-b border-border/50">
|
||||||
|
<td />
|
||||||
|
<td colSpan={showDetails ? 8 : 7} className="px-8 py-3">
|
||||||
|
<div className="flex items-start gap-2 text-sm text-blue-700">
|
||||||
|
<Info className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Détail</p>
|
||||||
|
<p className="text-blue-600 mt-0.5">{poste.detail}</p>
|
||||||
|
{poste.mode_ventilation && (
|
||||||
|
<p className="text-blue-500 text-xs mt-1">Mode de ventilation : {poste.mode_ventilation}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="bg-muted/40 border-t-2 border-border">
|
||||||
|
<td colSpan={showDetails ? 7 : 6} className="px-4 py-3 font-bold text-foreground">
|
||||||
|
TOTAL {selectedCategorie !== 'Toutes' ? selectedCategorie : 'OPEX DSI'} {selectedYear}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-bold text-orange-600 text-base tabular-nums">
|
||||||
|
{formatEuros(totalFiltre)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-sm text-muted-foreground">
|
||||||
|
{selectedCategorie !== 'Toutes'
|
||||||
|
? `${((totalFiltre / opexData.total_global) * 100).toFixed(1)}%`
|
||||||
|
: '100%'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Répartition par catégorie */}
|
||||||
|
<div className="mt-6 bg-card border border-border rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-foreground mb-4 flex items-center gap-2" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
Répartition par catégorie — {selectedYear}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(totalParCategorie)
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([cat, montant]) => {
|
||||||
|
const pct = (montant / opexData.total_global) * 100;
|
||||||
|
const color = CATEGORIE_COLORS[cat] || 'bg-gray-100 text-gray-600';
|
||||||
|
const barColor = color.includes('blue') ? 'bg-blue-500' :
|
||||||
|
color.includes('purple') ? 'bg-purple-500' :
|
||||||
|
color.includes('red') ? 'bg-red-500' :
|
||||||
|
color.includes('green') ? 'bg-green-500' :
|
||||||
|
color.includes('orange') ? 'bg-orange-500' :
|
||||||
|
color.includes('teal') ? 'bg-teal-500' : 'bg-gray-400';
|
||||||
|
return (
|
||||||
|
<div key={cat} className="flex items-center gap-3">
|
||||||
|
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium w-28 justify-center ${color}`}>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 bg-muted rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all ${barColor}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-foreground tabular-nums w-28 text-right">
|
||||||
|
{formatEuros(montant)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums w-12 text-right">
|
||||||
|
{pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* === VUE PAR ÉTABLISSEMENT === */}
|
||||||
|
{viewMode === 'etablissements' && (
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/40">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Code</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground">
|
||||||
|
<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>
|
||||||
|
{/* Postes principaux */}
|
||||||
|
{opexData.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>
|
||||||
|
))}
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground">
|
||||||
|
<SortBtn col="total" label={`Total ${selectedYear}`} />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedEtabs.map((etab, idx) => (
|
||||||
|
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/30 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/10'}`}>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{etab.code}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 font-medium text-foreground">{etab.nom}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-xs text-muted-foreground tabular-nums hidden lg:table-cell">
|
||||||
|
{etab.base_repartition > 0
|
||||||
|
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(etab.base_repartition) + ' €'
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
{opexData.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">
|
||||||
|
{formatNumShort(etab.montants[p.libelle] || 0)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-4 py-2.5 text-right font-semibold tabular-nums">
|
||||||
|
<span className={etab.total > 0 ? 'text-orange-600' : 'text-muted-foreground'}>
|
||||||
|
{etab.total > 0 ? formatEuros(etab.total) : '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="bg-muted/40 border-t-2 border-border">
|
||||||
|
<td colSpan={3 + (showDetails ? 8 : 4)} className="px-4 py-3 font-bold text-foreground hidden lg:table-cell">
|
||||||
|
TOTAL — {sortedEtabs.length} établissements
|
||||||
|
</td>
|
||||||
|
<td colSpan={3} className="px-4 py-3 font-bold text-foreground lg:hidden">
|
||||||
|
TOTAL
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-bold text-orange-600 text-base tabular-nums">
|
||||||
|
{formatEuros(sortedEtabs.reduce((s, e) => s + e.total, 0))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user