Checkpoint: Tableau de bord de suivi des questionnaires

 **Nouvelles fonctionnalités implémentées** :

**Backend complet** :
- Fichier questionnaireSuiviDb.ts avec 4 fonctions de statistiques
- getStatsByFormation() : Taux de réponse par formation
- getEvolutionTemporelle() : Évolution mensuelle des envois/réponses
- getStatsByFormateur() : Comparaison par formateur avec satisfaction moyenne
- getStatsGlobales() : Statistiques globales (questionnaires, envois, réponses, taux)
- Procédures tRPC complètes dans routers.ts

**Interface utilisateur** :
- Page AdminQuestionnaireSuivi avec graphiques interactifs (Recharts)
- 4 cartes de statistiques globales (questionnaires, envois, réponses, taux)
- Graphique en barres : Taux de réponse par formation
- Graphique en ligne : Évolution temporelle mensuelle
- Graphique camembert : Répartition des réponses par formation
- Graphique double axe : Comparaison formateurs (taux + satisfaction)
- Tableau détaillé des formateurs avec code couleur (vert >70%, orange >50%, rouge <50%)
- Lien "Suivi Questionnaires" ajouté dans le menu Analyses

Le tableau de bord offre une vue complète des performances des questionnaires pour optimiser le suivi et l'amélioration continue.
This commit is contained in:
Manus Sandbox
2025-12-07 16:03:24 -05:00
parent 5d8fd4dd1b
commit 46b3f377d1
6 changed files with 435 additions and 1 deletions

View File

@@ -24,6 +24,7 @@ import AdminImportExcel from "./pages/AdminImportExcel";
import AdminQuestionnaires from "./pages/AdminQuestionnaires";
import AdminQuestionnaireEdit from "./pages/AdminQuestionnaireEdit";
import AdminQuestionnaireAnalyse from "./pages/AdminQuestionnaireAnalyse";
import AdminQuestionnaireSuivi from "./pages/AdminQuestionnaireSuivi";
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
import Inscription from "./pages/Inscription";
import Login from "./pages/Login";
@@ -51,7 +52,8 @@ function Router() {
<Route path={"/admin/etablissements"} component={AdminEtablissements} />
<Route path={"/admin/import-excel"} component={AdminImportExcel} />
<Route path={"/admin/questionnaires"} component={AdminQuestionnaires} />
<Route path={"/admin/questionnaires/:id/analyse"} component={AdminQuestionnaireAnalyse} />
<Route path="/admin/questionnaires/:id/analyse" component={AdminQuestionnaireAnalyse} />
<Route path="/admin/questionnaires/suivi" component={AdminQuestionnaireSuivi} />
<Route path={"/admin/questionnaires/:id"} component={AdminQuestionnaireEdit} />
<Route path={"/questionnaire/:token"} component={QuestionnaireReponse} />
<Route path={"/404"} component={NotFound} />

View File

@@ -60,6 +60,7 @@ const menuSections = [
{ icon: Building2, label: "Établissements", path: "/admin/etablissements" },
{ icon: TrendingUp, label: "Tableau de bord analytique", path: "/admin/analytics" },
{ icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" },
{ icon: TrendingUp, label: "Suivi Questionnaires", path: "/admin/questionnaires/suivi" },
]
},
];

View File

