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

0
.gitkeep Normal file
View File

View File

@@ -1,17 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1" /> content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Budget Renouvellement SI 2027 - Itinova</title> <title>Budget Renouvellement SI 2027 Itinova</title>
<!-- THIS IS THE START OF A COMMENT BLOCK, BLOCK TO BE DELETED: Google Fonts here, example:
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
THIS IS THE END OF A COMMENT BLOCK, BLOCK TO BE DELETED -->
</head> </head>
<body> <body>

View File

@@ -1,3 +1,6 @@
// App.tsx — Budget Renouvellement SI 2027 - Itinova
// Design: Corporate Modernism — Thème clair institutionnel
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound"; import NotFound from "@/pages/NotFound";
@@ -6,30 +9,20 @@ import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home"; import Home from "./pages/Home";
function Router() { function Router() {
return ( return (
<Switch> <Switch>
<Route path={"/"} component={Home} /> <Route path={"/"} component={Home} />
<Route path={"/404"} component={NotFound} /> <Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
); );
} }
// NOTE: About Theme
// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css
// to keep consistent foreground/background color across components
// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook
function App() { function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ThemeProvider <ThemeProvider defaultTheme="light">
defaultTheme="light"
// switchable
>
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<Router /> <Router />

View File

@@ -0,0 +1,101 @@
// Composant EtablissementCard — Design Corporate Modernism Itinova
// Card d'établissement avec indicateurs de vétusté et budget
import { Monitor, Laptop, Euro, ChevronRight } from 'lucide-react';
import type { Etablissement } from '../types/budget';
import { formatEuros, getVetustePercent, getBarColor } from '../lib/format';
interface EtablissementCardProps {
etablissement: Etablissement;
onClick: () => void;
index: number;
}
export function EtablissementCard({ etablissement, onClick, index }: EtablissementCardProps) {
const { stats, nom, code } = etablissement;
const percentFixes = getVetustePercent(stats.nb_fixes_renouveler, stats.nb_fixes_total);
const percentPortables = getVetustePercent(stats.nb_portables_renouveler, stats.nb_portables_total);
const hasBudget = stats.budget_total > 0;
return (
<div
className="etab-card bg-card rounded-lg border border-border shadow-sm overflow-hidden cursor-pointer animate-fade-slide-up"
style={{ animationDelay: `${Math.min(index * 30, 600)}ms` }}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
aria-label={`Voir le détail de ${nom}`}
>
{/* Liseré de couleur en haut selon le budget */}
<div
className={`h-1 w-full ${hasBudget ? (stats.budget_total > 20000 ? 'bg-red-500' : 'bg-orange-400') : 'bg-green-500'}`}
/>
<div className="p-4">
{/* En-tête */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0 pr-2">
<p className="text-[11px] font-mono text-muted-foreground mb-0.5">{code}</p>
<h3 className="font-semibold text-sm text-foreground leading-tight line-clamp-2">
{nom}
</h3>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0 mt-0.5" />
</div>
{/* Budget total */}
<div className="mb-3 pb-3 border-b border-border">
<div className="flex items-center gap-1.5">
<Euro className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Budget renouvellement 2027</span>
</div>
<p className={`kpi-value text-xl mt-0.5 ${hasBudget ? 'text-orange-600' : 'text-green-600'}`}>
{formatEuros(stats.budget_total)}
</p>
</div>
{/* Indicateurs fixes */}
<div className="space-y-2">
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<Monitor className="w-3 h-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">PC Fixes</span>
</div>
<span className="text-[11px] font-medium text-foreground">
{stats.nb_fixes_renouveler}/{stats.nb_fixes_total}
<span className="text-muted-foreground ml-1">à renouveler</span>
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`vetusite-bar h-full ${getBarColor(percentFixes)}`}
style={{ width: `${percentFixes}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<Laptop className="w-3 h-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">PC Portables</span>
</div>
<span className="text-[11px] font-medium text-foreground">
{stats.nb_portables_renouveler}/{stats.nb_portables_total}
<span className="text-muted-foreground ml-1">à renouveler</span>
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`vetusite-bar h-full ${getBarColor(percentPortables)}`}
style={{ width: `${percentPortables}%` }}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,327 @@
// Composant EtablissementDetail — Design Corporate Modernism Itinova
// Panneau de détail d'un établissement avec budget détaillé et liste des équipements
import { useState } from 'react';
import { X, Monitor, Laptop, Euro, AlertTriangle, CheckCircle, Printer } from 'lucide-react';
import type { Etablissement } from '../types/budget';
import { formatEuros, getVetustePercent, getBarColor } from '../lib/format';
import { MaterielTable } from './MaterielTable';
interface EtablissementDetailProps {
etablissement: Etablissement | null;
onClose: () => void;
coutFixe: number;
coutPortable: number;
seuilFixesAns: number;
seuilPortablesAns: number;
}
type TabType = 'resume' | 'fixes' | 'portables';
export function EtablissementDetail({
etablissement,
onClose,
coutFixe,
coutPortable,
seuilFixesAns,
seuilPortablesAns,
}: EtablissementDetailProps) {
const [activeTab, setActiveTab] = useState<TabType>('resume');
const [showOnlyToRenew, setShowOnlyToRenew] = useState(false);
if (!etablissement) return null;
const { stats, nom, code, fixes, portables } = etablissement;
const percentFixes = getVetustePercent(stats.nb_fixes_renouveler, stats.nb_fixes_total);
const percentPortables = getVetustePercent(stats.nb_portables_renouveler, stats.nb_portables_total);
const handlePrint = () => {
window.print();
};
return (
<>
{/* Overlay */}
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* Panneau latéral */}
<div className="fixed right-0 top-0 h-full w-full max-w-3xl bg-background shadow-2xl z-50 flex flex-col overflow-hidden">
{/* En-tête */}
<div className="bg-[oklch(0.22_0.06_240)] text-white px-6 py-4 flex-shrink-0">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 pr-4">
<p className="text-[11px] font-mono text-white/60 mb-0.5">{code}</p>
<h2 className="font-bold text-lg leading-tight" style={{ fontFamily: 'Sora, sans-serif' }}>
{nom}
</h2>
<p className="text-sm text-white/70 mt-1">Budget Renouvellement Informatique 2027</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={handlePrint}
className="p-2 rounded-lg hover:bg-white/10 transition-colors text-white/70 hover:text-white"
title="Imprimer"
>
<Printer className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10 transition-colors text-white/70 hover:text-white"
aria-label="Fermer"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Résumé budgétaire */}
<div className="bg-muted/30 border-b border-border px-6 py-4 flex-shrink-0">
<div className="grid grid-cols-3 gap-4">
{/* Budget total */}
<div className="col-span-3 sm:col-span-1 bg-card rounded-lg border border-border p-4 section-accent">
<div className="flex items-center gap-2 mb-1">
<Euro className="w-4 h-4 text-orange-500" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Budget total</span>
</div>
<p className="kpi-value text-2xl text-orange-600">{formatEuros(stats.budget_total)}</p>
<p className="text-[11px] text-muted-foreground mt-1">TTC exercice 2027</p>
</div>
{/* PC Fixes */}
<div className="bg-card rounded-lg border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Monitor className="w-4 h-4 text-primary" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">PC Fixes</span>
</div>
<p className="kpi-value text-xl text-primary">{formatEuros(stats.budget_fixes)}</p>
<p className="text-[11px] text-muted-foreground mt-1">
{stats.nb_fixes_renouveler} PC × {formatEuros(coutFixe)}
</p>
<div className="mt-2">
<div className="flex justify-between text-[10px] text-muted-foreground mb-1">
<span>{stats.nb_fixes_renouveler}/{stats.nb_fixes_total} à renouveler</span>
<span>{percentFixes}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div className={`h-full ${getBarColor(percentFixes)} transition-all duration-700`} style={{ width: `${percentFixes}%` }} />
</div>
</div>
</div>
{/* PC Portables */}
<div className="bg-card rounded-lg border border-border p-4">
<div className="flex items-center gap-2 mb-1">
<Laptop className="w-4 h-4 text-primary" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">PC Portables</span>
</div>
<p className="kpi-value text-xl text-primary">{formatEuros(stats.budget_portables)}</p>
<p className="text-[11px] text-muted-foreground mt-1">
{stats.nb_portables_renouveler} PC × {formatEuros(coutPortable)}
</p>
<div className="mt-2">
<div className="flex justify-between text-[10px] text-muted-foreground mb-1">
<span>{stats.nb_portables_renouveler}/{stats.nb_portables_total} à renouveler</span>
<span>{percentPortables}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div className={`h-full ${getBarColor(percentPortables)} transition-all duration-700`} style={{ width: `${percentPortables}%` }} />
</div>
</div>
</div>
</div>
{/* Règles de calcul */}
<div className="mt-3 flex flex-wrap gap-3 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-orange-400" />
PC fixe &gt; {seuilFixesAns} ans {formatEuros(coutFixe)} TTC
</span>
<span className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-orange-400" />
PC portable &gt; {seuilPortablesAns} ans {formatEuros(coutPortable)} TTC
</span>
<span className="flex items-center gap-1">
<CheckCircle className="w-3 h-3 text-green-500" />
Référence : 01/01/2027
</span>
</div>
</div>
{/* Onglets */}
<div className="border-b border-border flex-shrink-0 px-6">
<div className="flex gap-0">
{[
{ id: 'resume' as TabType, label: 'Résumé', count: null },
{ id: 'fixes' as TabType, label: 'PC Fixes', count: stats.nb_fixes_total },
{ id: 'portables' as TabType, label: 'PC Portables', count: stats.nb_portables_total },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
{tab.count !== null && (
<span className={`ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full ${
activeTab === tab.id ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
</div>
{/* Contenu des onglets */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'resume' && (
<div className="p-6 space-y-4">
<h3 className="text-sm font-semibold text-foreground mb-3" style={{ fontFamily: 'Sora, sans-serif' }}>
Synthèse du renouvellement
</h3>
{/* Tableau récapitulatif */}
<div className="bg-card rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left py-2.5 px-4 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Catégorie</th>
<th className="text-right py-2.5 px-4 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Parc total</th>
<th className="text-right py-2.5 px-4 text-xs font-semibold text-muted-foreground uppercase tracking-wider">À renouveler</th>
<th className="text-right py-2.5 px-4 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Coût unitaire</th>
<th className="text-right py-2.5 px-4 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Budget</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/50">
<td className="py-3 px-4 flex items-center gap-2">
<Monitor className="w-4 h-4 text-primary" />
<span className="font-medium">PC Fixes</span>
<span className="text-[10px] text-muted-foreground">(seuil {seuilFixesAns} ans)</span>
</td>
<td className="py-3 px-4 text-right text-muted-foreground">{stats.nb_fixes_total}</td>
<td className="py-3 px-4 text-right">
<span className={`font-semibold ${stats.nb_fixes_renouveler > 0 ? 'text-orange-600' : 'text-green-600'}`}>
{stats.nb_fixes_renouveler}
</span>
</td>
<td className="py-3 px-4 text-right text-muted-foreground">{formatEuros(coutFixe)}</td>
<td className="py-3 px-4 text-right font-semibold">{formatEuros(stats.budget_fixes)}</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-4 flex items-center gap-2">
<Laptop className="w-4 h-4 text-primary" />
<span className="font-medium">PC Portables</span>
<span className="text-[10px] text-muted-foreground">(seuil {seuilPortablesAns} ans)</span>
</td>
<td className="py-3 px-4 text-right text-muted-foreground">{stats.nb_portables_total}</td>
<td className="py-3 px-4 text-right">
<span className={`font-semibold ${stats.nb_portables_renouveler > 0 ? 'text-orange-600' : 'text-green-600'}`}>
{stats.nb_portables_renouveler}
</span>
</td>
<td className="py-3 px-4 text-right text-muted-foreground">{formatEuros(coutPortable)}</td>
<td className="py-3 px-4 text-right font-semibold">{formatEuros(stats.budget_portables)}</td>
</tr>
<tr className="bg-muted/30">
<td className="py-3 px-4 font-bold" colSpan={2}>TOTAL</td>
<td className="py-3 px-4 text-right font-bold text-orange-600">
{stats.nb_fixes_renouveler + stats.nb_portables_renouveler}
</td>
<td className="py-3 px-4" />
<td className="py-3 px-4 text-right font-bold text-lg text-orange-600">
{formatEuros(stats.budget_total)}
</td>
</tr>
</tbody>
</table>
</div>
{/* Avertissements */}
{(stats.nb_fixes_sans_date > 0 || stats.nb_portables_sans_date > 0) && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-yellow-800">Données manquantes</p>
<p className="text-yellow-700 text-xs mt-0.5">
{stats.nb_fixes_sans_date > 0 && `${stats.nb_fixes_sans_date} PC fixe(s) sans date d'achat. `}
{stats.nb_portables_sans_date > 0 && `${stats.nb_portables_sans_date} PC portable(s) sans date d'achat. `}
Ces équipements ne sont pas comptabilisés dans le budget.
</p>
</div>
</div>
</div>
)}
{stats.budget_total === 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 text-sm">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<p className="text-green-800 font-medium">
Aucun renouvellement nécessaire pour cet établissement en 2027.
</p>
</div>
</div>
)}
</div>
)}
{activeTab === 'fixes' && (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
PC Fixes {stats.nb_fixes_total} équipements
</h3>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={showOnlyToRenew}
onChange={(e) => setShowOnlyToRenew(e.target.checked)}
className="rounded"
/>
Afficher uniquement à renouveler ({stats.nb_fixes_renouveler})
</label>
</div>
<div className="bg-card rounded-lg border border-border overflow-hidden">
<MaterielTable materiels={fixes} type="fixe" showOnlyToRenew={showOnlyToRenew} />
</div>
</div>
)}
{activeTab === 'portables' && (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
PC Portables {stats.nb_portables_total} équipements
</h3>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={showOnlyToRenew}
onChange={(e) => setShowOnlyToRenew(e.target.checked)}
className="rounded"
/>
Afficher uniquement à renouveler ({stats.nb_portables_renouveler})
</label>
</div>
<div className="bg-card rounded-lg border border-border overflow-hidden">
<MaterielTable materiels={portables} type="portable" showOnlyToRenew={showOnlyToRenew} />
</div>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,44 @@
// Composant KPI Card — Design Corporate Modernism Itinova
// Affiche un indicateur clé avec icône, valeur et sous-titre
import { type LucideIcon } from 'lucide-react';
interface KpiCardProps {
icon: LucideIcon;
label: string;
value: string;
subtitle?: string;
accentColor?: string; // classe Tailwind pour la couleur d'accent
delay?: number;
}
export function KpiCard({
icon: Icon,
label,
value,
subtitle,
accentColor = 'text-primary',
delay = 0,
}: KpiCardProps) {
return (
<div
className="bg-card rounded-lg border border-border p-5 shadow-sm animate-fade-slide-up section-accent"
style={{ animationDelay: `${delay}ms` }}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">
{label}
</p>
<p className={`kpi-value text-2xl ${accentColor}`}>{value}</p>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
)}
</div>
<div className={`ml-3 p-2.5 rounded-lg bg-current/8 ${accentColor}`}>
<Icon className="w-5 h-5" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
// Composant MaterielTable — Design Corporate Modernism Itinova
// Tableau des équipements d'un établissement avec indicateurs de vétusté
import { AlertTriangle, CheckCircle, HelpCircle } from 'lucide-react';
import type { Materiel } from '../types/budget';
import { getVetusteBadgeClass, getVetusteLabel } from '../lib/format';
interface MaterielTableProps {
materiels: Materiel[];
type: 'fixe' | 'portable';
showOnlyToRenew?: boolean;
}
export function MaterielTable({ materiels, type, showOnlyToRenew = false }: MaterielTableProps) {
const displayed = showOnlyToRenew
? materiels.filter((m) => m.a_renouveler)
: materiels;
if (displayed.length === 0) {
return (
<div className="text-center py-6 text-muted-foreground text-sm">
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-500 opacity-60" />
{showOnlyToRenew
? 'Aucun équipement à renouveler dans cette catégorie.'
: 'Aucun équipement dans cette catégorie.'}
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 font-semibold text-muted-foreground uppercase tracking-wider text-[10px]">
Libellé
</th>
<th className="text-left py-2 px-3 font-semibold text-muted-foreground uppercase tracking-wider text-[10px]">
Fabricant / Modèle
</th>
<th className="text-left py-2 px-3 font-semibold text-muted-foreground uppercase tracking-wider text-[10px]">
Date d'achat
</th>
<th className="text-left py-2 px-3 font-semibold text-muted-foreground uppercase tracking-wider text-[10px]">
Vétusté
</th>
<th className="text-center py-2 px-3 font-semibold text-muted-foreground uppercase tracking-wider text-[10px]">
Statut
</th>
</tr>
</thead>
<tbody>
{displayed.map((m, i) => (
<tr
key={m.libelle}
className={`border-b border-border/50 transition-colors hover:bg-muted/40 ${
i % 2 === 0 ? 'bg-transparent' : 'bg-muted/20'
}`}
>
<td className="py-2 px-3 font-mono text-[11px] text-foreground">{m.libelle}</td>
<td className="py-2 px-3 text-foreground">
<span className="font-medium">{m.fabricant}</span>
{m.modele !== '-' && (
<span className="text-muted-foreground ml-1">— {m.modele}</span>
)}
</td>
<td className="py-2 px-3 text-foreground">
{m.date_achat === '-' ? (
<span className="text-muted-foreground italic">Non renseignée</span>
) : (
m.date_achat
)}
</td>
<td className="py-2 px-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium ${getVetusteBadgeClass(m.age_ans, type)}`}>
{getVetusteLabel(m.age_ans, type)}
</span>
</td>
<td className="py-2 px-3 text-center">
{m.age_ans === null ? (
<HelpCircle className="w-4 h-4 text-muted-foreground mx-auto" />
) : m.a_renouveler ? (
<AlertTriangle className="w-4 h-4 text-orange-500 mx-auto" />
) : (
<CheckCircle className="w-4 h-4 text-green-500 mx-auto" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

18206
client/src/data_budget.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
// Hook pour charger et gérer les données budget
// Design: Corporate Modernism — Itinova Budget SI 2027
import { useState, useMemo } from 'react';
import budgetRaw from '../data_budget.json';
import type { BudgetData, Etablissement, SortField, SortOrder, FilterType } from '../types/budget';
const budgetData = budgetRaw as BudgetData;
export function useBudgetData() {
const [search, setSearch] = useState('');
const [sortField, setSortField] = useState<SortField>('budget_total');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [filterType, setFilterType] = useState<FilterType>('all');
const filteredAndSorted = useMemo(() => {
let list: Etablissement[] = [...budgetData.etablissements];
// Filtre par recherche
if (search.trim()) {
const q = search.toLowerCase().trim();
list = list.filter(
(e) =>
e.nom.toLowerCase().includes(q) ||
e.code.toLowerCase().includes(q)
);
}
// Filtre par type
if (filterType === 'has_budget') {
list = list.filter((e) => e.stats.budget_total > 0);
} else if (filterType === 'no_budget') {
list = list.filter((e) => e.stats.budget_total === 0);
}
// Tri
list.sort((a, b) => {
let valA: number | string;
let valB: number | string;
switch (sortField) {
case 'nom':
valA = a.nom;
valB = b.nom;
break;
case 'code':
valA = a.code;
valB = b.code;
break;
case 'budget_total':
valA = a.stats.budget_total;
valB = b.stats.budget_total;
break;
case 'nb_fixes_renouveler':
valA = a.stats.nb_fixes_renouveler;
valB = b.stats.nb_fixes_renouveler;
break;
case 'nb_portables_renouveler':
valA = a.stats.nb_portables_renouveler;
valB = b.stats.nb_portables_renouveler;
break;
default:
valA = a.stats.budget_total;
valB = b.stats.budget_total;
}
if (typeof valA === 'string' && typeof valB === 'string') {
return sortOrder === 'asc'
? valA.localeCompare(valB, 'fr')
: valB.localeCompare(valA, 'fr');
}
return sortOrder === 'asc'
? (valA as number) - (valB as number)
: (valB as number) - (valA as number);
});
return list;
}, [search, sortField, sortOrder, filterType]);
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortOrder('desc');
}
};
return {
meta: budgetData.meta,
etablissements: filteredAndSorted,
totalEtablissements: budgetData.etablissements.length,
search,
setSearch,
sortField,
sortOrder,
toggleSort,
filterType,
setFilterType,
};
}

View File

@@ -1,3 +1,12 @@
/* =============================================================
ITINOVA — Budget Renouvellement SI 2027
Design: Corporate Modernism
Palette: Bleu marine sidebar #1A2E4A, Blanc cassé #F8F6F2,
Bleu Itinova #0066CC, Orange vétusté #E8820C,
Vert récent #3D7A5F, Anthracite #2C2C2C
Fonts: Sora (titres) + Inter (corps)
============================================================= */
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@@ -40,77 +49,53 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
/* Custom Itinova tokens */
--font-display: 'Sora', sans-serif;
--font-body: 'Inter', sans-serif;
--itinova-navy: oklch(0.22 0.06 240);
--itinova-navy-light: oklch(0.28 0.06 240);
--itinova-blue: oklch(0.48 0.18 255);
--itinova-orange: oklch(0.65 0.17 50);
--itinova-green: oklch(0.50 0.10 160);
--itinova-red: oklch(0.55 0.20 25);
--itinova-bg: oklch(0.985 0.005 80);
} }
:root { :root {
--primary: var(--color-blue-700); --radius: 0.5rem;
--primary-foreground: var(--color-blue-50); --background: oklch(0.985 0.005 80);
--sidebar-primary: var(--color-blue-600); --foreground: oklch(0.18 0.01 260);
--sidebar-primary-foreground: var(--color-blue-50);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.235 0.015 65);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.235 0.015 65); --card-foreground: oklch(0.18 0.01 260);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.235 0.015 65); --popover-foreground: oklch(0.18 0.01 260);
--secondary: oklch(0.98 0.001 286.375); --primary: oklch(0.48 0.18 255);
--secondary-foreground: oklch(0.4 0.015 65); --primary-foreground: oklch(0.98 0 0);
--muted: oklch(0.967 0.001 286.375); --secondary: oklch(0.95 0.005 240);
--muted-foreground: oklch(0.552 0.016 285.938); --secondary-foreground: oklch(0.22 0.06 240);
--accent: oklch(0.967 0.001 286.375); --muted: oklch(0.94 0.005 80);
--accent-foreground: oklch(0.141 0.005 285.823); --muted-foreground: oklch(0.50 0.01 260);
--destructive: oklch(0.577 0.245 27.325); --accent: oklch(0.93 0.01 240);
--destructive-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.22 0.06 240);
--border: oklch(0.92 0.004 286.32); --destructive: oklch(0.55 0.20 25);
--input: oklch(0.92 0.004 286.32); --destructive-foreground: oklch(0.98 0 0);
--ring: oklch(0.623 0.214 259.815); --border: oklch(0.88 0.005 260);
--sidebar: oklch(0.985 0 0); --input: oklch(0.92 0.005 260);
--sidebar-foreground: oklch(0.235 0.015 65); --ring: oklch(0.48 0.18 255);
--sidebar-accent: oklch(0.967 0.001 286.375); --chart-1: oklch(0.48 0.18 255);
--sidebar-accent-foreground: oklch(0.141 0.005 285.823); --chart-2: oklch(0.65 0.17 50);
--sidebar-border: oklch(0.92 0.004 286.32); --chart-3: oklch(0.50 0.10 160);
--sidebar-ring: oklch(0.623 0.214 259.815); --chart-4: oklch(0.55 0.20 25);
} --chart-5: oklch(0.22 0.06 240);
--sidebar: oklch(0.22 0.06 240);
.dark { --sidebar-foreground: oklch(0.92 0.01 240);
--primary: var(--color-blue-700); --sidebar-primary: oklch(0.48 0.18 255);
--primary-foreground: var(--color-blue-50); --sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-primary: var(--color-blue-500); --sidebar-accent: oklch(0.28 0.06 240);
--sidebar-primary-foreground: var(--color-blue-50); --sidebar-accent-foreground: oklch(0.92 0.01 240);
--background: oklch(0.141 0.005 285.823); --sidebar-border: oklch(0.30 0.05 240);
--foreground: oklch(0.85 0.005 65); --sidebar-ring: oklch(0.48 0.18 255);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.85 0.005 65);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.85 0.005 65);
--secondary: oklch(0.24 0.006 286.033);
--secondary-foreground: oklch(0.7 0.005 65);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.92 0.005 65);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.85 0.005 65);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
} }
@layer base { @layer base {
@@ -119,6 +104,10 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: 'Inter', sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Sora', sans-serif;
} }
button:not(:disabled), button:not(:disabled),
[role="button"]:not([aria-disabled="true"]), [role="button"]:not([aria-disabled="true"]),
@@ -134,24 +123,11 @@
} }
@layer components { @layer components {
/**
* Custom container utility that centers content and adds responsive padding.
*
* This overrides Tailwind's default container behavior to:
* - Auto-center content (mx-auto)
* - Add responsive horizontal padding
* - Set max-width for large screens
*
* Usage: <div className="container">...</div>
*
* For custom widths, use max-w-* utilities directly:
* <div className="max-w-6xl mx-auto px-4">...</div>
*/
.container { .container {
width: 100%; width: 100%;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
padding-left: 1rem; /* 16px - mobile padding */ padding-left: 1rem;
padding-right: 1rem; padding-right: 1rem;
} }
@@ -162,16 +138,76 @@
@media (min-width: 640px) { @media (min-width: 640px) {
.container { .container {
padding-left: 1.5rem; /* 24px - tablet padding */ padding-left: 1.5rem;
padding-right: 1.5rem; padding-right: 1.5rem;
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.container { .container {
padding-left: 2rem; /* 32px - desktop padding */ padding-left: 2rem;
padding-right: 2rem; padding-right: 2rem;
max-width: 1280px; /* Standard content width */ max-width: 1400px;
} }
} }
}
/* Barre de vétusté */
.vetusite-bar {
height: 4px;
border-radius: 2px;
transition: width 0.6s ease-out;
}
/* Card établissement */
.etab-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.etab-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px oklch(0.22 0.06 240 / 0.12);
}
/* Liseré coloré en haut des sections */
.section-accent {
border-top: 3px solid oklch(0.48 0.18 255);
}
/* Chiffre clé */
.kpi-value {
font-family: 'Sora', sans-serif;
font-weight: 800;
letter-spacing: -0.02em;
}
}
/* Animations d'entrée */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-slide-up {
animation: fadeSlideUp 0.35s ease-out forwards;
}
/* Scrollbar personnalisée */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: oklch(0.94 0.005 80);
}
::-webkit-scrollbar-thumb {
background: oklch(0.70 0.01 260);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.48 0.18 255);
}

