Checkpoint: Suppression du bloc Répartition par catégorie en bas de la vue Postes (doublon des vignettes KPI). Refonte de la vue Établissements : tableau scrollable horizontalement avec tous les 35 postes en colonnes, code couleur vert (bordure emerald = calcul prorata automatique) / bleu (bordure bleue = saisie manuelle), colonnes Code et Établissement sticky à gauche, colonne Total sticky à droite, légende en haut du tableau.
This commit is contained in:
@@ -1159,82 +1159,88 @@ export default function DsiOpex() {
|
||||
</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 — {annee}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(totalParCategorie)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cat, montant]) => {
|
||||
const pct = totalGlobal > 0 ? (montant / totalGlobal) * 100 : 0;
|
||||
const colors = getCatColors(cat);
|
||||
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 ${colors.badge}`}>
|
||||
{cat}
|
||||
</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div className={`h-2 rounded-full transition-all ${colors.bar}`} 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="space-y-3">
|
||||
{/* Légende code couleur */}
|
||||
<div className="flex items-center gap-4 px-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded border-2 border-emerald-500 bg-emerald-50"></span>
|
||||
<span>Calcul automatique (prorata classe 6)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-3 rounded border-2 border-blue-500 bg-blue-50"></span>
|
||||
<span>Saisie manuelle</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tableau scrollable horizontalement avec tous les postes */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-xs" style={{ minWidth: 'max-content', width: '100%' }}>
|
||||
<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">
|
||||
<th className="text-left px-3 py-3 font-medium text-muted-foreground sticky left-0 bg-muted/40 z-10 min-w-[80px]">Code</th>
|
||||
<th className="text-left px-3 py-3 font-medium text-muted-foreground sticky left-[80px] bg-muted/40 z-10 min-w-[200px]">
|
||||
<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>
|
||||
{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>
|
||||
{/* Colonnes postes — vert = prorata auto, bleu = manuel */}
|
||||
{opexState.lignes.map(ligne => {
|
||||
const srcPoste = getOpexDataForAnnee(annee).postes.find(p => p.libelle === ligne.libelle);
|
||||
const isManuel = srcPoste?.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||||
return (
|
||||
<th
|
||||
key={ligne.id}
|
||||
className={`text-right px-2 py-2 font-medium min-w-[90px] max-w-[110px] ${
|
||||
isManuel
|
||||
? 'text-blue-700 bg-blue-50/60 border-b-2 border-blue-400'
|
||||
: 'text-emerald-700 bg-emerald-50/60 border-b-2 border-emerald-400'
|
||||
}`}
|
||||
title={`${ligne.libelle} — ${isManuel ? 'Saisie manuelle' : 'Prorata classe 6'} — ${formatEuros(ligne.montant)}`}
|
||||
>
|
||||
<span className="block truncate max-w-[100px]" style={{ writingMode: 'horizontal-tb' }}>
|
||||
{ligne.libelle.length > 18 ? ligne.libelle.slice(0, 16) + '…' : ligne.libelle}
|
||||
</span>
|
||||
<span className="block text-[10px] font-normal opacity-70 mt-0.5">{formatEuros(ligne.montant)}</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">
|
||||
);
|
||||
})}
|
||||
<th className="text-right px-3 py-3 font-medium text-muted-foreground min-w-[110px] sticky right-0 bg-muted/40 z-10">
|
||||
<SortBtn col="total" label={`Total ${annee}`} />
|
||||
</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>
|
||||
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/5'}`}>
|
||||
<td className="px-3 py-2 sticky left-0 bg-inherit z-10">
|
||||
<span className="font-mono 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 className="px-3 py-2 font-medium text-foreground sticky left-[80px] bg-inherit z-10 whitespace-nowrap">{etab.nom}</td>
|
||||
{opexState.lignes.map(ligne => {
|
||||
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;
|
||||
return (
|
||||
<td
|
||||
key={ligne.id}
|
||||
className={`px-2 py-2 text-right tabular-nums ${
|
||||
isManuel
|
||||
? 'border border-blue-200 bg-blue-50/30 text-blue-800'
|
||||
: 'border border-emerald-200 bg-emerald-50/20 text-emerald-800'
|
||||
}`}
|
||||
>
|
||||
{montant > 0
|
||||
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(montant) + ' €'
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
{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])) + ' €'
|
||||
: '—'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2.5 text-right font-semibold tabular-nums">
|
||||
);
|
||||
})}
|
||||
<td className="px-3 py-2 text-right font-semibold tabular-nums sticky right-0 bg-card z-10 border-l border-border">
|
||||
<span className={etab.total > 0 ? 'text-orange-600' : 'text-muted-foreground'}>
|
||||
{etab.total > 0 ? formatEuros(etab.total) : '—'}
|
||||
</span>
|
||||
@@ -1244,17 +1250,35 @@ export default function DsiOpex() {
|
||||
</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">
|
||||
<td colSpan={2} className="px-3 py-3 font-bold text-foreground sticky left-0 bg-muted/40 z-10">
|
||||
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">
|
||||
{opexState.lignes.map(ligne => {
|
||||
const srcPoste = getOpexDataForAnnee(annee).postes.find(p => p.libelle === ligne.libelle);
|
||||
const isManuel = srcPoste?.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||||
const totalColonne = sortedEtabs.reduce((s, e) => s + (e.montants[ligne.libelle] ?? 0), 0);
|
||||
return (
|
||||
<td
|
||||
key={ligne.id}
|
||||
className={`px-2 py-3 text-right font-semibold tabular-nums ${
|
||||
isManuel ? 'text-blue-700 bg-blue-50/40' : 'text-emerald-700 bg-emerald-50/30'
|
||||
}`}
|
||||
>
|
||||
{totalColonne > 0
|
||||
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalColonne) + ' €'
|
||||
: '—'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-3 py-3 text-right font-bold text-orange-600 text-sm tabular-nums sticky right-0 bg-muted/40 z-10 border-l border-border">
|
||||
{formatEuros(sortedEtabs.reduce((s, e) => s + e.total, 0))}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user