SONUM v7 - Évolution v6 (éditeurs/blocs CRUD, tableau de bord stats) + vue liste alternance couleurs
This commit is contained in:
348
client/src/pages/Statistiques.tsx
Normal file
348
client/src/pages/Statistiques.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user