72
client/src/lib/format.ts Normal file
View File

@@ -0,0 +1,72 @@
// Utilitaires de formatage — Budget SI 2027 Itinova
/**
* Formate un montant en euros avec séparateur de milliers
*/
export function formatEuros(amount: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
/**
* Formate un nombre avec séparateur de milliers
*/
export function formatNumber(n: number): string {
return new Intl.NumberFormat('fr-FR').format(n);
}
/**
* Retourne la couleur de vétusté selon l'âge
*/
export function getVetusteColor(ageAns: number | null, type: 'fixe' | 'portable'): string {
if (ageAns === null) return 'text-muted-foreground';
const seuil = type === 'fixe' ? 5 : 4;
if (ageAns >= seuil + 2) return 'text-red-600';
if (ageAns >= seuil) return 'text-orange-500';
if (ageAns >= seuil - 1) return 'text-yellow-600';
return 'text-green-600';
}
/**
* Retourne la classe de badge de vétusté
*/
export function getVetusteBadgeClass(ageAns: number | null, type: 'fixe' | 'portable'): string {
if (ageAns === null) return 'bg-gray-100 text-gray-500';
const seuil = type === 'fixe' ? 5 : 4;
if (ageAns >= seuil + 2) return 'bg-red-100 text-red-700 border border-red-200';
if (ageAns >= seuil) return 'bg-orange-100 text-orange-700 border border-orange-200';
if (ageAns >= seuil - 1) return 'bg-yellow-100 text-yellow-700 border border-yellow-200';
return 'bg-green-100 text-green-700 border border-green-200';
}
/**
* Retourne le libellé de vétusté
*/
export function getVetusteLabel(ageAns: number | null, type: 'fixe' | 'portable'): string {
if (ageAns === null) return 'Date inconnue';
const seuil = type === 'fixe' ? 5 : 4;
if (ageAns >= seuil) return `À renouveler (${ageAns.toFixed(1)} ans)`;
return `${ageAns.toFixed(1)} ans`;
}
/**
* Calcule le pourcentage de vétusté pour la barre de progression
*/
export function getVetustePercent(nbRenouveler: number, nbTotal: number): number {
if (nbTotal === 0) return 0;
return Math.round((nbRenouveler / nbTotal) * 100);
}
/**
* Retourne la couleur de la barre de vétusté selon le pourcentage
*/
export function getBarColor(percent: number): string {
if (percent >= 70) return 'bg-red-500';
if (percent >= 40) return 'bg-orange-400';
if (percent >= 20) return 'bg-yellow-400';
return 'bg-green-500';
}

View File

@@ -1,25 +1,297 @@
import { Button } from "@/components/ui/button"; // Page principale — Budget Renouvellement SI 2027 - Itinova
import { Loader2 } from "lucide-react"; // Design: Corporate Modernism — Sidebar bleu marine + grille de cards
import { Streamdown } from 'streamdown';
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() { export default function Home() {
// If theme is switchable in App.tsx, we can implement theme toggling like this: const {
// const { theme, toggleTheme } = useTheme(); 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 ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex bg-background">
<main> {/* Sidebar */}
{/* Example: lucide-react for icons */} <aside
<Loader2 className="animate-spin" /> 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`}
Example Page style={{ minHeight: '100vh' }}
{/* Example: Streamdown for markdown rendering */} >
<Streamdown>Any **markdown** content</Streamdown> {/* Logo / Titre */}
<Button variant="default">Example Button</Button> <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> </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> </div>
); );
} }

View File

@@ -0,0 +1,54 @@
// Types pour l'application Budget Renouvellement SI 2027 - Itinova
export interface Materiel {
libelle: string;
date_achat: string;
age_ans: number | null;
a_renouveler: boolean;
modele: string;
fabricant: string;
}
export interface EtablissementStats {
nb_fixes_total: number;
nb_portables_total: number;
nb_fixes_renouveler: number;
nb_portables_renouveler: number;
nb_fixes_sans_date: number;
nb_portables_sans_date: number;
budget_fixes: number;
budget_portables: number;
budget_total: number;
}
export interface Etablissement {
code: string;
nom: string;
fixes: Materiel[];
portables: Materiel[];
stats: EtablissementStats;
}
export interface BudgetMeta {
date_calcul: string;
date_reference: string;
seuil_fixes_ans: number;
seuil_portables_ans: number;
cout_fixe: number;
cout_portable: number;
nb_etablissements: number;
budget_global: number;
total_fixes: number;
total_portables: number;
total_fixes_renouveler: number;
total_portables_renouveler: number;
}
export interface BudgetData {
meta: BudgetMeta;
etablissements: Etablissement[];
}
export type SortField = 'nom' | 'budget_total' | 'nb_fixes_renouveler' | 'nb_portables_renouveler' | 'code';
export type SortOrder = 'asc' | 'desc';
export type FilterType = 'all' | 'has_budget' | 'no_budget';

135
ideas.md Normal file
View File

@@ -0,0 +1,135 @@
# Idées de design — Budget Renouvellement SI 2027 - Itinova
## Contexte
Application interne de gestion budgétaire pour le service informatique d'Itinova (groupe médico-social). Outil professionnel destiné à des responsables SI et directeurs d'établissements.
---
<response>
<probability>0.07</probability>
<idea>
**Design Movement:** Corporate Modernism — Bauhaus appliqué au tableau de bord analytique
**Core Principles:**
1. Hiérarchie visuelle stricte : chaque information a un rang clair (titre > chiffre clé > détail)
2. Densité informationnelle maîtrisée : beaucoup de données sans surcharge cognitive
3. Couleur fonctionnelle : la couleur encode le sens (vert = bon, orange = attention, rouge = critique)
4. Grille asymétrique : sidebar fixe à gauche, contenu principal à droite
**Color Philosophy:**
- Fond principal : blanc cassé chaud `#F8F6F2`
- Sidebar : bleu marine profond `#1A2E4A` (autorité, confiance)
- Accent primaire : bleu Itinova `#0066CC`
- Alerte vétusté : orange ambré `#E8820C`
- Succès/récent : vert sauge `#3D7A5F`
- Texte : anthracite `#2C2C2C`
**Layout Paradigm:**
Sidebar fixe (240px) + zone principale scrollable. En-tête avec KPIs globaux en bandeaux horizontaux. Liste des établissements en grille 3 colonnes avec cards. Fenêtre de détail en panneau latéral droit (drawer) ou modal pleine largeur.
**Signature Elements:**
1. Barre de vétusté colorée (rouge/orange/vert) sur chaque card établissement
2. Chiffres budgétaires en grande typographie avec unité en exposant
3. Ligne de séparation colorée en haut de chaque section (liseré 3px couleur primaire)
**Interaction Philosophy:**
Hover sur les cards révèle les détails. Clic ouvre un drawer latéral avec le détail complet. Filtres persistants dans la sidebar.
**Animation:**
- Entrée des cards : fade-in + slide-up (stagger 50ms)
- Ouverture drawer : slide depuis la droite (300ms ease-out)
- Compteurs budgétaires : animation count-up au chargement
**Typography System:**
- Titres : `Sora` Bold (moderne, institutionnel)
- Corps : `Inter` Regular/Medium
- Chiffres clés : `Sora` ExtraBold, taille 2xl-4xl
</idea>
</response>
<response>
<probability>0.06</probability>
<idea>
**Design Movement:** Data-Forward Minimalism — Inspiration Bloomberg Terminal modernisé
**Core Principles:**
1. L'information prime sur la décoration
2. Contraste élevé pour lisibilité en conditions de bureau
3. Tableaux comme élément central, pas les cards
4. Navigation par onglets horizontaux
**Color Philosophy:**
- Fond sombre `#0F1923` (confort visuel sur longues sessions)
- Texte blanc `#E8EDF2`
- Accent cyan `#00B4D8` pour les chiffres importants
- Rouge `#FF4757` pour les équipements à renouveler
- Vert `#2ED573` pour les équipements récents
**Layout Paradigm:**
Full-width avec navigation top. Dashboard principal avec 4 KPI cards en ligne. Tableau principal avec tri/filtre. Modal de détail.
**Signature Elements:**
1. Indicateurs LED (points colorés) pour le statut de vétusté
2. Mini sparklines dans les cells du tableau
3. Header avec gradient sombre
**Interaction Philosophy:**
Tri de colonnes, filtres inline, export CSV.
**Animation:**
Transitions subtiles, pas d'animations ostentatoires.
**Typography System:**
- `JetBrains Mono` pour les chiffres et codes
- `IBM Plex Sans` pour les libellés
</idea>
</response>
<response>
<probability>0.08</probability>
<idea>
**Design Movement:** Institutional Clarity — Design système gouvernemental français modernisé
**Core Principles:**
1. Accessibilité maximale (WCAG AA)
2. Structure tabulaire claire avec alternance de lignes
3. Couleurs institutionnelles sobres
4. Formulaires et filtres bien structurés
**Color Philosophy:**
- Fond blanc pur `#FFFFFF`
- Bleu institutionnel `#003189`
- Gris clair `#F5F5F5` pour alternance
- Orange `#E05206` pour alertes
**Layout Paradigm:**
Navigation top fixe. Contenu centré max-width 1200px. Tableau principal avec pagination.
**Signature Elements:**
1. Bandeau bleu en header avec logo Itinova
2. Tableau avec en-têtes fixes et tri
3. Badges colorés pour les statuts
**Interaction Philosophy:**
Simplicité maximale, pas d'effets superflus.
**Animation:**
Aucune animation, priorité à la performance.
**Typography System:**
- `Marianne` (police officielle française) ou `Source Sans Pro`
- Hiérarchie claire H1/H2/H3
</idea>
</response>
---
## Choix retenu : Approche 1 — Corporate Modernism
Design professionnel avec sidebar bleu marine, cards d'établissements avec indicateurs de vétusté colorés, et drawer de détail. Typographie Sora + Inter pour un rendu institutionnel moderne.