Files
itinova-budget-si/client/src/pages/DsiCapex.tsx

515 lines
25 KiB
TypeScript

// 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 { 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;
function loadInventaireData(annee: number): BudgetData | null {
try {
const raw = localStorage.getItem(`budgetsi_inventaire_data_${annee}`);
return raw ? JSON.parse(raw) as BudgetData : null;
} catch { return null; }
}
// 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<string, { header: string; cell: string; total: string }> = {
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<string, Partial<BudgetFormValues>> {
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 },
inventaire: BudgetData | null
): number {
if (!inventaire) return 0;
const etab = inventaire.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<Record<string, Partial<BudgetFormValues>>>({});
const [search, setSearch] = useState('');
const [sortCol, setSortCol] = useState<string>('nom');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [showOnlyFilled, setShowOnlyFilled] = useState(false);
const [lastRefresh, setLastRefresh] = useState(Date.now());
const [inventaire, setInventaire] = useState<BudgetData | null>(() => loadInventaireData(annee));
useEffect(() => {
setSaisies(loadSaisiesForAnnee(annee));
}, [lastRefresh, annee]);
// Recharger l'inventaire quand l'année change
useEffect(() => {
setInventaire(loadInventaireData(annee));
}, [annee]);
useEffect(() => {
const onImport = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail || detail.annee === annee) setInventaire(loadInventaireData(annee));
};
const onStorage = (e: StorageEvent) => {
if (e.key === `budgetsi_inventaire_data_${annee}`) setInventaire(loadInventaireData(annee));
};
window.addEventListener('budgetsi_inventaire_updated', onImport);
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener('budgetsi_inventaire_updated', onImport);
window.removeEventListener('storage', onStorage);
};
}, [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, inventaire);
const valeurs: Record<ColKey, number> = {
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<ColKey, number> = {} as Record<ColKey, number>;
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 un inventaire a été importé ou si des saisies CAPEX existent
const hasDonnees = inventaire !== null || nbFilled > 0;
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 <ArrowUpDown className="w-3 h-3 opacity-40" />;
return sortDir === 'asc'
? <ChevronUp className="w-3 h-3" />
: <ChevronDown className="w-3 h-3" />;
};
return (
<div className="min-h-screen flex bg-background">
<AppSidebar />
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* En-tête */}
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
CAPEX - Synthèse {annee}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Tableau consolidé des investissements SI par établissement
</p>
</div>
<div className="flex items-center gap-3">
<AnneeSelectorBar />
<button
onClick={handleRefresh}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<RefreshCw className="w-4 h-4" />
Actualiser
</button>
</div>
</div>
</header>
{/* KPIs — uniquement si données saisies pour cette année */}
{hasDonnees && (
<div className="px-6 py-4 border-b border-border bg-muted/20 flex-shrink-0">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Euro className="w-4 h-4 text-orange-500" />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Total CAPEX {annee}</span>
</div>
<p className="text-2xl font-bold text-orange-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
{formatEuros(totalGeneral)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">Tous établissements affichés</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Building2 className="w-4 h-4 text-blue-500" />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Établissements</span>
</div>
<p className="text-2xl font-bold text-blue-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
{bp2027Data.etablissements.length}
</p>
<p className="text-xs text-muted-foreground mt-0.5">dans le périmètre</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Budgets saisis</span>
</div>
<p className="text-2xl font-bold text-emerald-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
{nbFilled}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
sur {bp2027Data.etablissements.length} ({Math.round(nbFilled / bp2027Data.etablissements.length * 100)}%)
</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-amber-500" />
<span className="text-xs text-muted-foreground uppercase tracking-wide font-medium">En attente</span>
</div>
<p className="text-2xl font-bold text-amber-600 tabular-nums" style={{ fontFamily: 'Sora, sans-serif' }}>
{bp2027Data.etablissements.length - nbFilled}
</p>
<p className="text-xs text-muted-foreground mt-0.5">budgets non encore saisis</p>
</div>
</div>
</div>
)}
{/* Barre d'outils — uniquement si données saisies */}
{hasDonnees && (
<div className="px-6 py-3 border-b border-border bg-background flex-shrink-0 flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-48 max-w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Rechercher un établissement..."
value={search}
onChange={e => 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"
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={showOnlyFilled}
onChange={e => setShowOnlyFilled(e.target.checked)}
className="rounded border-border"
/>
Afficher uniquement les budgets saisis
</label>
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="w-3.5 h-3.5" />
<span>{sortedLignes.length} établissement{sortedLignes.length > 1 ? 's' : ''} affichés</span>
</div>
</div>
)}
{/* Tableau principal — scroll horizontal ou état vide */}
<div className="flex-1 overflow-auto px-6 py-4">
{!hasDonnees ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-2xl bg-muted/40 flex items-center justify-center mb-4">
<FileSpreadsheet className="w-8 h-8 text-muted-foreground/40" />
</div>
<p className="text-lg font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Aucun inventaire importé
</p>
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
Importez l'export ISI-APP via le bouton <strong>Imports &amp; Données</strong> pour pré-remplir les budgets de renouvellement, puis saisissez les autres postes CAPEX via la page <strong>CAPEX - Construction</strong>.
</p>
</div>
) : (
<div className="min-w-max">
<table className="w-full text-xs border-collapse shadow-sm">
<thead>
{/* Ligne 1 : Titre général + en-têtes colorés */}
<tr>
<td
colSpan={2}
className="bg-slate-800 text-white text-center font-bold py-2 px-4 border border-slate-600 align-middle"
style={{ fontFamily: 'Sora, sans-serif', fontSize: '13px', minWidth: '280px' }}
>
Campagne budgétaire — <span className="text-orange-300">CAPEX {annee}</span>
</td>
{COLONNES.map(col => (
<td
key={col.key}
className={`text-center font-bold py-2 px-2 border border-slate-600 leading-tight ${COL_COLORS[col.color].header}`}
style={{ minWidth: '90px' }}
>
{col.shortLabel.split('\n').map((line, i) => (
<span key={i} className="block">{line}</span>
))}
</td>
))}
<td className="bg-slate-800 text-white text-center font-bold py-2 px-3 border border-slate-600 leading-tight" style={{ minWidth: '100px' }}>
Total <span className="text-orange-300">BP {annee}</span>
</td>
<td className="bg-slate-600 text-slate-200 text-center font-bold py-2 px-3 border border-slate-500 leading-tight" style={{ minWidth: '120px' }}>
Commentaires
</td>
</tr>
{/* Ligne 3 : En-têtes de tri */}
<tr className="bg-muted/50">
<th className="text-left px-3 py-2 font-medium text-muted-foreground border border-border/50 w-24">
<button onClick={() => handleSort('code')} className="flex items-center gap-1 hover:text-foreground transition-colors">
Code <SortIcon col="code" />
</button>
</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground border border-border/50" style={{ minWidth: '200px' }}>
<button onClick={() => handleSort('nom')} className="flex items-center gap-1 hover:text-foreground transition-colors">
Établissement <SortIcon col="nom" />
</button>
</th>
{COLONNES.map(col => (
<th key={col.key} className="text-right px-2 py-2 font-medium text-muted-foreground border border-border/50">
<button onClick={() => handleSort(col.key)} className="flex items-center gap-1 justify-end w-full hover:text-foreground transition-colors">
<SortIcon col={col.key} />
</button>
</th>
))}
<th className="text-right px-3 py-2 font-medium text-muted-foreground border border-border/50">
<button onClick={() => handleSort('total')} className="flex items-center gap-1 justify-end w-full hover:text-foreground transition-colors">
Total <SortIcon col="total" />
</button>
</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground border border-border/50">
Commentaires
</th>
</tr>
</thead>
<tbody>
{sortedLignes.map((ligne, idx) => (
<tr
key={ligne.code}
className={`border-b border-border/40 hover:bg-primary/5 transition-colors ${
idx % 2 === 0 ? 'bg-background' : 'bg-muted/10'
}`}
>
{/* Code */}
<td className="px-3 py-2 border border-border/30">
<div className="flex items-center gap-1.5">
{ligne.hasSaisie
? <CheckCircle className="w-3 h-3 text-emerald-500 flex-shrink-0" />
: <Clock className="w-3 h-3 text-amber-400 flex-shrink-0" />
}
<span className="font-mono text-muted-foreground">{ligne.code}</span>
</div>
</td>
{/* Nom */}
<td className="px-3 py-2 font-medium text-foreground border border-border/30">
<span className="block truncate max-w-48" title={ligne.nom}>{ligne.nom}</span>
</td>
{/* Colonnes de valeurs */}
{COLONNES.map(col => {
const val = ligne.valeurs[col.key];
return (
<td
key={col.key}
className={`px-2 py-2 text-right tabular-nums border border-border/30 ${
val > 0 ? COL_COLORS[col.color].cell : 'text-muted-foreground/30'
}`}
>
{fmtCell(val)}
</td>
);
})}
{/* Total */}
<td className={`px-3 py-2 text-right font-bold tabular-nums border border-border/30 ${
ligne.total > 0 ? 'text-orange-600' : 'text-muted-foreground/30'
}`}>
{fmtCell(ligne.total)}
</td>
{/* Commentaires */}
<td className="px-3 py-2 text-muted-foreground border border-border/30">
<span className="block truncate max-w-28 text-xs" title={ligne.commentaires}>
{ligne.commentaires || ''}
</span>
</td>
</tr>
))}
</tbody>
{/* Ligne de totaux */}
<tfoot>
<tr>
<td
colSpan={2}
className="px-3 py-3 font-bold text-white bg-slate-800 border border-slate-700"
style={{ fontFamily: 'Sora, sans-serif', fontSize: '12px' }}
>
TOTAL — {sortedLignes.length} établissement{sortedLignes.length > 1 ? 's' : ''}
</td>
{COLONNES.map(col => (
<td
key={col.key}
className={`px-2 py-3 text-right font-bold tabular-nums border border-slate-600 ${COL_COLORS[col.color].total}`}
>
{totauxColonnes[col.key] > 0
? new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(totauxColonnes[col.key]) + ' '
: ''}
</td>
))}
<td className="px-3 py-3 text-right font-bold text-orange-600 tabular-nums border border-slate-600 bg-orange-50 text-sm">
{formatEuros(totalGeneral)}
</td>
<td className="px-3 py-3 bg-slate-700 border border-slate-600" />
</tr>
</tfoot>
</table>
</div>
)}
{/* Message si aucun résultat (visible uniquement quand hasDonnees=true mais filtre vide) */}
{hasDonnees && sortedLignes.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<FileSpreadsheet className="w-12 h-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Aucun établissement trouvé</p>
<p className="text-sm text-muted-foreground/70 mt-1">
{showOnlyFilled ? 'Aucun budget n\'a encore été saisi.' : 'Modifiez votre recherche.'}
</p>
</div>
)}
{/* Légende */}
<div className="mt-6 flex flex-wrap items-center gap-4 text-xs text-muted-foreground border-t border-border pt-4">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
<span>Budget saisi via "Construction BP 2027"</span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-amber-400" />
<span>Budget non encore saisi (renouvellement calculé par vétusté)</span>
</div>
<div className="ml-auto flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
<span>Cliquez sur "Actualiser" pour recharger les dernières saisies</span>
</div>
</div>
</div>
</main>
</div>
);
}