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

299 lines
12 KiB
TypeScript

// Page principale — Budget Renouvellement SI 2027 - Itinova
// Design: Corporate Modernism — Sidebar bleu marine + grille de cards + vue liste
import { useState } from 'react';
import {
Monitor,
Laptop,
Euro,
Search,
SortAsc,
SortDesc,
Filter,
Building2,
AlertTriangle,
CheckCircle,
ChevronDown,
LayoutGrid,
List,
} from 'lucide-react';
import { AppSidebar } from '../components/AppSidebar';
import { AnneeSelectorBar } from '../components/AnneeSelectorBar';
import { useAnnee } from '../contexts/AnneeContext';
import { useBudgetData } from '../hooks/useBudgetData';
import { formatEuros, formatNumber } from '../lib/format';
import { EtablissementCard } from '../components/EtablissementCard';
import { EtablissementDetail } from '../components/EtablissementDetail';
import { EtablissementListView } from '../components/EtablissementListView';
import { KpiCard } from '../components/KpiCard';
import type { Etablissement, SortField, FilterType } from '../types/budget';
type ViewMode = 'grid' | 'list';
export default function Home() {
const {
meta,
etablissements,
totalEtablissements,
hasInventaire,
search,
setSearch,
sortField,
sortOrder,
toggleSort,
filterType,
setFilterType,
} = useBudgetData();
const { annee } = useAnnee();
// Les données de parc proviennent uniquement d'un import inventaire
const hasDonnees = hasInventaire;
const [selectedEtab, setSelectedEtab] = useState<Etablissement | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const sortOptions: { value: SortField; label: string }[] = [
{ value: 'budget_total', label: 'Budget total' },
{ value: 'nom', label: 'Nom' },
{ value: 'code', label: 'Code' },
{ value: 'nb_fixes_renouveler', label: 'PC Fixes à renouveler' },
{ value: 'nb_portables_renouveler', label: 'PC Portables à renouveler' },
];
const filterOptions: { value: FilterType; label: string }[] = [
{ value: 'all', label: 'Tous les établissements' },
{ value: 'has_budget', label: 'Avec budget de renouvellement' },
{ value: 'no_budget', label: 'Sans renouvellement nécessaire' },
];
return (
<div className="min-h-screen flex bg-background">
<AppSidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(c => !c)} />
{/* Contenu principal */}
<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">
<div>
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Renouvellement PC {annee}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{totalEtablissements} établissements Itinova Vétusté du parc PC
</p>
</div>
<div className="flex items-center gap-3">
<AnneeSelectorBar />
<span className="text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded-full">
{etablissements.length} affiché{etablissements.length > 1 ? 's' : ''}
</span>
</div>
</div>
</header>
{/* KPIs globaux — uniquement si données disponibles */}
{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-3 lg:grid-cols-5 gap-3">
<KpiCard
icon={Euro}
label="Budget global"
value={formatEuros(meta.budget_global)}
subtitle={`TTC — exercice ${annee}`}
accentColor="text-orange-600"
delay={0}
/>
<KpiCard
icon={Building2}
label="Établissements"
value={formatNumber(meta.nb_etablissements)}
subtitle="dans le périmètre"
accentColor="text-primary"
delay={60}
/>
<KpiCard
icon={Monitor}
label="PC Fixes à renouveler"
value={formatNumber(meta.total_fixes_renouveler)}
subtitle={`sur ${formatNumber(meta.total_fixes)} au total`}
accentColor="text-primary"
delay={120}
/>
<KpiCard
icon={Laptop}
label="PC Portables à renouveler"
value={formatNumber(meta.total_portables_renouveler)}
subtitle={`sur ${formatNumber(meta.total_portables)} au total`}
accentColor="text-primary"
delay={180}
/>
<KpiCard
icon={AlertTriangle}
label="Équipements à renouveler"
value={formatNumber(meta.total_fixes_renouveler + meta.total_portables_renouveler)}
subtitle="fixes + portables"
accentColor="text-orange-500"
delay={240}
/>
</div>
</div>
)}
{/* Barre de filtres + toggle vue — uniquement si données disponibles */}
{hasDonnees && (
<div className="px-6 py-3 border-b border-border bg-card flex-shrink-0">
<div className="flex flex-wrap items-center gap-3">
{/* Recherche */}
<div className="relative flex-1 min-w-48 max-w-sm">
<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 bg-muted/50 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
/>
</div>
{/* Filtre */}
<div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as FilterType)}
className="pl-8 pr-8 py-2 text-sm bg-muted/50 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none cursor-pointer"
>
{filterOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Tri (masqué en vue liste car tri par colonne) */}
{viewMode === 'grid' && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Trier par :</span>
<div className="relative">
<select
value={sortField}
onChange={(e) => toggleSort(e.target.value as SortField)}
className="pl-3 pr-8 py-2 text-sm bg-muted/50 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 appearance-none cursor-pointer"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<button
onClick={() => toggleSort(sortField)}
className="p-2 rounded-lg border border-border bg-muted/50 hover:bg-muted transition-colors"
title={sortOrder === 'asc' ? 'Croissant' : 'Décroissant'}
>
{sortOrder === 'asc' ? (
<SortAsc className="w-4 h-4 text-muted-foreground" />
) : (
<SortDesc className="w-4 h-4 text-muted-foreground" />
)}
</button>
</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>
)}
{/* Contenu — vue vignettes ou liste */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{!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">
<Monitor 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> dans le menu de gauche pour afficher les données de vétusté et les budgets de renouvellement.
</p>
</div>
) : etablissements.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<CheckCircle 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">
Modifiez vos critères de recherche ou de filtre.
</p>
</div>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{etablissements.map((etab, index) => (
<EtablissementCard
key={etab.code}
etablissement={etab}
onClick={() => setSelectedEtab(etab)}
index={index}
/>
))}
</div>
) : (
<EtablissementListView
etablissements={etablissements}
onSelect={(etab) => setSelectedEtab(etab)}
sortField={sortField}
sortOrder={sortOrder}
onSort={toggleSort}
/>
)}
</div>
</main>
{/* Panneau de détail */}
{selectedEtab && (
<EtablissementDetail
etablissement={selectedEtab}
onClose={() => setSelectedEtab(null)}
coutFixe={meta.cout_fixe}
coutPortable={meta.cout_portable}
seuilFixesAns={meta.seuil_fixes_ans}
seuilPortablesAns={meta.seuil_portables_ans}
/>
)}
</div>
);
}