Checkpoint: Tableau de bord de suivi des questionnaires - Erreurs SQL corrigées

 **Corrections apportées** :

**Problèmes résolus** :
- Correction des requêtes SQL Drizzle ORM (remplacement de db.execute() par la syntaxe Drizzle correcte)
- Utilisation de sql template literals pour les fonctions MySQL (DATE_FORMAT, AVG, CAST)
- Correction des noms de colonnes (reponse → reponseNumerique, type → typeQuestion)
- Alignement avec le schéma de base de données existant

**Fonctionnalités opérationnelles** :
- getStatsGlobales() : Statistiques globales (questionnaires, envois, réponses, taux)
- getStatsByFormation() : Taux de réponse par formation avec graphique en barres
- getEvolutionTemporelle() : Évolution mensuelle avec graphique en ligne
- getStatsByFormateur() : Comparaison formateurs avec satisfaction moyenne

**Interface utilisateur** :
- 4 cartes de statistiques globales fonctionnelles
- 4 graphiques interactifs (Recharts) prêts à afficher les données
- Gestion élégante des états vides ("Aucune donnée disponible")
- Tableau détaillé des formateurs avec code couleur
- Lien "Suivi Questionnaires" dans le menu Analyses

Le tableau de bord est maintenant pleinement fonctionnel et affichera automatiquement les statistiques dès que des questionnaires seront envoyés et répondus.
This commit is contained in:
Manus Sandbox
2025-12-07 16:31:39 -05:00
parent 46b3f377d1
commit 239fea6797
7 changed files with 129 additions and 52 deletions

View File

@@ -0,0 +1,14 @@
{
"query": "SELECT YEAR(NOW()) as annee, MONTH(NOW()) as mois",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT YEAR(NOW()) as annee, MONTH(NOW()) as mois",
"rows": [
{
"annee": "2025",
"mois": "12"
}
],
"messages": [],
"stdout": "annee\tmois\n2025\t12\n",
"stderr": "",
"execution_time_ms": 149
}

View File

@@ -0,0 +1,13 @@
{
"query": "SELECT COUNT(*) as total FROM envoisQuestionnaires",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT COUNT(*) as total FROM envoisQuestionnaires",
"rows": [
{
"total": "0"
}
],
"messages": [],
"stdout": "total\n0\n",
"stderr": "",
"execution_time_ms": 62
}

View File

@@ -0,0 +1,9 @@
{
"query": "SELECT YEAR(dateEnvoi) as annee, MONTH(dateEnvoi) as mois, COUNT(DISTINCT id) as total FROM envoisQuestionnaires GROUP BY YEAR(dateEnvoi), MONTH(dateEnvoi) LIMIT 5",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT YEAR(dateEnvoi) as annee, MONTH(dateEnvoi) as mois, COUNT(DISTINCT id) as total FROM envoisQuestionnaires GROUP BY YEAR(dateEnvoi), MONTH(dateEnvoi) LIMIT 5",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 51
}

View File

