Checkpoint: Page DSI CAPEX : tableau consolidé des investissements SI 2027 par établissement, au format du fichier Excel BP2027, avec 9 colonnes colorées, totaux par colonne, KPIs, filtres et tri.
This commit is contained in:
@@ -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<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 loadSaisies(): Record<string, Partial<BudgetFormValues>> {
|
||||
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<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());
|
||||
|
||||
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<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]);
|
||||
|
||||
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 collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} />
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<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">
|
||||
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
CAPEX — Investissements DSI
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Budget d'investissement du Système d'Information</p>
|
||||
<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' }}>
|
||||
DSI CAPEX — Prévisionnel Budget 2027
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Tableau consolidé des investissements SI par établissement
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</header>
|
||||
<ComingSoon
|
||||
title="CAPEX DSI"
|
||||
subtitle="Le tableau de bord des investissements SI sera disponible prochainement."
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
|
||||
{/* KPIs */}
|
||||
<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 2027</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 */}
|
||||
<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 */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4">
|
||||
<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}
|
||||
rowSpan={2}
|
||||
className="bg-slate-800 text-white text-center font-bold py-3 px-4 border border-slate-600 align-middle"
|
||||
style={{ fontFamily: 'Sora, sans-serif', fontSize: '13px' }}
|
||||
>
|
||||
Campagne budgétaire<br />
|
||||
<span className="text-orange-300">CAPEX 2027</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<br /><span className="text-orange-300">BP 2027</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 2 : Année sous chaque colonne */}
|
||||
<tr>
|
||||
{COLONNES.map(col => (
|
||||
<td key={col.key} className="bg-slate-700 text-slate-300 text-center py-1 px-2 border border-slate-600 font-medium">
|
||||
2027
|
||||
</td>
|
||||
))}
|
||||
<td className="bg-slate-700 text-slate-300 text-center py-1 px-3 border border-slate-600 font-medium">2027</td>
|
||||
<td className="bg-slate-700 text-slate-400 text-center py-1 px-3 border border-slate-600">—</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 */}
|
||||
{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user