// DsiOpex.tsx — OPEX DSI : vignettes par catégorie, CRUD lignes, persistance BDD via tRPC // Migré depuis localStorage vers tRPC + MySQL import React, { 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, Loader2, Key, Upload, AlertCircle, } from 'lucide-react'; import { AppSidebar } from '../components/AppSidebar'; import { AnneeSelectorBar } from '../components/AnneeSelectorBar'; import { useAnnee } 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'; import { trpc } from '@/lib/trpc'; // ─── 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; libelle_court?: string; libelle_detail?: string; } interface EtabSource { code: string; nom: string; base_repartition: 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: EtabSource[]; categories_totaux: Record; tendances_categories?: Record; meta: { nb_postes: number; nb_etablissements?: number }; } // Ligne en mémoire (mappée depuis la BDD) interface LigneOpex { id: number; // id BDD libelle: string; fournisseur: string; categorie: string; type: string; facturation: string; compte: string; detail: string; budget_n1: number; montant: number; isCustom: boolean; mode_ventilation: string | null; libelleCourt?: string | null; libelleDetail?: string | null; colIdx: number; } // ─── Constantes ─────────────────────────────────────────────────────────────── const opexData2026 = opexRaw as OpexData; const opexData2025 = opex2025Raw as OpexData; function getOpexDataForAnnee(annee: number): OpexData { if (annee === 2025) return opexData2025; return opexData2026; } 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']; } 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) + ' €'; } // Libellés parasites (noms de catégories ou de synthèse qui ne sont pas de vrais postes) const LIBELLES_PARASITES = new Set([ 'Ventilation cout', 'Ventilation coût', 'Infogérance', 'Sécurité', 'Téléphonie', 'App global', 'App HEP', 'App SMR', 'Applicatifs communs', 'Applicatifs PA', 'Applicatifs HEP', 'Applicatifs SMR', 'Nouveautés', 'TOTAL', ]); function isLibelleParasite(libelle: string): boolean { const lib = libelle.trim(); return LIBELLES_PARASITES.has(lib) || /^(total|ventilation|répartition)/i.test(lib); } // Construire les postes sources pour l'initialisation BDD function buildPostesFromSource(annee: number) { const src = getOpexDataForAnnee(annee); return src.postes .filter(p => !isLibelleParasite(p.libelle)) .map((p, idx) => ({ colIdx: p.col_idx ?? idx, libelle: p.libelle, libelleCourt: p.libelle_court ?? null, libelleDetail: p.libelle_detail ?? null, fournisseur: p.fournisseur ?? null, categorie: p.categorie ?? 'Autre', type: p.type ?? null, facturation: p.facturation ?? null, modeVentilation: p.mode_ventilation ?? null, compte: p.compte ?? null, detail: p.detail ?? null, budgetN1: String(p.budget_n1 ?? 0), montant: String((annee === 2025 ? p.montant_previsionnel_2025 : p.montant_previsionnel_2026) ?? 0), isCustom: false, })); } const EMPTY_LIGNE_FORM = { libelle: '', fournisseur: '', categorie: 'App global', type: '', facturation: '', compte: '', detail: '', budget_n1: 0, montant: 0, }; // ─── 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 ────────────────────────────────────────────── type LigneFormData = typeof EMPTY_LIGNE_FORM; function LigneForm({ initial, onSave, onClose, }: { initial: LigneFormData; onSave: (data: LigneFormData) => 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" />