Checkpoint: Application Budget Renouvellement SI 2027 Itinova — Version complète avec dashboard KPIs, grille des 75 établissements, panneau de détail par établissement avec tableau de synthèse et liste des équipements filtrables.

This commit is contained in:
Manus
2026-04-01 03:48:42 -04:00
parent 0f673d9ab0
commit 745ea5dce6
14 changed files with 19551 additions and 117 deletions

View File

@@ -1,25 +1,297 @@
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { Streamdown } from 'streamdown';
// Page principale — Budget Renouvellement SI 2027 - Itinova
// Design: Corporate Modernism — Sidebar bleu marine + grille de cards
import { useState } from 'react';
import {
Building2,
Monitor,
Laptop,
Euro,
Search,
SortAsc,
SortDesc,
Filter,
BarChart3,
AlertTriangle,
CheckCircle,
ChevronDown,
} from 'lucide-react';
import { useBudgetData } from '../hooks/useBudgetData';
import { formatEuros, formatNumber } from '../lib/format';
import { EtablissementCard } from '../components/EtablissementCard';
import { EtablissementDetail } from '../components/EtablissementDetail';
import { KpiCard } from '../components/KpiCard';
import type { Etablissement, SortField, FilterType } from '../types/budget';
/**
* All content in this page are only for example, replace with your own feature implementation
* When building pages, remember your instructions in Frontend Best Practices, Design Guide and Common Pitfalls
*/
export default function Home() {
// If theme is switchable in App.tsx, we can implement theme toggling like this:
// const { theme, toggleTheme } = useTheme();
const {
meta,
etablissements,
totalEtablissements,
search,
setSearch,
sortField,
sortOrder,
toggleSort,
filterType,
setFilterType,
} = useBudgetData();
const [selectedEtab, setSelectedEtab] = useState<Etablissement | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
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 flex-col">
<main>
{/* Example: lucide-react for icons */}
<Loader2 className="animate-spin" />
Example Page
{/* Example: Streamdown for markdown rendering */}
<Streamdown>Any **markdown** content</Streamdown>
<Button variant="default">Example Button</Button>
<div className="min-h-screen flex bg-background">
{/* Sidebar */}
<aside
className={`${sidebarOpen ? 'w-64' : 'w-16'} flex-shrink-0 bg-[oklch(0.22_0.06_240)] text-white flex flex-col transition-all duration-300 ease-in-out`}
style={{ minHeight: '100vh' }}
>
{/* Logo / Titre */}
<div className="px-4 py-5 border-b border-white/10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center flex-shrink-0">
<BarChart3 className="w-4 h-4 text-white" />
</div>
{sidebarOpen && (
<div className="min-w-0">
<p className="font-bold text-sm leading-tight" style={{ fontFamily: 'Sora, sans-serif' }}>
Budget SI 2027
</p>
<p className="text-[10px] text-white/50 mt-0.5">Itinova Renouvellement</p>
</div>
)}
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1">
<div className={`flex items-center gap-3 px-3 py-2.5 rounded-lg bg-white/10 text-white`}>
<Building2 className="w-4 h-4 flex-shrink-0" />
{sidebarOpen && <span className="text-sm font-medium">Établissements</span>}
</div>
</nav>
{/* Infos calcul */}
{sidebarOpen && (
<div className="px-4 py-4 border-t border-white/10">
<p className="text-[10px] text-white/40 uppercase tracking-wider mb-2">Paramètres de calcul</p>
<div className="space-y-1.5 text-xs text-white/70">
<div className="flex justify-between">
<span>Référence</span>
<span className="text-white font-medium">{meta.date_reference}</span>
</div>
<div className="flex justify-between">
<span>Seuil fixes</span>
<span className="text-white font-medium">&gt; {meta.seuil_fixes_ans} ans</span>
</div>
<div className="flex justify-between">
<span>Seuil portables</span>
<span className="text-white font-medium">&gt; {meta.seuil_portables_ans} ans</span>
</div>
<div className="flex justify-between">
<span>Coût fixe</span>
<span className="text-white font-medium">{formatEuros(meta.cout_fixe)} TTC</span>
</div>
<div className="flex justify-between">
<span>Coût portable</span>
<span className="text-white font-medium">{formatEuros(meta.cout_portable)} TTC</span>
</div>
</div>
<p className="text-[10px] text-white/30 mt-3">Calculé le {meta.date_calcul}</p>
</div>
)}
{/* Toggle sidebar */}
<button
onClick={() => setSidebarOpen((v) => !v)}
className="px-4 py-3 border-t border-white/10 text-white/40 hover:text-white/70 transition-colors flex items-center justify-center"
>
<ChevronDown
className={`w-4 h-4 transition-transform duration-300 ${sidebarOpen ? '-rotate-90' : 'rotate-90'}`}
/>
</button>
</aside>
{/* 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' }}>
Budget Renouvellement Informatique 2027
</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-2">
<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 2027"
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 */}
<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 */}
<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>
</div>
</div>
{/* Grille des établissements */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{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>
) : (
<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>
)}
</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>
);
}