diff --git a/client/src/App.tsx b/client/src/App.tsx index f3871a2..369439c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,6 +18,7 @@ import AdminEmailTemplates from "./pages/AdminEmailTemplates"; import AdminEmailConfig from "./pages/AdminEmailConfig"; import AdminCalendrier from "./pages/AdminCalendrier"; import AdminRappels from "./pages/AdminRappels"; +import AdminAnalytics from "./pages/AdminAnalytics"; import Inscription from "./pages/Inscription"; import Login from "./pages/Login"; @@ -40,6 +41,7 @@ function Router() { + {/* Final fallback route */} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 801e73d..59e91ae 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/sidebar"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -37,6 +37,7 @@ const menuItems = [ { icon: UserCog, label: "Utilisateurs", path: "/admin/users" }, { icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" }, { icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" }, + { icon: TrendingUp, label: "Tableau de bord analytique", path: "/admin/analytics" }, { icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" }, ]; diff --git a/client/src/pages/AdminAnalytics.tsx b/client/src/pages/AdminAnalytics.tsx new file mode 100644 index 0000000..161480d --- /dev/null +++ b/client/src/pages/AdminAnalytics.tsx @@ -0,0 +1,284 @@ +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { BarChart3, TrendingUp, Users, Building2, Calendar, PieChart } from "lucide-react"; +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + PieChart as RePieChart, + Pie, + Cell, +} from "recharts"; + +const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; + +export default function AdminAnalytics() { + const { data: globalStats, isLoading: loadingGlobal } = trpc.analytics.globalStats.useQuery(); + const { data: inscriptionsByMonth, isLoading: loadingInscriptions } = trpc.analytics.inscriptionsByMonth.useQuery({}); + const { data: tauxRemplissage, isLoading: loadingTaux } = trpc.analytics.tauxRemplissageByMonth.useQuery(); + const { data: participationEtablissement, isLoading: loadingEtablissement } = trpc.analytics.participationByEtablissement.useQuery(); + const { data: participationFonction, isLoading: loadingFonction } = trpc.analytics.participationByFonction.useQuery(); + + // Formater les données pour les graphiques + const inscriptionsData = inscriptionsByMonth?.map(item => ({ + mois: item.mois, + Confirmées: Number(item.confirmees), + "Liste d'attente": Number(item.listeAttente), + Annulées: Number(item.annulees), + Total: Number(item.total), + })) || []; + + const tauxRemplissageData = tauxRemplissage?.map(item => ({ + mois: item.mois, + "Taux de remplissage (%)": Number(item.tauxRemplissage) || 0, + "Capacité totale": Number(item.capaciteTotale), + "Inscrits confirmés": Number(item.inscritsConfirmes), + })) || []; + + const etablissementData = participationEtablissement?.slice(0, 10).map(item => ({ + etablissement: item.codeEtablissement, + "Total inscriptions": Number(item.totalInscriptions), + Confirmées: Number(item.inscriptionsConfirmees), + "Liste d'attente": Number(item.inscriptionsListeAttente), + })) || []; + + const fonctionData = participationFonction?.map(item => ({ + name: item.fonction === 'directeur' ? 'Directeurs' : + item.fonction === 'chef_service' ? 'Chefs de service' : 'Autres', + value: Number(item.totalInscriptions), + confirmees: Number(item.inscriptionsConfirmees), + })) || []; + + return ( + +
+

Tableau de bord analytique

+

+ Statistiques et indicateurs de performance des formations +

+
+ + {/* Statistiques globales */} +
+ + + Total Formations + + + +
{globalStats?.totalFormations || 0}
+

+ {globalStats?.totalSequences || 0} séquences au total +

+
+
+ + + + Séquences ouvertes + + + +
{globalStats?.sequencesOuvertes || 0}
+

+ Disponibles pour inscription +

+
+
+ + + + Total Apprenants + + + +
{globalStats?.totalApprenants || 0}
+

+ {globalStats?.totalInscriptions || 0} inscriptions +

+
+
+ + + + Taux de remplissage + + + +
{globalStats?.tauxRemplissageMoyen || 0}%
+

+ Moyenne globale +

+
+
+
+ + {/* Graphiques */} +
+ {/* Évolution des inscriptions */} + + + + + Évolution des inscriptions par mois + + + Répartition des inscriptions confirmées, en liste d'attente et annulées + + + + {loadingInscriptions ? ( +
+

Chargement...

+
+ ) : inscriptionsData.length > 0 ? ( + + + + + + + + + + + + + ) : ( +
+

Aucune donnée disponible

+
+ )} +
+
+ + {/* Taux de remplissage */} + + + + + Taux de remplissage par mois + + + Pourcentage de places occupées par rapport à la capacité totale + + + + {loadingTaux ? ( +
+

Chargement...

+
+ ) : tauxRemplissageData.length > 0 ? ( + + + + + + + + + + + ) : ( +
+

Aucune donnée disponible

+
+ )} +
+
+
+ + {/* Participation par établissement et fonction */} +
+ {/* Top 10 établissements */} + + + + + Top 10 établissements + + + Établissements avec le plus d'inscriptions + + + + {loadingEtablissement ? ( +
+

