diff --git a/client/src/pages/DsiOpex.tsx b/client/src/pages/DsiOpex.tsx index 2272794..a62592e 100644 --- a/client/src/pages/DsiOpex.tsx +++ b/client/src/pages/DsiOpex.tsx @@ -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; + importBasesRepartition: ReturnType; +} + +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(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 ( +
+ {/* Bandeau d'information */} +
+ +
+

Base de répartition {anneeBase} — OPEX {annee}

+

La base de répartition utilisée pour l'OPEX {annee} correspond aux charges de classe 6 de l'exercice {anneeBase} (année OPEX − 2).

+

La colonne Base / HEP est utilisée exclusivement pour le poste DUI pôle HEP. Pour les établissements hors pôle HEP, cette valeur doit être 0.

+
+
+ + {/* Barre d'outils */} +
+
+ + 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" + /> +
+
+ {basesRepartitionRaw.length} établissements +
+
+ { const f = e.target.files?.[0]; if (f) handleFileImport(f); e.target.value = ''; }} + /> + +
+
+ + {/* Zone de drop */} +
{ 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 + Colonnes attendues : code établissement · base de répartition · base répartition HEP (optionnel) +
+ + {/* Tableau */} +
+ + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((row, idx) => ( + + + + {/* Colonne Base de répartition */} + + {/* Colonne Base / HEP */} + + + ))} + + + + + + + + +
CodeÉtablissement + Base de répartition {anneeBase} + + Base répartition {anneeBase} / HEP +
+ {basesRepartitionRaw.length === 0 + ? `Aucune base de répartition pour ${annee}. Importez un fichier ou saisissez les valeurs manuellement.` + : 'Aucun résultat pour cette recherche.'} +
{row.etablissementCode}{row.etablissementNom ?? row.etablissementCode} + {editingCell?.code === row.etablissementCode && editingCell.col === 'base' ? ( + 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 + /> + ) : ( + + )} + + {editingCell?.code === row.etablissementCode && editingCell.col === 'hep' ? ( + 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 + /> + ) : ( + + )} +
TOTAL — {basesRepartitionRaw.length} établissements + {new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalBase)} € + + {totalHep > 0 ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalHep) + ' €' : '—'} +
+
+
+ ); +} + // ─── 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, 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 = montantsManuelEtab[etab.code] ?? {}; const montantsRecalcules: Record = {}; 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() { Par établissement +