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

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

View File

@@ -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>
);
}