515 lines
25 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|