1149 lines
52 KiB
TypeScript
1149 lines
52 KiB
TypeScript
// Design: Corporate Modernism — Itinova Budget SI
|
|
// OPEX DSI : vignettes par catégorie, CRUD lignes, persistance par année, validation définitive
|
|
|
|
import { 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,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { AppSidebar } from '../components/AppSidebar';
|
|
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
|
|
import { useAnnee, getOpexStorageKey } 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';
|
|
|
|
// ─── 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;
|
|
}
|
|
|
|
interface Etablissement {
|
|
code: string;
|
|
nom: string;
|
|
base_repartition: number;
|
|
montants: Record<string, number>;
|
|
total: 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: Etablissement[];
|
|
categories_totaux: Record<string, number>;
|
|
tendances_categories?: Record<string, TendanceCategorie>;
|
|
meta: { nb_postes: number; nb_etablissements?: number };
|
|
}
|
|
|
|
// Ligne persistée (peut être source ou ajoutée par l'utilisateur)
|
|
interface LigneOpex {
|
|
id: string; // identifiant stable
|
|
libelle: string;
|
|
fournisseur: string;
|
|
categorie: string;
|
|
type: string;
|
|
facturation: string;
|
|
compte: string;
|
|
detail: string;
|
|
budget_n1: number;
|
|
montant: number; // montant prévisionnel éditable
|
|
isCustom: boolean; // true = ajoutée par l'utilisateur
|
|
}
|
|
|
|
interface OpexAnneeState {
|
|
lignes: LigneOpex[];
|
|
validated: boolean;
|
|
savedAt?: string;
|
|
validatedAt?: string;
|
|
}
|
|
|
|
// ─── Constantes ───────────────────────────────────────────────────────────────
|
|
|
|
const opexData2026 = opexRaw as OpexData;
|
|
const opexData2025 = opex2025Raw as OpexData;
|
|
|
|
function getOpexDataForAnnee(annee: number): OpexData {
|
|
if (annee === 2025) return opexData2025;
|
|
return opexData2026; // 2026 et autres années
|
|
}
|
|
|
|
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'];
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
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) + ' €';
|
|
}
|
|
|
|
function buildLignesFromSource(annee?: number): LigneOpex[] {
|
|
const src = getOpexDataForAnnee(annee ?? 2026);
|
|
return src.postes.map(p => ({
|
|
id: `src-${p.col_idx}`,
|
|
libelle: p.libelle,
|
|
fournisseur: p.fournisseur ?? '',
|
|
categorie: p.categorie ?? 'Autre',
|
|
type: p.type ?? '',
|
|
facturation: p.facturation ?? '',
|
|
compte: p.compte ?? '',
|
|
detail: p.detail ?? '',
|
|
budget_n1: p.budget_n1 ?? 0,
|
|
montant: (annee === 2025 ? p.montant_previsionnel_2025 : p.montant_previsionnel_2026) ?? 0,
|
|
isCustom: false,
|
|
}));
|
|
}
|
|
|
|
function loadOpexState(annee: number): OpexAnneeState {
|
|
try {
|
|
const raw = localStorage.getItem(getOpexStorageKey(annee));
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw) as OpexAnneeState;
|
|
if (parsed.lignes && Array.isArray(parsed.lignes)) return parsed;
|
|
}
|
|
} catch { /* ignore */ }
|
|
// Données sources disponibles pour 2025 et 2026 uniquement
|
|
if (annee === 2025 || annee === 2026) {
|
|
return { lignes: buildLignesFromSource(annee), validated: false };
|
|
}
|
|
return { lignes: [], validated: false };
|
|
}
|
|
|
|
function saveOpexState(annee: number, state: OpexAnneeState): void {
|
|
try {
|
|
localStorage.setItem(getOpexStorageKey(annee), JSON.stringify(state));
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function genId(): string {
|
|
return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
}
|
|
|
|
// ─── 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 ──────────────────────────────────────────────
|
|
|
|
const EMPTY_LIGNE: Omit<LigneOpex, 'id' | 'isCustom'> = {
|
|
libelle: '',
|
|
fournisseur: '',
|
|
categorie: 'App global',
|
|
type: '',
|
|
facturation: '',
|
|
compte: '',
|
|
detail: '',
|
|
budget_n1: 0,
|
|
montant: 0,
|
|
};
|
|
|
|
function LigneForm({
|
|
initial,
|
|
onSave,
|
|
onClose,
|
|
}: {
|
|
initial: Omit<LigneOpex, 'id' | 'isCustom'>;
|
|
onSave: (data: Omit<LigneOpex, 'id' | 'isCustom'>) => 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 principal ──────────────────────────────────────────────────────
|
|
|
|
type ViewMode = 'postes' | 'etablissements';
|
|
type SortDir = 'asc' | 'desc';
|
|
|
|
export default function DsiOpex() {
|
|
const { annee } = useAnnee();
|
|
|
|
const [opexState, setOpexState] = useState<OpexAnneeState>(() => loadOpexState(annee));
|
|
const [isDirty, setIsDirty] = useState(false);
|
|
|
|
// UI
|
|
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<string | 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<string | null>(null);
|
|
|
|
// Recharger quand l'année change
|
|
useEffect(() => {
|
|
setOpexState(loadOpexState(annee));
|
|
setIsDirty(false);
|
|
setSearch('');
|
|
setExpandedPoste(null);
|
|
}, [annee]);
|
|
|
|
const isValidated = opexState.validated;
|
|
|
|
// Catégories présentes dans les lignes
|
|
const categoriesPresentes = useMemo(() => {
|
|
const cats = Array.from(new Set(opexState.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'] : []);
|
|
}, [opexState.lignes]);
|
|
|
|
const CATEGORIES_FILTER = useMemo(() => ['Toutes', ...categoriesPresentes], [categoriesPresentes]);
|
|
|
|
// Modifier un montant directement dans le tableau
|
|
const handleMontantChange = useCallback((id: string, value: number) => {
|
|
if (isValidated) return;
|
|
setOpexState(prev => ({
|
|
...prev,
|
|
lignes: prev.lignes.map(l => l.id === id ? { ...l, montant: value } : l),
|
|
}));
|
|
setIsDirty(true);
|
|
}, [isValidated]);
|
|
|
|
// Enregistrer
|
|
const handleSave = useCallback(() => {
|
|
const newState = { ...opexState, savedAt: new Date().toISOString() };
|
|
setOpexState(newState);
|
|
saveOpexState(annee, newState);
|
|
setIsDirty(false);
|
|
toast.success(`OPEX ${annee} enregistré`, { description: 'Les montants prévisionnels ont été sauvegardés.' });
|
|
}, [opexState, annee]);
|
|
|
|
// Réinitialiser aux valeurs du JSON source
|
|
const handleReset = useCallback(() => {
|
|
if (isValidated) return;
|
|
const newState: OpexAnneeState = { lignes: buildLignesFromSource(annee), validated: false };
|
|
setOpexState(newState);
|
|
saveOpexState(annee, newState);
|
|
setIsDirty(false);
|
|
toast.info('Montants réinitialisés', { description: 'Les valeurs ont été restaurées depuis les données sources.' });
|
|
}, [isValidated, annee]);
|
|
|
|
// Valider définitivement
|
|
const handleValidate = useCallback(() => {
|
|
const newState: OpexAnneeState = {
|
|
...opexState,
|
|
validated: true,
|
|
savedAt: new Date().toISOString(),
|
|
validatedAt: new Date().toISOString(),
|
|
};
|
|
setOpexState(newState);
|
|
saveOpexState(annee, newState);
|
|
setIsDirty(false);
|
|
toast.success(`OPEX ${annee} validé`, {
|
|
description: 'Le prévisionnel est maintenant verrouillé et ne peut plus être modifié.',
|
|
});
|
|
}, [opexState, annee]);
|
|
|
|
// Ajouter une ligne
|
|
const handleAddLigne = useCallback((data: Omit<LigneOpex, 'id' | 'isCustom'>) => {
|
|
const newLigne: LigneOpex = { ...data, id: genId(), isCustom: true };
|
|
const newState = {
|
|
...opexState,
|
|
lignes: [...opexState.lignes, newLigne],
|
|
};
|
|
setOpexState(newState);
|
|
saveOpexState(annee, newState);
|
|
setIsDirty(false);
|
|
setDialogMode(null);
|
|
toast.success('Ligne ajoutée');
|
|
}, [opexState, annee]);
|
|
|
|
// Modifier une ligne
|
|
const handleEditLigne = useCallback((data: Omit<LigneOpex, 'id' | 'isCustom'>) => {
|
|
if (!editingLigne) return;
|
|
const newState = {
|
|
...opexState,
|
|
lignes: opexState.lignes.map(l =>
|
|
l.id === editingLigne.id ? { ...l, ...data } : l
|
|
),
|
|
};
|
|
setOpexState(newState);
|
|
saveOpexState(annee, newState);
|
|
setIsDirty(false);
|
|
setDialogMode(null);
|
|
setEditingLigne(null);
|
|
toast.success('Ligne modifiée');
|
|
}, [opexState, annee, editingLigne]);
|
|
|
|
// Supprimer une ligne
|
|
const handleDeleteLigne = useCallback((id: string) => {
|
|
const newState = {
|
|
...opexState,
|
|
lignes: opexState.lignes.filter(l => l.id !== id),
|
|
};
|
|
setOpexState(newState);
|
|
saveOpexState(annee, newState);
|
|
setIsDirty(false);
|
|
setDeleteConfirmId(null);
|
|
toast.success('Ligne supprimée');
|
|
}, [opexState, annee]);
|
|
|
|
// Totaux
|
|
const totalGlobal = useMemo(() =>
|
|
opexState.lignes.reduce((s, l) => s + l.montant, 0),
|
|
[opexState.lignes]);
|
|
|
|
const totalParCategorie = useMemo(() => {
|
|
const totaux: Record<string, number> = {};
|
|
for (const ligne of opexState.lignes) {
|
|
const cat = ligne.categorie || 'Autre';
|
|
totaux[cat] = (totaux[cat] || 0) + ligne.montant;
|
|
}
|
|
return totaux;
|
|
}, [opexState.lignes]);
|
|
|
|
// Filtrer les lignes
|
|
const filteredLignes = useMemo(() => {
|
|
return opexState.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;
|
|
});
|
|
}, [opexState.lignes, selectedCategorie, search]);
|
|
|
|
const totalFiltre = useMemo(() =>
|
|
filteredLignes.reduce((s, l) => s + l.montant, 0),
|
|
[filteredLignes]);
|
|
|
|
// Trier les lignes
|
|
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>
|
|
);
|
|
|
|
// Établissements (vue par établissement)
|
|
const filteredEtabs = useMemo(() => {
|
|
const srcData = getOpexDataForAnnee(annee);
|
|
return srcData.etablissements.filter(e => {
|
|
if (search && !e.nom.toLowerCase().includes(search.toLowerCase()) &&
|
|
!e.code.toLowerCase().includes(search.toLowerCase())) return false;
|
|
return true;
|
|
});
|
|
}, [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]);
|
|
|
|
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 {opexState.validatedAt ? new Date(opexState.validatedAt).toLocaleDateString('fr-FR') : ''}
|
|
</span>
|
|
)}
|
|
{!isValidated && isDirty && (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-700 border border-amber-200">
|
|
<PencilLine className="w-3 h-3" />
|
|
Modifications non enregistrées
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
{opexState.lignes.length} postes de charges · {getOpexDataForAnnee(annee).meta.nb_etablissements ?? opexData2026.meta.nb_etablissements} établissements
|
|
{opexState.savedAt && !isValidated && (
|
|
<span className="ml-2 text-xs">· Enregistré le {new Date(opexState.savedAt).toLocaleDateString('fr-FR')} à {new Date(opexState.savedAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<AnneeSelectorBar />
|
|
{!isValidated && (
|
|
<>
|
|
<button
|
|
onClick={handleReset}
|
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted transition-colors text-muted-foreground"
|
|
title="Réinitialiser aux valeurs sources"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
Réinitialiser
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={!isDirty}
|
|
className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg font-medium transition-all ${
|
|
isDirty
|
|
? 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm'
|
|
: 'bg-muted text-muted-foreground cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
Enregistrer
|
|
</button>
|
|
<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={handleValidate} 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 — vignette Total + une par catégorie */}
|
|
<div className="px-6 py-4 border-b border-border bg-muted/20 flex-shrink-0">
|
|
<div className="flex flex-wrap gap-3">
|
|
{/* Vignette Total */}
|
|
<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>
|
|
{/* Vignettes par catégorie */}
|
|
{categoriesPresentes.map(cat => {
|
|
const montant = totalParCategorie[cat] || 0;
|
|
const pct = totalGlobal > 0 ? (montant / totalGlobal * 100).toFixed(1) : '0';
|
|
const colors = getCatColors(cat);
|
|
// Tendance depuis les données sources de l'année courante
|
|
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>
|
|
{/* Flèche de tendance */}
|
|
{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' && variationPct !== null && (
|
|
<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 */}
|
|
<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>
|
|
</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 overflow-auto px-6 py-4">
|
|
|
|
{/* === VUE PAR POSTE === */}
|
|
{viewMode === 'postes' && (
|
|
<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. Utilisez les boutons <strong>Modifier</strong> / <strong>Supprimer</strong> pour gérer les lignes. La validation définitive verrouille tout.</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-muted/40">
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground w-8">#</th>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground">
|
|
<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">Catégorie</th>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">Type</th>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden xl:table-cell">Facturation</th>
|
|
{showDetails && <th className="text-left px-4 py-3 font-medium text-muted-foreground hidden xl:table-cell">Compte</th>}
|
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Budget N-1</th>
|
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground" style={{ minWidth: '140px' }}>
|
|
<SortBtn col="montant" label={`Prév. ${annee}`} />
|
|
</th>
|
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground w-24">Répartition</th>
|
|
{!isValidated && <th className="px-4 py-3 w-20"></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 (
|
|
<>
|
|
<tr
|
|
key={ligne.id}
|
|
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 lg:table-cell">{ligne.type || '—'}</td>
|
|
<td className="px-4 py-3 text-sm text-muted-foreground hidden xl:table-cell">{ligne.facturation || '—'}</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.id, v)}
|
|
disabled={isValidated}
|
|
/>
|
|
)}
|
|
</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>
|
|
{ligne.detail && (<><p className="font-medium">Détail</p><p className="text-blue-600 mt-0.5">{ligne.detail}</p></>)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
})}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="bg-muted/40 border-t-2 border-border">
|
|
<td colSpan={showDetails ? (isValidated ? 7 : 8) : (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>
|
|
|
|
{/* Répartition par catégorie */}
|
|
<div className="mt-6 bg-card border border-border rounded-xl p-5">
|
|
<h3 className="font-semibold text-foreground mb-4 flex items-center gap-2" style={{ fontFamily: 'Sora, sans-serif' }}>
|
|
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
|
Répartition par catégorie — {annee}
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{Object.entries(totalParCategorie)
|
|
.filter(([, v]) => v > 0)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([cat, montant]) => {
|
|
const pct = totalGlobal > 0 ? (montant / totalGlobal) * 100 : 0;
|
|
const colors = getCatColors(cat);
|
|
return (
|
|
<div key={cat} className="flex items-center gap-3">
|
|
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium w-28 justify-center ${colors.badge}`}>
|
|
{cat}
|
|
</span>
|
|
<div className="flex-1 bg-muted rounded-full h-2 overflow-hidden">
|
|
<div className={`h-2 rounded-full transition-all ${colors.bar}`} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<span className="text-sm font-semibold text-foreground tabular-nums w-28 text-right">
|
|
{formatEuros(montant)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground tabular-nums w-12 text-right">
|
|
{pct.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* === VUE PAR ÉTABLISSEMENT === */}
|
|
{viewMode === 'etablissements' && (
|
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-muted/40">
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Code</th>
|
|
<th className="text-left px-4 py-3 font-medium text-muted-foreground">
|
|
<SortBtn col="nom" label="Établissement" />
|
|
</th>
|
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">Base répartition</th>
|
|
{opexData2026.postes.slice(0, showDetails ? 8 : 4).map(p => (
|
|
<th key={p.libelle} className="text-right px-3 py-3 font-medium text-muted-foreground text-xs max-w-24 hidden xl:table-cell">
|
|
<span className="block truncate max-w-20" title={p.libelle}>{p.libelle.split(' ').slice(0, 3).join(' ')}</span>
|
|
</th>
|
|
))}
|
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground">
|
|
<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/30 transition-colors ${idx % 2 === 0 ? '' : 'bg-muted/10'}`}>
|
|
<td className="px-4 py-2.5">
|
|
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{etab.code}</span>
|
|
</td>
|
|
<td className="px-4 py-2.5 font-medium text-foreground">{etab.nom}</td>
|
|
<td className="px-4 py-2.5 text-right text-xs text-muted-foreground tabular-nums hidden lg:table-cell">
|
|
{etab.base_repartition > 0
|
|
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(etab.base_repartition) + ' €'
|
|
: '—'}
|
|
</td>
|
|
{opexData2026.postes.slice(0, showDetails ? 8 : 4).map(p => (
|
|
<td key={p.libelle} className="px-3 py-2.5 text-right text-xs tabular-nums text-muted-foreground hidden xl:table-cell">
|
|
{etab.montants[p.libelle] > 0
|
|
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(etab.montants[p.libelle])) + ' €'
|
|
: '—'}
|
|
</td>
|
|
))}
|
|
<td className="px-4 py-2.5 text-right font-semibold tabular-nums">
|
|
<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={3 + (showDetails ? 8 : 4)} className="px-4 py-3 font-bold text-foreground hidden lg:table-cell">
|
|
TOTAL — {sortedEtabs.length} établissements
|
|
</td>
|
|
<td colSpan={3} className="px-4 py-3 font-bold text-foreground lg:hidden">TOTAL</td>
|
|
<td className="px-4 py-3 text-right font-bold text-orange-600 text-base tabular-nums">
|
|
{formatEuros(sortedEtabs.reduce((s, e) => s + e.total, 0))}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</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}
|
|
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>
|
|
);
|
|
}
|