Checkpoint: Refonte complète de DsiOpex.tsx : chaque montant prévisionnel est maintenant éditable via un champ numérique inline. Bouton Enregistrer (actif uniquement si modifications non sauvegardées). Bouton Valider avec AlertDialog de confirmation — une fois validé, l'OPEX est verrouillé définitivement (badge "Validé + date", icône cadenas, champs en lecture seule). Persistance par année en localStorage via getOpexStorageKey. Réinitialisation aux valeurs sources possible. Les KPIs et totaux se recalculent en temps réel à chaque modification.
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
// DsiOpex.tsx — OPEX DSI : Charges annuelles du Système d'Information
|
// DsiOpex.tsx — OPEX DSI : Charges du Système d'Information
|
||||||
// Design: Corporate Modernism — Itinova Budget SI 2027
|
// Design: Corporate Modernism — Itinova Budget SI
|
||||||
// Tableau de charges par établissement, sélection d'année, vue prévisionnel / réel
|
// Montants prévisionnels éditables par poste, persistance par année, validation définitive (verrouillage)
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
Search,
|
Search,
|
||||||
@@ -16,14 +16,32 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
Save,
|
||||||
|
Lock,
|
||||||
|
CheckCircle2,
|
||||||
|
PencilLine,
|
||||||
|
RotateCcw,
|
||||||
} 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';
|
||||||
import { useAnnee } from '../contexts/AnneeContext';
|
import { useAnnee, getOpexStorageKey } from '../contexts/AnneeContext';
|
||||||
import opexRaw from '../data_opex.json';
|
import opexRaw from '../data_opex.json';
|
||||||
import { formatEuros } from '../lib/format';
|
import { formatEuros } from '../lib/format';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Types
|
|
||||||
interface Poste {
|
interface Poste {
|
||||||
col_idx: number;
|
col_idx: number;
|
||||||
fournisseur: string | null;
|
fournisseur: string | null;
|
||||||
@@ -55,9 +73,16 @@ interface OpexData {
|
|||||||
meta: { nb_postes: number; nb_etablissements: number };
|
meta: { nb_postes: number; nb_etablissements: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const opexData = opexRaw as OpexData;
|
interface OpexAnneeState {
|
||||||
|
montants: Record<string, number>; // clé = libelle poste, valeur = montant saisi
|
||||||
|
validated: boolean; // true = verrouillé définitivement
|
||||||
|
savedAt?: string; // ISO date de la dernière sauvegarde
|
||||||
|
validatedAt?: string; // ISO date de validation
|
||||||
|
}
|
||||||
|
|
||||||
// Catégories disponibles
|
// ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const opexData = opexRaw as OpexData;
|
||||||
const CATEGORIES = ['Toutes', 'App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR'];
|
const CATEGORIES = ['Toutes', 'App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR'];
|
||||||
|
|
||||||
const CATEGORIE_COLORS: Record<string, string> = {
|
const CATEGORIE_COLORS: Record<string, string> = {
|
||||||
@@ -69,6 +94,8 @@ const CATEGORIE_COLORS: Record<string, string> = {
|
|||||||
'App SMR': 'bg-teal-100 text-teal-700',
|
'App SMR': 'bg-teal-100 text-teal-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatNum(v: number | null | undefined): string {
|
function formatNum(v: number | null | undefined): string {
|
||||||
if (v === null || v === undefined || v === 0) return '—';
|
if (v === null || v === undefined || v === 0) return '—';
|
||||||
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
|
return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €';
|
||||||
@@ -80,11 +107,88 @@ function formatNumShort(v: number): string {
|
|||||||
return Math.round(v) + ' €';
|
return Math.round(v) + ' €';
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'etablissements' | 'postes';
|
// 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 {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getOpexStorageKey(annee));
|
||||||
|
if (raw) return JSON.parse(raw) as OpexAnneeState;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
// Valeur par défaut : montants initialisés depuis le JSON
|
||||||
|
const montants: Record<string, number> = {};
|
||||||
|
for (const p of opexData.postes) {
|
||||||
|
montants[p.libelle] = getMontantBase(p);
|
||||||
|
}
|
||||||
|
return { montants, validated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sauvegarder l'état OPEX dans localStorage
|
||||||
|
function saveOpexState(annee: number, state: OpexAnneeState): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(getOpexStorageKey(annee), JSON.stringify(state));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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-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 ${
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Composant principal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ViewMode = 'postes' | 'etablissements';
|
||||||
type SortDir = 'asc' | 'desc';
|
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 [isDirty, setIsDirty] = useState(false);
|
||||||
|
|
||||||
|
// UI
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('postes');
|
const [viewMode, setViewMode] = useState<ViewMode>('postes');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedCategorie, setSelectedCategorie] = useState('Toutes');
|
const [selectedCategorie, setSelectedCategorie] = useState('Toutes');
|
||||||
@@ -93,7 +197,84 @@ 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);
|
||||||
|
|
||||||
// Filtrer les postes par catégorie
|
// Recharger quand l'année change
|
||||||
|
useEffect(() => {
|
||||||
|
setOpexState(loadOpexState(annee));
|
||||||
|
setIsDirty(false);
|
||||||
|
setSearch('');
|
||||||
|
setExpandedPoste(null);
|
||||||
|
}, [annee]);
|
||||||
|
|
||||||
|
// Modifier un montant
|
||||||
|
const handleMontantChange = useCallback((libelle: string, value: number) => {
|
||||||
|
if (opexState.validated) return;
|
||||||
|
setOpexState(prev => ({
|
||||||
|
...prev,
|
||||||
|
montants: { ...prev.montants, [libelle]: value },
|
||||||
|
}));
|
||||||
|
setIsDirty(true);
|
||||||
|
}, [opexState.validated]);
|
||||||
|
|
||||||
|
// 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 (opexState.validated) return;
|
||||||
|
const montants: Record<string, number> = {};
|
||||||
|
for (const p of opexData.postes) {
|
||||||
|
montants[p.libelle] = getMontantBase(p);
|
||||||
|
}
|
||||||
|
const newState: OpexAnneeState = { montants, 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.' });
|
||||||
|
}, [opexState.validated, 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]);
|
||||||
|
|
||||||
|
// Montant effectif pour un poste (saisie ou base)
|
||||||
|
const getMontant = useCallback((libelle: string): number => {
|
||||||
|
return opexState.montants[libelle] ?? 0;
|
||||||
|
}, [opexState.montants]);
|
||||||
|
|
||||||
|
// Total global recalculé
|
||||||
|
const totalGlobal = useMemo(() => {
|
||||||
|
return opexData.postes.reduce((s, p) => s + getMontant(p.libelle), 0);
|
||||||
|
}, [getMontant]);
|
||||||
|
|
||||||
|
// Totaux par catégorie
|
||||||
|
const totalParCategorie = useMemo(() => {
|
||||||
|
const totaux: Record<string, number> = {};
|
||||||
|
for (const poste of opexData.postes) {
|
||||||
|
const cat = poste.categorie || 'Autre';
|
||||||
|
totaux[cat] = (totaux[cat] || 0) + getMontant(poste.libelle);
|
||||||
|
}
|
||||||
|
return totaux;
|
||||||
|
}, [getMontant]);
|
||||||
|
|
||||||
|
// Filtrer les postes
|
||||||
const filteredPostes = useMemo(() => {
|
const filteredPostes = useMemo(() => {
|
||||||
return opexData.postes.filter(p => {
|
return opexData.postes.filter(p => {
|
||||||
if (selectedCategorie !== 'Toutes' && p.categorie !== selectedCategorie) return false;
|
if (selectedCategorie !== 'Toutes' && p.categorie !== selectedCategorie) return false;
|
||||||
@@ -103,46 +284,17 @@ export default function DsiOpex() {
|
|||||||
});
|
});
|
||||||
}, [selectedCategorie, search]);
|
}, [selectedCategorie, search]);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Totaux par catégorie (postes filtrés)
|
|
||||||
const totalParCategorie = useMemo(() => {
|
|
||||||
const totaux: Record<string, number> = {};
|
|
||||||
for (const poste of opexData.postes) {
|
|
||||||
const cat = poste.categorie || 'Autre';
|
|
||||||
const montant = poste.montant_previsionnel_2026 || 0;
|
|
||||||
totaux[cat] = (totaux[cat] || 0) + montant;
|
|
||||||
}
|
|
||||||
return totaux;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const totalFiltre = useMemo(() => {
|
const totalFiltre = useMemo(() => {
|
||||||
return filteredPostes.reduce((sum, p) => sum + (p.montant_previsionnel_2026 || 0), 0);
|
return filteredPostes.reduce((s, p) => s + getMontant(p.libelle), 0);
|
||||||
}, [filteredPostes]);
|
}, [filteredPostes, getMontant]);
|
||||||
|
|
||||||
const handleSort = (col: string) => {
|
|
||||||
if (sortCol === col) {
|
|
||||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortCol(col);
|
|
||||||
setSortDir('desc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Trier les postes
|
// Trier les postes
|
||||||
const sortedPostes = useMemo(() => {
|
const sortedPostes = useMemo(() => {
|
||||||
const arr = [...filteredPostes];
|
const arr = [...filteredPostes];
|
||||||
if (sortCol === 'montant') {
|
if (sortCol === 'montant') {
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
const va = a.montant_previsionnel_2026 || 0;
|
const va = getMontant(a.libelle);
|
||||||
const vb = b.montant_previsionnel_2026 || 0;
|
const vb = getMontant(b.libelle);
|
||||||
return sortDir === 'asc' ? va - vb : vb - va;
|
return sortDir === 'asc' ? va - vb : vb - va;
|
||||||
});
|
});
|
||||||
} else if (sortCol === 'libelle') {
|
} else if (sortCol === 'libelle') {
|
||||||
@@ -151,7 +303,16 @@ export default function DsiOpex() {
|
|||||||
: b.libelle.localeCompare(a.libelle));
|
: b.libelle.localeCompare(a.libelle));
|
||||||
}
|
}
|
||||||
return arr;
|
return arr;
|
||||||
}, [filteredPostes, sortCol, sortDir]);
|
}, [filteredPostes, sortCol, sortDir, getMontant]);
|
||||||
|
|
||||||
|
// 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
|
// Trier les établissements
|
||||||
const sortedEtabs = useMemo(() => {
|
const sortedEtabs = useMemo(() => {
|
||||||
@@ -166,6 +327,11 @@ export default function DsiOpex() {
|
|||||||
return arr;
|
return arr;
|
||||||
}, [filteredEtabs, sortCol, sortDir]);
|
}, [filteredEtabs, 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 }) => (
|
const SortBtn = ({ col, label }: { col: string; label: string }) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort(col)}
|
onClick={() => handleSort(col)}
|
||||||
@@ -176,6 +342,8 @@ export default function DsiOpex() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isValidated = opexState.validated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
@@ -183,20 +351,92 @@ export default function DsiOpex() {
|
|||||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
OPEX DSI — Charges du Système d'Information
|
OPEX DSI — Charges {annee}
|
||||||
</h1>
|
</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">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
{opexData.meta.nb_postes} postes de charges · {opexData.meta.nb_etablissements} établissements
|
{opexData.meta.nb_postes} postes de charges · {opexData.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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Sélecteur d'année — remplacé par AnneeSelectorBar global */}
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<AnneeSelectorBar />
|
<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é. Assurez-vous que tous les montants sont corrects avant de confirmer.
|
||||||
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -209,9 +449,9 @@ export default function DsiOpex() {
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-orange-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<p className="text-2xl font-bold text-orange-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
{formatEuros(opexData.total_global)}
|
{formatEuros(totalGlobal)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">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">
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -221,7 +461,7 @@ export default function DsiOpex() {
|
|||||||
<p className="text-2xl font-bold text-purple-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<p className="text-2xl font-bold text-purple-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
{formatEuros(totalParCategorie['Infogérance'] || 0)}
|
{formatEuros(totalParCategorie['Infogérance'] || 0)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{((totalParCategorie['Infogérance'] || 0) / opexData.total_global * 100).toFixed(1)}% du total</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>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card border border-border rounded-xl p-4">
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -241,14 +481,13 @@ export default function DsiOpex() {
|
|||||||
<p className="text-2xl font-bold text-red-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
<p className="text-2xl font-bold text-red-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
{formatEuros(totalParCategorie['Sécurité'] || 0)}
|
{formatEuros(totalParCategorie['Sécurité'] || 0)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{((totalParCategorie['Sécurité'] || 0) / opexData.total_global * 100).toFixed(1)}% du total</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>
|
||||||
|
|
||||||
{/* Barre d'outils */}
|
{/* 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="px-6 py-3 border-b border-border bg-background flex-shrink-0 flex flex-wrap items-center gap-3">
|
||||||
{/* Recherche */}
|
|
||||||
<div className="relative flex-1 min-w-48 max-w-72">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
@@ -260,7 +499,6 @@ export default function DsiOpex() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtre catégorie (vue postes) */}
|
|
||||||
{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" />
|
||||||
@@ -280,7 +518,6 @@ export default function DsiOpex() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toggle vue */}
|
|
||||||
<div className="ml-auto flex items-center gap-1 bg-muted rounded-lg p-1">
|
<div className="ml-auto flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setViewMode('postes'); setSortCol(null); }}
|
onClick={() => { setViewMode('postes'); setSortCol(null); }}
|
||||||
@@ -302,7 +539,6 @@ export default function DsiOpex() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle détails */}
|
|
||||||
<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"
|
||||||
@@ -318,7 +554,6 @@ export default function DsiOpex() {
|
|||||||
{/* === VUE PAR POSTE === */}
|
{/* === VUE PAR POSTE === */}
|
||||||
{viewMode === 'postes' && (
|
{viewMode === 'postes' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* En-tête total filtré */}
|
|
||||||
{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">
|
||||||
@@ -330,7 +565,13 @@ export default function DsiOpex() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tableau des postes */}
|
{!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 prévisionnels sont modifiables. Cliquez sur un montant pour le modifier, puis enregistrez. La validation définitive verrouille tous les montants.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -344,7 +585,7 @@ export default function DsiOpex() {
|
|||||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground hidden xl:table-cell">Facturation</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>}
|
{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">Budget N-1</th>
|
||||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground" style={{ minWidth: '140px' }}>
|
||||||
<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>
|
||||||
@@ -352,8 +593,8 @@ export default function DsiOpex() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedPostes.map((poste, idx) => {
|
{sortedPostes.map((poste, idx) => {
|
||||||
const montant = poste.montant_previsionnel_2026 || 0;
|
const montant = getMontant(poste.libelle);
|
||||||
const pct = opexData.total_global > 0 ? (montant / opexData.total_global) * 100 : 0;
|
const pct = totalGlobal > 0 ? (montant / totalGlobal) * 100 : 0;
|
||||||
const isExpanded = expandedPoste === poste.libelle;
|
const isExpanded = expandedPoste === poste.libelle;
|
||||||
const catColor = CATEGORIE_COLORS[poste.categorie || ''] || 'bg-gray-100 text-gray-600';
|
const catColor = CATEGORIE_COLORS[poste.categorie || ''] || 'bg-gray-100 text-gray-600';
|
||||||
|
|
||||||
@@ -361,11 +602,13 @@ export default function DsiOpex() {
|
|||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
key={poste.libelle}
|
key={poste.libelle}
|
||||||
className={`border-b border-border/50 hover:bg-muted/30 transition-colors cursor-pointer ${isExpanded ? 'bg-muted/20' : ''}`}
|
className={`border-b border-border/50 hover:bg-muted/30 transition-colors ${isExpanded ? 'bg-muted/20' : ''}`}
|
||||||
onClick={() => setExpandedPoste(isExpanded ? null : poste.libelle)}
|
|
||||||
>
|
>
|
||||||
<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 className="px-4 py-3">
|
<td
|
||||||
|
className="px-4 py-3 cursor-pointer"
|
||||||
|
onClick={() => setExpandedPoste(isExpanded ? null : poste.libelle)}
|
||||||
|
>
|
||||||
<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>
|
||||||
@@ -389,10 +632,18 @@ export default function DsiOpex() {
|
|||||||
<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(poste.budget_n1)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-semibold tabular-nums">
|
<td className="px-4 py-2 text-right" style={{ minWidth: '140px' }}>
|
||||||
<span className={montant > 0 ? 'text-foreground' : 'text-muted-foreground'}>
|
{isValidated ? (
|
||||||
|
<span className={`font-semibold tabular-nums ${montant > 0 ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||||
{formatNum(montant)}
|
{formatNum(montant)}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<MoneyInput
|
||||||
|
value={montant}
|
||||||
|
onChange={v => handleMontantChange(poste.libelle, v)}
|
||||||
|
disabled={isValidated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
@@ -439,7 +690,7 @@ export default function DsiOpex() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-sm text-muted-foreground">
|
<td className="px-4 py-3 text-right text-sm text-muted-foreground">
|
||||||
{selectedCategorie !== 'Toutes'
|
{selectedCategorie !== 'Toutes'
|
||||||
? `${((totalFiltre / opexData.total_global) * 100).toFixed(1)}%`
|
? `${totalGlobal > 0 ? ((totalFiltre / totalGlobal) * 100).toFixed(1) : '0'}%`
|
||||||
: '100%'}
|
: '100%'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -458,7 +709,7 @@ export default function DsiOpex() {
|
|||||||
.filter(([, v]) => v > 0)
|
.filter(([, v]) => v > 0)
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
.map(([cat, montant]) => {
|
.map(([cat, montant]) => {
|
||||||
const pct = (montant / opexData.total_global) * 100;
|
const pct = totalGlobal > 0 ? (montant / totalGlobal) * 100 : 0;
|
||||||
const color = CATEGORIE_COLORS[cat] || 'bg-gray-100 text-gray-600';
|
const color = CATEGORIE_COLORS[cat] || 'bg-gray-100 text-gray-600';
|
||||||
const barColor = color.includes('blue') ? 'bg-blue-500' :
|
const barColor = color.includes('blue') ? 'bg-blue-500' :
|
||||||
color.includes('purple') ? 'bg-purple-500' :
|
color.includes('purple') ? 'bg-purple-500' :
|
||||||
@@ -502,7 +753,6 @@ export default function DsiOpex() {
|
|||||||
<SortBtn col="nom" label="Établissement" />
|
<SortBtn col="nom" label="Établissement" />
|
||||||
</th>
|
</th>
|
||||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">Base répartition</th>
|
<th className="text-right px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">Base répartition</th>
|
||||||
{/* Postes principaux */}
|
|
||||||
{opexData.postes.slice(0, showDetails ? 8 : 4).map(p => (
|
{opexData.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">
|
<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>
|
<span className="block truncate max-w-20" title={p.libelle}>{p.libelle.split(' ').slice(0, 3).join(' ')}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user