Checkpoint: Système de questionnaires de satisfaction et d'évaluation - Phase 1 & 2 complétées
✅ Schéma de base de données créé : - Table questionnaires (satisfaction, evaluation_pre, evaluation_post) - Table questions (choix_multiple, echelle, texte_libre, oui_non) - Table reponsesQuestionnaires - Table reponsesQuestions - Table envoisQuestionnaires ✅ Backend complet implémenté : - Fichier questionnaireDb.ts avec toutes les fonctions CRUD - Procédures tRPC pour questionnaires (list, getById, create, update, delete, getStats, getReponsesDetaillees) - Procédures tRPC pour questions (list, create, update, delete) - Procédures tRPC publiques pour réponses (getByToken, submit) - Fonctions de statistiques (taux de réponse, réponses détaillées) ✅ Corrections : - Erreurs TypeScript dans exportService.ts corrigées 🚧 À faire dans la prochaine session : - Interfaces d'administration (AdminQuestionnaires, AdminQuestionnaireEdit) - Page publique de réponse aux questionnaires - Système d'envoi automatique post-formation - Tableaux de bord d'analyse avec graphiques - Exports Excel/PDF des feedbacks
This commit is contained in:
@@ -206,7 +206,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
|
||||
|
||||
// Date de la journée
|
||||
doc.setFontSize(12);
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
@@ -215,7 +215,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`, 14, yPos);
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.setFont('helvetica', 'normal');
|
||||
yPos += 10;
|
||||
|
||||
// Tableau de présence avec signatures matin et après-midi
|
||||
@@ -256,9 +256,9 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
|
||||
const signatureY = finalY + 20;
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Signature du formateur :', 14, signatureY);
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
// Dessiner une ligne pour la signature
|
||||
doc.line(60, signatureY, 120, signatureY);
|
||||
|
||||
250
server/questionnaireDb.ts
Normal file
250
server/questionnaireDb.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import {
|
||||
questionnaires,
|
||||
questions,
|
||||
reponsesQuestionnaires,
|
||||
reponsesQuestions,
|
||||
envoisQuestionnaires,
|
||||
type Questionnaire,
|
||||
type InsertQuestionnaire,
|
||||
type Question,
|
||||
type InsertQuestion,
|
||||
type ReponseQuestionnaire,
|
||||
type InsertReponseQuestionnaire,
|
||||
type ReponseQuestion,
|
||||
type InsertReponseQuestion,
|
||||
type EnvoiQuestionnaire,
|
||||
type InsertEnvoiQuestionnaire,
|
||||
} from "../drizzle/schema";
|
||||
|
||||
// ========== QUESTIONNAIRES ==========
|
||||
|
||||
export async function getAllQuestionnaires() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return await db.select().from(questionnaires).orderBy(desc(questionnaires.createdAt));
|
||||
}
|
||||
|
||||
export async function getQuestionnaireById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.select().from(questionnaires).where(eq(questionnaires.id, id)).limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export async function createQuestionnaire(data: InsertQuestionnaire) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(questionnaires).values(data);
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function updateQuestionnaire(id: number, data: Partial<InsertQuestionnaire>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(questionnaires).set(data).where(eq(questionnaires.id, id));
|
||||
}
|
||||
|
||||
export async function deleteQuestionnaire(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
// Supprimer d'abord les questions associées
|
||||
await db.delete(questions).where(eq(questions.questionnaireId, id));
|
||||
// Puis le questionnaire
|
||||
await db.delete(questionnaires).where(eq(questionnaires.id, id));
|
||||
}
|
||||
|
||||
// ========== QUESTIONS ==========
|
||||
|
||||
export async function getQuestionsByQuestionnaireId(questionnaireId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return await db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(eq(questions.questionnaireId, questionnaireId))
|
||||
.orderBy(questions.ordre);
|
||||
}
|
||||
|
||||
export async function createQuestion(data: InsertQuestion) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(questions).values(data);
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function updateQuestion(id: number, data: Partial<InsertQuestion>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(questions).set(data).where(eq(questions.id, id));
|
||||
}
|
||||
|
||||
export async function deleteQuestion(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.delete(questions).where(eq(questions.id, id));
|
||||
}
|
||||
|
||||
// ========== RÉPONSES QUESTIONNAIRES ==========
|
||||
|
||||
export async function createReponseQuestionnaire(data: InsertReponseQuestionnaire) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(reponsesQuestionnaires).values(data);
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function getReponseQuestionnaire(questionnaireId: number, apprenantId: number, sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db
|
||||
.select()
|
||||
.from(reponsesQuestionnaires)
|
||||
.where(
|
||||
and(
|
||||
eq(reponsesQuestionnaires.questionnaireId, questionnaireId),
|
||||
eq(reponsesQuestionnaires.apprenantId, apprenantId),
|
||||
eq(reponsesQuestionnaires.sequenceId, sequenceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export async function updateReponseQuestionnaire(id: number, data: Partial<InsertReponseQuestionnaire>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(reponsesQuestionnaires).set(data).where(eq(reponsesQuestionnaires.id, id));
|
||||
}
|
||||
|
||||
// ========== RÉPONSES QUESTIONS ==========
|
||||
|
||||
export async function createReponseQuestion(data: InsertReponseQuestion) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(reponsesQuestions).values(data);
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function getReponsesByReponseQuestionnaireId(reponseQuestionnaireId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return await db
|
||||
.select()
|
||||
.from(reponsesQuestions)
|
||||
.where(eq(reponsesQuestions.reponseQuestionnaireId, reponseQuestionnaireId));
|
||||
}
|
||||
|
||||
// ========== ENVOIS QUESTIONNAIRES ==========
|
||||
|
||||
export async function createEnvoiQuestionnaire(data: InsertEnvoiQuestionnaire) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(envoisQuestionnaires).values(data);
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function getEnvoiByToken(token: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db
|
||||
.select()
|
||||
.from(envoisQuestionnaires)
|
||||
.where(eq(envoisQuestionnaires.token, token))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export async function updateEnvoiQuestionnaire(id: number, data: Partial<InsertEnvoiQuestionnaire>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(envoisQuestionnaires).set(data).where(eq(envoisQuestionnaires.id, id));
|
||||
}
|
||||
|
||||
export async function checkEnvoiExists(questionnaireId: number, apprenantId: number, sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return false;
|
||||
const result = await db
|
||||
.select()
|
||||
.from(envoisQuestionnaires)
|
||||
.where(
|
||||
and(
|
||||
eq(envoisQuestionnaires.questionnaireId, questionnaireId),
|
||||
eq(envoisQuestionnaires.apprenantId, apprenantId),
|
||||
eq(envoisQuestionnaires.sequenceId, sequenceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
// ========== STATISTIQUES ==========
|
||||
|
||||
/**
|
||||
* Récupère les statistiques d'un questionnaire
|
||||
*/
|
||||
export async function getQuestionnaireStats(questionnaireId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
// Nombre total d'envois
|
||||
const envoisResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(envoisQuestionnaires)
|
||||
.where(eq(envoisQuestionnaires.questionnaireId, questionnaireId));
|
||||
const totalEnvois = envoisResult[0]?.count || 0;
|
||||
|
||||
// Nombre de réponses
|
||||
const reponsesResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(reponsesQuestionnaires)
|
||||
.where(
|
||||
and(
|
||||
eq(reponsesQuestionnaires.questionnaireId, questionnaireId),
|
||||
eq(reponsesQuestionnaires.complete, true)
|
||||
)
|
||||
);
|
||||
const totalReponses = reponsesResult[0]?.count || 0;
|
||||
|
||||
// Taux de réponse
|
||||
const tauxReponse = totalEnvois > 0 ? (totalReponses / totalEnvois) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalEnvois,
|
||||
totalReponses,
|
||||
tauxReponse: Math.round(tauxReponse * 10) / 10, // 1 décimale
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les réponses détaillées d'un questionnaire pour analyse
|
||||
*/
|
||||
export async function getReponsesDetailleesQuestionnaire(questionnaireId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
reponseQuestionnaireId: reponsesQuestionnaires.id,
|
||||
apprenantId: reponsesQuestionnaires.apprenantId,
|
||||
sequenceId: reponsesQuestionnaires.sequenceId,
|
||||
dateReponse: reponsesQuestionnaires.dateReponse,
|
||||
questionId: reponsesQuestions.questionId,
|
||||
reponseNumerique: reponsesQuestions.reponseNumerique,
|
||||
reponseTexte: reponsesQuestions.reponseTexte,
|
||||
})
|
||||
.from(reponsesQuestionnaires)
|
||||
.leftJoin(
|
||||
reponsesQuestions,
|
||||
eq(reponsesQuestions.reponseQuestionnaireId, reponsesQuestionnaires.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(reponsesQuestionnaires.questionnaireId, questionnaireId),
|
||||
eq(reponsesQuestionnaires.complete, true)
|
||||
)
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -884,6 +884,13 @@ export const appRouter = router({
|
||||
await db.updateRappel(input.id, { actif: input.actif });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
envoyerAutomatiques: publicProcedure
|
||||
.mutation(async () => {
|
||||
const { processRappelsAutomatiques } = await import('./rappelScheduler');
|
||||
const result = await processRappelsAutomatiques();
|
||||
return result;
|
||||
}),
|
||||
}),
|
||||
|
||||
emailConfig: router({
|
||||
@@ -997,6 +1004,197 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== QUESTIONNAIRES =====
|
||||
questionnaires: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
return questionnaireDb.getAllQuestionnaires();
|
||||
}),
|
||||
|
||||
getById: adminProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
return questionnaireDb.getQuestionnaireById(input.id);
|
||||
}),
|
||||
|
||||
create: adminProcedure
|
||||
.input(z.object({
|
||||
nom: z.string(),
|
||||
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]),
|
||||
description: z.string().optional(),
|
||||
formationId: z.number().nullable().optional(),
|
||||
actif: z.boolean().default(true),
|
||||
envoiAutomatique: z.boolean().default(false),
|
||||
delaiEnvoiJours: z.number().default(1),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
const id = await questionnaireDb.createQuestionnaire(input);
|
||||
return { id, success: true };
|
||||
}),
|
||||
|
||||
update: adminProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
nom: z.string().optional(),
|
||||
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]).optional(),
|
||||
description: z.string().optional(),
|
||||
formationId: z.number().nullable().optional(),
|
||||
actif: z.boolean().optional(),
|
||||
envoiAutomatique: z.boolean().optional(),
|
||||
delaiEnvoiJours: z.number().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
await questionnaireDb.updateQuestionnaire(id, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
await questionnaireDb.deleteQuestionnaire(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getStats: adminProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
return questionnaireDb.getQuestionnaireStats(input.id);
|
||||
}),
|
||||
|
||||
getReponsesDetaillees: adminProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id);
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== QUESTIONS =====
|
||||
questions: router({
|
||||
list: adminProcedure
|
||||
.input(z.object({ questionnaireId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
return questionnaireDb.getQuestionsByQuestionnaireId(input.questionnaireId);
|
||||
}),
|
||||
|
||||
create: adminProcedure
|
||||
.input(z.object({
|
||||
questionnaireId: z.number(),
|
||||
ordre: z.number(),
|
||||
texte: z.string(),
|
||||
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]),
|
||||
options: z.string().optional(),
|
||||
echelleMin: z.number().optional(),
|
||||
echelleMax: z.number().optional(),
|
||||
echelleLabelMin: z.string().optional(),
|
||||
echelleLabelMax: z.string().optional(),
|
||||
obligatoire: z.boolean().default(false),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
const id = await questionnaireDb.createQuestion(input);
|
||||
return { id, success: true };
|
||||
}),
|
||||
|
||||
update: adminProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
ordre: z.number().optional(),
|
||||
texte: z.string().optional(),
|
||||
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
|
||||
options: z.string().optional(),
|
||||
echelleMin: z.number().optional(),
|
||||
echelleMax: z.number().optional(),
|
||||
echelleLabelMin: z.string().optional(),
|
||||
echelleLabelMax: z.string().optional(),
|
||||
obligatoire: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
await questionnaireDb.updateQuestion(id, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
await questionnaireDb.deleteQuestion(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== RÉPONSES QUESTIONNAIRES (PUBLIC) =====
|
||||
reponses: router({
|
||||
getByToken: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
|
||||
if (!envoi) return null;
|
||||
|
||||
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
|
||||
const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId);
|
||||
|
||||
return {
|
||||
envoi,
|
||||
questionnaire,
|
||||
questions,
|
||||
};
|
||||
}),
|
||||
|
||||
submit: publicProcedure
|
||||
.input(z.object({
|
||||
token: z.string(),
|
||||
reponses: z.array(z.object({
|
||||
questionId: z.number(),
|
||||
reponseNumerique: z.number().optional(),
|
||||
reponseTexte: z.string().optional(),
|
||||
})),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const questionnaireDb = await import("./questionnaireDb");
|
||||
|
||||
// Récupérer l'envoi
|
||||
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
|
||||
if (!envoi) throw new Error("Token invalide");
|
||||
if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu");
|
||||
|
||||
// Créer la réponse au questionnaire
|
||||
const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({
|
||||
questionnaireId: envoi.questionnaireId,
|
||||
apprenantId: envoi.apprenantId,
|
||||
sequenceId: envoi.sequenceId,
|
||||
complete: true,
|
||||
});
|
||||
|
||||
// Enregistrer chaque réponse
|
||||
for (const reponse of input.reponses) {
|
||||
await questionnaireDb.createReponseQuestion({
|
||||
reponseQuestionnaireId,
|
||||
questionId: reponse.questionId,
|
||||
reponseNumerique: reponse.reponseNumerique,
|
||||
reponseTexte: reponse.reponseTexte,
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour l'envoi
|
||||
await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, {
|
||||
dateReponse: new Date(),
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== ÉTABLISSEMENTS =====
|
||||
etablissements: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
|
||||
Reference in New Issue
Block a user