diff --git a/client/src/App.tsx b/client/src/App.tsx index c1e49cd..717cf65 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { - + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index fc3cb41..69354bc 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -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" }, ] }, ]; diff --git a/client/src/pages/AdminQuestionnaireSuivi.tsx b/client/src/pages/AdminQuestionnaireSuivi.tsx new file mode 100644 index 0000000..8b7387e --- /dev/null +++ b/client/src/pages/AdminQuestionnaireSuivi.tsx @@ -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 ( + +
+ +
+
+ ); + } + + return ( + +
+
+

Suivi des Questionnaires

+

+ Tableau de bord de suivi des questionnaires avec taux de réponse et statistiques +

+
+ + {/* Statistiques globales */} +
+ + + Questionnaires + + + +
{statsGlobales?.totalQuestionnaires || 0}
+

Total créés

+
+
+ + + + Envois + + + +
{statsGlobales?.totalEnvois || 0}
+

Total envoyés

+
+
+ + + + Réponses + + + +
{statsGlobales?.totalReponses || 0}
+

Total reçues

+
+
+ + + + Taux de réponse + + + +
{statsGlobales?.tauxReponseGlobal?.toFixed(1) || 0}%
+

Global

+
+
+
+ + {/* Taux de réponse par formation */} + + + Taux de réponse par formation + Comparaison des taux de réponse entre les différentes formations + + + {statsByFormation && statsByFormation.length > 0 ? ( + + + + + + + + + + + + ) : ( +

Aucune donnée disponible

+ )} +
+
+ +
+ {/* Évolution temporelle */} + + + Évolution dans le temps + Évolution mensuelle des envois et réponses + + + {evolutionTemporelle && evolutionTemporelle.length > 0 ? ( + + + + + + + + + + + + ) : ( +

Aucune donnée disponible

+ )} +
+
+ + {/* Taux de réponse par formation (camembert) */} + + + Répartition des réponses + Distribution des réponses par formation + + + {statsByFormation && statsByFormation.length > 0 ? ( + + + `${entry.formationNom}: ${entry.tauxReponse}%`} + > + {statsByFormation.map((entry, index) => ( + + ))} + + + + + ) : ( +

Aucune donnée disponible

+ )} +
+
+
+ + {/* Comparaison par formateur */} + + + Comparaison par formateur + Taux de réponse et satisfaction moyenne par formateur + + + {statsByFormateur && statsByFormateur.length > 0 ? ( +
+ + + + + + + + + + + + + + {/* Tableau détaillé */} +
+ + + + + + + + + + + + {statsByFormateur.map((formateur) => ( + + + + + + + + ))} + +
FormateurEnvoisRéponsesTauxSatisfaction
{formateur.formateurNom}{formateur.totalEnvois}{formateur.totalReponses} + = 70 ? 'text-green-600' : + formateur.tauxReponse >= 50 ? 'text-orange-600' : + 'text-red-600' + }`}> + {formateur.tauxReponse}% + + + {formateur.moyenneSatisfaction ? ( + {formateur.moyenneSatisfaction}/10 + ) : ( + - + )} +
+
+
+ ) : ( +

Aucune donnée disponible

+ )} +
+
+
+
+ ); +} diff --git a/server/questionnaireSuiviDb.ts b/server/questionnaireSuiviDb.ts new file mode 100644 index 0000000..65eba72 --- /dev/null +++ b/server/questionnaireSuiviDb.ts @@ -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`COUNT(DISTINCT ${envoisQuestionnaires.id})`, + totalReponses: sql`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`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`, + totalEnvois: sql`COUNT(DISTINCT ${envoisQuestionnaires.id})`, + totalReponses: sql`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`COUNT(DISTINCT ${envoisQuestionnaires.id})`, + totalReponses: sql`COUNT(DISTINCT CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN ${envoisQuestionnaires.id} END)`, + moyenneSatisfaction: sql`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`COUNT(DISTINCT ${questionnaires.id})`, + totalEnvois: sql`COUNT(DISTINCT ${envoisQuestionnaires.id})`, + totalReponses: sql`COUNT(DISTINCT CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN ${envoisQuestionnaires.id} END)`, + tauxReponseGlobal: sql`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 + }; +} diff --git a/server/routers.ts b/server/routers.ts index 28a6b82..4114a94 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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 () => { diff --git a/todo.md b/todo.md index 7d0e9f1..b03b95f 100644 --- a/todo.md +++ b/todo.md @@ -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