1. Loading/error state : ClesRepartitionView reçoit désormais isLoadingBases et isErrorBases depuis la requête trpc.opex.getBasesRepartition.useQuery(). Affiche un spinner pendant le chargement et un message d'erreur si la requête échoue (au lieu d'un tableau vide trompeur). 2. Import fichier amélioré : détection intelligente de la feuille (mots-clés OPEX/DSI/répartition/base, insensible aux accents), recherche de l'en-tête dans les 15 premières lignes, normalisation des accents pour la détection des colonnes, message d'erreur détaillé avec les colonnes détectées, toast informatif si plusieurs feuilles présentes. 14 tests Vitest passants. 0 erreur TypeScript.
1881 lines
94 KiB
TypeScript
1881 lines
94 KiB
TypeScript
// DsiOpex.tsx — OPEX DSI : vignettes par catégorie, CRUD lignes, persistance BDD via tRPC
|
||
// Migré depuis localStorage vers tRPC + MySQL
|
||
|
||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||
import {
|
||
TrendingDown,
|
||
TrendingUp,
|
||
Search,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Info,
|
||
BarChart3,
|
||
Building2,
|
||
Euro,
|
||
Filter,
|
||
Eye,
|
||
EyeOff,
|
||
ArrowUpDown,
|
||
Save,
|
||
Lock,
|
||
CheckCircle2,
|
||
PencilLine,
|
||
RotateCcw,
|
||
Plus,
|
||
Pencil,
|
||
Trash2,
|
||
Loader2,
|
||
Key,
|
||
Upload,
|
||
AlertCircle,
|
||
} from 'lucide-react';
|
||
import { AppSidebar } from '../components/AppSidebar';
|
||
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
||
import { useAnnee } from '../contexts/AnneeContext';
|
||
import opexRaw from '../data_opex.json';
|
||
import opex2025Raw from '../data_opex_2025.json';
|
||
import { formatEuros } from '../lib/format';
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
AlertDialogTrigger,
|
||
} from '@/components/ui/alert-dialog';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from '@/components/ui/dialog';
|
||
import { toast } from 'sonner';
|
||
import { trpc } from '@/lib/trpc';
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
interface Poste {
|
||
col_idx: number;
|
||
fournisseur: string | null;
|
||
libelle: string;
|
||
detail: string | null;
|
||
facturation: string | null;
|
||
mode_ventilation: string | null;
|
||
categorie: string | null;
|
||
type: string | null;
|
||
compte: string | null;
|
||
budget_n1: number | null;
|
||
montant_previsionnel_2026?: number | null;
|
||
montant_previsionnel_2025?: number | null;
|
||
libelle_court?: string;
|
||
libelle_detail?: string;
|
||
}
|
||
|
||
interface EtabSource {
|
||
code: string;
|
||
nom: string;
|
||
base_repartition: number;
|
||
}
|
||
|
||
interface TendanceCategorie {
|
||
montant_n1: number | null;
|
||
montant_n: number;
|
||
variation_pct: number | null;
|
||
tendance: 'hausse' | 'baisse' | 'stable' | 'new';
|
||
}
|
||
|
||
interface OpexData {
|
||
annee: number;
|
||
total_global: number;
|
||
postes: Poste[];
|
||
etablissements: EtabSource[];
|
||
categories_totaux: Record<string, number>;
|
||
tendances_categories?: Record<string, TendanceCategorie>;
|
||
meta: { nb_postes: number; nb_etablissements?: number };
|
||
}
|
||
|
||
// Ligne en mémoire (mappée depuis la BDD)
|
||
interface LigneOpex {
|
||
id: number; // id BDD
|
||
libelle: string;
|
||
fournisseur: string;
|
||
categorie: string;
|
||
type: string;
|
||
facturation: string;
|
||
compte: string;
|
||
detail: string;
|
||
budget_n1: number;
|
||
montant: number;
|
||
isCustom: boolean;
|
||
mode_ventilation: string | null;
|
||
libelleCourt?: string | null;
|
||
libelleDetail?: string | null;
|
||
colIdx: number;
|
||
}
|
||
|
||
// ─── Constantes ───────────────────────────────────────────────────────────────
|
||
|
||
const opexData2026 = opexRaw as OpexData;
|
||
const opexData2025 = opex2025Raw as OpexData;
|
||
|
||
function getOpexDataForAnnee(annee: number): OpexData {
|
||
if (annee === 2025) return opexData2025;
|
||
return opexData2026;
|
||
}
|
||
|
||
const CATEGORIES_FIXES = ['App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR'];
|
||
|
||
const CATEGORIE_COLORS: Record<string, { badge: string; bar: string; kpi: string; icon: string }> = {
|
||
'App global': { badge: 'bg-blue-100 text-blue-700', bar: 'bg-blue-500', kpi: 'text-blue-600', icon: 'text-blue-500' },
|
||
'Infogérance': { badge: 'bg-purple-100 text-purple-700', bar: 'bg-purple-500', kpi: 'text-purple-600', icon: 'text-purple-500' },
|
||
'Sécurité': { badge: 'bg-red-100 text-red-700', bar: 'bg-red-500', kpi: 'text-red-600', icon: 'text-red-500' },
|
||
'Téléphonie': { badge: 'bg-green-100 text-green-700', bar: 'bg-green-500', kpi: 'text-green-600', icon: 'text-green-500' },
|
||
'App HEP': { badge: 'bg-orange-100 text-orange-700', bar: 'bg-orange-500', kpi: 'text-orange-600', icon: 'text-orange-500' },
|
||
'App SMR': { badge: 'bg-teal-100 text-teal-700', bar: 'bg-teal-500', kpi: 'text-teal-600', icon: 'text-teal-500' },
|
||
'Autre': { badge: 'bg-gray-100 text-gray-600', bar: 'bg-gray-400', kpi: 'text-gray-600', icon: 'text-gray-400' },
|
||
};
|
||
|
||
function getCatColors(cat: string) {
|
||
return CATEGORIE_COLORS[cat] ?? CATEGORIE_COLORS['Autre'];
|
||
}
|
||
|
||
function formatNum(v: number | null | undefined): string {
|
||
if (v === null || v === undefined || v === 0) return '—';
|
||
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
|
||
}
|
||
|
||
// Libellés parasites (noms de catégories ou de synthèse qui ne sont pas de vrais postes)
|
||
const LIBELLES_PARASITES = new Set([
|
||
'Ventilation cout', 'Ventilation coût',
|
||
'Infogérance', 'Sécurité', 'Téléphonie',
|
||
'App global', 'App HEP', 'App SMR',
|
||
'Applicatifs communs', 'Applicatifs PA', 'Applicatifs HEP', 'Applicatifs SMR',
|
||
'Nouveautés', 'TOTAL',
|
||
]);
|
||
|
||
function isLibelleParasite(libelle: string): boolean {
|
||
const lib = libelle.trim();
|
||
return LIBELLES_PARASITES.has(lib) || /^(total|ventilation|répartition)/i.test(lib);
|
||
}
|
||
|
||
// Construire les postes sources pour l'initialisation BDD
|
||
function buildPostesFromSource(annee: number) {
|
||
const src = getOpexDataForAnnee(annee);
|
||
return src.postes
|
||
.filter(p => !isLibelleParasite(p.libelle))
|
||
.map((p, idx) => ({
|
||
colIdx: p.col_idx ?? idx,
|
||
libelle: p.libelle,
|
||
libelleCourt: p.libelle_court ?? null,
|
||
libelleDetail: p.libelle_detail ?? null,
|
||
fournisseur: p.fournisseur ?? null,
|
||
categorie: p.categorie ?? 'Autre',
|
||
type: p.type ?? null,
|
||
facturation: p.facturation ?? null,
|
||
modeVentilation: p.mode_ventilation ?? null,
|
||
compte: p.compte ?? null,
|
||
detail: p.detail ?? null,
|
||
budgetN1: String(p.budget_n1 ?? 0),
|
||
montant: String((annee === 2025 ? p.montant_previsionnel_2025 : p.montant_previsionnel_2026) ?? 0),
|
||
isCustom: false,
|
||
}));
|
||
}
|
||
|
||
const EMPTY_LIGNE_FORM = {
|
||
libelle: '',
|
||
fournisseur: '',
|
||
categorie: 'App global',
|
||
type: '',
|
||
facturation: '',
|
||
compte: '',
|
||
detail: '',
|
||
budget_n1: 0,
|
||
montant: 0,
|
||
};
|
||
|
||
// ─── Composant champ numérique ─────────────────────────────────────────────
|
||
|
||
function MoneyInput({
|
||
value,
|
||
onChange,
|
||
disabled = false,
|
||
}: {
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
const [raw, setRaw] = useState(value === 0 ? '' : String(value));
|
||
|
||
useEffect(() => {
|
||
setRaw(value === 0 ? '' : String(value));
|
||
}, [value]);
|
||
|
||
return (
|
||
<div className="relative">
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={100}
|
||
value={raw}
|
||
placeholder="0"
|
||
disabled={disabled}
|
||
onChange={e => {
|
||
setRaw(e.target.value);
|
||
const v = parseFloat(e.target.value);
|
||
onChange(isNaN(v) || v < 0 ? 0 : Math.round(v));
|
||
}}
|
||
onBlur={() => setRaw(value === 0 ? '' : String(value))}
|
||
className={`w-full pl-6 pr-2 py-1.5 text-xs border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary tabular-nums transition-colors text-right ${
|
||
disabled
|
||
? 'bg-muted/30 text-muted-foreground cursor-not-allowed border-border/40'
|
||
: 'bg-background border-border hover:border-primary/50'
|
||
}`}
|
||
/>
|
||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground text-xs">€</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Formulaire ajout/édition ──────────────────────────────────────────────
|
||
|
||
type LigneFormData = typeof EMPTY_LIGNE_FORM;
|
||
|
||
function LigneForm({
|
||
initial,
|
||
onSave,
|
||
onClose,
|
||
}: {
|
||
initial: LigneFormData;
|
||
onSave: (data: LigneFormData) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [form, setForm] = useState(initial);
|
||
const set = (k: keyof typeof form, v: string | number) =>
|
||
setForm(prev => ({ ...prev, [k]: v }));
|
||
|
||
const allCats = [...CATEGORIES_FIXES, 'Autre'];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div className="sm:col-span-2">
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Libellé du poste *</label>
|
||
<input
|
||
type="text"
|
||
value={form.libelle}
|
||
onChange={e => set('libelle', e.target.value)}
|
||
placeholder="Ex : Maintenance serveurs"
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Fournisseur</label>
|
||
<input
|
||
type="text"
|
||
value={form.fournisseur}
|
||
onChange={e => set('fournisseur', e.target.value)}
|
||
placeholder="Ex : Microsoft"
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Catégorie *</label>
|
||
<select
|
||
value={form.categorie}
|
||
onChange={e => set('categorie', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background"
|
||
>
|
||
{allCats.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Type</label>
|
||
<input
|
||
type="text"
|
||
value={form.type}
|
||
onChange={e => set('type', e.target.value)}
|
||
placeholder="Ex : Licence, Maintenance…"
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Facturation</label>
|
||
<input
|
||
type="text"
|
||
value={form.facturation}
|
||
onChange={e => set('facturation', e.target.value)}
|
||
placeholder="Ex : Annuelle, Mensuelle…"
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Compte comptable</label>
|
||
<input
|
||
type="text"
|
||
value={form.compte}
|
||
onChange={e => set('compte', e.target.value)}
|
||
placeholder="Ex : 6156"
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Budget N-1 (€)</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={form.budget_n1 || ''}
|
||
onChange={e => set('budget_n1', parseFloat(e.target.value) || 0)}
|
||
placeholder="0"
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Montant prévisionnel (€) *</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={form.montant || ''}
|
||
onChange={e => set('montant', parseFloat(e.target.value) || 0)}
|
||
placeholder="0"
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||
/>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<label className="block text-xs font-medium text-muted-foreground mb-1">Détail / Description</label>
|
||
<textarea
|
||
value={form.detail}
|
||
onChange={e => set('detail', e.target.value)}
|
||
rows={2}
|
||
placeholder="Description complémentaire…"
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter className="gap-2">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted transition-colors text-muted-foreground"
|
||
>
|
||
Annuler
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (!form.libelle.trim()) { toast.error('Le libellé est obligatoire'); return; }
|
||
onSave(form);
|
||
}}
|
||
className="px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 font-medium transition-all shadow-sm"
|
||
>
|
||
Enregistrer
|
||
</button>
|
||
</DialogFooter>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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[];
|
||
isLoadingBases: boolean;
|
||
isErrorBases: boolean;
|
||
setBaseRepartition: ReturnType<typeof trpc.opex.setBaseRepartition.useMutation>;
|
||
importBasesRepartition: ReturnType<typeof trpc.opex.importBasesRepartition.useMutation>;
|
||
}
|
||
|
||
function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErrorBases, 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' });
|
||
|
||
// ── Sélection intelligente de la feuille ──────────────────────────────
|
||
// Priorité : feuille dont le nom contient OPEX, DSI, répartition ou base
|
||
// (insensible à la casse et aux accents). Sinon, première feuille.
|
||
const SHEET_KEYWORDS = ['opex', 'dsi', 'repartition', 'répartition', 'base'];
|
||
const selectedSheetName =
|
||
wb.SheetNames.find(name => {
|
||
const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||
return SHEET_KEYWORDS.some(kw => lower.includes(kw));
|
||
}) ?? wb.SheetNames[0];
|
||
|
||
const ws = wb.Sheets[selectedSheetName];
|
||
|
||
// Informer l'utilisateur de la feuille utilisée si le classeur en contient plusieurs
|
||
if (wb.SheetNames.length > 1) {
|
||
toast.info(`Feuille utilisée : « ${selectedSheetName} »`, {
|
||
description: `${wb.SheetNames.length} feuilles disponibles dans le classeur.`,
|
||
});
|
||
}
|
||
|
||
const data: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][];
|
||
|
||
// ── Détection de la ligne d'en-tête ──────────────────────────────────
|
||
// Cherche dans les 15 premières lignes une ligne contenant un code établissement
|
||
let headerIdx = -1;
|
||
for (let i = 0; i < Math.min(data.length, 15); i++) {
|
||
const row = data[i].map(c => String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''));
|
||
if (row.some(c =>
|
||
c.includes('code') ||
|
||
c.includes('etablissement') ||
|
||
c.includes('etab') ||
|
||
c.includes('structure')
|
||
)) {
|
||
headerIdx = i;
|
||
break;
|
||
}
|
||
}
|
||
if (headerIdx === -1) {
|
||
toast.error('Format non reconnu', {
|
||
description: `Impossible de trouver la ligne d'en-tête dans la feuille « ${selectedSheetName} ». Colonnes attendues : code établissement, base de répartition.`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// ── Détection des colonnes ────────────────────────────────────────────
|
||
const headers = data[headerIdx].map(c =>
|
||
String(c).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||
);
|
||
const codeIdx = headers.findIndex(h => h.includes('code'));
|
||
const nomIdx = headers.findIndex(h =>
|
||
h.includes('nom') || h.includes('etablissement') || h.includes('etab') || h.includes('libelle')
|
||
);
|
||
// Colonne base standard : contient 'base' ou 'repartition' mais PAS 'hep'
|
||
const baseIdx = headers.findIndex(h =>
|
||
(h.includes('base') || h.includes('repartition')) && !h.includes('hep')
|
||
);
|
||
// Colonne base HEP : contient '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. Colonnes détectées : ${headers.filter(Boolean).join(', ') || '(aucune)'}`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// ── Construction des lignes à importer ───────────────────────────────
|
||
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, '').replace(',', '.')) || 0,
|
||
baseRepartitionHep: hepIdx >= 0
|
||
? parseFloat(String(row[hepIdx]).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0
|
||
: 0,
|
||
}));
|
||
|
||
if (importRows.length === 0) {
|
||
toast.error('Aucune ligne importée', {
|
||
description: 'Le fichier ne contient aucune ligne de données valide après la ligne d\'en-tête.',
|
||
});
|
||
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) + ' €';
|
||
}
|
||
|
||
if (isLoadingBases) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-muted-foreground">
|
||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||
<p className="text-sm">Chargement des bases de répartition…</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isErrorBases) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||
<AlertCircle className="w-8 h-8 text-destructive" />
|
||
<p className="text-sm font-semibold text-destructive">Erreur lors du chargement des bases de répartition</p>
|
||
<p className="text-xs text-muted-foreground">Vérifiez votre connexion et rechargez la page.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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' | 'cles_repartition';
|
||
type SortDir = 'asc' | 'desc';
|
||
|
||
export default function DsiOpex() {
|
||
const { annee } = useAnnee();
|
||
const utils = trpc.useUtils();
|
||
|
||
// ── Requêtes tRPC ──────────────────────────────────────────────────────────
|
||
const { data: postesRaw, isLoading: loadingPostes } = trpc.opex.getPostes.useQuery({ annee });
|
||
const { data: montantsEtabRaw, isLoading: loadingMontants } = trpc.opex.getMontantsEtab.useQuery({ annee });
|
||
const { data: validatedRow } = trpc.opex.getValidated.useQuery({ annee });
|
||
// Bases de répartition depuis la BDD (charges classe 6 par établissement)
|
||
const { data: basesRepartitionRaw, isLoading: isLoadingBases, isError: isErrorBases } = trpc.opex.getBasesRepartition.useQuery({ annee });
|
||
|
||
// ── Mutations tRPC ─────────────────────────────────────────────────────────
|
||
const upsertPoste = trpc.opex.upsertPoste.useMutation({
|
||
onSuccess: () => utils.opex.getPostes.invalidate({ annee }),
|
||
onError: (err) => toast.error('Erreur sauvegarde', { description: err.message }),
|
||
});
|
||
const initFromSource = trpc.opex.initFromSource.useMutation({
|
||
onSuccess: () => { utils.opex.getPostes.invalidate({ annee }); toast.success(`OPEX ${annee} initialisé`); },
|
||
onError: (err) => toast.error('Erreur initialisation', { description: err.message }),
|
||
});
|
||
const deleteAll = trpc.opex.deleteAll.useMutation({
|
||
onSuccess: () => { utils.opex.getPostes.invalidate({ annee }); utils.opex.getMontantsEtab.invalidate({ annee }); toast.success(`OPEX ${annee} supprimé`); },
|
||
onError: (err) => toast.error('Erreur suppression', { description: err.message }),
|
||
});
|
||
const setMontantEtab = trpc.opex.setMontantEtab.useMutation({
|
||
onSuccess: () => utils.opex.getMontantsEtab.invalidate({ annee }),
|
||
onError: (err) => toast.error('Erreur sauvegarde montant', { description: err.message }),
|
||
});
|
||
const validateMutation = trpc.opex.validate.useMutation({
|
||
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(() => {
|
||
if (!postesRaw) return [];
|
||
return postesRaw
|
||
.filter(p => !isLibelleParasite(p.libelle))
|
||
.map(p => ({
|
||
id: p.id,
|
||
libelle: p.libelle,
|
||
fournisseur: p.fournisseur ?? '',
|
||
categorie: p.categorie ?? 'Autre',
|
||
type: p.type ?? '',
|
||
facturation: p.facturation ?? '',
|
||
compte: p.compte ?? '',
|
||
detail: p.detail ?? '',
|
||
budget_n1: parseFloat(p.budgetN1 ?? '0') || 0,
|
||
montant: parseFloat(p.montant ?? '0') || 0,
|
||
isCustom: p.isCustom ?? false,
|
||
mode_ventilation: p.modeVentilation ?? null,
|
||
libelleCourt: p.libelleCourt ?? null,
|
||
libelleDetail: p.libelleDetail ?? null,
|
||
colIdx: p.colIdx,
|
||
}));
|
||
}, [postesRaw]);
|
||
|
||
// Overrides par établissement : { codeEtab: { libellePoste: montant } }
|
||
const montantsManuelEtab = useMemo(() => {
|
||
if (!montantsEtabRaw) return {} as Record<string, Record<string, number>>;
|
||
const result: Record<string, Record<string, number>> = {};
|
||
for (const row of montantsEtabRaw) {
|
||
if (!result[row.etablissementCode]) result[row.etablissementCode] = {};
|
||
result[row.etablissementCode][row.libellePoste] = parseFloat(row.montant ?? '0') || 0;
|
||
}
|
||
return result;
|
||
}, [montantsEtabRaw]);
|
||
|
||
const isValidated = !!validatedRow;
|
||
const isLoading = loadingPostes || loadingMontants;
|
||
|
||
// ── UI state ───────────────────────────────────────────────────────────────
|
||
const [viewMode, setViewMode] = useState<ViewMode>('postes');
|
||
const [search, setSearch] = useState('');
|
||
const [selectedCategorie, setSelectedCategorie] = useState('Toutes');
|
||
const [sortCol, setSortCol] = useState<string | null>(null);
|
||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||
const [expandedPoste, setExpandedPoste] = useState<number | null>(null);
|
||
const [showDetails, setShowDetails] = useState(false);
|
||
|
||
// CRUD
|
||
const [dialogMode, setDialogMode] = useState<'add' | 'edit' | null>(null);
|
||
const [editingLigne, setEditingLigne] = useState<LigneOpex | null>(null);
|
||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
|
||
|
||
// Édition inline établissements
|
||
const [inlineEditKey, setInlineEditKey] = useState<string | null>(null);
|
||
const [inlineEditVal, setInlineEditVal] = useState<string>('');
|
||
|
||
// Réinitialiser la recherche quand l'année change
|
||
useEffect(() => {
|
||
setSearch('');
|
||
setExpandedPoste(null);
|
||
setSelectedCategorie('Toutes');
|
||
}, [annee]);
|
||
|
||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||
|
||
const handleToggleVentilation = useCallback((ligne: LigneOpex) => {
|
||
if (isValidated) return;
|
||
const newMode = ligne.mode_ventilation === 'Manuel' ? 'Prorata C 6' : 'Manuel';
|
||
upsertPoste.mutate({
|
||
id: ligne.id,
|
||
annee,
|
||
colIdx: ligne.colIdx,
|
||
libelle: ligne.libelle,
|
||
libelleCourt: ligne.libelleCourt,
|
||
libelleDetail: ligne.libelleDetail,
|
||
fournisseur: ligne.fournisseur || null,
|
||
categorie: ligne.categorie,
|
||
type: ligne.type || null,
|
||
facturation: ligne.facturation || null,
|
||
modeVentilation: newMode,
|
||
compte: ligne.compte || null,
|
||
detail: ligne.detail || null,
|
||
budgetN1: String(ligne.budget_n1),
|
||
montant: String(ligne.montant),
|
||
isCustom: ligne.isCustom,
|
||
});
|
||
}, [isValidated, upsertPoste, annee]);
|
||
|
||
const handleMontantChange = useCallback((ligne: LigneOpex, value: number) => {
|
||
if (isValidated) return;
|
||
upsertPoste.mutate({
|
||
id: ligne.id,
|
||
annee,
|
||
colIdx: ligne.colIdx,
|
||
libelle: ligne.libelle,
|
||
libelleCourt: ligne.libelleCourt,
|
||
libelleDetail: ligne.libelleDetail,
|
||
fournisseur: ligne.fournisseur || null,
|
||
categorie: ligne.categorie,
|
||
type: ligne.type || null,
|
||
facturation: ligne.facturation || null,
|
||
modeVentilation: ligne.mode_ventilation,
|
||
compte: ligne.compte || null,
|
||
detail: ligne.detail || null,
|
||
budgetN1: String(ligne.budget_n1),
|
||
montant: String(value),
|
||
isCustom: ligne.isCustom,
|
||
});
|
||
}, [isValidated, upsertPoste, annee]);
|
||
|
||
const handleAddLigne = useCallback((data: LigneFormData) => {
|
||
const maxColIdx = lignes.length > 0 ? Math.max(...lignes.map(l => l.colIdx)) + 1 : 0;
|
||
upsertPoste.mutate({
|
||
annee,
|
||
colIdx: maxColIdx,
|
||
libelle: data.libelle,
|
||
fournisseur: data.fournisseur || null,
|
||
categorie: data.categorie,
|
||
type: data.type || null,
|
||
facturation: data.facturation || null,
|
||
modeVentilation: 'Manuel',
|
||
compte: data.compte || null,
|
||
detail: data.detail || null,
|
||
budgetN1: String(data.budget_n1),
|
||
montant: String(data.montant),
|
||
isCustom: true,
|
||
}, {
|
||
onSuccess: () => { setDialogMode(null); toast.success('Ligne ajoutée'); },
|
||
});
|
||
}, [lignes, upsertPoste, annee]);
|
||
|
||
const handleEditLigne = useCallback((data: LigneFormData) => {
|
||
if (!editingLigne) return;
|
||
upsertPoste.mutate({
|
||
id: editingLigne.id,
|
||
annee,
|
||
colIdx: editingLigne.colIdx,
|
||
libelle: data.libelle,
|
||
libelleCourt: editingLigne.libelleCourt,
|
||
libelleDetail: editingLigne.libelleDetail,
|
||
fournisseur: data.fournisseur || null,
|
||
categorie: data.categorie,
|
||
type: data.type || null,
|
||
facturation: data.facturation || null,
|
||
modeVentilation: editingLigne.mode_ventilation,
|
||
compte: data.compte || null,
|
||
detail: data.detail || null,
|
||
budgetN1: String(data.budget_n1),
|
||
montant: String(data.montant),
|
||
isCustom: editingLigne.isCustom,
|
||
}, {
|
||
onSuccess: () => { setDialogMode(null); setEditingLigne(null); toast.success('Ligne modifiée'); },
|
||
});
|
||
}, [editingLigne, upsertPoste, annee]);
|
||
|
||
const handleDeleteLigne = useCallback((id: number) => {
|
||
// On ne peut pas vraiment "supprimer" via upsert — on utilise deleteAll + réinsertion
|
||
// Pour simplifier, on marque le poste avec montant 0 et libelle préfixé [SUPPRIMÉ]
|
||
// Mais la meilleure approche est d'ajouter une procédure deletePoste
|
||
// Pour l'instant, on utilise une mutation directe via deleteAll n'est pas approprié
|
||
// On va ajouter une procédure deletePoste dans le routeur
|
||
toast.error('Suppression non disponible — rechargez la page après avoir ajouté la procédure deletePoste');
|
||
setDeleteConfirmId(null);
|
||
}, []);
|
||
|
||
const handleInlineEditStart = useCallback((codeEtab: string, libelle: string, currentVal: number) => {
|
||
if (isValidated) return;
|
||
setInlineEditKey(`${codeEtab}|${libelle}`);
|
||
setInlineEditVal(currentVal === 0 ? '' : String(currentVal));
|
||
}, [isValidated]);
|
||
|
||
const handleInlineEditCommit = useCallback((codeEtab: string, libelle: string) => {
|
||
const v = parseFloat(inlineEditVal);
|
||
const montant = isNaN(v) || v < 0 ? 0 : Math.round(v);
|
||
setMontantEtab.mutate({
|
||
annee,
|
||
etablissementCode: codeEtab,
|
||
libellePoste: libelle,
|
||
montant: String(montant),
|
||
});
|
||
setInlineEditKey(null);
|
||
}, [inlineEditVal, setMontantEtab, annee]);
|
||
|
||
const handleInlineEditCancel = useCallback(() => {
|
||
setInlineEditKey(null);
|
||
}, []);
|
||
|
||
const handleInitFromSource = useCallback(() => {
|
||
const postes = buildPostesFromSource(annee);
|
||
initFromSource.mutate({ annee, postes });
|
||
}, [annee, initFromSource]);
|
||
|
||
const handleCreateFromN1 = useCallback(async () => {
|
||
const anneeN1 = annee - 1;
|
||
// On récupère les postes N-1 depuis la BDD via une requête directe
|
||
// Pour l'instant, on utilise les données JSON si disponibles
|
||
const srcN1 = anneeN1 === 2025 || anneeN1 === 2026 ? buildPostesFromSource(anneeN1) : null;
|
||
if (!srcN1 || srcN1.length === 0) {
|
||
toast.error(`Aucune donnée disponible pour ${anneeN1}`);
|
||
return;
|
||
}
|
||
const postes = srcN1.map((p, idx) => ({
|
||
...p,
|
||
colIdx: idx,
|
||
budgetN1: p.montant, // budget N-1 = montant de l'année précédente
|
||
}));
|
||
initFromSource.mutate({ annee, postes }, {
|
||
onSuccess: () => toast.success(`OPEX ${annee} créé depuis ${anneeN1}`, { description: `${postes.length} postes copiés.` }),
|
||
});
|
||
}, [annee, initFromSource]);
|
||
|
||
// ── Calculs ────────────────────────────────────────────────────────────────
|
||
|
||
const categoriesPresentes = useMemo(() => {
|
||
const cats = Array.from(new Set(lignes.map(l => l.categorie)));
|
||
return CATEGORIES_FIXES.filter(c => cats.includes(c)).concat(
|
||
cats.filter(c => !CATEGORIES_FIXES.includes(c) && c !== 'Autre')
|
||
).concat(cats.includes('Autre') ? ['Autre'] : []);
|
||
}, [lignes]);
|
||
|
||
const CATEGORIES_FILTER = useMemo(() => ['Toutes', ...categoriesPresentes], [categoriesPresentes]);
|
||
|
||
const totalGlobal = useMemo(() =>
|
||
lignes.reduce((s, l) => s + l.montant, 0),
|
||
[lignes]);
|
||
|
||
const totalParCategorie = useMemo(() => {
|
||
const totaux: Record<string, number> = {};
|
||
for (const ligne of lignes) {
|
||
const cat = ligne.categorie || 'Autre';
|
||
totaux[cat] = (totaux[cat] || 0) + ligne.montant;
|
||
}
|
||
return totaux;
|
||
}, [lignes]);
|
||
|
||
const filteredLignes = useMemo(() => {
|
||
return lignes.filter(l => {
|
||
if (selectedCategorie !== 'Toutes' && l.categorie !== selectedCategorie) return false;
|
||
if (search && !l.libelle.toLowerCase().includes(search.toLowerCase()) &&
|
||
!l.fournisseur.toLowerCase().includes(search.toLowerCase())) return false;
|
||
return true;
|
||
});
|
||
}, [lignes, selectedCategorie, search]);
|
||
|
||
const totalFiltre = useMemo(() =>
|
||
filteredLignes.reduce((s, l) => s + l.montant, 0),
|
||
[filteredLignes]);
|
||
|
||
const sortedLignes = useMemo(() => {
|
||
const arr = [...filteredLignes];
|
||
if (sortCol === 'montant') {
|
||
arr.sort((a, b) => sortDir === 'asc' ? a.montant - b.montant : b.montant - a.montant);
|
||
} else if (sortCol === 'libelle') {
|
||
arr.sort((a, b) => sortDir === 'asc'
|
||
? a.libelle.localeCompare(b.libelle)
|
||
: b.libelle.localeCompare(a.libelle));
|
||
}
|
||
return arr;
|
||
}, [filteredLignes, sortCol, sortDir]);
|
||
|
||
const handleSort = (col: string) => {
|
||
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||
else { setSortCol(col); setSortDir('desc'); }
|
||
};
|
||
|
||
const SortBtn = ({ col, label }: { col: string; label: string }) => (
|
||
<button
|
||
onClick={() => handleSort(col)}
|
||
className="flex items-center gap-1 hover:text-foreground transition-colors group"
|
||
>
|
||
{label}
|
||
<ArrowUpDown className={`w-3 h-3 ${sortCol === col ? 'text-primary' : 'text-muted-foreground/50 group-hover:text-muted-foreground'}`} />
|
||
</button>
|
||
);
|
||
|
||
// Vue établissements : recalcul dynamique depuis les postes éditables
|
||
// Les bases de répartition viennent de la BDD (table opex_bases_repartition)
|
||
// Fallback sur le JSON source si la BDD n'a pas de données pour cette année
|
||
const etablissementsRecalcules = useMemo(() => {
|
||
// Priorité : BDD > JSON source
|
||
const basesFromBdd = basesRepartitionRaw && basesRepartitionRaw.length > 0
|
||
? basesRepartitionRaw.map(b => ({
|
||
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 * ratioApplique);
|
||
} else {
|
||
montant = 0;
|
||
}
|
||
if (montant > 0) montantsRecalcules[ligne.libelle] = montant;
|
||
totalEtab += montant;
|
||
}
|
||
|
||
return { ...etab, montants: montantsRecalcules, total: totalEtab };
|
||
});
|
||
}, [lignes, montantsManuelEtab, annee, basesRepartitionRaw]);
|
||
|
||
const filteredEtabs = useMemo(() => {
|
||
return etablissementsRecalcules.filter(e => {
|
||
if (search && !e.nom.toLowerCase().includes(search.toLowerCase()) &&
|
||
!e.code.toLowerCase().includes(search.toLowerCase())) return false;
|
||
return true;
|
||
});
|
||
}, [etablissementsRecalcules, search]);
|
||
|
||
const sortedEtabs = useMemo(() => {
|
||
const arr = [...filteredEtabs];
|
||
if (sortCol === 'total') arr.sort((a, b) => sortDir === 'asc' ? a.total - b.total : b.total - a.total);
|
||
else if (sortCol === 'nom') arr.sort((a, b) => sortDir === 'asc' ? a.nom.localeCompare(b.nom) : b.nom.localeCompare(a.nom));
|
||
return arr;
|
||
}, [filteredEtabs, sortCol, sortDir]);
|
||
|
||
// ── Rendu ──────────────────────────────────────────────────────────────────
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="min-h-screen flex bg-background">
|
||
<AppSidebar />
|
||
<main className="flex-1 flex items-center justify-center">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||
<p className="text-sm text-muted-foreground">Chargement des données OPEX…</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen flex bg-background">
|
||
<AppSidebar />
|
||
|
||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||
{/* En-tête */}
|
||
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||
<div>
|
||
<div className="flex items-center gap-3">
|
||
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||
OPEX DSI — Charges {annee}
|
||
</h1>
|
||
{isValidated && (
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700 border border-emerald-200">
|
||
<Lock className="w-3 h-3" />
|
||
Validé le {validatedRow?.validatedAt ? new Date(validatedRow.validatedAt).toLocaleDateString('fr-FR') : ''}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground mt-0.5">
|
||
{lignes.length} postes de charges · {getOpexDataForAnnee(annee).meta.nb_etablissements ?? opexData2026.meta.nb_etablissements} établissements
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<AnneeSelectorBar />
|
||
{!isValidated && lignes.length > 0 && (
|
||
<>
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<button
|
||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-destructive/40 bg-card hover:bg-destructive/10 transition-colors text-destructive"
|
||
title="Supprimer ce prévisionnel"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
Supprimer
|
||
</button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Supprimer l'OPEX {annee} ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Toutes les données du prévisionnel OPEX {annee} seront <strong>définitivement supprimées</strong>. Cette action ne peut pas être annulée.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={() => deleteAll.mutate({ annee })}
|
||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||
>
|
||
Supprimer définitivement
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<button className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg font-medium bg-emerald-600 text-white hover:bg-emerald-700 transition-all shadow-sm">
|
||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||
Valider
|
||
</button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Valider l'OPEX {annee} ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Cette action est <strong>irréversible</strong>. Une fois validé, le prévisionnel OPEX {annee} sera verrouillé et ne pourra plus être modifié.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction onClick={() => validateMutation.mutate({ annee })} className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||
Confirmer la validation
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
)}
|
||
{isValidated && (
|
||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-700 text-sm">
|
||
<Lock className="w-4 h-4" />
|
||
<span className="font-medium">Prévisionnel verrouillé</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* KPIs */}
|
||
{lignes.length > 0 && (
|
||
<div className="px-6 py-4 border-b border-border bg-muted/20 flex-shrink-0">
|
||
<div className="flex flex-wrap gap-3">
|
||
<div className="bg-card border border-border rounded-xl p-4 min-w-[160px] flex-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Euro className="w-4 h-4 text-orange-500" />
|
||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Total OPEX {annee}</span>
|
||
</div>
|
||
<p className="text-2xl font-bold text-orange-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||
{formatEuros(totalGlobal)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">{isValidated ? 'Validé' : 'Prévisionnel'}</p>
|
||
</div>
|
||
{categoriesPresentes.map(cat => {
|
||
const montant = totalParCategorie[cat] || 0;
|
||
const pct = totalGlobal > 0 ? (montant / totalGlobal * 100).toFixed(1) : '0';
|
||
const colors = getCatColors(cat);
|
||
const srcData = getOpexDataForAnnee(annee);
|
||
const tendanceInfo: TendanceCategorie | undefined = srcData.tendances_categories?.[cat];
|
||
const tendance = tendanceInfo?.tendance;
|
||
const variationPct = tendanceInfo?.variation_pct;
|
||
const montantN1 = tendanceInfo?.montant_n1;
|
||
return (
|
||
<div key={cat} className="bg-card border border-border rounded-xl p-4 min-w-[140px] flex-1">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<div className="flex items-center gap-2">
|
||
<BarChart3 className={`w-4 h-4 ${colors.icon}`} />
|
||
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium truncate">{cat}</span>
|
||
</div>
|
||
{tendance && tendance !== 'stable' && tendance !== 'new' && variationPct !== null && variationPct !== undefined && (
|
||
<div className={`flex items-center gap-0.5 text-xs font-semibold px-1.5 py-0.5 rounded-full ${
|
||
tendance === 'hausse' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||
}`}>
|
||
{tendance === 'hausse' ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||
<span>{variationPct > 0 ? '+' : ''}{variationPct}%</span>
|
||
</div>
|
||
)}
|
||
{tendance === 'stable' && (
|
||
<div className="flex items-center gap-0.5 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-600">
|
||
<span>≈ stable</span>
|
||
</div>
|
||
)}
|
||
{tendance === 'new' && (
|
||
<div className="flex items-center gap-0.5 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-600">
|
||
<span>Nouveau</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className={`text-xl font-bold tabular-nums ${colors.kpi}`} style={{ fontFamily: 'Sora, sans-serif' }}>
|
||
{formatEuros(montant)}
|
||
</p>
|
||
<div className="flex items-center justify-between mt-0.5">
|
||
<p className="text-xs text-muted-foreground">{pct}% du total</p>
|
||
{montantN1 !== null && montantN1 !== undefined && montantN1 > 0 && (
|
||
<p className="text-xs text-muted-foreground">N-1 : {formatEuros(montantN1)}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Barre d'outils */}
|
||
{lignes.length > 0 && (
|
||
<div className="px-6 py-3 border-b border-border bg-background flex-shrink-0 flex flex-wrap items-center gap-3">
|
||
<div className="relative flex-1 min-w-48 max-w-72">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<input
|
||
type="text"
|
||
placeholder={viewMode === 'postes' ? 'Rechercher un poste...' : 'Rechercher un établissement...'}
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||
/>
|
||
</div>
|
||
|
||
{viewMode === 'postes' && (
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<Filter className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||
{CATEGORIES_FILTER.map(cat => (
|
||
<button
|
||
key={cat}
|
||
onClick={() => setSelectedCategorie(cat)}
|
||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-all ${
|
||
selectedCategorie === cat
|
||
? 'bg-primary text-white'
|
||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||
}`}
|
||
>
|
||
{cat}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{viewMode === 'postes' && !isValidated && (
|
||
<button
|
||
onClick={() => { setDialogMode('add'); setEditingLigne(null); }}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-all shadow-sm"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Ajouter une ligne
|
||
</button>
|
||
)}
|
||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||
<button
|
||
onClick={() => { setViewMode('postes'); setSortCol(null); }}
|
||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||
viewMode === 'postes' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
<BarChart3 className="w-3.5 h-3.5" />
|
||
Par poste
|
||
</button>
|
||
<button
|
||
onClick={() => { setViewMode('etablissements'); setSortCol(null); }}
|
||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||
viewMode === 'etablissements' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
<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)}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||
>
|
||
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||
{showDetails ? 'Masquer détails' : 'Voir détails'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Contenu principal */}
|
||
<div className="flex-1 min-h-0 px-6 py-4 overflow-y-hidden">
|
||
|
||
{/* État vide */}
|
||
{lignes.length === 0 && (
|
||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||
<BarChart3 className="w-8 h-8 text-muted-foreground" />
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-foreground mb-1" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||
Aucune donnée pour {annee}
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground mb-6 max-w-sm">
|
||
L'OPEX {annee} n'a pas encore été créé. Vous pouvez le créer en copiant les charges de {annee - 1} ou en ajoutant les lignes manuellement.
|
||
</p>
|
||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||
{(annee === 2025 || annee === 2026) && (
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<button className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 transition-all shadow-sm">
|
||
<RotateCcw className="w-4 h-4" />
|
||
Initialiser depuis les données sources {annee}
|
||
</button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Initialiser l'OPEX {annee} ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Les données sources {annee} seront chargées en base de données. Vous pourrez ensuite les modifier.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleInitFromSource} className="bg-primary hover:bg-primary/90">
|
||
Confirmer
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
)}
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<button className="flex items-center gap-2 px-5 py-2.5 rounded-lg border border-border bg-card font-medium text-sm hover:bg-muted transition-colors">
|
||
<Plus className="w-4 h-4" />
|
||
Créer depuis {annee - 1}
|
||
</button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Créer l'OPEX {annee} depuis {annee - 1} ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Tous les postes de charges de {annee - 1} seront copiés comme base de travail pour {annee}.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleCreateFromN1} className="bg-primary hover:bg-primary/90">
|
||
Confirmer
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
<button
|
||
onClick={() => { setDialogMode('add'); setEditingLigne(null); }}
|
||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg border border-border bg-card font-medium text-sm hover:bg-muted transition-colors"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Ajouter une ligne manuellement
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Vue par poste */}
|
||
{viewMode === 'postes' && lignes.length > 0 && (
|
||
<div className="space-y-2">
|
||
{selectedCategorie !== 'Toutes' && (
|
||
<div className="flex items-center justify-between mb-3 px-1">
|
||
<span className="text-sm text-muted-foreground">
|
||
{sortedLignes.length} poste{sortedLignes.length > 1 ? 's' : ''} — catégorie <strong>{selectedCategorie}</strong>
|
||
</span>
|
||
<span className="text-sm font-semibold text-foreground">
|
||
Sous-total : <span className="text-primary">{formatEuros(totalFiltre)}</span>
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{!isValidated && (
|
||
<div className="flex items-center gap-2 mb-3 px-1 py-2 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-xs">
|
||
<PencilLine className="w-3.5 h-3.5 flex-shrink-0" />
|
||
<span>Les montants sont modifiables. Chaque modification est sauvegardée automatiquement en base de données.</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-card border border-border rounded-xl" style={{ maxHeight: 'calc(100vh - 380px)', overflowY: 'auto', overflowX: 'auto' }}>
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border" style={{ position: 'sticky', top: 0, zIndex: 20 }}>
|
||
<th className="text-left px-4 py-3 font-medium text-muted-foreground w-8" style={{ background: 'oklch(0.94 0.005 80)' }}>#</th>
|
||
<th className="text-left px-4 py-3 font-medium text-muted-foreground" style={{ background: 'oklch(0.94 0.005 80)' }}>
|
||
<SortBtn col="libelle" label="Poste de charge" />
|
||
</th>
|
||
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden md:table-cell" style={{ background: 'oklch(0.94 0.005 80)' }}>Catégorie</th>
|
||
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden xl:table-cell" style={{ background: 'oklch(0.94 0.005 80)' }}>Facturation</th>
|
||
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden xl:table-cell" style={{ background: 'oklch(0.94 0.005 80)' }}>Ventilation</th>
|
||
{showDetails && <th className="text-left px-3 py-3 font-medium text-muted-foreground hidden lg:table-cell w-20 max-w-[80px]" style={{ background: 'oklch(0.94 0.005 80)' }}>Type</th>}
|
||
{showDetails && <th className="text-left px-4 py-3 font-medium text-muted-foreground hidden xl:table-cell" style={{ background: 'oklch(0.94 0.005 80)' }}>Compte</th>}
|
||
<th className="text-right px-4 py-3 font-medium text-muted-foreground" style={{ background: 'oklch(0.94 0.005 80)' }}>Budget N-1</th>
|
||
<th className="text-right px-4 py-3 font-medium text-muted-foreground" style={{ minWidth: '140px', background: 'oklch(0.94 0.005 80)' }}>
|
||
<SortBtn col="montant" label={`Prév. ${annee}`} />
|
||
</th>
|
||
<th className="text-center px-3 py-3 font-medium text-muted-foreground w-24" style={{ background: 'oklch(0.94 0.005 80)' }}>Tendance</th>
|
||
<th className="text-right px-4 py-3 font-medium text-muted-foreground w-24" style={{ background: 'oklch(0.94 0.005 80)' }}>Répartition</th>
|
||
{!isValidated && <th className="px-4 py-3 w-20" style={{ background: 'oklch(0.94 0.005 80)' }}></th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sortedLignes.map((ligne, idx) => {
|
||
const pct = totalGlobal > 0 ? (ligne.montant / totalGlobal) * 100 : 0;
|
||
const isExpanded = expandedPoste === ligne.id;
|
||
const catColors = getCatColors(ligne.categorie);
|
||
|
||
return (
|
||
<React.Fragment key={ligne.id}>
|
||
<tr className={`border-b border-border/50 hover:bg-muted/30 transition-colors ${isExpanded ? 'bg-muted/20' : ''} ${ligne.isCustom ? 'bg-blue-50/30' : ''}`}>
|
||
<td className="px-4 py-3 text-muted-foreground text-xs">{idx + 1}</td>
|
||
<td
|
||
className="px-4 py-3 cursor-pointer"
|
||
onClick={() => setExpandedPoste(isExpanded ? null : ligne.id)}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
{isExpanded ? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" /> : <ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />}
|
||
<div>
|
||
<p className="font-medium text-foreground leading-tight">{ligne.libelle}</p>
|
||
{ligne.fournisseur && ligne.fournisseur !== ligne.libelle && (
|
||
<p className="text-xs text-muted-foreground mt-0.5">{ligne.fournisseur}</p>
|
||
)}
|
||
{ligne.isCustom && (
|
||
<span className="text-xs text-blue-500 font-medium">Ajouté manuellement</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 hidden md:table-cell">
|
||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${catColors.badge}`}>
|
||
{ligne.categorie}
|
||
</span>
|
||
</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-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 === 'Manuel' ? 'Manuel' : 'Prorata C 6'}</span>
|
||
) : (
|
||
<button
|
||
onClick={() => handleToggleVentilation(ligne)}
|
||
title="Cliquer pour basculer Prorata C 6 ↔ 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 === 'Manuel' ? 'Manuel' : 'Prorata C 6'}
|
||
</button>
|
||
)}
|
||
</td>
|
||
{showDetails && <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>}
|
||
{showDetails && <td className="px-4 py-3 text-xs font-mono text-muted-foreground hidden xl:table-cell">{ligne.compte || '—'}</td>}
|
||
<td className="px-4 py-3 text-right text-sm text-muted-foreground tabular-nums">
|
||
{formatNum(ligne.budget_n1)}
|
||
</td>
|
||
<td className="px-4 py-2 text-right" style={{ minWidth: '140px' }}>
|
||
{isValidated ? (
|
||
<span className={`font-semibold tabular-nums ${ligne.montant > 0 ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||
{formatNum(ligne.montant)}
|
||
</span>
|
||
) : (
|
||
<MoneyInput
|
||
value={ligne.montant}
|
||
onChange={v => handleMontantChange(ligne, v)}
|
||
disabled={isValidated}
|
||
/>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-3 text-center">
|
||
{(() => {
|
||
const n1 = ligne.budget_n1;
|
||
const n = ligne.montant;
|
||
if (!n1 || n1 === 0) {
|
||
return n > 0
|
||
? <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-600">Nouveau</span>
|
||
: <span className="text-muted-foreground text-xs">—</span>;
|
||
}
|
||
const diff = n - n1;
|
||
const pctVar = (diff / n1) * 100;
|
||
if (Math.abs(pctVar) < 2) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-orange-100 text-orange-600">
|
||
<span>≈ stable</span>
|
||
</span>
|
||
);
|
||
} else if (diff > 0) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
|
||
<TrendingUp className="w-3 h-3" />
|
||
<span>+{pctVar.toFixed(1)}%</span>
|
||
</span>
|
||
);
|
||
} else {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-600">
|
||
<TrendingDown className="w-3 h-3" />
|
||
<span>{pctVar.toFixed(1)}%</span>
|
||
</span>
|
||
);
|
||
}
|
||
})()}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2 justify-end">
|
||
<div className="w-16 bg-muted rounded-full h-1.5 overflow-hidden">
|
||
<div
|
||
className={`h-1.5 rounded-full transition-all ${catColors.bar}`}
|
||
style={{ width: `${Math.min(100, pct * 5)}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground tabular-nums w-10 text-right">
|
||
{pct.toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
</td>
|
||
{!isValidated && (
|
||
<td className="px-2 py-2">
|
||
<div className="flex items-center gap-1 justify-end">
|
||
<button
|
||
onClick={() => { setEditingLigne(ligne); setDialogMode('edit'); }}
|
||
className="p-1.5 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||
title="Modifier"
|
||
>
|
||
<Pencil className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => setDeleteConfirmId(ligne.id)}
|
||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||
title="Supprimer"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
{isExpanded && ligne.detail && (
|
||
<tr key={`${ligne.id}-detail`} className="bg-blue-50/50 border-b border-border/50">
|
||
<td />
|
||
<td colSpan={!isValidated ? 9 : 8} className="px-8 py-3">
|
||
<div className="flex items-start gap-2 text-sm text-blue-700">
|
||
<Info className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||
<div>
|
||
<p className="font-medium">Détail</p>
|
||
<p className="text-blue-600 mt-0.5">{ligne.detail}</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="bg-muted/40 border-t-2 border-border">
|
||
<td colSpan={showDetails ? (isValidated ? 9 : 10) : (isValidated ? 6 : 7)} className="px-4 py-3 font-bold text-foreground">
|
||
TOTAL {selectedCategorie !== 'Toutes' ? selectedCategorie : 'OPEX DSI'} {annee}
|
||
</td>
|
||
<td className="px-4 py-3 text-right font-bold text-orange-600 text-base tabular-nums">
|
||
{formatEuros(totalFiltre)}
|
||
</td>
|
||
<td className="px-4 py-3 text-right text-sm text-muted-foreground">
|
||
{selectedCategorie !== 'Toutes'
|
||
? `${totalGlobal > 0 ? ((totalFiltre / totalGlobal) * 100).toFixed(1) : '0'}%`
|
||
: '100%'}
|
||
</td>
|
||
{!isValidated && <td />}
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Vue par établissement */}
|
||
{viewMode === 'etablissements' && lignes.length > 0 && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-4 px-1 text-xs text-muted-foreground">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="inline-block w-3 h-3 rounded border-2 border-emerald-500 bg-emerald-50"></span>
|
||
<span>Calcul automatique (prorata classe 6)</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="inline-block w-3 h-3 rounded border-2 border-blue-500 bg-blue-50"></span>
|
||
<span>Saisie manuelle</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||
<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, 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>
|
||
{lignes.map(ligne => {
|
||
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||
return (
|
||
<th
|
||
key={ligne.id}
|
||
style={{
|
||
position: 'sticky',
|
||
top: 0,
|
||
zIndex: 35,
|
||
background: isManuel ? 'rgb(239 246 255)' : 'rgb(240 253 244)',
|
||
borderBottom: isManuel ? '2px solid rgb(96 165 250)' : '2px solid rgb(52 211 153)'
|
||
}}
|
||
className={`text-right px-2 py-2 font-medium min-w-[90px] max-w-[110px] ${
|
||
isManuel ? 'text-blue-700' : 'text-emerald-700'
|
||
}`}
|
||
title={`${ligne.libelle} — ${isManuel ? 'Saisie manuelle' : 'Prorata C 6'} — ${formatEuros(ligne.montant)}`}
|
||
>
|
||
<span className="block font-semibold text-[11px] leading-tight">
|
||
{ligne.libelleCourt ?? ligne.libelle.split(' ')[0]}
|
||
</span>
|
||
{ligne.libelleDetail && (
|
||
<span className="block text-[10px] font-normal opacity-80 leading-tight mt-0.5">
|
||
{ligne.libelleDetail}
|
||
</span>
|
||
)}
|
||
<span className="block text-[10px] font-normal opacity-60 mt-0.5">{formatEuros(ligne.montant)}</span>
|
||
</th>
|
||
);
|
||
})}
|
||
<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>
|
||
</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' }}>
|
||
<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>
|
||
{lignes.map(ligne => {
|
||
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||
const montant = etab.montants[ligne.libelle] ?? 0;
|
||
const cellKey = `${etab.code}|${ligne.libelle}`;
|
||
const isEditing = inlineEditKey === cellKey;
|
||
const hasOverride = montantsManuelEtab[etab.code]?.[ligne.libelle] !== undefined;
|
||
|
||
if (isManuel && isEditing) {
|
||
return (
|
||
<td key={ligne.id} className="px-1 py-1 border border-blue-400 bg-blue-50 min-w-[90px]">
|
||
<input
|
||
autoFocus
|
||
type="number"
|
||
min={0}
|
||
step={1}
|
||
value={inlineEditVal}
|
||
placeholder="0"
|
||
onChange={e => setInlineEditVal(e.target.value)}
|
||
onBlur={() => handleInlineEditCommit(etab.code, ligne.libelle)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') handleInlineEditCommit(etab.code, ligne.libelle);
|
||
if (e.key === 'Escape') handleInlineEditCancel();
|
||
}}
|
||
className="w-full text-right text-xs tabular-nums px-1 py-0.5 border-0 bg-transparent focus:outline-none text-blue-800 font-medium"
|
||
style={{ minWidth: '70px' }}
|
||
/>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<td
|
||
key={ligne.id}
|
||
onClick={() => isManuel && !isValidated && handleInlineEditStart(etab.code, ligne.libelle, montant)}
|
||
className={`px-2 py-2 text-right tabular-nums transition-colors ${
|
||
isManuel
|
||
? `border border-blue-200 bg-blue-50/30 text-blue-800 ${
|
||
!isValidated ? 'cursor-pointer hover:bg-blue-100/60 hover:border-blue-400' : ''
|
||
} ${hasOverride ? 'font-semibold' : ''}`
|
||
: 'border border-emerald-200 bg-emerald-50/20 text-emerald-800'
|
||
}`}
|
||
title={isManuel && !isValidated ? 'Cliquer pour saisir le montant' : undefined}
|
||
>
|
||
{montant > 0
|
||
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(montant) + ' €'
|
||
: isManuel && !isValidated
|
||
? <span className="text-blue-300 text-[10px]">cliquer</span>
|
||
: <span className="text-muted-foreground">—</span>}
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="px-3 py-2 text-right font-semibold tabular-nums" style={{ position: 'sticky', right: 0, zIndex: 20, background: idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)', boxShadow: '-3px 0 8px -2px rgba(0,0,0,0.18)', width: '120px', minWidth: '120px' }}>
|
||
<span className={etab.total > 0 ? 'text-orange-600' : 'text-muted-foreground'}>
|
||
{etab.total > 0 ? formatEuros(etab.total) : '—'}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="bg-muted/40 border-t-2 border-border">
|
||
<td colSpan={2} className="px-3 py-3 font-bold text-foreground" style={{ position: 'sticky', left: 0, zIndex: 20, background: 'oklch(0.94 0.005 80)', boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '330px', minWidth: '330px' }}>
|
||
TOTAL — {sortedEtabs.length} établissements
|
||
</td>
|
||
{lignes.map(ligne => {
|
||
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||
// Utiliser le montant du poste (source BDD) pour éviter les écarts d'arrondis
|
||
const montantPoste = ligne.montant;
|
||
return (
|
||
<td
|
||
key={ligne.id}
|
||
className={`px-2 py-3 text-right font-semibold tabular-nums ${
|
||
isManuel ? 'text-blue-700 bg-blue-50/40' : 'text-emerald-700 bg-emerald-50/30'
|
||
}`}
|
||
>
|
||
{montantPoste > 0
|
||
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(montantPoste) + ' €'
|
||
: '—'}
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="px-3 py-3 text-right font-bold text-orange-600 text-sm tabular-nums" style={{ position: 'sticky', right: 0, zIndex: 20, background: 'oklch(0.94 0.005 80)', boxShadow: '-3px 0 8px -2px rgba(0,0,0,0.18)', width: '120px', minWidth: '120px' }}>
|
||
{/* Total depuis les montants BDD — pas la somme des arrondis par établissement */}
|
||
{formatEuros(totalGlobal)}
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Vue Clés de répartition */}
|
||
{viewMode === 'cles_repartition' && (
|
||
<ClesRepartitionView
|
||
annee={annee}
|
||
basesRepartitionRaw={basesRepartitionRaw ?? []}
|
||
isLoadingBases={isLoadingBases}
|
||
isErrorBases={isErrorBases}
|
||
setBaseRepartition={setBaseRepartition}
|
||
importBasesRepartition={importBasesRepartition}
|
||
/>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
{/* Modale ajout / édition */}
|
||
<Dialog open={dialogMode !== null} onOpenChange={open => { if (!open) { setDialogMode(null); setEditingLigne(null); } }}>
|
||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
{dialogMode === 'add' ? <Plus className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||
{dialogMode === 'add' ? 'Ajouter une ligne OPEX' : 'Modifier la ligne'}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{dialogMode === 'add' && (
|
||
<LigneForm
|
||
initial={EMPTY_LIGNE_FORM}
|
||
onSave={handleAddLigne}
|
||
onClose={() => setDialogMode(null)}
|
||
/>
|
||
)}
|
||
{dialogMode === 'edit' && editingLigne && (
|
||
<LigneForm
|
||
initial={{
|
||
libelle: editingLigne.libelle,
|
||
fournisseur: editingLigne.fournisseur,
|
||
categorie: editingLigne.categorie,
|
||
type: editingLigne.type,
|
||
facturation: editingLigne.facturation,
|
||
compte: editingLigne.compte,
|
||
detail: editingLigne.detail,
|
||
budget_n1: editingLigne.budget_n1,
|
||
montant: editingLigne.montant,
|
||
}}
|
||
onSave={handleEditLigne}
|
||
onClose={() => { setDialogMode(null); setEditingLigne(null); }}
|
||
/>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Confirmation suppression */}
|
||
<AlertDialog open={deleteConfirmId !== null} onOpenChange={open => { if (!open) setDeleteConfirmId(null); }}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Supprimer cette ligne ?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Cette action est irréversible. La ligne sera définitivement retirée du prévisionnel OPEX {annee}.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={() => deleteConfirmId && handleDeleteLigne(deleteConfirmId)}
|
||
className="bg-destructive hover:bg-destructive/90 text-white"
|
||
>
|
||
Supprimer
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|