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:
@@ -493,6 +493,19 @@ export default function DsiOpex() {
|
|||||||
|
|
||||||
const CATEGORIES_FILTER = useMemo(() => ['Toutes', ...categoriesPresentes], [categoriesPresentes]);
|
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
|
// Modifier un montant directement dans le tableau
|
||||||
const handleMontantChange = useCallback((id: string, value: number) => {
|
const handleMontantChange = useCallback((id: string, value: number) => {
|
||||||
if (isValidated) return;
|
if (isValidated) return;
|
||||||
@@ -683,14 +696,20 @@ export default function DsiOpex() {
|
|||||||
const montantsRecalcules: Record<string, number> = {};
|
const montantsRecalcules: Record<string, number> = {};
|
||||||
let totalEtab = 0;
|
let totalEtab = 0;
|
||||||
for (const ligne of opexState.lignes) {
|
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 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;
|
let montant: number;
|
||||||
if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
|
if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
|
||||||
// Override saisi manuellement par l'utilisateur
|
// Override saisi manuellement par l'utilisateur
|
||||||
montant = etabOverrides[ligne.libelle];
|
montant = etabOverrides[ligne.libelle];
|
||||||
} else if (ligne.montant > 0) {
|
} else if (!isManuel && ligne.montant > 0) {
|
||||||
|
// Prorata automatique
|
||||||
montant = Math.round(ligne.montant * ratio);
|
montant = Math.round(ligne.montant * ratio);
|
||||||
|
} else if (isManuel) {
|
||||||
|
// Manuel sans override : 0 (en attente de saisie)
|
||||||
|
montant = 0;
|
||||||
} else {
|
} else {
|
||||||
montant = 0;
|
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-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>
|
<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 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">
|
<td className="px-4 py-3 text-right text-sm text-muted-foreground tabular-nums">
|
||||||
{formatNum(ligne.budget_n1)}
|
{formatNum(ligne.budget_n1)}
|
||||||
</td>
|
</td>
|
||||||
@@ -1265,21 +1305,23 @@ export default function DsiOpex() {
|
|||||||
|
|
||||||
{/* Tableau scrollable horizontalement avec tous les postes */}
|
{/* Tableau scrollable horizontalement avec tous les postes */}
|
||||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
<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%' }}>
|
<table className="text-xs border-separate border-spacing-0" style={{ minWidth: 'max-content', width: '100%' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/40">
|
<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: 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', 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: '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" />
|
<SortBtn col="nom" label="Établissement" />
|
||||||
</th>
|
</th>
|
||||||
{/* Colonnes postes — vert = prorata auto, bleu = manuel */}
|
{/* Colonnes postes — vert = prorata auto, bleu = manuel */}
|
||||||
{opexState.lignes.map(ligne => {
|
{opexState.lignes.map(ligne => {
|
||||||
const srcPoste = getOpexDataForAnnee(annee).postes.find(p => p.libelle === ligne.libelle);
|
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 (
|
return (
|
||||||
<th
|
<th
|
||||||
key={ligne.id}
|
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] ${
|
className={`text-right px-2 py-2 font-medium min-w-[90px] max-w-[110px] ${
|
||||||
isManuel
|
isManuel
|
||||||
? 'text-blue-700 bg-blue-50/60 border-b-2 border-blue-400'
|
? 'text-blue-700 bg-blue-50/60 border-b-2 border-blue-400'
|
||||||
@@ -1294,7 +1336,7 @@ export default function DsiOpex() {
|
|||||||
</th>
|
</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}`} />
|
<SortBtn col="total" label={`Total ${annee}`} />
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</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>
|
<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 => {
|
{opexState.lignes.map(ligne => {
|
||||||
const srcPoste = getOpexDataForAnnee(annee).postes.find(p => p.libelle === ligne.libelle);
|
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 montant = etab.montants[ligne.libelle] ?? 0;
|
||||||
const cellKey = `${etab.code}|${ligne.libelle}`;
|
const cellKey = `${etab.code}|${ligne.libelle}`;
|
||||||
const isEditing = inlineEditKey === cellKey;
|
const isEditing = inlineEditKey === cellKey;
|
||||||
@@ -1373,7 +1416,8 @@ export default function DsiOpex() {
|
|||||||
</td>
|
</td>
|
||||||
{opexState.lignes.map(ligne => {
|
{opexState.lignes.map(ligne => {
|
||||||
const srcPoste = getOpexDataForAnnee(annee).postes.find(p => p.libelle === ligne.libelle);
|
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);
|
const totalColonne = sortedEtabs.reduce((s, e) => s + (e.montants[ligne.libelle] ?? 0), 0);
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
|
|||||||
Reference in New Issue
Block a user