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:
Manus
2026-06-10 07:43:25 +00:00
parent fa30872300
commit eb236f4b8f

View File

@@ -1159,101 +1159,125 @@ export default function DsiOpex() {
</table> </table>
</div> </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> </div>
)} )}
{/* === VUE PAR ÉTABLISSEMENT === */} {/* === VUE PAR ÉTABLISSEMENT === */}
{viewMode === 'etablissements' && ( {viewMode === 'etablissements' && (
<div className="bg-card border border-border rounded-xl overflow-hidden"> <div className="space-y-3">
<table className="w-full text-sm"> {/* Légende code couleur */}
<thead> <div className="flex items-center gap-4 px-1 text-xs text-muted-foreground">
<tr className="border-b border-border bg-muted/40"> <div className="flex items-center gap-1.5">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Code</th> <span className="inline-block w-3 h-3 rounded border-2 border-emerald-500 bg-emerald-50"></span>
<th className="text-left px-4 py-3 font-medium text-muted-foreground"> <span>Calcul automatique (prorata classe 6)</span>
<SortBtn col="nom" label="Établissement" /> </div>
</th> <div className="flex items-center gap-1.5">
<th className="text-right px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">Base répartition</th> <span className="inline-block w-3 h-3 rounded border-2 border-blue-500 bg-blue-50"></span>
{opexData2026.postes.slice(0, showDetails ? 8 : 4).map(p => ( <span>Saisie manuelle</span>
<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"> </div>
<span className="block truncate max-w-20" title={p.libelle}>{p.libelle.split(' ').slice(0, 3).join(' ')}</span> </div>
</th>
))} {/* Tableau scrollable horizontalement avec tous les postes */}
<th className="text-right px-4 py-3 font-medium text-muted-foreground"> <div className="bg-card border border-border rounded-xl overflow-hidden">
<SortBtn col="total" label={`Total ${annee}`} /> <div className="overflow-x-auto">
</th> <table className="text-xs" style={{ minWidth: 'max-content', width: '100%' }}>
</tr> <thead>
</thead> <tr className="border-b border-border bg-muted/40">
<tbody> <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>
{sortedEtabs.map((etab, idx) => ( <th className="text-left px-3 py-3 font-medium text-muted-foreground sticky left-[80px] bg-muted/40 z-10 min-w-[200px]">
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/30 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/10'}`}> <SortBtn col="nom" label="Établissement" />
<td className="px-4 py-2.5"> </th>
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{etab.code}</span> {/* Colonnes postes — vert = prorata auto, bleu = manuel */}
</td> {opexState.lignes.map(ligne => {
<td className="px-4 py-2.5 font-medium text-foreground">{etab.nom}</td> const srcPoste = getOpexDataForAnnee(annee).postes.find(p => p.libelle === ligne.libelle);
<td className="px-4 py-2.5 text-right text-xs text-muted-foreground tabular-nums hidden lg:table-cell"> const isManuel = srcPoste?.mode_ventilation === 'Manuel' || ligne.isCustom;
{etab.base_repartition > 0 return (
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(etab.base_repartition) + ' ' <th
: ''} key={ligne.id}
</td> className={`text-right px-2 py-2 font-medium min-w-[90px] max-w-[110px] ${
{opexData2026.postes.slice(0, showDetails ? 8 : 4).map(p => ( isManuel
<td key={p.libelle} className="px-3 py-2.5 text-right text-xs tabular-nums text-muted-foreground hidden xl:table-cell"> ? 'text-blue-700 bg-blue-50/60 border-b-2 border-blue-400'
{etab.montants[p.libelle] > 0 : 'text-emerald-700 bg-emerald-50/60 border-b-2 border-emerald-400'
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(etab.montants[p.libelle])) + ' ' }`}
: ''} title={`${ligne.libelle} — ${isManuel ? 'Saisie manuelle' : 'Prorata classe 6'} — ${formatEuros(ligne.montant)}`}
</td> >
<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-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/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-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>
);
})}
<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>
</td>
</tr>
))} ))}
<td className="px-4 py-2.5 text-right font-semibold tabular-nums"> </tbody>
<span className={etab.total > 0 ? 'text-orange-600' : 'text-muted-foreground'}> <tfoot>
{etab.total > 0 ? formatEuros(etab.total) : ''} <tr className="bg-muted/40 border-t-2 border-border">
</span> <td colSpan={2} className="px-3 py-3 font-bold text-foreground sticky left-0 bg-muted/40 z-10">
</td> TOTAL — {sortedEtabs.length} établissements
</tr> </td>
))} {opexState.lignes.map(ligne => {
</tbody> const srcPoste = getOpexDataForAnnee(annee).postes.find(p => p.libelle === ligne.libelle);
<tfoot> const isManuel = srcPoste?.mode_ventilation === 'Manuel' || ligne.isCustom;
<tr className="bg-muted/40 border-t-2 border-border"> const totalColonne = sortedEtabs.reduce((s, e) => s + (e.montants[ligne.libelle] ?? 0), 0);
<td colSpan={3 + (showDetails ? 8 : 4)} className="px-4 py-3 font-bold text-foreground hidden lg:table-cell"> return (
TOTAL — {sortedEtabs.length} établissements <td
</td> key={ligne.id}
<td colSpan={3} className="px-4 py-3 font-bold text-foreground lg:hidden">TOTAL</td> className={`px-2 py-3 text-right font-semibold tabular-nums ${
<td className="px-4 py-3 text-right font-bold text-orange-600 text-base tabular-nums"> isManuel ? 'text-blue-700 bg-blue-50/40' : 'text-emerald-700 bg-emerald-50/30'
{formatEuros(sortedEtabs.reduce((s, e) => s + e.total, 0))} }`}
</td> >
</tr> {totalColonne > 0
</tfoot> ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalColonne) + ' '
</table> : ''}
</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>
)} )}
</div> </div>