296 lines
12 KiB
TypeScript
296 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,
|
|
search,
|
|
setSearch,
|
|
sortField,
|
|
sortOrder,
|
|
toggleSort,
|
|
filterType,
|
|
setFilterType,
|
|
} = useBudgetData();
|
|
|
|
const { annee } = useAnnee();
|
|
// Les données de parc ne sont disponibles que pour 2026 et 2027
|
|
const ANNEES_AVEC_DONNEES = [2026, 2027];
|
|
const hasDonnees = ANNEES_AVEC_DONNEES.includes(annee);
|
|
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 */}
|
|
<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 */}
|
|
<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' }}>
|
|
Aucune donnée pour {annee}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
|
Les données d'inventaire parc PC pour l'exercice {annee} n'ont pas encore été importées.
|
|
Les années disponibles sont 2026 et 2027.
|
|
</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>
|
|
);
|
|
}
|