Chargement...

+
+ ) : etablissementData.length > 0 ? ( + + + + + + + + + + + + ) : ( +
+

Aucune donnée disponible

+
+ )} +
+
+ + {/* Répartition par fonction */} + + + + + Répartition par fonction + + + Distribution des inscriptions par type de fonction + + + + {loadingFonction ? ( +
+

Chargement...

+
+ ) : fonctionData.length > 0 ? ( + + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {fonctionData.map((entry, index) => ( + + ))} + + + + + ) : ( +
+

Aucune donnée disponible

+
+ )} +
+
+
+
+ ); +} diff --git a/server/analyticsDb.ts b/server/analyticsDb.ts new file mode 100644 index 0000000..5f1bcc4 --- /dev/null +++ b/server/analyticsDb.ts @@ -0,0 +1,147 @@ +/** + * Requêtes analytiques pour le tableau de bord + */ + +import { eq, sql, and, gte, lte, desc } from "drizzle-orm"; +import { getDb } from "./db"; +import { inscriptions, sequences, apprenants, datesFormation, formations } from "../drizzle/schema"; + +/** + * Récupère les statistiques d'inscriptions par mois + * @param startDate - Date de début de la période + * @param endDate - Date de fin de la période + */ +export async function getInscriptionsByMonth(startDate?: Date, endDate?: Date) { + const db = await getDb(); + if (!db) return []; + + const conditions = []; + if (startDate) { + conditions.push(gte(inscriptions.dateInscription, startDate)); + } + if (endDate) { + conditions.push(lte(inscriptions.dateInscription, endDate)); + } + + const result = await db + .select({ + mois: sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`, + total: sql`COUNT(*)`, + confirmees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`, + listeAttente: sql`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`, + annulees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'annulee' THEN 1 ELSE 0 END)`, + }) + .from(inscriptions) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .groupBy(sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`) + .orderBy(sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`); + + return result; +} + +/** + * Récupère le taux de remplissage des séquences par mois + */ +export async function getTauxRemplissageByMonth() { + const db = await getDb(); + if (!db) return []; + + const result = await db + .select({ + mois: sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`, + capaciteTotale: sql`SUM(${sequences.capaciteMax})`, + inscritsConfirmes: sql`SUM( + (SELECT COUNT(*) + FROM ${inscriptions} + WHERE ${inscriptions.sequenceId} = ${sequences.id} + AND ${inscriptions.statut} = 'confirmee') + )`, + tauxRemplissage: sql`ROUND( + (SUM( + (SELECT COUNT(*) + FROM ${inscriptions} + WHERE ${inscriptions.sequenceId} = ${sequences.id} + AND ${inscriptions.statut} = 'confirmee') + ) / SUM(${sequences.capaciteMax})) * 100, + 2 + )`, + }) + .from(sequences) + .groupBy(sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`) + .orderBy(sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`); + + return result; +} + +/** + * Récupère les statistiques de participation par établissement + */ +export async function getParticipationByEtablissement() { + const db = await getDb(); + if (!db) return []; + + const result = await db + .select({ + codeEtablissement: apprenants.codeEtablissement, + totalApprenants: sql`COUNT(DISTINCT ${apprenants.id})`, + totalInscriptions: sql`COUNT(${inscriptions.id})`, + inscriptionsConfirmees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`, + inscriptionsListeAttente: sql`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`, + inscriptionsAnnulees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'annulee' THEN 1 ELSE 0 END)`, + }) + .from(apprenants) + .leftJoin(inscriptions, eq(apprenants.id, inscriptions.apprenantId)) + .groupBy(apprenants.codeEtablissement) + .orderBy(desc(sql`COUNT(${inscriptions.id})`)); + + return result; +} + +/** + * Récupère les statistiques globales du tableau de bord + */ +export async function getGlobalStats() { + const db = await getDb(); + if (!db) return null; + + const [stats] = await db + .select({ + totalFormations: sql`(SELECT COUNT(*) FROM ${formations} WHERE ${formations.actif} = 1)`, + totalSequences: sql`(SELECT COUNT(*) FROM ${sequences})`, + sequencesOuvertes: sql`(SELECT COUNT(*) FROM ${sequences} WHERE ${sequences.statut} = 'ouverte')`, + totalApprenants: sql`(SELECT COUNT(*) FROM ${apprenants})`, + totalInscriptions: sql`(SELECT COUNT(*) FROM ${inscriptions})`, + inscriptionsConfirmees: sql`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee')`, + inscriptionsListeAttente: sql`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'liste_attente')`, + tauxRemplissageMoyen: sql`ROUND( + (SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee') / + (SELECT SUM(${sequences.capaciteMax}) FROM ${sequences}) * 100, + 2 + )`, + }) + .from(sql`dual`); + + return stats; +} + +/** + * Récupère les statistiques de participation par fonction + */ +export async function getParticipationByFonction() { + const db = await getDb(); + if (!db) return []; + + const result = await db + .select({ + fonction: apprenants.fonction, + totalApprenants: sql`COUNT(DISTINCT ${apprenants.id})`, + totalInscriptions: sql`COUNT(${inscriptions.id})`, + inscriptionsConfirmees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`, + }) + .from(apprenants) + .leftJoin(inscriptions, eq(apprenants.id, inscriptions.apprenantId)) + .groupBy(apprenants.fonction) + .orderBy(desc(sql`COUNT(${inscriptions.id})`)); + + return result; +} diff --git a/server/routers.ts b/server/routers.ts index e2b9319..0021fce 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -5,6 +5,7 @@ import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; import { parseLocalDateTime } from "./dateUtils"; import { z } from "zod"; import * as db from "./db"; +import * as analyticsDb from "./analyticsDb"; import { TRPCError } from "@trpc/server"; import { sendInscriptionConfirmation, sendGroupEmail } from "./emailService"; import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; @@ -828,6 +829,36 @@ export const appRouter = router({ } }), }), + + // ===== ANALYTICS ===== + analytics: router({ + globalStats: adminProcedure.query(async () => { + return analyticsDb.getGlobalStats(); + }), + + inscriptionsByMonth: adminProcedure + .input(z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + })) + .query(async ({ input }) => { + const startDate = input.startDate ? new Date(input.startDate) : undefined; + const endDate = input.endDate ? new Date(input.endDate) : undefined; + return analyticsDb.getInscriptionsByMonth(startDate, endDate); + }), + + tauxRemplissageByMonth: adminProcedure.query(async () => { + return analyticsDb.getTauxRemplissageByMonth(); + }), + + participationByEtablissement: adminProcedure.query(async () => { + return analyticsDb.getParticipationByEtablissement(); + }), + + participationByFonction: adminProcedure.query(async () => { + return analyticsDb.getParticipationByFonction(); + }), + }), }); export type AppRouter = typeof appRouter; diff --git a/todo.md b/todo.md index 1b3ca09..22cc4b5 100644 --- a/todo.md +++ b/todo.md @@ -511,3 +511,12 @@ - [x] Ajouter un bouton "Aperçu email" dans l'interface de gestion des templates - [x] Créer un modal d'aperçu montrant l'email final avec variables remplacées - [x] Tester l'envoi d'emails avec variables + +## Tableau de bord analytique avancé + +- [x] Créer les requêtes backend pour récupérer les données analytiques +- [x] Créer une page dédiée au tableau de bord analytique +- [x] Ajouter un graphique d'évolution temporelle des inscriptions +- [x] Ajouter un graphique de taux de remplissage par mois +- [x] Ajouter des statistiques de participation par établissement +- [x] Intégrer la page dans le menu de navigation