// DsiCapex.tsx — DSI CAPEX : Tableau consolidé des budgets prévisionnels 2027 // Design: Corporate Modernism — Itinova Budget SI 2027 // Reprend les saisies de la page "Construction Budget 2027" sous forme de tableau // au format du fichier BP2027-Prévisionnel2027CAPEXDSIétablissements.xlsx import { useState, useMemo, useEffect } from 'react'; import { Search, ArrowUpDown, CheckCircle, Clock, Euro, Building2, FileSpreadsheet, RefreshCw, ChevronUp, ChevronDown, Info, } from 'lucide-react'; import { AppSidebar } from '../components/AppSidebar'; import { AnneeSelectorBar } from '../components/AnneeSelectorBar'; import { useAnnee, getCapexStorageKey } from '../contexts/AnneeContext'; import bp2027Raw from '../data_bp2027.json'; import budgetRaw from '../data_budget.json'; import { useParametres } from '../contexts/ParametresContext'; import { formatEuros } from '../lib/format'; import type { BP2027Data, BudgetFormValues } from '../types/bp2027'; import type { BudgetData } from '../types/budget'; const bp2027Data = bp2027Raw as BP2027Data; const budgetData = budgetRaw as BudgetData; // Clé de stockage dynamique par année (voir getCapexStorageKey) // Colonnes du tableau (correspondance exacte avec le fichier Excel BP2027) const COLONNES = [ { key: 'renouvellement_2027', shortLabel: 'Renouvellement\nParc Info', color: 'blue' }, { key: 'machines_supplementaires', shortLabel: 'Machines\nSuppl.', color: 'indigo' }, { key: 'appel_malade', shortLabel: 'Appel\nMalade', color: 'red' }, { key: 'telephonie', shortLabel: 'Téléphonie', color: 'green' }, { key: 'wifi', shortLabel: 'WiFi', color: 'cyan' }, { key: 'video_surveillance', shortLabel: 'Vidéo\nSurveill.', color: 'orange' }, { key: 'copieurs', shortLabel: 'Copieurs', color: 'purple' }, { key: 'visio', shortLabel: 'Visio', color: 'teal' }, { key: 'autres', shortLabel: 'Autres\nInvest. SI', color: 'slate' }, ] as const; type ColKey = typeof COLONNES[number]['key']; const COL_COLORS: Record = { blue: { header: 'bg-blue-600 text-white', cell: 'text-blue-700', total: 'bg-blue-50 text-blue-800 font-semibold' }, indigo: { header: 'bg-indigo-600 text-white', cell: 'text-indigo-700', total: 'bg-indigo-50 text-indigo-800 font-semibold' }, red: { header: 'bg-red-500 text-white', cell: 'text-red-700', total: 'bg-red-50 text-red-800 font-semibold' }, green: { header: 'bg-emerald-600 text-white', cell: 'text-emerald-700', total: 'bg-emerald-50 text-emerald-800 font-semibold' }, cyan: { header: 'bg-cyan-600 text-white', cell: 'text-cyan-700', total: 'bg-cyan-50 text-cyan-800 font-semibold' }, orange: { header: 'bg-orange-500 text-white', cell: 'text-orange-700', total: 'bg-orange-50 text-orange-800 font-semibold' }, purple: { header: 'bg-purple-600 text-white', cell: 'text-purple-700', total: 'bg-purple-50 text-purple-800 font-semibold' }, teal: { header: 'bg-teal-600 text-white', cell: 'text-teal-700', total: 'bg-teal-50 text-teal-800 font-semibold' }, slate: { header: 'bg-slate-500 text-white', cell: 'text-slate-700', total: 'bg-slate-50 text-slate-800 font-semibold' }, }; function loadSaisiesForAnnee(annee: number): Record> { try { const raw = localStorage.getItem(getCapexStorageKey(annee)); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } function getBudgetRenouvellement2027( code: string, parametres: { coutFixe: number; coutPortable: number; seuilFixesAns: number; seuilPortablesAns: number } ): number { const etab = budgetData.etablissements.find(e => e.code === code); if (!etab) return 0; const nbFixesR = (etab.fixes ?? []).filter(f => f.age_ans !== null && f.age_ans >= parametres.seuilFixesAns).length; const nbPortablesR = (etab.portables ?? []).filter(p => p.age_ans !== null && p.age_ans >= parametres.seuilPortablesAns).length; return nbFixesR * parametres.coutFixe + nbPortablesR * parametres.coutPortable; } function fmtCell(v: number): string { if (!v || v === 0) return '—'; return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(v) + ' €'; } type SortDir = 'asc' | 'desc'; export default function DsiCapex() { const { parametres } = useParametres(); const { annee } = useAnnee(); const [saisies, setSaisies] = useState>>({}); const [search, setSearch] = useState(''); const [sortCol, setSortCol] = useState('nom'); const [sortDir, setSortDir] = useState('asc'); const [showOnlyFilled, setShowOnlyFilled] = useState(false); const [lastRefresh, setLastRefresh] = useState(Date.now()); useEffect(() => { setSaisies(loadSaisiesForAnnee(annee)); }, [lastRefresh, annee]); const handleRefresh = () => setLastRefresh(Date.now()); // Construire les lignes du tableau const lignes = useMemo(() => { return bp2027Data.etablissements.map(etab => { const saisie = saisies[etab.code] || {}; const renouv = saisie.renouvellement_2027 !== undefined ? saisie.renouvellement_2027 : getBudgetRenouvellement2027(etab.code, parametres); const valeurs: Record = { renouvellement_2027: renouv, machines_supplementaires: saisie.machines_supplementaires ?? 0, appel_malade: saisie.appel_malade ?? 0, telephonie: saisie.telephonie ?? 0, wifi: saisie.wifi ?? 0, video_surveillance: saisie.video_surveillance ?? 0, copieurs: saisie.copieurs ?? 0, visio: saisie.visio ?? 0, autres: saisie.autres ?? 0, }; const total = Object.values(valeurs).reduce((s, v) => s + (v || 0), 0); const hasSaisie = Object.keys(saisies).includes(etab.code); return { code: etab.code, nom: etab.nom, valeurs, total, hasSaisie, commentaires: saisie.commentaires || '', }; }); }, [saisies, parametres]); const filteredLignes = useMemo(() => { return lignes.filter(l => { if (showOnlyFilled && !l.hasSaisie) return false; if (search && !l.nom.toLowerCase().includes(search.toLowerCase()) && !l.code.toLowerCase().includes(search.toLowerCase())) return false; return true; }); }, [lignes, search, showOnlyFilled]); const sortedLignes = useMemo(() => { const arr = [...filteredLignes]; arr.sort((a, b) => { let va: number | string, vb: number | string; if (sortCol === 'nom') { va = a.nom; vb = b.nom; } else if (sortCol === 'code') { va = a.code; vb = b.code; } else if (sortCol === 'total') { va = a.total; vb = b.total; } else { va = a.valeurs[sortCol as ColKey] ?? 0; vb = b.valeurs[sortCol as ColKey] ?? 0; } if (typeof va === 'string') { return sortDir === 'asc' ? va.localeCompare(vb as string) : (vb as string).localeCompare(va); } return sortDir === 'asc' ? (va as number) - (vb as number) : (vb as number) - (va as number); }); return arr; }, [filteredLignes, sortCol, sortDir]); const totauxColonnes = useMemo(() => { const t: Record = {} as Record; for (const col of COLONNES) { t[col.key] = sortedLignes.reduce((s, l) => s + (l.valeurs[col.key] || 0), 0); } return t; }, [sortedLignes]); const totalGeneral = useMemo(() => sortedLignes.reduce((s, l) => s + l.total, 0), [sortedLignes]); const nbFilled = useMemo(() => lignes.filter(l => l.hasSaisie).length, [lignes]); // La synthèse affiche les données si : // - au moins un établissement a été saisi manuellement (saisie CAPEX), OU // - l'année a des données de renouvellement calculées par vétusté (2026 et 2027) const ANNEES_AVEC_INVENTAIRE = [2026, 2027]; const hasDonnees = nbFilled > 0 || ANNEES_AVEC_INVENTAIRE.includes(annee); const handleSort = (col: string) => { if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(col); setSortDir('asc'); } }; const SortIcon = ({ col }: { col: string }) => { if (sortCol !== col) return ; return sortDir === 'asc' ? : ; }; return (
{/* En-tête */}

