Files
sonum/client/src/pages/Statistiques.tsx

349 lines
14 KiB
TypeScript

import { useAuth } from "@/_core/hooks/useAuth";
import SonumLayout from "@/components/SonumLayout";
import { trpc } from "@/lib/trpc";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
} from "recharts";
import {
Building2,
LayoutGrid,
FileText,
TrendingUp,
Shield,
CheckCircle,
} from "lucide-react";
// ─── Palette de couleurs ──────────────────────────────────────────────────────
const COLORS = [
"#1e40af", // bleu foncé
"#3b82f6", // bleu
"#60a5fa", // bleu clair
"#93c5fd", // bleu très clair
"#1d4ed8",
"#2563eb",
"#6366f1",
"#818cf8",
"#a5b4fc",
"#c7d2fe",
];
const ETAT_COLORS: Record<string, string> = {
"en production": "#16a34a",
"en cours de déploiement": "#ca8a04",
"en projet": "#2563eb",
"abandonné": "#dc2626",
"Inconnu": "#9ca3af",
};
// ─── Composant carte KPI ──────────────────────────────────────────────────────
function KpiCard({
icon,
label,
value,
sub,
color = "primary",
}: {
icon: React.ReactNode;
label: string;
value: string | number;
sub?: string;
color?: "primary" | "green" | "amber" | "blue";
}) {
const colorMap = {
primary: "bg-primary/10 text-primary",
green: "bg-emerald-50 text-emerald-600",
amber: "bg-amber-50 text-amber-600",
blue: "bg-blue-50 text-blue-600",
};
return (
<div className="bg-card border border-border rounded-xl p-5 shadow-sm flex items-start gap-4">
<div className={`p-3 rounded-xl ${colorMap[color]} shrink-0`}>{icon}</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-0.5">{label}</p>
<p className="text-2xl font-bold text-foreground">{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
</div>
</div>
);
}
// ─── Tooltip personnalisé ─────────────────────────────────────────────────────
function CustomTooltip({ active, payload, label }: any) {
if (active && payload && payload.length) {
return (
<div className="bg-card border border-border rounded-lg shadow-lg px-3 py-2 text-sm">
<p className="font-medium text-foreground mb-1">{label}</p>
<p className="text-primary font-semibold">{payload[0].value} établissement{payload[0].value !== 1 ? "s" : ""}</p>
</div>
);
}
return null;
}
// ─── Page principale ──────────────────────────────────────────────────────────
export default function Statistiques() {
const { user } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const statsQuery = trpc.referentiel.statistiques.useQuery(undefined, {
enabled: isGestionnaire,
});
if (!isGestionnaire) {
return (
<SonumLayout>
<div className="p-8 text-center">
<Shield size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Accès réservé aux gestionnaires SONUM</p>
</div>
</SonumLayout>
);
}
const stats = statsQuery.data;
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-7xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Tableau de bord statistiques</h1>
<p className="text-muted-foreground text-sm">
Vue d'ensemble de la cartographie des solutions numériques FEHAP
</p>
</div>
{statsQuery.isLoading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full" />
</div>
)}
{statsQuery.isError && (
<div className="bg-destructive/10 border border-destructive/20 rounded-xl p-6 text-center">
<p className="text-destructive font-medium">Erreur lors du chargement des statistiques</p>
<p className="text-sm text-muted-foreground mt-1">{statsQuery.error?.message}</p>
</div>
)}
{stats && (
<div className="space-y-8">
{/* KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
icon={<Building2 size={22} />}
label="Établissements"
value={stats.totalEtablissements}
sub="adhérents FEHAP référencés"
color="primary"
/>
<KpiCard
icon={<LayoutGrid size={22} />}
label="Solutions distinctes"
value={stats.totalSolutions}
sub="logiciels référencés"
color="blue"
/>
<KpiCard
icon={<FileText size={22} />}
label="Fiches logiciels"
value={stats.totalFiches}
sub="rattachements établissement / solution"
color="amber"
/>
<KpiCard
icon={<TrendingUp size={22} />}
label="Taux de remplissage"
value={`${stats.tauxRemplissage} %`}
sub={`${stats.etabAvecLogiciel} étab. avec au moins 1 logiciel`}
color="green"
/>
</div>
{/* Graphiques ligne 1 : Blocs fonctionnels + Régions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Répartition par bloc fonctionnel */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<LayoutGrid size={16} className="text-primary" />
Répartition par bloc fonctionnel
</h2>
{stats.parBloc.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={stats.parBloc}
layout="vertical"
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} />
<YAxis
type="category"
dataKey="nom"
width={130}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Répartition par région */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<Building2 size={16} className="text-primary" />
Répartition par région
</h2>
{stats.parRegion.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={stats.parRegion}
layout="vertical"
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} />
<YAxis
type="category"
dataKey="nom"
width={130}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{stats.parRegion.map((_: { nom: string; count: number }, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Graphiques ligne 2 : État de déploiement + Top solutions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Répartition par état de déploiement */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<CheckCircle size={16} className="text-primary" />
État de déploiement
</h2>
{stats.parEtat.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={stats.parEtat}
dataKey="count"
nameKey="nom"
cx="50%"
cy="45%"
outerRadius={90}
innerRadius={50}
paddingAngle={3}
>
{stats.parEtat.map((entry: { nom: string; count: number }, index: number) => (
<Cell
key={`cell-${index}`}
fill={ETAT_COLORS[entry.nom] ?? COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [`${value} fiche${value !== 1 ? "s" : ""}`, name]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/>
<Legend
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: "12px", paddingTop: "8px" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Top 10 solutions */}
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
<h2 className="text-base font-semibold text-foreground mb-4 flex items-center gap-2">
<TrendingUp size={16} className="text-primary" />
Top 10 solutions les plus utilisées
</h2>
{stats.topSolutions.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground text-sm">
Aucune donnée disponible
</div>
) : (
<div className="space-y-2 overflow-y-auto max-h-64">
{stats.topSolutions.map((sol: { nom: string; editeur: string; count: number }, index: number) => (
<div key={index} className="flex items-center gap-3">
<span className="text-xs font-bold text-muted-foreground w-5 shrink-0 text-right">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-sm font-medium text-foreground truncate">{sol.nom}</span>
<span className="text-xs font-semibold text-primary shrink-0">
{sol.count} étab.
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div
className="bg-primary rounded-full h-1.5 transition-all"
style={{
width: `${Math.round((sol.count / (stats.topSolutions[0]?.count || 1)) * 100)}%`,
}}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">{sol.editeur}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</SonumLayout>
);
}