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

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