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:
181
client/src/components/EtablissementListView.tsx
Normal file
181
client/src/components/EtablissementListView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Page principale — Budget Renouvellement SI 2027 - Itinova
|
// Page principale — Budget Renouvellement SI 2027 - Itinova
|
||||||
// Design: Corporate Modernism — Sidebar bleu marine + grille de cards
|
// Design: Corporate Modernism — Sidebar bleu marine + grille de cards + vue liste
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -15,14 +15,19 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useBudgetData } from '../hooks/useBudgetData';
|
import { useBudgetData } from '../hooks/useBudgetData';
|
||||||
import { formatEuros, formatNumber } from '../lib/format';
|
import { formatEuros, formatNumber } from '../lib/format';
|
||||||
import { EtablissementCard } from '../components/EtablissementCard';
|
import { EtablissementCard } from '../components/EtablissementCard';
|
||||||
import { EtablissementDetail } from '../components/EtablissementDetail';
|
import { EtablissementDetail } from '../components/EtablissementDetail';
|
||||||
|
import { EtablissementListView } from '../components/EtablissementListView';
|
||||||
import { KpiCard } from '../components/KpiCard';
|
import { KpiCard } from '../components/KpiCard';
|
||||||
import type { Etablissement, SortField, FilterType } from '../types/budget';
|
import type { Etablissement, SortField, FilterType } from '../types/budget';
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const {
|
const {
|
||||||
meta,
|
meta,
|
||||||
@@ -39,6 +44,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const [selectedEtab, setSelectedEtab] = useState<Etablissement | null>(null);
|
const [selectedEtab, setSelectedEtab] = useState<Etablissement | null>(null);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
|
|
||||||
const sortOptions: { value: SortField; label: string }[] = [
|
const sortOptions: { value: SortField; label: string }[] = [
|
||||||
{ value: 'budget_total', label: 'Budget total' },
|
{ value: 'budget_total', label: 'Budget total' },
|
||||||
@@ -194,7 +200,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barre de filtres */}
|
{/* Barre de filtres + toggle vue */}
|
||||||
<div className="px-6 py-3 border-b border-border bg-card flex-shrink-0">
|
<div className="px-6 py-3 border-b border-border bg-card flex-shrink-0">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* Recherche */}
|
{/* Recherche */}
|
||||||
@@ -225,7 +231,8 @@ export default function Home() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tri */}
|
{/* Tri (masqué en vue liste car tri par colonne) */}
|
||||||
|
{viewMode === 'grid' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">Trier par :</span>
|
<span className="text-xs text-muted-foreground">Trier par :</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -253,10 +260,42 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Toggle vue vignettes / liste */}
|
||||||
|
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
title="Vue vignettes"
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-white shadow-sm text-primary border border-border'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Vignettes</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
title="Vue liste"
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-white shadow-sm text-primary border border-border'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Liste</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grille des établissements */}
|
{/* Contenu — vue vignettes ou liste */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
{etablissements.length === 0 ? (
|
{etablissements.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
@@ -266,7 +305,7 @@ export default function Home() {
|
|||||||
Modifiez vos critères de recherche ou de filtre.
|
Modifiez vos critères de recherche ou de filtre.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : viewMode === 'grid' ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{etablissements.map((etab, index) => (
|
{etablissements.map((etab, index) => (
|
||||||
<EtablissementCard
|
<EtablissementCard
|
||||||
@@ -277,6 +316,14 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<EtablissementListView
|
||||||
|
etablissements={etablissements}
|
||||||
|
onSelect={(etab) => setSelectedEtab(etab)}
|
||||||
|
sortField={sortField}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSort={toggleSort}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user