Checkpoint: Refonte complète de la page DSI OPEX : grille de vignettes dynamique pour toutes les catégories (App global, Infogérance, Sécurité, Téléphonie, App HEP, App SMR, Autre), montants éditables inline, boutons Modifier/Supprimer par ligne, modale d'ajout/édition complète, confirmation de suppression, persistance par année en localStorage, validation définitive avec verrouillage.

This commit is contained in:
Manus
2026-06-03 16:12:34 +00:00
parent f9ea5dbba5
commit 2304724ba6

View File

@@ -1,6 +1,5 @@
// DsiOpex.tsx — OPEX DSI : Charges du Système d'Information
// Design: Corporate Modernism — Itinova Budget SI // Design: Corporate Modernism — Itinova Budget SI
// Montants prévisionnels éditables par poste, persistance par année, validation définitive (verrouillage) // OPEX DSI : vignettes par catégorie, CRUD lignes, persistance par année, validation définitive
import { useState, useMemo, useEffect, useCallback } from 'react'; import { useState, useMemo, useEffect, useCallback } from 'react';
import { import {
@@ -21,6 +20,10 @@ import {
CheckCircle2, CheckCircle2,
PencilLine, PencilLine,
RotateCcw, RotateCcw,
Plus,
Pencil,
Trash2,
X,
} from 'lucide-react'; } from 'lucide-react';
import { AppSidebar } from '../components/AppSidebar'; import { AppSidebar } from '../components/AppSidebar';
import { AnneeSelectorBar } from '../components/AnneeSelectorBar'; import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
@@ -38,6 +41,13 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -73,27 +83,48 @@ interface OpexData {
meta: { nb_postes: number; nb_etablissements: number }; 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 { interface OpexAnneeState {
montants: Record<string, number>; // clé = libelle poste, valeur = montant saisi lignes: LigneOpex[];
validated: boolean; // true = verrouillé définitivement validated: boolean;
savedAt?: string; // ISO date de la dernière sauvegarde savedAt?: string;
validatedAt?: string; // ISO date de validation validatedAt?: string;
} }
// ─── Constantes ─────────────────────────────────────────────────────────────── // ─── Constantes ───────────────────────────────────────────────────────────────
const opexData = opexRaw as OpexData; const opexData = opexRaw as OpexData;
const CATEGORIES = ['Toutes', 'App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR'];
const CATEGORIE_COLORS: Record<string, string> = { const CATEGORIES_FIXES = ['App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR'];
'App global': 'bg-blue-100 text-blue-700',
'Infogérance': 'bg-purple-100 text-purple-700', const CATEGORIE_COLORS: Record<string, { badge: string; bar: string; kpi: string; icon: string }> = {
'Sécurité': 'bg-red-100 text-red-700', 'App global': { badge: 'bg-blue-100 text-blue-700', bar: 'bg-blue-500', kpi: 'text-blue-600', icon: 'text-blue-500' },
'Téléphonie': 'bg-green-100 text-green-700', 'Infogérance': { badge: 'bg-purple-100 text-purple-700', bar: 'bg-purple-500', kpi: 'text-purple-600', icon: 'text-purple-500' },
'App HEP': 'bg-orange-100 text-orange-700', 'Sécurité': { badge: 'bg-red-100 text-red-700', bar: 'bg-red-500', kpi: 'text-red-600', icon: 'text-red-500' },
'App SMR': 'bg-teal-100 text-teal-700', '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 ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNum(v: number | null | undefined): string { function formatNum(v: number | null | undefined): string {
@@ -101,38 +132,44 @@ function formatNum(v: number | null | undefined): string {
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €'; return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
} }
function formatNumShort(v: number): string { function buildLignesFromSource(): LigneOpex[] {
if (v === 0) return '—'; return opexData.postes.map(p => ({
if (v >= 1000) return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(Math.round(v)) + ' €'; id: `src-${p.col_idx}`,
return Math.round(v) + ' €'; 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: p.montant_previsionnel_2026 ?? 0,
isCustom: false,
}));
} }
// Montant de base (depuis le JSON source) pour un poste
function getMontantBase(poste: Poste): number {
return poste.montant_previsionnel_2026 ?? 0;
}
// Charger l'état OPEX depuis localStorage pour une année
function loadOpexState(annee: number): OpexAnneeState { function loadOpexState(annee: number): OpexAnneeState {
try { try {
const raw = localStorage.getItem(getOpexStorageKey(annee)); const raw = localStorage.getItem(getOpexStorageKey(annee));
if (raw) return JSON.parse(raw) as OpexAnneeState; if (raw) {
} catch { /* ignore */ } const parsed = JSON.parse(raw) as OpexAnneeState;
// Valeur par défaut : montants initialisés depuis le JSON // Compatibilité ascendante : si l'ancien format (montants dict) est détecté
const montants: Record<string, number> = {}; if (parsed.lignes && Array.isArray(parsed.lignes)) return parsed;
for (const p of opexData.postes) {
montants[p.libelle] = getMontantBase(p);
} }
return { montants, validated: false }; } catch { /* ignore */ }
return { lignes: buildLignesFromSource(), validated: false };
} }
// Sauvegarder l'état OPEX dans localStorage
function saveOpexState(annee: number, state: OpexAnneeState): void { function saveOpexState(annee: number, state: OpexAnneeState): void {
try { try {
localStorage.setItem(getOpexStorageKey(annee), JSON.stringify(state)); localStorage.setItem(getOpexStorageKey(annee), JSON.stringify(state));
} catch { /* ignore */ } } catch { /* ignore */ }
} }
function genId(): string {
return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
// ─── Composant champ numérique ───────────────────────────────────────────── // ─── Composant champ numérique ─────────────────────────────────────────────
function MoneyInput({ function MoneyInput({
@@ -165,7 +202,7 @@ function MoneyInput({
onChange(isNaN(v) || v < 0 ? 0 : Math.round(v)); onChange(isNaN(v) || v < 0 ? 0 : Math.round(v));
}} }}
onBlur={() => setRaw(value === 0 ? '' : String(value))} onBlur={() => setRaw(value === 0 ? '' : String(value))}
className={`w-full pl-6 pr-8 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 ${ 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 disabled
? 'bg-muted/30 text-muted-foreground cursor-not-allowed border-border/40' ? 'bg-muted/30 text-muted-foreground cursor-not-allowed border-border/40'
: 'bg-background border-border hover:border-primary/50' : 'bg-background border-border hover:border-primary/50'
@@ -176,6 +213,152 @@ function MoneyInput({
); );
} }
// ─── 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 ────────────────────────────────────────────────────── // ─── Composant principal ──────────────────────────────────────────────────────
type ViewMode = 'postes' | 'etablissements'; type ViewMode = 'postes' | 'etablissements';
@@ -184,7 +367,6 @@ type SortDir = 'asc' | 'desc';
export default function DsiOpex() { export default function DsiOpex() {
const { annee } = useAnnee(); const { annee } = useAnnee();
// État OPEX pour l'année courante
const [opexState, setOpexState] = useState<OpexAnneeState>(() => loadOpexState(annee)); const [opexState, setOpexState] = useState<OpexAnneeState>(() => loadOpexState(annee));
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
@@ -197,6 +379,11 @@ export default function DsiOpex() {
const [expandedPoste, setExpandedPoste] = useState<string | null>(null); const [expandedPoste, setExpandedPoste] = useState<string | null>(null);
const [showDetails, setShowDetails] = useState(false); 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 // Recharger quand l'année change
useEffect(() => { useEffect(() => {
setOpexState(loadOpexState(annee)); setOpexState(loadOpexState(annee));
@@ -205,15 +392,27 @@ export default function DsiOpex() {
setExpandedPoste(null); setExpandedPoste(null);
}, [annee]); }, [annee]);
// Modifier un montant const isValidated = opexState.validated;
const handleMontantChange = useCallback((libelle: string, value: number) => {
if (opexState.validated) return; // 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 => ({ setOpexState(prev => ({
...prev, ...prev,
montants: { ...prev.montants, [libelle]: value }, lignes: prev.lignes.map(l => l.id === id ? { ...l, montant: value } : l),
})); }));
setIsDirty(true); setIsDirty(true);
}, [opexState.validated]); }, [isValidated]);
// Enregistrer // Enregistrer
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@@ -226,17 +425,13 @@ export default function DsiOpex() {
// Réinitialiser aux valeurs du JSON source // Réinitialiser aux valeurs du JSON source
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
if (opexState.validated) return; if (isValidated) return;
const montants: Record<string, number> = {}; const newState: OpexAnneeState = { lignes: buildLignesFromSource(), validated: false };
for (const p of opexData.postes) {
montants[p.libelle] = getMontantBase(p);
}
const newState: OpexAnneeState = { montants, validated: false };
setOpexState(newState); setOpexState(newState);
saveOpexState(annee, newState); saveOpexState(annee, newState);
setIsDirty(false); setIsDirty(false);
toast.info('Montants réinitialisés', { description: 'Les valeurs ont été restaurées depuis les données sources.' }); toast.info('Montants réinitialisés', { description: 'Les valeurs ont été restaurées depuis les données sources.' });
}, [opexState.validated, annee]); }, [isValidated, annee]);
// Valider définitivement // Valider définitivement
const handleValidate = useCallback(() => { const handleValidate = useCallback(() => {
@@ -254,78 +449,90 @@ export default function DsiOpex() {
}); });
}, [opexState, annee]); }, [opexState, annee]);
// Montant effectif pour un poste (saisie ou base) // Ajouter une ligne
const getMontant = useCallback((libelle: string): number => { const handleAddLigne = useCallback((data: Omit<LigneOpex, 'id' | 'isCustom'>) => {
return opexState.montants[libelle] ?? 0; const newLigne: LigneOpex = { ...data, id: genId(), isCustom: true };
}, [opexState.montants]); const newState = {
...opexState,
lignes: [...opexState.lignes, newLigne],
};
setOpexState(newState);
saveOpexState(annee, newState);
setIsDirty(false);
setDialogMode(null);
toast.success('Ligne ajoutée');
}, [opexState, annee]);
// Total global recalculé // Modifier une ligne
const totalGlobal = useMemo(() => { const handleEditLigne = useCallback((data: Omit<LigneOpex, 'id' | 'isCustom'>) => {
return opexData.postes.reduce((s, p) => s + getMontant(p.libelle), 0); if (!editingLigne) return;
}, [getMontant]); 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]);
// Totaux par catégorie
const totalParCategorie = useMemo(() => { const totalParCategorie = useMemo(() => {
const totaux: Record<string, number> = {}; const totaux: Record<string, number> = {};
for (const poste of opexData.postes) { for (const ligne of opexState.lignes) {
const cat = poste.categorie || 'Autre'; const cat = ligne.categorie || 'Autre';
totaux[cat] = (totaux[cat] || 0) + getMontant(poste.libelle); totaux[cat] = (totaux[cat] || 0) + ligne.montant;
} }
return totaux; return totaux;
}, [getMontant]); }, [opexState.lignes]);
// Filtrer les postes // Filtrer les lignes
const filteredPostes = useMemo(() => { const filteredLignes = useMemo(() => {
return opexData.postes.filter(p => { return opexState.lignes.filter(l => {
if (selectedCategorie !== 'Toutes' && p.categorie !== selectedCategorie) return false; if (selectedCategorie !== 'Toutes' && l.categorie !== selectedCategorie) return false;
if (search && !p.libelle.toLowerCase().includes(search.toLowerCase()) && if (search && !l.libelle.toLowerCase().includes(search.toLowerCase()) &&
!(p.fournisseur || '').toLowerCase().includes(search.toLowerCase())) return false; !l.fournisseur.toLowerCase().includes(search.toLowerCase())) return false;
return true; return true;
}); });
}, [selectedCategorie, search]); }, [opexState.lignes, selectedCategorie, search]);
const totalFiltre = useMemo(() => { const totalFiltre = useMemo(() =>
return filteredPostes.reduce((s, p) => s + getMontant(p.libelle), 0); filteredLignes.reduce((s, l) => s + l.montant, 0),
}, [filteredPostes, getMontant]); [filteredLignes]);
// Trier les postes // Trier les lignes
const sortedPostes = useMemo(() => { const sortedLignes = useMemo(() => {
const arr = [...filteredPostes]; const arr = [...filteredLignes];
if (sortCol === 'montant') { if (sortCol === 'montant') {
arr.sort((a, b) => { arr.sort((a, b) => sortDir === 'asc' ? a.montant - b.montant : b.montant - a.montant);
const va = getMontant(a.libelle);
const vb = getMontant(b.libelle);
return sortDir === 'asc' ? va - vb : vb - va;
});
} else if (sortCol === 'libelle') { } else if (sortCol === 'libelle') {
arr.sort((a, b) => sortDir === 'asc' arr.sort((a, b) => sortDir === 'asc'
? a.libelle.localeCompare(b.libelle) ? a.libelle.localeCompare(b.libelle)
: b.libelle.localeCompare(a.libelle)); : b.libelle.localeCompare(a.libelle));
} }
return arr; return arr;
}, [filteredPostes, sortCol, sortDir, getMontant]); }, [filteredLignes, sortCol, sortDir]);
// Filtrer les établissements
const filteredEtabs = useMemo(() => {
return opexData.etablissements.filter(e => {
if (search && !e.nom.toLowerCase().includes(search.toLowerCase()) &&
!e.code.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [search]);
// Trier les établissements
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]);
const handleSort = (col: string) => { const handleSort = (col: string) => {
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
@@ -342,7 +549,21 @@ export default function DsiOpex() {
</button> </button>
); );
const isValidated = opexState.validated; // Établissements (vue par établissement)
const filteredEtabs = useMemo(() => {
return opexData.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 ( return (
<div className="min-h-screen flex bg-background"> <div className="min-h-screen flex bg-background">
@@ -371,7 +592,7 @@ export default function DsiOpex() {
)} )}
</div> </div>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
{opexData.meta.nb_postes} postes de charges · {opexData.meta.nb_etablissements} établissements {opexState.lignes.length} postes de charges · {opexData.meta.nb_etablissements} établissements
{opexState.savedAt && !isValidated && ( {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> <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>
)} )}
@@ -403,9 +624,7 @@ export default function DsiOpex() {
</button> </button>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<button <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">
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" /> <CheckCircle2 className="w-3.5 h-3.5" />
Valider Valider
</button> </button>
@@ -414,15 +633,12 @@ export default function DsiOpex() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Valider l'OPEX {annee} ?</AlertDialogTitle> <AlertDialogTitle>Valider l'OPEX {annee} ?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Cette action est <strong>irréversible</strong>. Une fois validé, le prévisionnel OPEX {annee} sera verrouillé et ne pourra plus être modifié. Assurez-vous que tous les montants sont corrects avant de confirmer. Cette action est <strong>irréversible</strong>. Une fois validé, le prévisionnel OPEX {annee} sera verrouillé et ne pourra plus être modifié.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel> <AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={handleValidate} className="bg-emerald-600 hover:bg-emerald-700 text-white">
onClick={handleValidate}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
Confirmer la validation Confirmer la validation
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -440,10 +656,11 @@ export default function DsiOpex() {
</div> </div>
</header> </header>
{/* KPIs */} {/* 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="px-6 py-4 border-b border-border bg-muted/20 flex-shrink-0">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="flex flex-wrap gap-3">
<div className="bg-card border border-border rounded-xl p-4"> {/* 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"> <div className="flex items-center gap-2 mb-1">
<Euro className="w-4 h-4 text-orange-500" /> <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> <span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Total OPEX {annee}</span>
@@ -453,36 +670,24 @@ export default function DsiOpex() {
</p> </p>
<p className="text-xs text-muted-foreground mt-0.5">{isValidated ? 'Validé' : 'Prévisionnel'}</p> <p className="text-xs text-muted-foreground mt-0.5">{isValidated ? 'Validé' : 'Prévisionnel'}</p>
</div> </div>
<div className="bg-card border border-border rounded-xl p-4"> {/* 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);
return (
<div key={cat} className="bg-card border border-border rounded-xl p-4 min-w-[140px] flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<TrendingDown className="w-4 h-4 text-purple-500" /> <BarChart3 className={`w-4 h-4 ${colors.icon}`} />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Infogérance</span> <span className="text-xs text-muted-foreground uppercase tracking-wide font-medium truncate">{cat}</span>
</div> </div>
<p className="text-2xl font-bold text-purple-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}> <p className={`text-xl font-bold tabular-nums ${colors.kpi}`} style={{ fontFamily: 'Sora, sans-serif' }}>
{formatEuros(totalParCategorie['Infogérance'] || 0)} {formatEuros(montant)}
</p> </p>
<p className="text-xs text-muted-foreground mt-0.5">{totalGlobal > 0 ? ((totalParCategorie['Infogérance'] || 0) / totalGlobal * 100).toFixed(1) : '0'}% du total</p> <p className="text-xs text-muted-foreground mt-0.5">{pct}% du total</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<BarChart3 className="w-4 h-4 text-blue-500" />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Applicatifs</span>
</div>
<p className="text-2xl font-bold text-blue-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
{formatEuros((totalParCategorie['App global'] || 0) + (totalParCategorie['App HEP'] || 0) + (totalParCategorie['App SMR'] || 0))}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Global + HEP + SMR</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Building2 className="w-4 h-4 text-primary" />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Sécurité</span>
</div>
<p className="text-2xl font-bold text-red-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
{formatEuros(totalParCategorie['Sécurité'] || 0)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{totalGlobal > 0 ? ((totalParCategorie['Sécurité'] || 0) / totalGlobal * 100).toFixed(1) : '0'}% du total</p>
</div> </div>
);
})}
</div> </div>
</div> </div>
@@ -502,7 +707,7 @@ export default function DsiOpex() {
{viewMode === 'postes' && ( {viewMode === 'postes' && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<Filter className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" /> <Filter className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{CATEGORIES.map(cat => ( {CATEGORIES_FILTER.map(cat => (
<button <button
key={cat} key={cat}
onClick={() => setSelectedCategorie(cat)} onClick={() => setSelectedCategorie(cat)}
@@ -518,7 +723,17 @@ export default function DsiOpex() {
</div> </div>
)} )}
<div className="ml-auto flex items-center gap-1 bg-muted rounded-lg p-1"> <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 <button
onClick={() => { setViewMode('postes'); setSortCol(null); }} 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 ${ className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
@@ -538,7 +753,6 @@ export default function DsiOpex() {
Par établissement Par établissement
</button> </button>
</div> </div>
<button <button
onClick={() => setShowDetails(d => !d)} 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" 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"
@@ -547,6 +761,7 @@ export default function DsiOpex() {
{showDetails ? 'Masquer détails' : 'Voir détails'} {showDetails ? 'Masquer détails' : 'Voir détails'}
</button> </button>
</div> </div>
</div>
{/* Contenu principal */} {/* Contenu principal */}
<div className="flex-1 overflow-auto px-6 py-4"> <div className="flex-1 overflow-auto px-6 py-4">
@@ -557,7 +772,7 @@ export default function DsiOpex() {
{selectedCategorie !== 'Toutes' && ( {selectedCategorie !== 'Toutes' && (
<div className="flex items-center justify-between mb-3 px-1"> <div className="flex items-center justify-between mb-3 px-1">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{sortedPostes.length} poste{sortedPostes.length > 1 ? 's' : ''} catégorie <strong>{selectedCategorie}</strong> {sortedLignes.length} poste{sortedLignes.length > 1 ? 's' : ''} catégorie <strong>{selectedCategorie}</strong>
</span> </span>
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
Sous-total : <span className="text-primary">{formatEuros(totalFiltre)}</span> Sous-total : <span className="text-primary">{formatEuros(totalFiltre)}</span>
@@ -568,7 +783,7 @@ export default function DsiOpex() {
{!isValidated && ( {!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"> <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" /> <PencilLine className="w-3.5 h-3.5 flex-shrink-0" />
<span>Les montants prévisionnels sont modifiables. Cliquez sur un montant pour le modifier, puis enregistrez. La validation définitive verrouille tous les montants.</span> <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>
)} )}
@@ -589,58 +804,59 @@ export default function DsiOpex() {
<SortBtn col="montant" label={`Prév. ${annee}`} /> <SortBtn col="montant" label={`Prév. ${annee}`} />
</th> </th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground w-24">Répartition</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> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedPostes.map((poste, idx) => { {sortedLignes.map((ligne, idx) => {
const montant = getMontant(poste.libelle); const pct = totalGlobal > 0 ? (ligne.montant / totalGlobal) * 100 : 0;
const pct = totalGlobal > 0 ? (montant / totalGlobal) * 100 : 0; const isExpanded = expandedPoste === ligne.id;
const isExpanded = expandedPoste === poste.libelle; const catColors = getCatColors(ligne.categorie);
const catColor = CATEGORIE_COLORS[poste.categorie || ''] || 'bg-gray-100 text-gray-600';
return ( return (
<> <>
<tr <tr
key={poste.libelle} key={ligne.id}
className={`border-b border-border/50 hover:bg-muted/30 transition-colors ${isExpanded ? 'bg-muted/20' : ''}`} 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 text-muted-foreground text-xs">{idx + 1}</td>
<td <td
className="px-4 py-3 cursor-pointer" className="px-4 py-3 cursor-pointer"
onClick={() => setExpandedPoste(isExpanded ? null : poste.libelle)} onClick={() => setExpandedPoste(isExpanded ? null : ligne.id)}
> >
<div className="flex items-center gap-2"> <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" />} {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> <div>
<p className="font-medium text-foreground leading-tight">{poste.libelle}</p> <p className="font-medium text-foreground leading-tight">{ligne.libelle}</p>
{poste.fournisseur && poste.fournisseur !== poste.libelle && ( {ligne.fournisseur && ligne.fournisseur !== ligne.libelle && (
<p className="text-xs text-muted-foreground mt-0.5">{poste.fournisseur}</p> <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>
</div> </div>
</td> </td>
<td className="px-4 py-3 hidden md:table-cell"> <td className="px-4 py-3 hidden md:table-cell">
{poste.categorie && ( <span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${catColors.badge}`}>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${catColor}`}> {ligne.categorie}
{poste.categorie}
</span> </span>
)}
</td> </td>
<td className="px-4 py-3 text-sm text-muted-foreground hidden lg:table-cell">{poste.type || '—'}</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">{poste.facturation || '—'}</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">{poste.compte || '—'}</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"> <td className="px-4 py-3 text-right text-sm text-muted-foreground tabular-nums">
{formatNum(poste.budget_n1)} {formatNum(ligne.budget_n1)}
</td> </td>
<td className="px-4 py-2 text-right" style={{ minWidth: '140px' }}> <td className="px-4 py-2 text-right" style={{ minWidth: '140px' }}>
{isValidated ? ( {isValidated ? (
<span className={`font-semibold tabular-nums ${montant > 0 ? 'text-foreground' : 'text-muted-foreground'}`}> <span className={`font-semibold tabular-nums ${ligne.montant > 0 ? 'text-foreground' : 'text-muted-foreground'}`}>
{formatNum(montant)} {formatNum(ligne.montant)}
</span> </span>
) : ( ) : (
<MoneyInput <MoneyInput
value={montant} value={ligne.montant}
onChange={v => handleMontantChange(poste.libelle, v)} onChange={v => handleMontantChange(ligne.id, v)}
disabled={isValidated} disabled={isValidated}
/> />
)} )}
@@ -649,7 +865,7 @@ export default function DsiOpex() {
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<div className="w-16 bg-muted rounded-full h-1.5 overflow-hidden"> <div className="w-16 bg-muted rounded-full h-1.5 overflow-hidden">
<div <div
className="h-1.5 rounded-full bg-primary transition-all" className={`h-1.5 rounded-full transition-all ${catColors.bar}`}
style={{ width: `${Math.min(100, pct * 5)}%` }} style={{ width: `${Math.min(100, pct * 5)}%` }}
/> />
</div> </div>
@@ -658,19 +874,35 @@ export default function DsiOpex() {
</span> </span>
</div> </div>
</td> </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> </tr>
{isExpanded && showDetails && poste.detail && ( {isExpanded && ligne.detail && (
<tr key={`${poste.libelle}-detail`} className="bg-blue-50/50 border-b border-border/50"> <tr key={`${ligne.id}-detail`} className="bg-blue-50/50 border-b border-border/50">
<td /> <td />
<td colSpan={showDetails ? 8 : 7} className="px-8 py-3"> <td colSpan={!isValidated ? 9 : 8} className="px-8 py-3">
<div className="flex items-start gap-2 text-sm text-blue-700"> <div className="flex items-start gap-2 text-sm text-blue-700">
<Info className="w-4 h-4 mt-0.5 flex-shrink-0" /> <Info className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div> <div>
<p className="font-medium">Détail</p> {ligne.detail && (<><p className="font-medium">Détail</p><p className="text-blue-600 mt-0.5">{ligne.detail}</p></>)}
<p className="text-blue-600 mt-0.5">{poste.detail}</p>
{poste.mode_ventilation && (
<p className="text-blue-500 text-xs mt-1">Mode de ventilation : {poste.mode_ventilation}</p>
)}
</div> </div>
</div> </div>
</td> </td>
@@ -682,7 +914,7 @@ export default function DsiOpex() {
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="bg-muted/40 border-t-2 border-border"> <tr className="bg-muted/40 border-t-2 border-border">
<td colSpan={showDetails ? 7 : 6} className="px-4 py-3 font-bold text-foreground"> <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} TOTAL {selectedCategorie !== 'Toutes' ? selectedCategorie : 'OPEX DSI'} {annee}
</td> </td>
<td className="px-4 py-3 text-right font-bold text-orange-600 text-base tabular-nums"> <td className="px-4 py-3 text-right font-bold text-orange-600 text-base tabular-nums">
@@ -693,6 +925,7 @@ export default function DsiOpex() {
? `${totalGlobal > 0 ? ((totalFiltre / totalGlobal) * 100).toFixed(1) : '0'}%` ? `${totalGlobal > 0 ? ((totalFiltre / totalGlobal) * 100).toFixed(1) : '0'}%`
: '100%'} : '100%'}
</td> </td>
{!isValidated && <td />}
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -710,23 +943,14 @@ export default function DsiOpex() {
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)
.map(([cat, montant]) => { .map(([cat, montant]) => {
const pct = totalGlobal > 0 ? (montant / totalGlobal) * 100 : 0; const pct = totalGlobal > 0 ? (montant / totalGlobal) * 100 : 0;
const color = CATEGORIE_COLORS[cat] || 'bg-gray-100 text-gray-600'; const colors = getCatColors(cat);
const barColor = color.includes('blue') ? 'bg-blue-500' :
color.includes('purple') ? 'bg-purple-500' :
color.includes('red') ? 'bg-red-500' :
color.includes('green') ? 'bg-green-500' :
color.includes('orange') ? 'bg-orange-500' :
color.includes('teal') ? 'bg-teal-500' : 'bg-gray-400';
return ( return (
<div key={cat} className="flex items-center gap-3"> <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 ${color}`}> <span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium w-28 justify-center ${colors.badge}`}>
{cat} {cat}
</span> </span>
<div className="flex-1 bg-muted rounded-full h-2 overflow-hidden"> <div className="flex-1 bg-muted rounded-full h-2 overflow-hidden">
<div <div className={`h-2 rounded-full transition-all ${colors.bar}`} style={{ width: `${pct}%` }} />
className={`h-2 rounded-full transition-all ${barColor}`}
style={{ width: `${pct}%` }}
/>
</div> </div>
<span className="text-sm font-semibold text-foreground tabular-nums w-28 text-right"> <span className="text-sm font-semibold text-foreground tabular-nums w-28 text-right">
{formatEuros(montant)} {formatEuros(montant)}
@@ -777,7 +1001,9 @@ export default function DsiOpex() {
</td> </td>
{opexData.postes.slice(0, showDetails ? 8 : 4).map(p => ( {opexData.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"> <td key={p.libelle} className="px-3 py-2.5 text-right text-xs tabular-nums text-muted-foreground hidden xl:table-cell">
{formatNumShort(etab.montants[p.libelle] || 0)} {etab.montants[p.libelle] > 0
? new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(etab.montants[p.libelle])) + ' €'
: '—'}
</td> </td>
))} ))}
<td className="px-4 py-2.5 text-right font-semibold tabular-nums"> <td className="px-4 py-2.5 text-right font-semibold tabular-nums">
@@ -793,9 +1019,7 @@ export default function DsiOpex() {
<td colSpan={3 + (showDetails ? 8 : 4)} className="px-4 py-3 font-bold text-foreground hidden lg:table-cell"> <td colSpan={3 + (showDetails ? 8 : 4)} className="px-4 py-3 font-bold text-foreground hidden lg:table-cell">
TOTAL {sortedEtabs.length} établissements TOTAL {sortedEtabs.length} établissements
</td> </td>
<td colSpan={3} className="px-4 py-3 font-bold text-foreground lg:hidden"> <td colSpan={3} className="px-4 py-3 font-bold text-foreground lg:hidden">TOTAL</td>
TOTAL
</td>
<td className="px-4 py-3 text-right font-bold text-orange-600 text-base tabular-nums"> <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))} {formatEuros(sortedEtabs.reduce((s, e) => s + e.total, 0))}
</td> </td>
@@ -806,6 +1030,63 @@ export default function DsiOpex() {
)} )}
</div> </div>
</main> </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> </div>
); );
} }