@@ -0,0 +1,239 @@
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 { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Loader2, TrendingUp, Users, FileText, Target } from "lucide-react";
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82CA9D', '#FFC658', '#FF6B6B'];
export default function AdminQuestionnaireSuivi() {
const { data: statsGlobales, isLoading: loadingGlobales } = trpc.questionnaireSuivi.statsGlobales.useQuery();
const { data: statsByFormation, isLoading: loadingFormation } = trpc.questionnaireSuivi.statsByFormation.useQuery();
const { data: evolutionTemporelle, isLoading: loadingEvolution } = trpc.questionnaireSuivi.evolutionTemporelle.useQuery({});
const { data: statsByFormateur, isLoading: loadingFormateur } = trpc.questionnaireSuivi.statsByFormateur.useQuery();
const isLoading = loadingGlobales || loadingFormation || loadingEvolution || loadingFormateur;
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Suivi des Questionnaires</h1>
<p className="text-muted-foreground mt-2">
Tableau de bord de suivi des questionnaires avec taux de réponse et statistiques
</p>
</div>
{/* Statistiques globales */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Questionnaires</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{statsGlobales?.totalQuestionnaires || 0}</div>
<p className="text-xs text-muted-foreground">Total créés</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Envois</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{statsGlobales?.totalEnvois || 0}</div>
<p className="text-xs text-muted-foreground">Total envoyés</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Réponses</CardTitle>
<Target className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{statsGlobales?.totalReponses || 0}</div>
<p className="text-xs text-muted-foreground">Total reçues</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 réponse</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{statsGlobales?.tauxReponseGlobal?.toFixed(1) || 0}%</div>
<p className="text-xs text-muted-foreground">Global</p>
</CardContent>
</Card>
</div>
{/* Taux de réponse par formation */}
<Card>
<CardHeader>
<CardTitle>Taux de réponse par formation</CardTitle>
<CardDescription>Comparaison des taux de réponse entre les différentes formations</CardDescription>
</CardHeader>
<CardContent>
{statsByFormation && statsByFormation.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={statsByFormation}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formationNom" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="totalEnvois" fill="#8884d8" name="Envois" />
<Bar dataKey="totalReponses" fill="#82ca9d" name="Réponses" />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-center text-muted-foreground py-8">Aucune donnée disponible</p>
)}
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2">
{/* Évolution temporelle */}
<Card>
<CardHeader>
<CardTitle>Évolution dans le temps</CardTitle>
<CardDescription>Évolution mensuelle des envois et réponses</CardDescription>
</CardHeader>
<CardContent>
{evolutionTemporelle && evolutionTemporelle.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={evolutionTemporelle}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mois" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="totalEnvois" stroke="#8884d8" name="Envois" />
<Line type="monotone" dataKey="totalReponses" stroke="#82ca9d" name="Réponses" />
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-center text-muted-foreground py-8">Aucune donnée disponible</p>
)}
</CardContent>
</Card>
{/* Taux de réponse par formation (camembert) */}
<Card>
<CardHeader>
<CardTitle>Répartition des réponses</CardTitle>
<CardDescription>Distribution des réponses par formation</CardDescription>
</CardHeader>
<CardContent>
{statsByFormation && statsByFormation.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={statsByFormation}
dataKey="totalReponses"
nameKey="formationNom"
cx="50%"
cy="50%"
outerRadius={80}
label={(entry) => `${entry.formationNom}: ${entry.tauxReponse}%`}
>
{statsByFormation.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-center text-muted-foreground py-8">Aucune donnée disponible</p>
)}
</CardContent>
</Card>
</div>
{/* Comparaison par formateur */}
<Card>
<CardHeader>
<CardTitle>Comparaison par formateur</CardTitle>
<CardDescription>Taux de réponse et satisfaction moyenne par formateur</CardDescription>
</CardHeader>
<CardContent>
{statsByFormateur && statsByFormateur.length > 0 ? (
<div className="space-y-4">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={statsByFormateur}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="formateurNom" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" domain={[0, 10]} />
<Tooltip />
<Legend />
<Bar yAxisId="left" dataKey="tauxReponse" fill="#8884d8" name="Taux de réponse (%)" />
<Bar yAxisId="right" dataKey="moyenneSatisfaction" fill="#82ca9d" name="Satisfaction moyenne (/10)" />
</BarChart>
</ResponsiveContainer>
{/* Tableau détaillé */}
<div className="rounded-md border">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-2 text-left font-medium">Formateur</th>
<th className="p-2 text-center font-medium">Envois</th>
<th className="p-2 text-center font-medium">Réponses</th>
<th className="p-2 text-center font-medium">Taux</th>
<th className="p-2 text-center font-medium">Satisfaction</th>
</tr>
</thead>
<tbody>
{statsByFormateur.map((formateur) => (
<tr key={formateur.formateurId} className="border-b">
<td className="p-2">{formateur.formateurNom}</td>
<td className="p-2 text-center">{formateur.totalEnvois}</td>
<td className="p-2 text-center">{formateur.totalReponses}</td>
<td className="p-2 text-center">
<span className={`font-medium ${
formateur.tauxReponse >= 70 ? 'text-green-600' :
formateur.tauxReponse >= 50 ? 'text-orange-600' :
'text-red-600'
}`}>
{formateur.tauxReponse}%
</span>
</td>
<td className="p-2 text-center">
{formateur.moyenneSatisfaction ? (
<span className="font-medium">{formateur.moyenneSatisfaction}/10</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-center text-muted-foreground py-8">Aucune donnée disponible</p>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,154 @@
import { eq, sql, and, gte, lte } from "drizzle-orm";
import { getDb } from "./db";
import {
questionnaires,
envoisQuestionnaires,
reponsesQuestionnaires,
sequences,
formations,
formateurs
} from "../drizzle/schema";
/**
* Statistiques de taux de réponse par formation
*/
export async function getStatsByFormation() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const results = await db
.select({
formationId: formations.id,
formationNom: formations.nom,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`COUNT(DISTINCT CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN ${envoisQuestionnaires.id} END)`,
})
.from(envoisQuestionnaires)
.innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id))
.innerJoin(formations, eq(sequences.formationId, formations.id))
.groupBy(formations.id, formations.nom);
return results.map(r => ({
formationId: r.formationId,
formationNom: r.formationNom,
totalEnvois: Number(r.totalEnvois),
totalReponses: Number(r.totalReponses),
tauxReponse: Number(r.totalEnvois) > 0
? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100)
: 0
}));
}
/**
* Évolution temporelle des réponses (par mois)
*/
export async function getEvolutionTemporelle(startDate?: Date, endDate?: Date) {
const db = await getDb();
if (!db) throw new Error("Database not available");
let query = db
.select({
mois: sql<string>`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`COUNT(DISTINCT CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN ${envoisQuestionnaires.id} END)`,
})
.from(envoisQuestionnaires);
// Filtrer par dates si spécifiées
const conditions = [];
if (startDate) {
conditions.push(gte(envoisQuestionnaires.dateEnvoi, startDate));
}
if (endDate) {
conditions.push(lte(envoisQuestionnaires.dateEnvoi, endDate));
}
if (conditions.length > 0) {
query = query.where(and(...conditions)) as any;
}
const results = await query.groupBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`);
return results.map(r => ({
mois: r.mois,
totalEnvois: Number(r.totalEnvois),
totalReponses: Number(r.totalReponses),
tauxReponse: Number(r.totalEnvois) > 0
? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100)
: 0
}));
}
/**
* Comparaison par formateur
*/
export async function getStatsByFormateur() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const results = await db
.select({
formateurId: formateurs.id,
formateurNom: formateurs.nom,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`COUNT(DISTINCT CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN ${envoisQuestionnaires.id} END)`,
moyenneSatisfaction: sql<number>`AVG(CASE
WHEN ${reponsesQuestionnaires.complete} = 1
THEN (SELECT AVG(CAST(rq.reponse AS DECIMAL(10,2)))
FROM reponsesQuestions rq
INNER JOIN questions q ON rq.questionId = q.id
WHERE rq.reponseQuestionnaireId = ${reponsesQuestionnaires.id}
AND q.type = 'echelle'
AND rq.reponse REGEXP '^[0-9]+$')
END)`,
})
.from(envoisQuestionnaires)
.innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.leftJoin(reponsesQuestionnaires, and(
eq(envoisQuestionnaires.apprenantId, reponsesQuestionnaires.apprenantId),
eq(envoisQuestionnaires.sequenceId, reponsesQuestionnaires.sequenceId)
))
.where(sql`${formateurs.id} IS NOT NULL`)
.groupBy(formateurs.id, formateurs.nom);
return results.map(r => ({
formateurId: r.formateurId,
formateurNom: r.formateurNom || "Non spécifié",
totalEnvois: Number(r.totalEnvois),
totalReponses: Number(r.totalReponses),
tauxReponse: Number(r.totalEnvois) > 0
? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100)
: 0,
moyenneSatisfaction: r.moyenneSatisfaction ? Number(r.moyenneSatisfaction).toFixed(1) : null
}));
}
/**
* Statistiques globales pour le tableau de bord
*/
export async function getStatsGlobales() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [stats] = await db
.select({
totalQuestionnaires: sql<number>`COUNT(DISTINCT ${questionnaires.id})`,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`COUNT(DISTINCT CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN ${envoisQuestionnaires.id} END)`,
tauxReponseGlobal: sql<number>`ROUND(
COUNT(DISTINCT CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN ${envoisQuestionnaires.id} END) * 100.0 /
NULLIF(COUNT(DISTINCT ${envoisQuestionnaires.id}), 0),
1
)`,
})
.from(questionnaires)
.leftJoin(envoisQuestionnaires, eq(questionnaires.id, envoisQuestionnaires.questionnaireId));
return {
totalQuestionnaires: Number(stats.totalQuestionnaires),
totalEnvois: Number(stats.totalEnvois),
totalReponses: Number(stats.totalReponses),
tauxReponseGlobal: Number(stats.tauxReponseGlobal) || 0
};
}

