Checkpoint: Bug corrigé : ajout contrainte UNIQUE (annee, etablissementCode) sur opex_bases_repartition + déduplication des lignes existantes — le clic ne crée plus de doublon. Nouvelle fonctionnalité : colonne "Tout manuel" (toggle) dans l'onglet Clés de répartition — quand activé pour un établissement, le calcul prorata est ignoré et seuls les montants saisis manuellement dans la vue Établissements sont utilisés. Badge ambre visible dans les deux vues.
This commit is contained in:
@@ -384,6 +384,7 @@ interface BaseRow {
|
||||
etablissementNom: string | null;
|
||||
baseRepartition: string;
|
||||
baseRepartitionHep: string | null;
|
||||
modeManuel: boolean;
|
||||
}
|
||||
|
||||
interface ClesRepartitionViewProps {
|
||||
@@ -432,10 +433,22 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
etablissementNom: row.etablissementNom,
|
||||
baseRepartition,
|
||||
baseRepartitionHep,
|
||||
modeManuel: row.modeManuel,
|
||||
});
|
||||
setEditingCell(null);
|
||||
}
|
||||
|
||||
function toggleModeManuel(row: BaseRow) {
|
||||
setBaseRepartition.mutate({
|
||||
annee,
|
||||
etablissementCode: row.etablissementCode,
|
||||
etablissementNom: row.etablissementNom,
|
||||
baseRepartition: parseFloat(row.baseRepartition ?? '0'),
|
||||
baseRepartitionHep: parseFloat(row.baseRepartitionHep ?? '0'),
|
||||
modeManuel: !row.modeManuel,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFileImport(file: File) {
|
||||
try {
|
||||
const XLSX = await import('xlsx');
|
||||
@@ -629,12 +642,15 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
<th className="px-4 py-3 text-right font-semibold text-foreground text-xs uppercase tracking-wide w-48">
|
||||
Base répartition {anneeBase} / HEP
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-semibold text-foreground text-xs uppercase tracking-wide w-36" title="Si activé, tous les montants OPEX de cet établissement sont saisis manuellement (pas de calcul prorata)">
|
||||
Tout manuel
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-12 text-center text-muted-foreground">
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-muted-foreground">
|
||||
{basesRepartitionRaw.length === 0
|
||||
? `Aucune base de répartition pour ${annee}. Importez un fichier ou saisissez les valeurs manuellement.`
|
||||
: 'Aucun résultat pour cette recherche.'}
|
||||
@@ -642,9 +658,16 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={row.etablissementCode} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/10'}`}>
|
||||
<tr key={row.etablissementCode} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${row.modeManuel ? 'bg-amber-50/60' : idx % 2 === 0 ? '' : 'bg-muted/10'}`}>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{row.etablissementCode}</td>
|
||||
<td className="px-4 py-2.5 font-medium text-foreground">{row.etablissementNom ?? row.etablissementCode}</td>
|
||||
<td className="px-4 py-2.5 font-medium text-foreground">
|
||||
{row.etablissementNom ?? row.etablissementCode}
|
||||
{row.modeManuel && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-amber-100 text-amber-700 border border-amber-200">
|
||||
Manuel
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Colonne Base de répartition */}
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{editingCell?.code === row.etablissementCode && editingCell.col === 'base' ? (
|
||||
@@ -691,18 +714,48 @@ function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErr
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
{/* Colonne Mode Manuel */}
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<button
|
||||
onClick={() => toggleModeManuel(row)}
|
||||
disabled={setBaseRepartition.isPending}
|
||||
title={row.modeManuel
|
||||
? 'Mode manuel activé — cliquer pour revenir au calcul prorata automatique'
|
||||
: 'Cliquer pour activer le mode tout manuel (montants OPEX saisis manuellement pour cet établissement)'}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 ${
|
||||
row.modeManuel ? 'bg-amber-500' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
|
||||
row.modeManuel ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-muted/40 border-t-2 border-border font-bold">
|
||||
<td colSpan={2} className="px-4 py-3 text-sm font-semibold text-foreground">TOTAL — {basesRepartitionRaw.length} établissements</td>
|
||||
<td colSpan={2} className="px-4 py-3 text-sm font-semibold text-foreground">TOTAL — {basesRepartitionRaw.length} établissements
|
||||
{basesRepartitionRaw.filter(r => r.modeManuel).length > 0 && (
|
||||
<span className="ml-2 text-xs font-normal text-amber-600">
|
||||
({basesRepartitionRaw.filter(r => r.modeManuel).length} en mode manuel)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-bold text-foreground tabular-nums">
|
||||
{new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalBase)} €
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-bold text-orange-600 tabular-nums">
|
||||
{totalHep > 0 ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalHep) + ' €' : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-xs text-muted-foreground">
|
||||
{basesRepartitionRaw.filter(r => r.modeManuel).length > 0
|
||||
? `${basesRepartitionRaw.filter(r => r.modeManuel).length} actif${basesRepartitionRaw.filter(r => r.modeManuel).length > 1 ? 's' : ''}`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -1043,12 +1096,14 @@ export default function DsiOpex() {
|
||||
nom: b.etablissementNom ?? b.etablissementCode,
|
||||
base_repartition: parseFloat(b.baseRepartition ?? '0') || 0,
|
||||
base_repartition_hep: parseFloat(b.baseRepartitionHep ?? '0') || 0,
|
||||
modeManuel: b.modeManuel ?? false,
|
||||
}))
|
||||
: getOpexDataForAnnee(annee).etablissements.map(e => ({
|
||||
code: e.code,
|
||||
nom: e.nom,
|
||||
base_repartition: e.base_repartition,
|
||||
base_repartition_hep: 0, // JSON source n'a pas de base HEP
|
||||
modeManuel: false,
|
||||
}));
|
||||
|
||||
const totalBase = basesFromBdd.reduce((s, e) => s + e.base_repartition, 0);
|
||||
@@ -1071,7 +1126,11 @@ export default function DsiOpex() {
|
||||
(ligne.libelleDetail ?? '').toLowerCase().includes('hep'));
|
||||
const ratioApplique = isHepPoste ? ratioHep : ratio;
|
||||
let montant: number;
|
||||
if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
|
||||
if (etab.modeManuel) {
|
||||
// Mode tout manuel : utiliser uniquement les montants saisis manuellement,
|
||||
// sans aucun calcul prorata — si pas de saisie, le montant est 0
|
||||
montant = etabOverrides[ligne.libelle] ?? 0;
|
||||
} else if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
|
||||
montant = etabOverrides[ligne.libelle];
|
||||
} else if (!isManuel && ligne.montant > 0) {
|
||||
montant = Math.round(ligne.montant * ratioApplique);
|
||||
@@ -1707,12 +1766,21 @@ export default function DsiOpex() {
|
||||
</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 whitespace-nowrap" style={{ position: 'sticky', left: 0, zIndex: 20, background: idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)', boxShadow: '2px 0 4px -1px rgba(0,0,0,0.12)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>
|
||||
{sortedEtabs.map((etab, idx) => {
|
||||
const rowBg = etab.modeManuel ? 'oklch(0.98 0.02 80)' : (idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)');
|
||||
return (
|
||||
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${etab.modeManuel ? 'bg-amber-50/40' : idx % 2 === 0 ? '' : 'bg-muted/5'}`}>
|
||||
<td className="px-3 py-2 whitespace-nowrap" style={{ position: 'sticky', left: 0, zIndex: 20, background: rowBg, boxShadow: '2px 0 4px -1px rgba(0,0,0,0.12)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>
|
||||
<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 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: rowBg, boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }} title={etab.nom}>
|
||||
{etab.nom}
|
||||
{etab.modeManuel && (
|
||||
<span className="ml-1.5 inline-flex items-center px-1 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 border border-amber-200" title="Mode tout manuel activé — montants saisis manuellement, pas de calcul prorata">
|
||||
Manuel
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{lignes.map(ligne => {
|
||||
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||||
const montant = etab.montants[ligne.libelle] ?? 0;
|
||||
@@ -1770,7 +1838,8 @@ export default function DsiOpex() {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-muted/40 border-t-2 border-border">
|
||||
|
||||
Reference in New Issue
Block a user