@@ -4,6 +4,8 @@ import {
questionnaires,
envoisQuestionnaires,
reponsesQuestionnaires,
reponsesQuestions,
questions,
sequences,
formations,
formateurs
@@ -21,7 +23,7 @@ export async function getStatsByFormation() {
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)`,
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
})
.from(envoisQuestionnaires)
.innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id))
@@ -46,15 +48,6 @@ 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));
@@ -63,11 +56,16 @@ export async function getEvolutionTemporelle(startDate?: Date, endDate?: Date) {
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')`);
const results = await db
.select({
mois: sql<string>`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
})
.from(envoisQuestionnaires)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.groupBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`)
.orderBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`);
return results.map(r => ({
mois: r.mois,
@@ -86,42 +84,55 @@ export async function getStatsByFormateur() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const results = await db
// Première requête : statistiques d'envois et réponses par formateur
const statsEnvois = 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)`,
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 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`)
.innerJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.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)
// Pour chaque formateur, calculer la moyenne de satisfaction
const results = await Promise.all(statsEnvois.map(async (stat) => {
// Calculer la moyenne des réponses de type échelle pour ce formateur
const satisfactionResult = await db
.select({
moyenne: sql<number>`AVG(CAST(${reponsesQuestions.reponseNumerique} AS DECIMAL(10,2)))`,
})
.from(reponsesQuestionnaires)
.innerJoin(sequences, eq(reponsesQuestionnaires.sequenceId, sequences.id))
.innerJoin(reponsesQuestions, eq(reponsesQuestionnaires.id, reponsesQuestions.reponseQuestionnaireId))
.innerJoin(questions, eq(reponsesQuestions.questionId, questions.id))
.where(
and(
eq(sequences.formateurId, stat.formateurId),
eq(reponsesQuestionnaires.complete, true),
eq(questions.typeQuestion, 'echelle')
)
);
const moyenneSatisfaction = satisfactionResult[0]?.moyenne
? Number(satisfactionResult[0].moyenne).toFixed(1)
: null;
return {
formateurId: stat.formateurId,
formateurNom: stat.formateurNom || "Non spécifié",
totalEnvois: Number(stat.totalEnvois),
totalReponses: Number(stat.totalReponses),
tauxReponse: Number(stat.totalEnvois) > 0
? Math.round((Number(stat.totalReponses) / Number(stat.totalEnvois)) * 100)
: 0,
moyenneSatisfaction: r.moyenneSatisfaction ? Number(r.moyenneSatisfaction).toFixed(1) : null
moyenneSatisfaction
};
}));
return results;
}
/**
@@ -131,24 +142,23 @@ export async function getStatsGlobales() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [stats] = await db
const results = 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
)`,
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
})
.from(questionnaires)
.leftJoin(envoisQuestionnaires, eq(questionnaires.id, envoisQuestionnaires.questionnaireId));
const stats = results[0];
const totalEnvois = Number(stats.totalEnvois) || 0;
const totalReponses = Number(stats.totalReponses) || 0;
return {
totalQuestionnaires: Number(stats.totalQuestionnaires),
totalEnvois: Number(stats.totalEnvois),
totalReponses: Number(stats.totalReponses),
tauxReponseGlobal: Number(stats.tauxReponseGlobal) || 0
totalQuestionnaires: Number(stats.totalQuestionnaires) || 0,
totalEnvois,
totalReponses,
tauxReponseGlobal: totalEnvois > 0 ? Number(((totalReponses / totalEnvois) * 100).toFixed(1)) : 0
};
}

14
test-query.mjs Normal file
View File

@@ -0,0 +1,14 @@
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const db = drizzle(connection);
try {
const result = await connection.query("SELECT YEAR(NOW()) as annee, MONTH(NOW()) as mois");
console.log("Test YEAR/MONTH réussi:", result[0]);
} catch (error) {
console.error("Erreur:", error.message);
}
await connection.end();

10
test-trpc.mjs Normal file
View File

@@ -0,0 +1,10 @@
import { getStatsGlobales } from './server/questionnaireSuiviDb.ts';
try {
console.log("Test des statistiques globales...");
const stats = await getStatsGlobales();
console.log("Résultat:", stats);
} catch (error) {
console.error("Erreur:", error.message);
console.error("Stack:", error.stack);
}

View File

@@ -36,3 +36,10 @@
- [x] Créer la page AdminQuestionnaireSuivi avec graphiques
- [x] Ajouter le lien dans le menu de navigation
- [x] Tester toutes les fonctionnalités
## Correction des erreurs SQL du tableau de bord de suivi
- [x] Corriger la requête getEvolutionTemporelle (DATE_FORMAT)
- [x] Corriger la requête getStatsByFormateur (sous-requête AVG)
- [x] Simplifier les requêtes pour compatibilité MySQL
- [x] Tester toutes les requêtes corrigées