From 5801ba4add7df0374d04c80103a308ba8f82f531 Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 17 Apr 2026 11:11:15 -0400 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20=C3=89volution=20v6=20compl=C3=A8?= =?UTF-8?q?te=20:=20onglets=20=C3=89diteurs=20et=20Blocs=20fonctionnels=20?= =?UTF-8?q?dans=20Admin=20(CRUD=20complet),=20page=20Tableau=20de=20bord?= =?UTF-8?q?=20statistiques=20avec=20graphiques=20Recharts=20(KPIs,=20barre?= =?UTF-8?q?s=20par=20bloc/r=C3=A9gion,=20camembert=20=C3=A9tat=20d=C3=A9pl?= =?UTF-8?q?oiement,=20top=2010=20solutions),=20lien=20dans=20la=20sidebar?= =?UTF-8?q?=20r=C3=A9serv=C3=A9=20aux=20gestionnaires.=2033=20tests=20Vite?= =?UTF-8?q?st=20pass=C3=A9s,=200=20erreur=20TypeScript.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 2 + client/src/components/SonumLayout.tsx | 1 + client/src/pages/Admin.tsx | 336 ++++++++++++++++++++++++- client/src/pages/Statistiques.tsx | 348 ++++++++++++++++++++++++++ server/db.ts | 126 ++++++++++ server/routers.ts | 18 ++ todo.md | 10 + 7 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/Statistiques.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 5748093..da8cc0a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + {/* Fallback */} diff --git a/client/src/components/SonumLayout.tsx b/client/src/components/SonumLayout.tsx index ace5f09..3afbc40 100644 --- a/client/src/components/SonumLayout.tsx +++ b/client/src/components/SonumLayout.tsx @@ -37,6 +37,7 @@ const navItems: NavItem[] = [ ]; const adminNavItems: NavItem[] = [ + { label: "Tableau de bord statistiques", href: "/statistiques", icon: , adminOnly: true }, { label: "Administration", href: "/admin", icon: , adminOnly: true }, ]; diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx index ac1c5db..c5aa7c5 100644 --- a/client/src/pages/Admin.tsx +++ b/client/src/pages/Admin.tsx @@ -46,7 +46,7 @@ const ROLE_COLORS: Record = { 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: }, { id: "import-etab" as const, label: "Import Établissements", icon: }, { id: "import-contacts" as const, label: "Import Contacts", icon: }, + { id: "editeurs" as const, label: "Éditeurs", icon: }, + { id: "blocs" as const, label: "Blocs fonctionnels", icon: }, ]).map((tab) => ( + + + {showCreate && ( +
+

Nouvel éditeur

+
+ 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 })} + /> + + +
+
+ )} + +
+ + + + + + + + + {editeursQuery.isLoading ? ( + + ) : editeurs.length === 0 ? ( + + ) : editeurs.map((e) => ( + + + + + ))} + +
Nom de l'éditeurActions
Chargement…
Aucun éditeur référencé
+ {editingId === e.id ? ( + 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); + }} + /> + ) : ( + {e.nom} + )} + +
+ {editingId === e.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ + ); +} + +// ─── 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(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 ( +
+
+
+

Blocs fonctionnels

+

{blocs.length} bloc{blocs.length !== 1 ? "s" : ""} référencé{blocs.length !== 1 ? "s" : ""}

+
+ +
+ + {showCreate && ( +
+

Nouveau bloc fonctionnel

+
+ 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 })} + /> + + +
+
+ )} + +
+ + + + + + + + + {blocsQuery.isLoading ? ( + + ) : blocs.length === 0 ? ( + + ) : blocs.map((b) => ( + + + + + ))} + +
Nom du bloc fonctionnelActions
Chargement…
Aucun bloc fonctionnel référencé
+ {editingId === b.id ? ( + 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); + }} + /> + ) : ( + {b.nom} + )} + +
+ {editingId === b.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+ ); +} diff --git a/client/src/pages/Statistiques.tsx b/client/src/pages/Statistiques.tsx new file mode 100644 index 0000000..2203144 --- /dev/null +++ b/client/src/pages/Statistiques.tsx @@ -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 = { + "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 ( +
+
{icon}
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ ); +} + +// ─── Tooltip personnalisé ───────────────────────────────────────────────────── + +function CustomTooltip({ active, payload, label }: any) { + if (active && payload && payload.length) { + return ( +
+

{label}

+

{payload[0].value} établissement{payload[0].value !== 1 ? "s" : ""}

