349 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|