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:
@@ -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">> {meta.seuil_fixes_ans} ans</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Seuil portables</span>
|
||||
<span className="text-white font-medium">> {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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user