+
+ ); + } + 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 ( + +
+ +

Accès réservé aux gestionnaires SONUM

+
+
+ ); + } + + const stats = statsQuery.data; + + return ( + +
+ {/* En-tête */} +
+

Tableau de bord statistiques

+

+ Vue d'ensemble de la cartographie des solutions numériques FEHAP +

+
+ + {statsQuery.isLoading && ( +
+
+
+ )} + + {statsQuery.isError && ( +
+

Erreur lors du chargement des statistiques

+

{statsQuery.error?.message}

+
+ )} + + {stats && ( +
+ {/* KPIs */} +
+ } + label="Établissements" + value={stats.totalEtablissements} + sub="adhérents FEHAP référencés" + color="primary" + /> + } + label="Solutions distinctes" + value={stats.totalSolutions} + sub="logiciels référencés" + color="blue" + /> + } + label="Fiches logiciels" + value={stats.totalFiches} + sub="rattachements établissement / solution" + color="amber" + /> + } + label="Taux de remplissage" + value={`${stats.tauxRemplissage} %`} + sub={`${stats.etabAvecLogiciel} étab. avec au moins 1 logiciel`} + color="green" + /> +
+ + {/* Graphiques ligne 1 : Blocs fonctionnels + Régions */} +
+ {/* Répartition par bloc fonctionnel */} +
+

+ + Répartition par bloc fonctionnel +

+ {stats.parBloc.length === 0 ? ( +
+ Aucune donnée disponible +
+ ) : ( + + + + + + } /> + + + + )} +
+ + {/* Répartition par région */} +
+

+ + Répartition par région +

+ {stats.parRegion.length === 0 ? ( +
+ Aucune donnée disponible +
+ ) : ( + + + + + + } /> + + {stats.parRegion.map((_: { nom: string; count: number }, index: number) => ( + + ))} + + + + )} +
+
+ + {/* Graphiques ligne 2 : État de déploiement + Top solutions */} +
+ {/* Répartition par état de déploiement */} +
+

+ + État de déploiement +

+ {stats.parEtat.length === 0 ? ( +
+ Aucune donnée disponible +
+ ) : ( +
+ + + + {stats.parEtat.map((entry: { nom: string; count: number }, index: number) => ( + + ))} + + [`${value} fiche${value !== 1 ? "s" : ""}`, name]} + contentStyle={{ + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + fontSize: "12px", + }} + /> + + + +
+ )} +
+ + {/* Top 10 solutions */} +
+

+ + Top 10 solutions les plus utilisées +

