Checkpoint: Ajout de la vue liste (tableau) en complément de la vue vignettes — toggle Vignettes/Liste dans la barre de filtres, tableau avec tri par colonne, barres de vétusté colorées, budget en orange/vert.

This commit is contained in:
Manus
2026-04-01 05:45:51 -04:00
parent 908cf74046
commit c4dce77ecc
2 changed files with 254 additions and 26 deletions

View File

@@ -0,0 +1,181 @@
// EtablissementListView.tsx — Vue liste tableau des établissements
// Design: Corporate Modernism — Tableau dense avec indicateurs visuels de vétusté
import { Monitor, Laptop, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { Etablissement, SortField, SortOrder } from '../types/budget';
import { formatEuros } from '../lib/format';
interface Props {
etablissements: Etablissement[];
onSelect: (etab: Etablissement) => void;
sortField: SortField;
sortOrder: SortOrder;
onSort: (field: SortField) => void;
}
function SortIcon({ field, sortField, sortOrder }: { field: SortField; sortField: SortField; sortOrder: SortOrder }) {
if (sortField !== field) return <ArrowUpDown className="w-3.5 h-3.5 text-muted-foreground/40" />;
return sortOrder === 'asc'
? <ArrowUp className="w-3.5 h-3.5 text-primary" />
: <ArrowDown className="w-3.5 h-3.5 text-primary" />;
}
function VetusteBar({ value, total, color }: { value: number; total: number; color: string }) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
const barColor =
pct >= 80 ? 'bg-red-500' :
pct >= 50 ? 'bg-orange-400' :
pct > 0 ? 'bg-yellow-400' :
'bg-emerald-400';
return (
<div className="flex items-center gap-2 min-w-0">
<span className={`font-semibold text-sm tabular-nums ${pct >= 80 ? 'text-red-600' : pct >= 50 ? 'text-orange-500' : pct > 0 ? 'text-yellow-600' : 'text-emerald-600'}`}>
{value}
</span>
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden min-w-[40px]">
<div className={`h-full rounded-full ${barColor} transition-all`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted-foreground tabular-nums w-12 text-right">{value}/{total}</span>
</div>
);
}
export function EtablissementListView({ etablissements, onSelect, sortField, sortOrder, onSort }: Props) {
const thClass = "px-3 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground select-none";
const thSortClass = `${thClass} cursor-pointer hover:text-foreground hover:bg-muted/60 transition-colors`;
return (
<div className="w-full overflow-x-auto rounded-xl border border-border bg-card shadow-sm">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className={thClass} style={{ width: '90px' }}>Code</th>
<th
className={thSortClass}
onClick={() => onSort('nom')}
>
<div className="flex items-center gap-1.5">
Établissement
<SortIcon field="nom" sortField={sortField} sortOrder={sortOrder} />
</div>
</th>
<th
className={thSortClass}
onClick={() => onSort('nb_fixes_renouveler')}
style={{ width: '200px' }}
>
<div className="flex items-center gap-1.5">
<Monitor className="w-3.5 h-3.5" />
PC Fixes
<SortIcon field="nb_fixes_renouveler" sortField={sortField} sortOrder={sortOrder} />
</div>
</th>
<th
className={thSortClass}
onClick={() => onSort('nb_portables_renouveler')}
style={{ width: '200px' }}
>
<div className="flex items-center gap-1.5">
<Laptop className="w-3.5 h-3.5" />
PC Portables
<SortIcon field="nb_portables_renouveler" sortField={sortField} sortOrder={sortOrder} />
</div>
</th>
<th className={thClass} style={{ width: '80px' }}>
Total à renouveler
</th>
<th
className={thSortClass}
onClick={() => onSort('budget_total')}
style={{ width: '130px' }}
>
<div className="flex items-center gap-1.5">
Budget 2027
<SortIcon field="budget_total" sortField={sortField} sortOrder={sortOrder} />
</div>
</th>
<th className={thClass} style={{ width: '40px' }}></th>
</tr>
</thead>
<tbody>
{etablissements.map((etab, index) => {
const nbFixesTotal = etab.fixes?.length ?? 0;
const nbPortablesTotal = etab.portables?.length ?? 0;
const nbFixesR = etab.stats.nb_fixes_renouveler;
const nbPortablesR = etab.stats.nb_portables_renouveler;
const totalR = nbFixesR + nbPortablesR;
const budget = etab.stats.budget_total;
const hasBudget = budget > 0;
return (
<tr
key={etab.code}
onClick={() => onSelect(etab)}
className={`
border-b border-border last:border-0 cursor-pointer transition-colors
hover:bg-primary/5 group
${index % 2 === 0 ? 'bg-card' : 'bg-muted/10'}
`}
>
{/* Code */}
<td className="px-3 py-3">
<span className="font-mono text-[11px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{etab.code}
</span>
</td>
{/* Nom */}
<td className="px-3 py-3">
<span className="font-medium text-foreground group-hover:text-primary transition-colors line-clamp-1">
{etab.nom}
</span>
</td>
{/* PC Fixes */}
<td className="px-3 py-3">
<VetusteBar value={nbFixesR} total={nbFixesTotal} color="blue" />
</td>
{/* PC Portables */}
<td className="px-3 py-3">
<VetusteBar value={nbPortablesR} total={nbPortablesTotal} color="orange" />
</td>
{/* Total à renouveler */}
<td className="px-3 py-3 text-center">
{totalR > 0 ? (
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-orange-100 text-orange-700 font-bold text-sm">
{totalR}
</span>
) : (
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100 text-emerald-700 font-bold text-sm">
0
</span>
)}
</td>
{/* Budget */}
<td className="px-3 py-3">
<span className={`font-bold tabular-nums ${hasBudget ? 'text-orange-600' : 'text-emerald-600'}`}>
{formatEuros(budget)}
</span>
</td>
{/* Chevron */}
<td className="px-3 py-3 text-right">
<ChevronRight className="w-4 h-4 text-muted-foreground/40 group-hover:text-primary transition-colors ml-auto" />
</td>
</tr>
);
})}
</tbody>
</table>
{etablissements.length === 0 && (
<div className="py-12 text-center text-muted-foreground text-sm">
Aucun établissement correspondant aux critères
</div>
)}
</div>
);
}