CAPEX - Synthèse {annee}

Tableau consolidé des investissements SI par établissement

{/* KPIs — uniquement si données saisies pour cette année */} {hasDonnees && (
Total CAPEX {annee}

{formatEuros(totalGeneral)}

Tous établissements affichés

Établissements

{bp2027Data.etablissements.length}

dans le périmètre

Budgets saisis

{nbFilled}

sur {bp2027Data.etablissements.length} ({Math.round(nbFilled / bp2027Data.etablissements.length * 100)}%)

En attente

{bp2027Data.etablissements.length - nbFilled}

budgets non encore saisis

)} {/* Barre d'outils — uniquement si données saisies */} {hasDonnees && (
setSearch(e.target.value)} className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/30" />
{sortedLignes.length} établissement{sortedLignes.length > 1 ? 's' : ''} affichés
)} {/* Tableau principal — scroll horizontal ou état vide */}
{!hasDonnees ? (

Aucune donnée pour {annee}

Aucun budget CAPEX n'a encore été saisi pour l'exercice {annee}. Utilisez la page CAPEX - Construction pour saisir les budgets des établissements.

) : (
{/* Ligne 1 : Titre général + en-têtes colorés */} {COLONNES.map(col => ( ))} {/* Ligne 3 : En-têtes de tri */} {COLONNES.map(col => ( ))} {sortedLignes.map((ligne, idx) => ( {/* Code */} {/* Nom */} {/* Colonnes de valeurs */} {COLONNES.map(col => { const val = ligne.valeurs[col.key]; return ( ); })} {/* Total */} {/* Commentaires */} ))} {/* Ligne de totaux */} {COLONNES.map(col => ( ))}
Campagne budgétaire — CAPEX {annee} {col.shortLabel.split('\n').map((line, i) => ( {line} ))} Total BP {annee} Commentaires
Commentaires
{ligne.hasSaisie ? : } {ligne.code}
{ligne.nom} 0 ? COL_COLORS[col.color].cell : 'text-muted-foreground/30' }`} > {fmtCell(val)} 0 ? 'text-orange-600' : 'text-muted-foreground/30' }`}> {fmtCell(ligne.total)} {ligne.commentaires || '—'}
TOTAL — {sortedLignes.length} établissement{sortedLignes.length > 1 ? 's' : ''} {totauxColonnes[col.key] > 0 ? new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(totauxColonnes[col.key]) + ' €' : '—'} {formatEuros(totalGeneral)}
)} {/* Message si aucun résultat (visible uniquement quand hasDonnees=true mais filtre vide) */} {hasDonnees && sortedLignes.length === 0 && (

Aucun établissement trouvé

{showOnlyFilled ? 'Aucun budget n\'a encore été saisi.' : 'Modifiez votre recherche.'}

)} {/* Légende */}
Budget saisi via "Construction BP 2027"
Budget non encore saisi (renouvellement calculé par vétusté)
Cliquez sur "Actualiser" pour recharger les dernières saisies
); }