View File

@@ -1238,6 +1238,34 @@ export const appRouter = router({
}),
}),
// ===== SUIVI QUESTIONNAIRES =====
questionnaireSuivi: router({
statsGlobales: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsGlobales();
}),
statsByFormation: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsByFormation();
}),
evolutionTemporelle: adminProcedure
.input(z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
}))
.query(async ({ input }) => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getEvolutionTemporelle(input.startDate, input.endDate);
}),
statsByFormateur: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsByFormateur();
}),
}),
// ===== ÉTABLISSEMENTS =====
etablissements: router({
list: adminProcedure.query(async () => {

10
todo.md
View File

@@ -26,3 +26,13 @@
- [x] Ajouter les boutons d'export dans la page d'analyse
- [x] Implémenter le système de notifications pour les administrateurs
- [x] Envoyer une notification lors de chaque nouvelle réponse à un questionnaire
## Tableau de bord de suivi des questionnaires
- [x] Créer les fonctions backend pour les statistiques par formation
- [x] Créer les fonctions backend pour l'évolution temporelle
- [x] Créer les fonctions backend pour la comparaison par formateur
- [x] Créer les procédures tRPC pour le tableau de bord
- [x] Créer la page AdminQuestionnaireSuivi avec graphiques
- [x] Ajouter le lien dans le menu de navigation
- [x] Tester toutes les fonctionnalités