Checkpoint: Création d'un tableau de bord analytique avancé avec graphiques interactifs (Recharts). Affiche les statistiques globales, l'évolution temporelle des inscriptions par mois, le taux de remplissage des séquences, le top 10 des établissements participants et la répartition par fonction. Accessible via le menu "Tableau de bord analytique".

This commit is contained in:
Manus Sandbox
2025-11-23 16:25:22 -05:00
parent 33161d1a4d
commit 64db3f1aa6
6 changed files with 475 additions and 1 deletions

View File

@@ -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() {
<Route path={"/admin/email-config"} component={AdminEmailConfig} />
<Route path={"/admin/calendrier"} component={AdminCalendrier} />
<Route path={"/admin/rappels"} component={AdminRappels} />
<Route path={"/admin/analytics"} component={AdminAnalytics} />
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@@ -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" },
];

View File

@@ -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 (
<DashboardLayout>
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Tableau de bord analytique</h1>
<p className="text-muted-foreground">
Statistiques et indicateurs de performance des formations
</p>
</div>
{/* Statistiques globales */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Formations</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{globalStats?.totalFormations || 0}</div>
<p className="text-xs text-muted-foreground">
{globalStats?.totalSequences || 0} séquences au total
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Séquences ouvertes</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{globalStats?.sequencesOuvertes || 0}</div>
<p className="text-xs text-muted-foreground">
Disponibles pour inscription
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Apprenants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{globalStats?.totalApprenants || 0}</div>
<p className="text-xs text-muted-foreground">
{globalStats?.totalInscriptions || 0} inscriptions
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Taux de remplissage</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{globalStats?.tauxRemplissageMoyen || 0}%</div>
<p className="text-xs text-muted-foreground">
Moyenne globale
</p>
</CardContent>
</Card>
</div>
{/* Graphiques */}
<div className="grid gap-6 md:grid-cols-2 mb-6">
{/* Évolution des inscriptions */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Évolution des inscriptions par mois
</CardTitle>
<CardDescription>
Répartition des inscriptions confirmées, en liste d'attente et annulées
</CardDescription>
</CardHeader>
<CardContent>
{loadingInscriptions ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : inscriptionsData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={inscriptionsData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mois" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="Confirmées" stroke="#10b981" strokeWidth={2} />
<Line type="monotone" dataKey="Liste d'attente" stroke="#f59e0b" strokeWidth={2} />
<Line type="monotone" dataKey="Annulées" stroke="#ef4444" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Aucune donnée disponible</p>
</div>
)}
</CardContent>
</Card>
{/* Taux de remplissage */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Taux de remplissage par mois
</CardTitle>
<CardDescription>
Pourcentage de places occupées par rapport à la capacité totale
</CardDescription>
</CardHeader>
<CardContent>
{loadingTaux ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : tauxRemplissageData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={tauxRemplissageData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mois" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="Taux de remplissage (%)" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Aucune donnée disponible</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Participation par établissement et fonction */}
<div className="grid gap-6 md:grid-cols-2">
{/* Top 10 établissements */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Top 10 établissements
</CardTitle>
<CardDescription>
Établissements avec le plus d'inscriptions
</CardDescription>
</CardHeader>
<CardContent>
{loadingEtablissement ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : etablissementData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={etablissementData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="etablissement" type="category" width={80} />
<Tooltip />
<Legend />
<Bar dataKey="Confirmées" fill="#10b981" />
<Bar dataKey="Liste d'attente" fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Aucune donnée disponible</p>
</div>
)}
</CardContent>
</Card>
{/* Répartition par fonction */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChart className="h-5 w-5" />
Répartition par fonction
</CardTitle>
<CardDescription>
Distribution des inscriptions par type de fonction
</CardDescription>
</CardHeader>
<CardContent>
{loadingFonction ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : fonctionData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<RePieChart>
<Pie
data={fonctionData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{fonctionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</RePieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Aucune donnée disponible</p>
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

147
server/analyticsDb.ts Normal file
View File

@@ -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<string>`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`,
total: sql<number>`COUNT(*)`,
confirmees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`,
listeAttente: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`,
annulees: sql<number>`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<string>`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`,
capaciteTotale: sql<number>`SUM(${sequences.capaciteMax})`,
inscritsConfirmes: sql<number>`SUM(
(SELECT COUNT(*)
FROM ${inscriptions}
WHERE ${inscriptions.sequenceId} = ${sequences.id}
AND ${inscriptions.statut} = 'confirmee')
)`,
tauxRemplissage: sql<number>`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<number>`COUNT(DISTINCT ${apprenants.id})`,
totalInscriptions: sql<number>`COUNT(${inscriptions.id})`,
inscriptionsConfirmees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`,
inscriptionsListeAttente: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`,
inscriptionsAnnulees: sql<number>`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<number>`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<number>`(SELECT COUNT(*) FROM ${formations} WHERE ${formations.actif} = 1)`,
totalSequences: sql<number>`(SELECT COUNT(*) FROM ${sequences})`,
sequencesOuvertes: sql<number>`(SELECT COUNT(*) FROM ${sequences} WHERE ${sequences.statut} = 'ouverte')`,
totalApprenants: sql<number>`(SELECT COUNT(*) FROM ${apprenants})`,
totalInscriptions: sql<number>`(SELECT COUNT(*) FROM ${inscriptions})`,
inscriptionsConfirmees: sql<number>`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee')`,
inscriptionsListeAttente: sql<number>`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'liste_attente')`,
tauxRemplissageMoyen: sql<number>`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<number>`COUNT(DISTINCT ${apprenants.id})`,
totalInscriptions: sql<number>`COUNT(${inscriptions.id})`,
inscriptionsConfirmees: sql<number>`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<number>`COUNT(${inscriptions.id})`));
return result;
}

View File

@@ -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;

View File

@@ -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