// Design: Corporate Modernism — Itinova Budget SI // OPEX DSI : vignettes par catégorie, CRUD lignes, persistance par année, validation définitive import { useState, useMemo, useEffect, useCallback } from 'react'; import { TrendingDown, TrendingUp, Search, ChevronDown, ChevronUp, Info, BarChart3, Building2, Euro, Filter, Eye, EyeOff, ArrowUpDown, Save, Lock, CheckCircle2, PencilLine, RotateCcw, Plus, Pencil, Trash2, X, } from 'lucide-react'; import { AppSidebar } from '../components/AppSidebar'; import { AnneeSelectorBar } from '../components/AnneeSelectorBar'; import { useAnnee, getOpexStorageKey } from '../contexts/AnneeContext'; import opexRaw from '../data_opex.json'; import opex2025Raw from '../data_opex_2025.json'; import { formatEuros } from '../lib/format'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { toast } from 'sonner'; // ─── Types ──────────────────────────────────────────────────────────────────── interface Poste { col_idx: number; fournisseur: string | null; libelle: string; detail: string | null; facturation: string | null; mode_ventilation: string | null; categorie: string | null; type: string | null; compte: string | null; budget_n1: number | null; montant_previsionnel_2026?: number | null; montant_previsionnel_2025?: number | null; } interface Etablissement { code: string; nom: string; base_repartition: number; montants: Record; total: number; } interface TendanceCategorie { montant_n1: number | null; montant_n: number; variation_pct: number | null; tendance: 'hausse' | 'baisse' | 'stable' | 'new'; } interface OpexData { annee: number; total_global: number; postes: Poste[]; etablissements: Etablissement[]; categories_totaux: Record; tendances_categories?: Record; meta: { nb_postes: number; nb_etablissements?: number }; } // Ligne persistée (peut être source ou ajoutée par l'utilisateur) interface LigneOpex { id: string; // identifiant stable libelle: string; fournisseur: string; categorie: string; type: string; facturation: string; compte: string; detail: string; budget_n1: number; montant: number; // montant prévisionnel éditable isCustom: boolean; // true = ajoutée par l'utilisateur } interface OpexAnneeState { lignes: LigneOpex[]; validated: boolean; savedAt?: string; validatedAt?: string; } // ─── Constantes ─────────────────────────────────────────────────────────────── const opexData2026 = opexRaw as OpexData; const opexData2025 = opex2025Raw as OpexData; function getOpexDataForAnnee(annee: number): OpexData { if (annee === 2025) return opexData2025; return opexData2026; // 2026 et autres années } const CATEGORIES_FIXES = ['App global', 'Infogérance', 'Sécurité', 'Téléphonie', 'App HEP', 'App SMR']; const CATEGORIE_COLORS: Record = { 'App global': { badge: 'bg-blue-100 text-blue-700', bar: 'bg-blue-500', kpi: 'text-blue-600', icon: 'text-blue-500' }, 'Infogérance': { badge: 'bg-purple-100 text-purple-700', bar: 'bg-purple-500', kpi: 'text-purple-600', icon: 'text-purple-500' }, 'Sécurité': { badge: 'bg-red-100 text-red-700', bar: 'bg-red-500', kpi: 'text-red-600', icon: 'text-red-500' }, 'Téléphonie': { badge: 'bg-green-100 text-green-700', bar: 'bg-green-500', kpi: 'text-green-600', icon: 'text-green-500' }, 'App HEP': { badge: 'bg-orange-100 text-orange-700', bar: 'bg-orange-500', kpi: 'text-orange-600', icon: 'text-orange-500' }, 'App SMR': { badge: 'bg-teal-100 text-teal-700', bar: 'bg-teal-500', kpi: 'text-teal-600', icon: 'text-teal-500' }, 'Autre': { badge: 'bg-gray-100 text-gray-600', bar: 'bg-gray-400', kpi: 'text-gray-600', icon: 'text-gray-400' }, }; function getCatColors(cat: string) { return CATEGORIE_COLORS[cat] ?? CATEGORIE_COLORS['Autre']; } // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNum(v: number | null | undefined): string { if (v === null || v === undefined || v === 0) return '—'; return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €'; } function buildLignesFromSource(annee?: number): LigneOpex[] { const src = getOpexDataForAnnee(annee ?? 2026); return src.postes.map(p => ({ id: `src-${p.col_idx}`, libelle: p.libelle, fournisseur: p.fournisseur ?? '', categorie: p.categorie ?? 'Autre', type: p.type ?? '', facturation: p.facturation ?? '', compte: p.compte ?? '', detail: p.detail ?? '', budget_n1: p.budget_n1 ?? 0, montant: (annee === 2025 ? p.montant_previsionnel_2025 : p.montant_previsionnel_2026) ?? 0, isCustom: false, })); } function loadOpexState(annee: number): OpexAnneeState { try { const raw = localStorage.getItem(getOpexStorageKey(annee)); if (raw) { const parsed = JSON.parse(raw) as OpexAnneeState; if (parsed.lignes && Array.isArray(parsed.lignes)) return parsed; } } catch { /* ignore */ } // Données sources disponibles pour 2025 et 2026 uniquement if (annee === 2025 || annee === 2026) { return { lignes: buildLignesFromSource(annee), validated: false }; } return { lignes: [], validated: false }; } function saveOpexState(annee: number, state: OpexAnneeState): void { try { localStorage.setItem(getOpexStorageKey(annee), JSON.stringify(state)); } catch { /* ignore */ } } function genId(): string { return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; } // ─── Composant champ numérique ───────────────────────────────────────────── function MoneyInput({ value, onChange, disabled = false, }: { value: number; onChange: (v: number) => void; disabled?: boolean; }) { const [raw, setRaw] = useState(value === 0 ? '' : String(value)); useEffect(() => { setRaw(value === 0 ? '' : String(value)); }, [value]); return (
{ setRaw(e.target.value); const v = parseFloat(e.target.value); onChange(isNaN(v) || v < 0 ? 0 : Math.round(v)); }} onBlur={() => setRaw(value === 0 ? '' : String(value))} className={`w-full pl-6 pr-2 py-1.5 text-xs border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary tabular-nums transition-colors text-right ${ disabled ? 'bg-muted/30 text-muted-foreground cursor-not-allowed border-border/40' : 'bg-background border-border hover:border-primary/50' }`} />
); } // ─── Formulaire ajout/édition ────────────────────────────────────────────── const EMPTY_LIGNE: Omit = { libelle: '', fournisseur: '', categorie: 'App global', type: '', facturation: '', compte: '', detail: '', budget_n1: 0, montant: 0, }; function LigneForm({ initial, onSave, onClose, }: { initial: Omit; onSave: (data: Omit) => 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 (
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" />
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" />
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" />
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" />
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" />
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" />
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" />