2069 lines
103 KiB
TypeScript
2069 lines
103 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,
|
||
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 { calculateCategoryTrends } from '../lib/opexTrends';
|
||
import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from '@shared/opexValidation';
|
||
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 OpexData {
|
||
annee: number;
|
||
total_global: number;
|
||
postes: Poste[];
|
||
etablissements: EtabSource[];
|
||
categories_totaux: Record<string, number>;
|
||
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;
|
||
}
|
||
|
||
/** Sous-ensemble commun aux lignes brutes OPEX retournées par la BDD. */
|
||
interface OpexPosteRow {
|
||
id: number;
|
||
libelle: string;
|
||
fournisseur: string | null;
|
||
categorie: string | null;
|
||
type: string | null;
|
||
facturation: string | null;
|
||
compte: string | null;
|
||
detail: string | null;
|
||
budgetN1: string | null;
|
||
montant: string | null;
|
||
isCustom: boolean | null;
|
||
modeVentilation: 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,
|
||
}));
|
||
}
|
||
|
||
/** Convertit les lignes Drizzle en modèle de vue, en écartant les synthèses importées. */
|
||
function mapDbPostesToLignes(postes: OpexPosteRow[]): LigneOpex[] {
|
||
return postes
|
||
.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,
|
||
}));
|
||
}
|
||
|
||
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;
|
||
modeManuel: boolean;
|
||
}
|
||
|
||
interface ClesRepartitionViewProps {
|
||
annee: number;
|
||
basesRepartitionRaw: BaseRow[];
|
||
isLoadingBases: boolean;
|
||
isErrorBases: boolean;
|
||
setBaseRepartition: ReturnType<typeof trpc.opex.setBaseRepartition.useMutation>;
|
||
importBasesRepartition: ReturnType<typeof trpc.opex.importBasesRepartition.useMutation>;
|
||
batchSetMontantsEtab: ReturnType<typeof trpc.opex.batchSetMontantsEtab.useMutation>;
|
||
etablissementsRecalcules: Array<{ code: string; nom: string; montants: Record<string, number>; total: number }>;
|
||
}
|
||
|
||
function ClesRepartitionView({ annee, basesRepartitionRaw, isLoadingBases, isErrorBases, setBaseRepartition, importBasesRepartition, batchSetMontantsEtab, etablissementsRecalcules }: 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,
|
||
modeManuel: row.modeManuel,
|
||
});
|
||
setEditingCell(null);
|
||
}
|
||
|
||
function toggleModeManuel(row: BaseRow) {
|
||
const activating = !row.modeManuel;
|
||
if (activating) {
|
||
// Avant d'activer le mode manuel, on pré-remplit les montants avec les valeurs
|
||
// calculées automatiquement (prorata) pour cet établissement
|
||
const etabCalc = etablissementsRecalcules.find(e => e.code === row.etablissementCode);
|
||
if (etabCalc && Object.keys(etabCalc.montants).length > 0) {
|
||
const montantsASeeder = Object.entries(etabCalc.montants)
|
||
.filter(([, v]) => v > 0)
|
||
.map(([libellePoste, v]) => ({ libellePoste, montant: String(v) }));
|
||
if (montantsASeeder.length > 0) {
|
||
batchSetMontantsEtab.mutate(
|
||
{ annee, etablissementCode: row.etablissementCode, montants: montantsASeeder },
|
||
{
|
||
onSettled: () => {
|
||
// Activer le flag modeManuel après la sauvegarde des montants
|
||
setBaseRepartition.mutate({
|
||
annee,
|
||
etablissementCode: row.etablissementCode,
|
||
etablissementNom: row.etablissementNom,
|
||
baseRepartition: parseFloat(row.baseRepartition ?? '0'),
|
||
baseRepartitionHep: parseFloat(row.baseRepartitionHep ?? '0'),
|
||
modeManuel: true,
|
||
});
|
||
},
|
||
}
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
// Cas normal : désactivation ou activation sans montants à seeder
|
||
setBaseRepartition.mutate({
|
||
annee,
|
||
etablissementCode: row.etablissementCode,
|
||
etablissementNom: row.etablissementNom,
|
||
baseRepartition: parseFloat(row.baseRepartition ?? '0'),
|
||
baseRepartitionHep: parseFloat(row.baseRepartitionHep ?? '0'),
|
||
modeManuel: activating,
|
||
});
|
||
}
|
||
|
||
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 => {
|
||
const code = String(row[codeIdx] ?? '').trim();
|
||
return isOpexEtablissementCode(code);
|
||
})
|
||
.map(row => ({
|
||
etablissementCode: normalizeOpexEtablissementCode(String(row[codeIdx])),
|
||
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>
|
||
<th className="px-4 py-3 text-center font-semibold text-foreground text-xs uppercase tracking-wide w-36" title="Si activé, tous les montants OPEX de cet établissement sont saisis manuellement (pas de calcul prorata)">
|
||
Tout manuel
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.length === 0 && (
|
||
<tr>
|
||
<td colSpan={5} className="px-4 py-12 text-center text-muted-foreground">
|
||
{basesRepartitionRaw.length === 0
|
||
? `Aucune base de répartition pour ${annee}. Importez un fichier ou saisissez les valeurs manuellement.`
|
||
: 'Aucun résultat pour cette recherche.'}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{rows.map((row, idx) => (
|
||
<tr key={row.etablissementCode} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${row.modeManuel ? 'bg-amber-50/60' : idx % 2 === 0 ? '' : 'bg-muted/10'}`}>
|
||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{row.etablissementCode}</td>
|
||
<td className="px-4 py-2.5 font-medium text-foreground">
|
||
{row.etablissementNom ?? row.etablissementCode}
|
||
{row.modeManuel && (
|
||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold bg-amber-100 text-amber-700 border border-amber-200">
|
||
Manuel
|
||
</span>
|
||
)}
|
||
</td>
|
||
{/* Colonne Base de répartition */}
|
||
<td className="px-4 py-2.5 text-right">
|
||
{editingCell?.code === row.etablissementCode && editingCell.col === 'base' ? (
|
||
<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>
|
||
{/* Colonne Mode Manuel */}
|
||
<td className="px-4 py-2.5 text-center">
|
||
<button
|
||
onClick={() => toggleModeManuel(row)}
|
||
disabled={setBaseRepartition.isPending}
|
||
title={row.modeManuel
|
||
? 'Mode manuel activé — cliquer pour revenir au calcul prorata automatique'
|
||
: 'Cliquer pour activer le mode tout manuel (montants OPEX saisis manuellement pour cet établissement)'}
|
||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 ${
|
||
row.modeManuel ? 'bg-amber-500' : 'bg-muted-foreground/30'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
|
||
row.modeManuel ? 'translate-x-4.5' : 'translate-x-0.5'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="bg-muted/40 border-t-2 border-border font-bold">
|
||
<td colSpan={2} className="px-4 py-3 text-sm font-semibold text-foreground">TOTAL — {basesRepartitionRaw.length} établissements
|
||
{basesRepartitionRaw.filter(r => r.modeManuel).length > 0 && (
|
||
<span className="ml-2 text-xs font-normal text-amber-600">
|
||
({basesRepartitionRaw.filter(r => r.modeManuel).length} en mode manuel)
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 text-right text-sm font-bold text-foreground tabular-nums">
|
||
{new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalBase)} €
|
||
</td>
|
||
<td className="px-4 py-3 text-right text-sm font-bold text-orange-600 tabular-nums">
|
||
{totalHep > 0 ? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(totalHep) + ' €' : '—'}
|
||
</td>
|
||
<td className="px-4 py-3 text-center text-xs text-muted-foreground">
|
||
{basesRepartitionRaw.filter(r => r.modeManuel).length > 0
|
||
? `${basesRepartitionRaw.filter(r => r.modeManuel).length} actif${basesRepartitionRaw.filter(r => r.modeManuel).length > 1 ? 's' : ''}`
|
||
: '—'}
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</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 });
|
||
// Les vignettes doivent comparer l'exercice affiché avec ses données BDD N-1,
|
||
// et non avec les tendances figées du fichier source 2026.
|
||
const { data: postesN1Raw, isLoading: loadingPostesN1 } = trpc.opex.getPostes.useQuery({ annee: annee - 1 });
|
||
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 deletePoste = trpc.opex.deletePoste.useMutation({
|
||
onSuccess: () => {
|
||
utils.opex.getPostes.invalidate({ annee });
|
||
utils.opex.getMontantsEtab.invalidate({ annee });
|
||
toast.success('Poste OPEX supprimé');
|
||
},
|
||
onError: (err) => toast.error('Erreur suppression', { 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 }),
|
||
});
|
||
const batchSetMontantsEtab = trpc.opex.batchSetMontantsEtab.useMutation({
|
||
onSuccess: () => utils.opex.getMontantsEtab.invalidate({ annee }),
|
||
onError: (err) => toast.error('Erreur sauvegarde montants', { description: err.message }),
|
||
});
|
||
|
||
// ── Mapper les données BDD vers le format interne ──────────────────────────
|
||
const lignes: LigneOpex[] = useMemo(() => {
|
||
if (!postesRaw) return [];
|
||
return mapDbPostesToLignes(postesRaw);
|
||
}, [postesRaw]);
|
||
|
||
const lignesN1 = useMemo(() => {
|
||
if (postesN1Raw && postesN1Raw.length > 0) {
|
||
return mapDbPostesToLignes(postesN1Raw);
|
||
}
|
||
|
||
// Seuls les fichiers 2025 et 2026 sont des sources historiques fiables.
|
||
// Pour les années sans source ni données BDD, l'interface indique simplement
|
||
// qu'il n'existe pas de référence N-1 au lieu de réutiliser 2026 par défaut.
|
||
if (annee - 1 === 2025 || annee - 1 === 2026) {
|
||
return buildPostesFromSource(annee - 1).map((poste, index) => ({
|
||
id: -(index + 1),
|
||
libelle: poste.libelle,
|
||
fournisseur: poste.fournisseur ?? '',
|
||
categorie: poste.categorie ?? 'Autre',
|
||
type: poste.type ?? '',
|
||
facturation: poste.facturation ?? '',
|
||
compte: poste.compte ?? '',
|
||
detail: poste.detail ?? '',
|
||
budget_n1: parseFloat(poste.budgetN1 ?? '0') || 0,
|
||
montant: parseFloat(poste.montant ?? '0') || 0,
|
||
isCustom: poste.isCustom ?? false,
|
||
mode_ventilation: poste.modeVentilation ?? null,
|
||
libelleCourt: poste.libelleCourt ?? null,
|
||
libelleDetail: poste.libelleDetail ?? null,
|
||
colIdx: poste.colIdx,
|
||
}));
|
||
}
|
||
|
||
return [];
|
||
}, [annee, postesN1Raw]);
|
||
|
||
// 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(() => {
|
||
if (!deleteConfirmId) return;
|
||
deletePoste.mutate({ annee, id: deleteConfirmId });
|
||
setDeleteConfirmId(null);
|
||
}, [annee, deleteConfirmId, deletePoste]);
|
||
|
||
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 tendancesParCategorie = useMemo(
|
||
() => calculateCategoryTrends(lignes, lignesN1),
|
||
[lignes, lignesN1],
|
||
);
|
||
|
||
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,
|
||
modeManuel: b.modeManuel ?? false,
|
||
}))
|
||
: getOpexDataForAnnee(annee).etablissements.map(e => ({
|
||
code: e.code,
|
||
nom: e.nom,
|
||
base_repartition: e.base_repartition,
|
||
base_repartition_hep: 0, // JSON source n'a pas de base HEP
|
||
modeManuel: false,
|
||
}));
|
||
|
||
const totalBase = basesFromBdd.reduce((s, e) => s + e.base_repartition, 0);
|
||
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 (etab.modeManuel) {
|
||
// Mode tout manuel : utiliser uniquement les montants saisis manuellement,
|
||
// sans aucun calcul prorata — si pas de saisie, le montant est 0
|
||
montant = etabOverrides[ligne.libelle] ?? 0;
|
||
} else if (isManuel && etabOverrides[ligne.libelle] !== undefined) {
|
||
montant = etabOverrides[ligne.libelle];
|
||
} else if (!isManuel && ligne.montant > 0) {
|
||
montant = Math.round(ligne.montant * ratioApplique);
|
||
} 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));
|
||
else if (sortCol === 'code') arr.sort((a, b) => sortDir === 'asc' ? a.code.localeCompare(b.code) : b.code.localeCompare(a.code));
|
||
else arr.sort((a, b) => a.code.localeCompare(b.code)); // tri par défaut : code croissant
|
||
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 tendanceInfo = tendancesParCategorie[cat];
|
||
// Tant que la requête N-1 est active, ne pas présenter de badge
|
||
// temporairement erroné (par exemple « Nouveau »).
|
||
const tendance = loadingPostesN1 ? undefined : tendanceInfo?.tendance;
|
||
const variationPct = tendanceInfo?.variationPct;
|
||
const montantN1 = tendanceInfo?.montantN1;
|
||
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' }}>
|
||
<SortBtn col="code" label="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) => {
|
||
const rowBg = etab.modeManuel ? 'oklch(0.98 0.02 80)' : (idx % 2 === 0 ? 'oklch(1 0 0)' : 'oklch(0.97 0.003 80)');
|
||
return (
|
||
<tr key={etab.code} className={`border-b border-border/50 hover:bg-muted/20 transition-colors ${etab.modeManuel ? 'bg-amber-50/40' : idx % 2 === 0 ? '' : 'bg-muted/5'}`}>
|
||
<td className="px-3 py-2 whitespace-nowrap" style={{ position: 'sticky', left: 0, zIndex: 20, background: rowBg, boxShadow: '2px 0 4px -1px rgba(0,0,0,0.12)', width: '110px', minWidth: '110px', maxWidth: '110px' }}>
|
||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{etab.code}</span>
|
||
</td>
|
||
<td className="px-3 py-2 font-medium text-foreground whitespace-nowrap overflow-hidden text-ellipsis" style={{ position: 'sticky', left: '110px', zIndex: 20, background: rowBg, boxShadow: '2px 0 8px -2px rgba(0,0,0,0.2)', width: '220px', minWidth: '220px', maxWidth: '220px' }} title={etab.nom}>
|
||
{etab.nom}
|
||
{etab.modeManuel && (
|
||
<span className="ml-1.5 inline-flex items-center px-1 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 border border-amber-200" title="Mode tout manuel activé — montants saisis manuellement, pas de calcul prorata">
|
||
Manuel
|
||
</span>
|
||
)}
|
||
</td>
|
||
{lignes.map(ligne => {
|
||
const isManuel = ligne.mode_ventilation === 'Manuel' || ligne.isCustom;
|
||
// En mode tout manuel pour cet établissement, toutes les cellules sont éditables
|
||
const isCellEditable = etab.modeManuel || isManuel;
|
||
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 (isCellEditable && isEditing) {
|
||
return (
|
||
<td key={ligne.id} className="px-1 py-1 border border-amber-400 bg-amber-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-amber-800 font-medium"
|
||
style={{ minWidth: '70px' }}
|
||
/>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<td
|
||
key={ligne.id}
|
||
onClick={() => isCellEditable && !isValidated && handleInlineEditStart(etab.code, ligne.libelle, montant)}
|
||
className={`px-2 py-2 text-right tabular-nums transition-colors ${
|
||
etab.modeManuel
|
||
? `border border-amber-200 bg-amber-50/30 text-amber-800 ${
|
||
!isValidated ? 'cursor-pointer hover:bg-amber-100/60 hover:border-amber-400' : ''
|
||
} ${hasOverride ? 'font-semibold' : 'opacity-60'}`
|
||
: 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={
|
||
etab.modeManuel && !isValidated
|
||
? 'Cliquer pour saisir le montant (mode tout manuel)'
|
||
: isManuel && !isValidated
|
||
? 'Cliquer pour saisir le montant'
|
||
: undefined
|
||
}
|
||
>
|
||
{montant > 0
|
||
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(montant) + ' €'
|
||
: isCellEditable && !isValidated
|
||
? <span className={etab.modeManuel ? 'text-amber-300 text-[10px]' : '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}
|
||
batchSetMontantsEtab={batchSetMontantsEtab}
|
||
etablissementsRecalcules={etablissementsRecalcules}
|
||
/>
|
||
)}
|
||
</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={handleDeleteLigne}
|
||
className="bg-destructive hover:bg-destructive/90 text-white"
|
||
>
|
||
Supprimer
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|