From 1cdfbd1b12ad283132e03e582d5122b1ac42368a Mon Sep 17 00:00:00 2001 From: Manus Date: Tue, 2 Jun 2026 21:06:15 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Page=20DSI=20CAPEX=20:=20tableau?= =?UTF-8?q?=20consolid=C3=A9=20des=20investissements=20SI=202027=20par=20?= =?UTF-8?q?=C3=A9tablissement,=20au=20format=20du=20fichier=20Excel=20BP20?= =?UTF-8?q?27,=20avec=209=20colonnes=20color=C3=A9es,=20totaux=20par=20col?= =?UTF-8?q?onne,=20KPIs,=20filtres=20et=20tri.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/DsiCapex.tsx | 475 ++++++++++++++++++++++++++++++++-- 1 file changed, 459 insertions(+), 16 deletions(-) diff --git a/client/src/pages/DsiCapex.tsx b/client/src/pages/DsiCapex.tsx index 5fafced..9d1cf2c 100644 --- a/client/src/pages/DsiCapex.tsx +++ b/client/src/pages/DsiCapex.tsx @@ -1,26 +1,469 @@ -// DsiCapex.tsx — CAPEX DSI (Investissement) -import { useState } from 'react'; +// 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 { ComingSoon } from '../components/ComingSoon'; -import { TrendingUp } from 'lucide-react'; +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; +const STORAGE_KEY = 'itinova_budget2027_saisies'; + +// 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 loadSaisies(): Record> { + try { + const raw = localStorage.getItem(STORAGE_KEY); + 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 [collapsed, setCollapsed] = useState(false); + const { parametres } = useParametres(); + 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(loadSaisies()); + }, [lastRefresh]); + + 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]); + + 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 (
- setCollapsed(c => !c)} /> -
+ + +
+ {/* En-tête */}
-

- CAPEX — Investissements DSI -

-

Budget d'investissement du Système d'Information

+
+
+

+ DSI CAPEX — Prévisionnel Budget 2027 +

+

+ Tableau consolidé des investissements SI par établissement +

+
+ +
- + + {/* KPIs */} +
+
+
+
+ + Total CAPEX 2027 +
+

+ {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 */} +
+
+ + 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 */} +
+
+ + + {/* Ligne 1 : Titre général + en-têtes colorés */} + + + {COLONNES.map(col => ( + + ))} + + + + {/* Ligne 2 : Année sous chaque colonne */} + + {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 2027 +
+ {col.shortLabel.split('\n').map((line, i) => ( + {line} + ))} + + Total
BP 2027 +
+ Commentaires +
+ 2027 + 2027
+ + + + + + + + + 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 */} + {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 +
+
+
);