Checkpoint: Évolution v6 complète : onglets Éditeurs et Blocs fonctionnels dans Admin (CRUD complet), page Tableau de bord statistiques avec graphiques Recharts (KPIs, barres par bloc/région, camembert état déploiement, top 10 solutions), lien dans la sidebar réservé aux gestionnaires. 33 tests Vitest passés, 0 erreur TypeScript.
This commit is contained in:
@@ -13,6 +13,7 @@ import Login from "./pages/Login";
|
||||
import LoginLocal from "./pages/LoginLocal";
|
||||
import MesSolutions from "./pages/MesSolutions";
|
||||
import SolutionsLogicielles from "./pages/SolutionsLogicielles";
|
||||
import Statistiques from "./pages/Statistiques";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
@@ -29,6 +30,7 @@ function Router() {
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/mes-solutions" component={MesSolutions} />
|
||||
<Route path="/solutions" component={SolutionsLogicielles} />
|
||||
<Route path="/statistiques" component={Statistiques} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="/404" component={NotFound} />
|
||||
|
||||
@@ -37,6 +37,7 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
{ label: "Tableau de bord statistiques", href: "/statistiques", icon: <LayoutDashboard size={18} />, adminOnly: true },
|
||||
{ label: "Administration", href: "/admin", icon: <Shield size={18} />, adminOnly: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const ROLE_COLORS: Record<SonumRole, string> = {
|
||||
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<"users" | "etablissements" | "import-etab" | "import-contacts">("users");
|
||||
const [activeTab, setActiveTab] = useState<"users" | "etablissements" | "import-etab" | "import-contacts" | "editeurs" | "blocs">("users");
|
||||
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
|
||||
|
||||
if (!isGestionnaire) {
|
||||
@@ -78,6 +78,8 @@ export default function Admin() {
|
||||
{ id: "etablissements" as const, label: "Établissements", icon: <Building2 size={15} /> },
|
||||
{ id: "import-etab" as const, label: "Import Établissements", icon: <Upload size={15} /> },
|
||||
{ id: "import-contacts" as const, label: "Import Contacts", icon: <Upload size={15} /> },
|
||||
{ id: "editeurs" as const, label: "Éditeurs", icon: <Pencil size={15} /> },
|
||||
{ id: "blocs" as const, label: "Blocs fonctionnels", icon: <FileText size={15} /> },
|
||||
]).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -98,6 +100,8 @@ export default function Admin() {
|
||||
{activeTab === "etablissements" && <EtablissementsPanel />}
|
||||
{activeTab === "import-etab" && <ImportEtablissementsPanel />}
|
||||
{activeTab === "import-contacts" && <ImportContactsPanel />}
|
||||
{activeTab === "editeurs" && <EditeursPanel />}
|
||||
{activeTab === "blocs" && <BlocsFonctionnelsPanel />}
|
||||
</div>
|
||||
</SonumLayout>
|
||||
);
|
||||
@@ -1259,3 +1263,333 @@ function ImportContactsPanel() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Éditeurs ───────────────────────────────────────────────────────────
|
||||
|
||||
function EditeursPanel() {
|
||||
const utils = trpc.useUtils();
|
||||
const editeursQuery = trpc.referentiel.editeurs.useQuery();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createNom, setCreateNom] = useState("");
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editNom, setEditNom] = useState("");
|
||||
|
||||
const createMutation = trpc.referentiel.createEditeur.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Éditeur créé");
|
||||
setShowCreate(false);
|
||||
setCreateNom("");
|
||||
utils.referentiel.editeurs.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.referentiel.updateEditeur.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Éditeur mis à jour");
|
||||
setEditingId(null);
|
||||
utils.referentiel.editeurs.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.referentiel.deleteEditeur.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Éditeur supprimé");
|
||||
utils.referentiel.editeurs.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const editeurs = editeursQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Éditeurs de logiciels</h2>
|
||||
<p className="text-sm text-muted-foreground">{editeurs.length} éditeur{editeurs.length !== 1 ? "s" : ""} référencé{editeurs.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
|
||||
>
|
||||
<Plus size={15} />
|
||||
Ajouter un éditeur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="bg-card border border-primary/20 rounded-xl p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-foreground mb-3 text-sm">Nouvel éditeur</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={createNom}
|
||||
onChange={(e) => setCreateNom(e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
placeholder="Nom de l'éditeur"
|
||||
onKeyDown={(e) => e.key === "Enter" && createMutation.mutate({ nom: createNom })}
|
||||
/>
|
||||
<button
|
||||
onClick={() => createMutation.mutate({ nom: createNom })}
|
||||
disabled={!createNom.trim() || createMutation.isPending}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(false); setCreateNom(""); }}
|
||||
className="px-3 py-2 text-muted-foreground hover:text-foreground rounded-lg border border-border transition-colors"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Nom de l'éditeur</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editeursQuery.isLoading ? (
|
||||
<tr><td colSpan={2} className="px-4 py-8 text-center text-muted-foreground text-sm">Chargement…</td></tr>
|
||||
) : editeurs.length === 0 ? (
|
||||
<tr><td colSpan={2} className="px-4 py-8 text-center text-muted-foreground text-sm">Aucun éditeur référencé</td></tr>
|
||||
) : editeurs.map((e) => (
|
||||
<tr key={e.id} className="border-t border-border hover:bg-muted/20 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
{editingId === e.id ? (
|
||||
<input
|
||||
value={editNom}
|
||||
onChange={(ev) => setEditNom(ev.target.value)}
|
||||
className="px-3 py-1.5 text-sm bg-background border border-primary/40 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 w-full max-w-xs"
|
||||
autoFocus
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") updateMutation.mutate({ id: e.id, nom: editNom });
|
||||
if (ev.key === "Escape") setEditingId(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{e.nom}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{editingId === e.id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ id: e.id, nom: editNom })}
|
||||
disabled={!editNom.trim() || updateMutation.isPending}
|
||||
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Enregistrer"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground rounded-lg transition-colors"
|
||||
title="Annuler"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setEditingId(e.id); setEditNom(e.nom); }}
|
||||
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Modifier"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Supprimer l'éditeur "${e.nom}" ?`)) deleteMutation.mutate({ id: e.id });
|
||||
}}
|
||||
className="p-1.5 text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Blocs Fonctionnels ─────────────────────────────────────────────────
|
||||
|
||||
function BlocsFonctionnelsPanel() {
|
||||
const utils = trpc.useUtils();
|
||||
const blocsQuery = trpc.referentiel.blocsFonctionnels.useQuery();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createNom, setCreateNom] = useState("");
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editNom, setEditNom] = useState("");
|
||||
|
||||
const createMutation = trpc.referentiel.createBlocFonctionnel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Bloc fonctionnel créé");
|
||||
setShowCreate(false);
|
||||
setCreateNom("");
|
||||
utils.referentiel.blocsFonctionnels.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.referentiel.updateBlocFonctionnel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Bloc fonctionnel mis à jour");
|
||||
setEditingId(null);
|
||||
utils.referentiel.blocsFonctionnels.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.referentiel.deleteBlocFonctionnel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Bloc fonctionnel supprimé");
|
||||
utils.referentiel.blocsFonctionnels.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const blocs = blocsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Blocs fonctionnels</h2>
|
||||
<p className="text-sm text-muted-foreground">{blocs.length} bloc{blocs.length !== 1 ? "s" : ""} référencé{blocs.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
|
||||
>
|
||||
<Plus size={15} />
|
||||
Ajouter un bloc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="bg-card border border-primary/20 rounded-xl p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-foreground mb-3 text-sm">Nouveau bloc fonctionnel</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={createNom}
|
||||
onChange={(e) => setCreateNom(e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
placeholder="Nom du bloc fonctionnel"
|
||||
onKeyDown={(e) => e.key === "Enter" && createMutation.mutate({ nom: createNom })}
|
||||
/>
|
||||
<button
|
||||
onClick={() => createMutation.mutate({ nom: createNom })}
|
||||
disabled={!createNom.trim() || createMutation.isPending}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(false); setCreateNom(""); }}
|
||||
className="px-3 py-2 text-muted-foreground hover:text-foreground rounded-lg border border-border transition-colors"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Nom du bloc fonctionnel</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blocsQuery.isLoading ? (
|
||||
<tr><td colSpan={2} className="px-4 py-8 text-center text-muted-foreground text-sm">Chargement…</td></tr>
|
||||
) : blocs.length === 0 ? (
|
||||
<tr><td colSpan={2} className="px-4 py-8 text-center text-muted-foreground text-sm">Aucun bloc fonctionnel référencé</td></tr>
|
||||
) : blocs.map((b) => (
|
||||
<tr key={b.id} className="border-t border-border hover:bg-muted/20 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
{editingId === b.id ? (
|
||||
<input
|
||||
value={editNom}
|
||||
onChange={(ev) => setEditNom(ev.target.value)}
|
||||
className="px-3 py-1.5 text-sm bg-background border border-primary/40 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 w-full max-w-xs"
|
||||
autoFocus
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") updateMutation.mutate({ id: b.id, nom: editNom });
|
||||
if (ev.key === "Escape") setEditingId(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{b.nom}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{editingId === b.id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ id: b.id, nom: editNom })}
|
||||
disabled={!editNom.trim() || updateMutation.isPending}
|
||||
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Enregistrer"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground rounded-lg transition-colors"
|
||||
title="Annuler"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setEditingId(b.id); setEditNom(b.nom); }}
|
||||
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Modifier"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Supprimer le bloc "${b.nom}" ?`)) deleteMutation.mutate({ id: b.id });
|
||||
}}
|
||||
className="p-1.5 text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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