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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user