Checkpoint: Ajout de l'onglet 'Clés de répartition' dans DSI OPEX : tableau éditable avec base de répartition standard et base /HEP par établissement, import fichier Excel/CSV, info année (OPEX N → base N-2). Calcul automatique du poste SAAS DUI Pôle HEP branché sur la colonne /HEP. Nouvelle table opex_bases_repartition avec colonne baseRepartitionHep. Seed 2026 : 64 établissements dont 19 HEP. 14 tests passants, 0 erreur TypeScript.
This commit is contained in:
@@ -25,6 +25,9 @@ import {
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Key,
|
||||
Upload,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { AppSidebar } from '../components/AppSidebar';
|
||||
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
||||
@@ -373,9 +376,265 @@ function LigneForm({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Composant Clés de répartition ──────────────────────────────────────────
|
||||
|
||||
interface BaseRow {
|
||||
annee: number;
|
||||
etablissementCode: string;
|
||||
etablissementNom: string | null;
|
||||
baseRepartition: string;
|
||||
baseRepartitionHep: string | null;
|
||||
}
|
||||
|
||||
interface ClesRepartitionViewProps {
|
||||
annee: number;
|
||||
basesRepartitionRaw: BaseRow[];
|
||||
setBaseRepartition: ReturnType<typeof trpc.opex.setBaseRepartition.useMutation>;
|
||||
importBasesRepartition: ReturnType<typeof trpc.opex.importBasesRepartition.useMutation>;
|
||||
}
|
||||
|
||||
function ClesRepartitionView({ annee, basesRepartitionRaw, setBaseRepartition, importBasesRepartition }: ClesRepartitionViewProps) {
|
||||
const anneeBase = annee - 2; // La base de répartition correspond à l'année OPEX - 2
|
||||
const [editingCell, setEditingCell] = useState<{ code: string; col: 'base' | 'hep' } | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [searchBase, setSearchBase] = useState('');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return basesRepartitionRaw
|
||||
.filter(r => {
|
||||
if (!searchBase) return true;
|
||||
const s = searchBase.toLowerCase();
|
||||
return r.etablissementCode.toLowerCase().includes(s) || (r.etablissementNom ?? '').toLowerCase().includes(s);
|
||||
})
|
||||
.sort((a, b) => a.etablissementCode.localeCompare(b.etablissementCode));
|
||||
}, [basesRepartitionRaw, searchBase]);
|
||||
|
||||
const totalBase = useMemo(() => basesRepartitionRaw.reduce((s, r) => s + parseFloat(r.baseRepartition ?? '0'), 0), [basesRepartitionRaw]);
|
||||
const totalHep = useMemo(() => basesRepartitionRaw.reduce((s, r) => s + parseFloat(r.baseRepartitionHep ?? '0'), 0), [basesRepartitionRaw]);
|
||||
|
||||
function startEdit(code: string, col: 'base' | 'hep', currentVal: string) {
|
||||
setEditingCell({ code, col });
|
||||
setEditValue(currentVal.replace(/[^0-9.]/g, ''));
|
||||
}
|
||||
|
||||
function commitEdit(row: BaseRow) {
|
||||
if (!editingCell) return;
|
||||
const numVal = parseFloat(editValue.replace(',', '.')) || 0;
|
||||
const baseRepartition = editingCell.col === 'base' ? numVal : parseFloat(row.baseRepartition ?? '0');
|
||||
const baseRepartitionHep = editingCell.col === 'hep' ? numVal : parseFloat(row.baseRepartitionHep ?? '0');
|
||||
setBaseRepartition.mutate({
|
||||
annee,
|
||||
etablissementCode: row.etablissementCode,
|
||||
etablissementNom: row.etablissementNom,
|
||||
baseRepartition,
|
||||
baseRepartitionHep,
|
||||
});
|
||||
setEditingCell(null);
|
||||
}
|
||||
|
||||
async function handleFileImport(file: File) {
|
||||
try {
|
||||
const XLSX = await import('xlsx');
|
||||
const buf = await file.arrayBuffer();
|
||||
const wb = XLSX.read(buf, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const data: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][];
|
||||
// Trouver la ligne d'en-tête
|
||||
let headerIdx = -1;
|
||||
for (let i = 0; i < Math.min(data.length, 10); i++) {
|
||||
const row = data[i].map(c => String(c).toLowerCase());
|
||||
if (row.some(c => c.includes('code') || c.includes('établissement') || c.includes('etab'))) {
|
||||
headerIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerIdx === -1) { toast.error('Format non reconnu', { description: 'Impossible de trouver la ligne d\'en-tête (code établissement)' }); return; }
|
||||
const headers = data[headerIdx].map(c => String(c).toLowerCase());
|
||||
const codeIdx = headers.findIndex(h => h.includes('code'));
|
||||
const nomIdx = headers.findIndex(h => h.includes('nom') || h.includes('établissement') || h.includes('etab'));
|
||||
const baseIdx = headers.findIndex(h => (h.includes('base') || h.includes('répartition') || h.includes('repartition')) && !h.includes('hep'));
|
||||
const hepIdx = headers.findIndex(h => h.includes('hep'));
|
||||
if (codeIdx === -1 || baseIdx === -1) { toast.error('Colonnes manquantes', { description: 'Le fichier doit contenir au minimum les colonnes : code établissement, base de répartition' }); return; }
|
||||
const importRows = data.slice(headerIdx + 1)
|
||||
.filter(row => row[codeIdx] && String(row[codeIdx]).trim())
|
||||
.map(row => ({
|
||||
etablissementCode: String(row[codeIdx]).trim(),
|
||||
etablissementNom: nomIdx >= 0 ? String(row[nomIdx]).trim() || null : null,
|
||||
baseRepartition: parseFloat(String(row[baseIdx]).replace(/[^0-9.]/g, '')) || 0,
|
||||
baseRepartitionHep: hepIdx >= 0 ? parseFloat(String(row[hepIdx]).replace(/[^0-9.]/g, '')) || 0 : 0,
|
||||
}));
|
||||
if (importRows.length === 0) { toast.error('Aucune ligne importée', { description: 'Le fichier ne contient aucune ligne de données valide' }); return; }
|
||||
importBasesRepartition.mutate({ annee, rows: importRows });
|
||||
} catch (e) {
|
||||
toast.error('Erreur lecture fichier', { description: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
function formatBaseNum(val: string | null | undefined): string {
|
||||
const n = parseFloat(val ?? '0');
|
||||
if (!n) return '—';
|
||||
return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(n) + ' €';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Bandeau d'information */}
|
||||
<div className="flex-shrink-0 mb-4 p-4 rounded-xl bg-blue-50 border border-blue-200 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-semibold mb-1">Base de répartition {anneeBase} — OPEX {annee}</p>
|
||||
<p>La base de répartition utilisée pour l'OPEX <strong>{annee}</strong> correspond aux charges de classe 6 de l'exercice <strong>{anneeBase}</strong> (année OPEX − 2).</p>
|
||||
<p className="mt-1">La colonne <strong>Base / HEP</strong> est utilisée exclusivement pour le poste <em>DUI pôle HEP</em>. Pour les établissements hors pôle HEP, cette valeur doit être 0.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barre d'outils */}
|
||||
<div className="flex-shrink-0 flex items-center gap-3 mb-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un établissement..."
|
||||
value={searchBase}
|
||||
onChange={e => setSearchBase(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm bg-muted/50 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{basesRepartitionRaw.length}</span> établissements
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileImport(f); e.target.value = ''; }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importBasesRepartition.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{importBasesRepartition.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
Importer fichier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone de drop */}
|
||||
<div
|
||||
className={`flex-shrink-0 mb-3 border-2 border-dashed rounded-xl p-3 text-center text-xs text-muted-foreground transition-colors ${
|
||||
isDragging ? 'border-primary bg-primary/5 text-primary' : 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={e => { e.preventDefault(); setIsDragging(false); const f = e.dataTransfer.files?.[0]; if (f) handleFileImport(f); }}
|
||||
>
|
||||
Glissez un fichier Excel (.xlsx) ou CSV ici pour importer les bases de répartition
|
||||
<span className="block mt-0.5 text-muted-foreground/60">Colonnes attendues : code établissement · base de répartition · base répartition HEP (optionnel)</span>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="flex-1 overflow-auto rounded-xl border border-border">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 border-b border-border sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left font-semibold text-foreground text-xs uppercase tracking-wide w-28">Code</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-foreground text-xs uppercase tracking-wide">Établissement</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-foreground text-xs uppercase tracking-wide w-48">
|
||||
Base de répartition {anneeBase}
|
||||
</th>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} 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.'}
|
||||
</td>
|
||||
</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'}`}>
|
||||
<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>
|
||||
{/* Colonne Base de répartition */}
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{editingCell?.code === row.etablissementCode && editingCell.col === 'base' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={() => commitEdit(row)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitEdit(row); if (e.key === 'Escape') setEditingCell(null); }}
|
||||
className="w-full text-right px-2 py-1 border border-primary rounded text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 tabular-nums"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEdit(row.etablissementCode, 'base', row.baseRepartition)}
|
||||
className="w-full text-right px-2 py-1 rounded hover:bg-primary/10 transition-colors tabular-nums font-medium text-foreground group"
|
||||
>
|
||||
{formatBaseNum(row.baseRepartition)}
|
||||
<Pencil className="inline-block ml-1 w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
{/* Colonne Base / HEP */}
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{editingCell?.code === row.etablissementCode && editingCell.col === 'hep' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={() => commitEdit(row)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitEdit(row); if (e.key === 'Escape') setEditingCell(null); }}
|
||||
className="w-full text-right px-2 py-1 border border-primary rounded text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 tabular-nums"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEdit(row.etablissementCode, 'hep', row.baseRepartitionHep ?? '0')}
|
||||
className={`w-full text-right px-2 py-1 rounded hover:bg-primary/10 transition-colors tabular-nums group ${
|
||||
parseFloat(row.baseRepartitionHep ?? '0') > 0 ? 'font-medium text-orange-600' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{parseFloat(row.baseRepartitionHep ?? '0') > 0 ? formatBaseNum(row.baseRepartitionHep) : '—'}
|
||||
<Pencil className="inline-block ml-1 w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</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 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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Composant principal ──────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'postes' | 'etablissements';
|
||||
type ViewMode = 'postes' | 'etablissements' | 'cles_repartition';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
export default function DsiOpex() {
|
||||
@@ -410,6 +669,14 @@ export default function DsiOpex() {
|
||||
onSuccess: () => { utils.opex.getValidated.invalidate({ annee }); toast.success(`OPEX ${annee} validé`, { description: 'Le prévisionnel est maintenant verrouillé.' }); },
|
||||
onError: (err) => toast.error('Erreur validation', { description: err.message }),
|
||||
});
|
||||
const setBaseRepartition = trpc.opex.setBaseRepartition.useMutation({
|
||||
onSuccess: () => utils.opex.getBasesRepartition.invalidate({ annee }),
|
||||
onError: (err) => toast.error('Erreur sauvegarde base', { description: err.message }),
|
||||
});
|
||||
const importBasesRepartition = trpc.opex.importBasesRepartition.useMutation({
|
||||
onSuccess: (data) => { utils.opex.getBasesRepartition.invalidate({ annee }); toast.success(`${data.count} bases de répartition importées`); },
|
||||
onError: (err) => toast.error('Erreur import', { description: err.message }),
|
||||
});
|
||||
|
||||
// ── Mapper les données BDD vers le format interne ──────────────────────────
|
||||
const lignes: LigneOpex[] = useMemo(() => {
|
||||
@@ -696,29 +963,39 @@ export default function DsiOpex() {
|
||||
code: b.etablissementCode,
|
||||
nom: b.etablissementNom ?? b.etablissementCode,
|
||||
base_repartition: parseFloat(b.baseRepartition ?? '0') || 0,
|
||||
base_repartition_hep: parseFloat(b.baseRepartitionHep ?? '0') || 0,
|
||||
}))
|
||||
: 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
|
||||
}));
|
||||
|
||||
const totalBase = basesFromBdd.reduce((s, e) => s + e.base_repartition, 0);
|
||||
const totalBaseHep = basesFromBdd.reduce((s, e) => s + e.base_repartition_hep, 0);
|
||||
if (totalBase === 0) return basesFromBdd.map(e => ({ ...e, montants: {} as Record<string, number>, total: 0 }));
|
||||
|
||||
return basesFromBdd.map(etab => {
|
||||
const ratio = etab.base_repartition / totalBase;
|
||||
// Ratio HEP : utiliser la base /HEP si disponible, sinon fallback sur base standard
|
||||
const ratioHep = totalBaseHep > 0 ? etab.base_repartition_hep / totalBaseHep : ratio;
|
||||
const etabOverrides: Record<string, number> = montantsManuelEtab[etab.code] ?? {};
|
||||
|
||||
const montantsRecalcules: Record<string, number> = {};
|
||||
let totalEtab = 0;
|
||||
for (const ligne of lignes) {
|
||||
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||||
// Le poste "SAAS DUI Pole HEP" utilise la base de répartition /HEP
|
||||
const isHepPoste = ligne.libelle.toLowerCase().includes('dui') &&
|
||||
(ligne.libelle.toLowerCase().includes('hep') ||
|
||||
(ligne.libelleDetail ?? '').toLowerCase().includes('hep'));
|
||||
const ratioApplique = isHepPoste ? ratioHep : ratio;
|
||||
let montant: number;
|
||||
if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
|
||||
montant = etabOverrides[ligne.libelle];
|
||||
} else if (!isManuel && ligne.montant > 0) {
|
||||
montant = Math.round(ligne.montant * ratio);
|
||||
montant = Math.round(ligne.montant * ratioApplique);
|
||||
} else {
|
||||
montant = 0;
|
||||
}
|
||||
@@ -978,6 +1255,15 @@ export default function DsiOpex() {
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
Par établissement
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setViewMode('cles_repartition'); setSortCol(null); }}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
viewMode === 'cles_repartition' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
Clés de répartition
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDetails(d => !d)}
|
||||
@@ -1440,6 +1726,15 @@ export default function DsiOpex() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Vue Clés de répartition */}
|
||||
{viewMode === 'cles_repartition' && (
|
||||
<ClesRepartitionView
|
||||
annee={annee}
|
||||
basesRepartitionRaw={basesRepartitionRaw ?? []}
|
||||
setBaseRepartition={setBaseRepartition}
|
||||
importBasesRepartition={importBasesRepartition}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user