diff --git a/client/src/pages/DsiOpex.tsx b/client/src/pages/DsiOpex.tsx index dbc18be..aa968b2 100644 --- a/client/src/pages/DsiOpex.tsx +++ b/client/src/pages/DsiOpex.tsx @@ -1,8 +1,8 @@ -// DsiOpex.tsx — OPEX DSI : Charges annuelles du Système d'Information -// Design: Corporate Modernism — Itinova Budget SI 2027 -// Tableau de charges par établissement, sélection d'année, vue prévisionnel / réel +// DsiOpex.tsx — OPEX DSI : Charges du Système d'Information +// Design: Corporate Modernism — Itinova Budget SI +// 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 { TrendingDown, Search, @@ -16,14 +16,32 @@ import { Eye, EyeOff, ArrowUpDown, + Save, + Lock, + CheckCircle2, + PencilLine, + RotateCcw, } from 'lucide-react'; import { AppSidebar } from '../components/AppSidebar'; import { AnneeSelectorBar } from '../components/AnneeSelectorBar'; -import { useAnnee } from '../contexts/AnneeContext'; +import { useAnnee, getOpexStorageKey } from '../contexts/AnneeContext'; import opexRaw from '../data_opex.json'; 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 { col_idx: number; fournisseur: string | null; @@ -55,9 +73,16 @@ interface OpexData { meta: { nb_postes: number; nb_etablissements: number }; } -const opexData = opexRaw as OpexData; +interface OpexAnneeState { + montants: Record; // 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 CATEGORIE_COLORS: Record = { @@ -69,6 +94,8 @@ const CATEGORIE_COLORS: Record = { 'App SMR': 'bg-teal-100 text-teal-700', }; +// ─── 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) + ' €'; @@ -80,11 +107,88 @@ function formatNumShort(v: number): string { 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 = {}; + 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 ( +
+ { + 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' + }`} + /> + +
+ ); +} + +// ─── Composant principal ────────────────────────────────────────────────────── + +type ViewMode = 'postes' | 'etablissements'; type SortDir = 'asc' | 'desc'; export default function DsiOpex() { const { annee } = useAnnee(); + + // État OPEX pour l'année courante + const [opexState, setOpexState] = useState(() => loadOpexState(annee)); + const [isDirty, setIsDirty] = useState(false); + + // UI const [viewMode, setViewMode] = useState('postes'); const [search, setSearch] = useState(''); const [selectedCategorie, setSelectedCategorie] = useState('Toutes'); @@ -93,7 +197,84 @@ export default function DsiOpex() { const [expandedPoste, setExpandedPoste] = useState(null); 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 = {}; + 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 = {}; + 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(() => { return opexData.postes.filter(p => { if (selectedCategorie !== 'Toutes' && p.categorie !== selectedCategorie) return false; @@ -103,46 +284,17 @@ export default function DsiOpex() { }); }, [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 = {}; - 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(() => { - return filteredPostes.reduce((sum, p) => sum + (p.montant_previsionnel_2026 || 0), 0); - }, [filteredPostes]); - - const handleSort = (col: string) => { - if (sortCol === col) { - setSortDir(d => d === 'asc' ? 'desc' : 'asc'); - } else { - setSortCol(col); - setSortDir('desc'); - } - }; + return filteredPostes.reduce((s, p) => s + getMontant(p.libelle), 0); + }, [filteredPostes, getMontant]); // Trier les postes const sortedPostes = useMemo(() => { const arr = [...filteredPostes]; if (sortCol === 'montant') { arr.sort((a, b) => { - const va = a.montant_previsionnel_2026 || 0; - const vb = b.montant_previsionnel_2026 || 0; + const va = getMontant(a.libelle); + const vb = getMontant(b.libelle); return sortDir === 'asc' ? va - vb : vb - va; }); } else if (sortCol === 'libelle') { @@ -151,7 +303,16 @@ export default function DsiOpex() { : b.libelle.localeCompare(a.libelle)); } 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 const sortedEtabs = useMemo(() => { @@ -166,6 +327,11 @@ export default function DsiOpex() { return arr; }, [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 isValidated = opexState.validated; + return (
@@ -183,20 +351,92 @@ export default function DsiOpex() {
{/* En-tête */}
-
+
-

- OPEX DSI — Charges du Système d'Information -

+
+

+ OPEX DSI — Charges {annee} +

+ {isValidated && ( + + + Validé le {opexState.validatedAt ? new Date(opexState.validatedAt).toLocaleDateString('fr-FR') : ''} + + )} + {!isValidated && isDirty && ( + + + Modifications non enregistrées + + )} +

{opexData.meta.nb_postes} postes de charges · {opexData.meta.nb_etablissements} établissements + {opexState.savedAt && !isValidated && ( + · Enregistré le {new Date(opexState.savedAt).toLocaleDateString('fr-FR')} à {new Date(opexState.savedAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} + )}

- {/* Sélecteur d'année — remplacé par AnneeSelectorBar global */} -
+
+ {!isValidated && ( + <> + + + + + + + + + Valider l'OPEX {annee} ? + + Cette action est irréversible. 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. + + + + Annuler + + Confirmer la validation + + + + + + )} + {isValidated && ( +
+ + Prévisionnel verrouillé +
+ )}
-
@@ -209,9 +449,9 @@ export default function DsiOpex() { Total OPEX {annee}

- {formatEuros(opexData.total_global)} + {formatEuros(totalGlobal)}

-

Prévisionnel

+

{isValidated ? 'Validé' : 'Prévisionnel'}

@@ -221,7 +461,7 @@ export default function DsiOpex() {

{formatEuros(totalParCategorie['Infogérance'] || 0)}

-

{((totalParCategorie['Infogérance'] || 0) / opexData.total_global * 100).toFixed(1)}% du total

+

{totalGlobal > 0 ? ((totalParCategorie['Infogérance'] || 0) / totalGlobal * 100).toFixed(1) : '0'}% du total

@@ -241,14 +481,13 @@ export default function DsiOpex() {

{formatEuros(totalParCategorie['Sécurité'] || 0)}

-

{((totalParCategorie['Sécurité'] || 0) / opexData.total_global * 100).toFixed(1)}% du total

+

{totalGlobal > 0 ? ((totalParCategorie['Sécurité'] || 0) / totalGlobal * 100).toFixed(1) : '0'}% du total

{/* Barre d'outils */}
- {/* Recherche */}
- {/* Filtre catégorie (vue postes) */} {viewMode === 'postes' && (
@@ -280,7 +518,6 @@ export default function DsiOpex() {
)} - {/* Toggle vue */}
- {/* Toggle détails */}