Checkpoint: 1. Bouton cliquable dans la colonne Ventilation (vue Postes, onglet Détails) pour basculer Prorata classe 6 ↔ Manuel avec répercussion immédiate sur la vue Établissements. 2. Scroll vertical du tableau vue Établissements avec en-tête de colonnes sticky (top:0 sur tous les th). 3. Recalcul des montants par établissement utilise ligne.mode_ventilation en priorité (valeur modifiée par l'utilisateur) au lieu de la source JSON.

This commit is contained in:
Manus
2026-06-10 09:43:30 +00:00
parent 772dff3061
commit 1abd40cc1c

View File

@@ -493,6 +493,19 @@ export default function DsiOpex() {
const CATEGORIES_FILTER = useMemo(() => ['Toutes', ...categoriesPresentes], [categoriesPresentes]);
// Basculer le mode de ventilation d'une ligne (Prorata classe 6 ↔ Manuel)
const handleToggleVentilation = useCallback((id: string) => {
if (isValidated) return;
setOpexState(prev => ({
...prev,
lignes: prev.lignes.map(l => l.id === id ? {
...l,
mode_ventilation: (l.mode_ventilation === 'Manuel') ? 'Prorata classe 6' : 'Manuel',
} : l),
}));
setIsDirty(true);
}, [isValidated]);
// Modifier un montant directement dans le tableau
const handleMontantChange = useCallback((id: string, value: number) => {
if (isValidated) return;
@@ -683,14 +696,20 @@ export default function DsiOpex() {
const montantsRecalcules: Record<string, number> = {};
let totalEtab = 0;
for (const ligne of opexState.lignes) {
// Priorité : mode_ventilation de la ligne (modifié par l'utilisateur) > source JSON > isCustom
const srcPoste = srcData.postes.find(p => p.libelle === ligne.libelle);
const isManuel = srcPoste?.mode_ventilation === 'Manuel' || ligne.isCustom;
const modeVentilation = ligne.mode_ventilation ?? srcPoste?.mode_ventilation ?? null;
const isManuel = modeVentilation === 'Manuel' || ligne.isCustom;
let montant: number;
if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
// Override saisi manuellement par l'utilisateur
montant = etabOverrides[ligne.libelle];
} else if (ligne.montant > 0) {
} else if (!isManuel && ligne.montant > 0) {
// Prorata automatique
montant = Math.round(ligne.montant * ratio);
} else if (isManuel) {
// Manuel sans override : 0 (en attente de saisie)
montant = 0;
} else {
montant = 0;
}
@@ -1122,7 +1141,28 @@ export default function DsiOpex() {
<td className="px-3 py-3 text-xs text-muted-foreground hidden lg:table-cell w-20 max-w-[80px] truncate" title={ligne.type || ''}>{ligne.type || ''}</td>
<td className="px-4 py-3 text-sm text-muted-foreground hidden xl:table-cell">{ligne.facturation || ''}</td>
{showDetails && <td className="px-4 py-3 text-xs font-mono text-muted-foreground hidden xl:table-cell">{ligne.compte || ''}</td>}
{showDetails && <td className="px-4 py-3 text-xs hidden xl:table-cell">{(() => { const mv = ligne.mode_ventilation || 'Prorata'; const isManuelV = mv === 'Manuel'; return <span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${isManuelV ? 'bg-blue-100 text-blue-700' : 'bg-emerald-100 text-emerald-700'}`}>{mv}</span>; })()}</td>}
{showDetails && (
<td className="px-4 py-3 text-xs hidden xl:table-cell">
{isValidated ? (
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
ligne.mode_ventilation === 'Manuel' ? 'bg-blue-100 text-blue-700' : 'bg-emerald-100 text-emerald-700'
}`}>{ligne.mode_ventilation || 'Prorata classe 6'}</span>
) : (
<button
onClick={() => handleToggleVentilation(ligne.id)}
title="Cliquer pour basculer Prorata ↔ Manuel"
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border transition-colors hover:opacity-80 cursor-pointer ${
ligne.mode_ventilation === 'Manuel'
? 'bg-blue-100 text-blue-700 border-blue-300 hover:bg-blue-200'
: 'bg-emerald-100 text-emerald-700 border-emerald-300 hover:bg-emerald-200'
}`}
>
{ligne.mode_ventilation === 'Manuel' ? '🔒' : '🔓'}
{ligne.mode_ventilation || 'Prorata classe 6'}
</button>
)}
</td>
)}
<td className="px-4 py-3 text-right text-sm text-muted-foreground tabular-nums">
{formatNum(ligne.budget_n1)}
</td>
@@ -1265,21 +1305,23 @@ export default function DsiOpex() {
{/* Tableau scrollable horizontalement avec tous les postes */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto" style={{ isolation: 'isolate' }}>
<div className="overflow-x-auto overflow-y-auto" style={{ isolation: 'isolate', maxHeight: 'calc(100vh - 320px)' }}>
<table className="text-xs border-separate border-spacing-0" style={{ minWidth: 'max-content', width: '100%' }}>
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="text-left px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', left: 0, zIndex: 30, background: 'oklch(0.94 0.005 80)', boxShadow: '2px 0 4px -1px rgba(0,0,0,0.15)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>Code</th>
<th className="text-left px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', left: '110px', zIndex: 30, background: 'oklch(0.94 0.005 80)', boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }}>
<th className="text-left px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', left: 0, top: 0, zIndex: 40, background: 'oklch(0.94 0.005 80)', boxShadow: '2px 0 4px -1px rgba(0,0,0,0.15)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>Code</th>
<th className="text-left px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', left: '110px', top: 0, zIndex: 40, background: 'oklch(0.94 0.005 80)', boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }}>
<SortBtn col="nom" label="Établissement" />
</th>
{/* 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;
const modeVentilation = ligne.mode_ventilation ?? srcPoste?.mode_ventilation ?? null;
const isManuel = modeVentilation === 'Manuel' || ligne.isCustom;
return (
<th
key={ligne.id}
style={{ position: 'sticky', top: 0, zIndex: 35 }}
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'
@@ -1294,7 +1336,7 @@ export default function DsiOpex() {
</th>
);
})}
<th className="text-right px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', right: 0, zIndex: 30, background: 'oklch(0.94 0.005 80)', boxShadow: '-2px 0 8px -2px rgba(0,0,0,0.2)', width: '120px', minWidth: '120px' }}>
<th className="text-right px-3 py-3 font-medium text-muted-foreground" style={{ position: 'sticky', right: 0, top: 0, zIndex: 40, background: 'oklch(0.94 0.005 80)', boxShadow: '-2px 0 8px -2px rgba(0,0,0,0.2)', width: '120px', minWidth: '120px' }}>
<SortBtn col="total" label={`Total ${annee}`} />
</th>
</tr>
@@ -1308,7 +1350,8 @@ export default function DsiOpex() {
<td className="px-3 py-2 font-medium text-foreground whitespace-nowrap overflow-hidden text-ellipsis" style={{ position: 'sticky', left: '110px', zIndex: 20, background: idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)', boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }} title={etab.nom}>{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 modeVentilation = ligne.mode_ventilation ?? srcPoste?.mode_ventilation ?? null;
const isManuel = modeVentilation === 'Manuel' || ligne.isCustom;
const montant = etab.montants[ligne.libelle] ?? 0;
const cellKey = `${etab.code}|${ligne.libelle}`;
const isEditing = inlineEditKey === cellKey;
@@ -1373,7 +1416,8 @@ export default function DsiOpex() {
</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 modeVentilation = ligne.mode_ventilation ?? srcPoste?.mode_ventilation ?? null;
const isManuel = modeVentilation === 'Manuel' || ligne.isCustom;
const totalColonne = sortedEtabs.reduce((s, e) => s + (e.montants[ligne.libelle] ?? 0), 0);
return (
<td