+ {stats.topSolutions.length === 0 ? ( +
+ Aucune donnée disponible +
+ ) : ( +
+ {stats.topSolutions.map((sol: { nom: string; editeur: string; count: number }, index: number) => ( +
+ + {index + 1} + +
+
+ {sol.nom} + + {sol.count} étab. + +
+
+
+
+
+ {sol.editeur} +
+
+
+ ))} +
+ )} +
+
+
+ )} +
+ + ); +} diff --git a/server/db.ts b/server/db.ts index aacc1f1..9e90ddf 100644 --- a/server/db.ts +++ b/server/db.ts @@ -777,3 +777,129 @@ export async function deleteSolution(id: number) { await db.delete(solutions).where(eq(solutions.id, id)); return { success: true }; } + +// ─── CRUD Éditeurs ──────────────────────────────────────────────────────────── + +export async function updateEditeur(id: number, nom: string) { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + await db.update(editeurs).set({ nom }).where(eq(editeurs.id, id)); + return { id }; +} + +export async function deleteEditeur(id: number) { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + await db.delete(editeurs).where(eq(editeurs.id, id)); + return { id }; +} + +// ─── CRUD Blocs Fonctionnels ────────────────────────────────────────────────── + +export async function updateBlocFonctionnel(id: number, nom: string) { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + await db.update(blocsFonctionnels).set({ nom }).where(eq(blocsFonctionnels.id, id)); + return { id }; +} + +export async function deleteBlocFonctionnel(id: number) { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + await db.delete(blocsFonctionnels).where(eq(blocsFonctionnels.id, id)); + return { id }; +} + +// ─── Statistiques ───────────────────────────────────────────────────────────── + +export async function getStatistiques() { + const db = await getDb(); + if (!db) return null; + + // Total établissements + const [{ total: totalEtablissements }] = await db + .select({ total: sql`COUNT(*)` }) + .from(etablissements); + + // Total solutions distinctes utilisées + const [{ total: totalSolutions }] = await db + .select({ total: sql`COUNT(DISTINCT solutionId)` }) + .from(logicielsEtablissements); + + // Total fiches logiciels (lignes logiciels_etablissements) + const [{ total: totalFiches }] = await db + .select({ total: sql`COUNT(*)` }) + .from(logicielsEtablissements); + + // Établissements avec au moins un logiciel + const [{ total: etabAvecLogiciel }] = await db + .select({ total: sql`COUNT(DISTINCT etablissementId)` }) + .from(logicielsEtablissements); + + // Répartition par bloc fonctionnel + const parBloc = await db + .select({ + blocNom: blocsFonctionnels.nom, + count: sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId})`, + }) + .from(logicielsEtablissements) + .innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id)) + .leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id)) + .groupBy(blocsFonctionnels.nom) + .orderBy(sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId}) DESC`); + + // Répartition par région + const parRegion = await db + .select({ + region: etablissements.region, + count: sql`COUNT(DISTINCT ${etablissements.id})`, + }) + .from(etablissements) + .groupBy(etablissements.region) + .orderBy(sql`COUNT(DISTINCT ${etablissements.id}) DESC`); + + // Répartition par état de déploiement + const parEtat = await db + .select({ + etat: logicielsEtablissements.etatDeploiement, + count: sql`COUNT(*)`, + }) + .from(logicielsEtablissements) + .groupBy(logicielsEtablissements.etatDeploiement) + .orderBy(sql`COUNT(*) DESC`); + + // Top 10 solutions les plus utilisées + const topSolutions = await db + .select({ + solutionNom: solutions.nom, + editeurNom: editeurs.nom, + count: sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId})`, + }) + .from(logicielsEtablissements) + .innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id)) + .leftJoin(editeurs, eq(solutions.editeurId, editeurs.id)) + .groupBy(solutions.id, solutions.nom, editeurs.nom) + .orderBy(sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId}) DESC`) + .limit(10); + + // Taux de remplissage (% établissements avec au moins 1 logiciel) + const tauxRemplissage = totalEtablissements > 0 + ? Math.round((Number(etabAvecLogiciel) / Number(totalEtablissements)) * 100) + : 0; + + return { + totalEtablissements: Number(totalEtablissements), + totalSolutions: Number(totalSolutions), + totalFiches: Number(totalFiches), + etabAvecLogiciel: Number(etabAvecLogiciel), + tauxRemplissage, + parBloc: parBloc.map((r) => ({ nom: r.blocNom ?? "Non renseigné", count: Number(r.count) })), + parRegion: parRegion.map((r) => ({ nom: r.region ?? "Non renseignée", count: Number(r.count) })), + parEtat: parEtat.map((r) => ({ nom: r.etat ?? "Inconnu", count: Number(r.count) })), + topSolutions: topSolutions.map((r) => ({ + nom: r.solutionNom ?? "", + editeur: r.editeurNom ?? "", + count: Number(r.count), + })), + }; +} diff --git a/server/routers.ts b/server/routers.ts index b470432..3b1223b 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -5,11 +5,16 @@ import { authenticateLocalUser, createDemandeContact, createBlocFonctionnel, + updateBlocFonctionnel, + deleteBlocFonctionnel, createEditeur, + updateEditeur, + deleteEditeur, createLocalUser, createSolution, updateSolution, deleteSolution, + getStatistiques, deleteLogicielEtablissement, deleteUser, getAllDemandes, @@ -141,6 +146,19 @@ export const appRouter = router({ const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; return createBlocFonctionnel(input.nom, isGestionnaire); }), + updateBlocFonctionnel: gestionnaireProcedure + .input(z.object({ id: z.number().int(), nom: z.string().min(1) })) + .mutation(({ input }) => updateBlocFonctionnel(input.id, input.nom)), + deleteBlocFonctionnel: gestionnaireProcedure + .input(z.object({ id: z.number().int() })) + .mutation(({ input }) => deleteBlocFonctionnel(input.id)), + updateEditeur: gestionnaireProcedure + .input(z.object({ id: z.number().int(), nom: z.string().min(1) })) + .mutation(({ input }) => updateEditeur(input.id, input.nom)), + deleteEditeur: gestionnaireProcedure + .input(z.object({ id: z.number().int() })) + .mutation(({ input }) => deleteEditeur(input.id)), + statistiques: gestionnaireProcedure.query(() => getStatistiques()), createSolution: protectedProcedure .input(z.object({ diff --git a/todo.md b/todo.md index 8d0b3cc..f5d40d9 100644 --- a/todo.md +++ b/todo.md @@ -74,3 +74,13 @@ ## Évolution v5 - [x] Solutions Logicielles : toggle vue accordéon / vue liste à plat (solutions + établissements visibles sans clic) + +## Évolution v6 + +- [x] Admin : onglet "Éditeurs" — CRUD éditeurs (ajouter, modifier, supprimer) +- [x] Admin : onglet "Blocs fonctionnels" — CRUD blocs fonctionnels (ajouter, modifier, supprimer) +- [x] Backend : procédures tRPC CRUD éditeurs (createEditeur, updateEditeur, deleteEditeur) +- [x] Backend : procédures tRPC CRUD blocs fonctionnels (createBlocFonctionnel, updateBlocFonctionnel, deleteBlocFonctionnel) +- [x] Backend : procédure statistiques (stats par bloc fonctionnel, par région, taux de remplissage) +- [x] Page "Tableau de bord" : graphiques recharts (barres, camembert, indicateurs clés) +- [x] Lien "Tableau de bord" dans la sidebar (visible uniquement gestionnaires)