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,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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
101
client/src/components/EtablissementCard.tsx
Normal file
101
client/src/components/EtablissementCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
client/src/components/EtablissementDetail.tsx
Normal file
327
client/src/components/EtablissementDetail.tsx
Normal 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 > {seuilFixesAns} ans → {formatEuros(coutFixe)} TTC
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-orange-400" />
|
||||||
|
PC portable > {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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
client/src/components/KpiCard.tsx
Normal file
44
client/src/components/KpiCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
client/src/components/MaterielTable.tsx
Normal file
94
client/src/components/MaterielTable.tsx
Normal 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
18206
client/src/data_budget.json
Normal file
File diff suppressed because it is too large
Load Diff
102
client/src/hooks/useBudgetData.ts
Normal file
102
client/src/hooks/useBudgetData.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
72
client/src/lib/format.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -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">> {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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
54
client/src/types/budget.ts
Normal file
54
client/src/types/budget.ts
Normal 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
135
ideas.md
Normal 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.
|
||||||
Reference in New Issue
Block a user