Checkpoint: Exports Excel/PDF et notifications pour les questionnaires

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

**Exports des résultats** :
- Export Excel avec 3 feuilles (statistiques globales, questions, réponses brutes)
- Export PDF avec mise en page professionnelle et pagination
- Boutons d'export dans la page d'analyse des questionnaires
- Téléchargement automatique des fichiers générés

**Système de notifications** :
- Notification automatique à l'administrateur lors de chaque nouvelle réponse
- Utilisation du système de notifications intégré Manus
- Gestion des erreurs sans bloquer la soumission du questionnaire

**Système complet de questionnaires** :
- Création et édition de questionnaires 
- Prévisualisation avant envoi 
- Envoi automatique post-formation 
- Analyse des résultats avec graphiques 
- Exports Excel/PDF 
- Notifications administrateur 

Le système de questionnaires est maintenant complet et prêt pour la production.
This commit is contained in:
Manus Sandbox
2025-12-07 14:00:15 -05:00
parent b21ad8f978
commit 5d8fd4dd1b
4 changed files with 284 additions and 4 deletions

View File

@@ -0,0 +1,174 @@
import ExcelJS from 'exceljs';
import { jsPDF } from 'jspdf';
import { getQuestionnaireById, getQuestionsByQuestionnaireId, getQuestionnaireStats, getReponsesDetailleesQuestionnaire } from './questionnaireDb';
import { getDb } from './db';
import { sql } from 'drizzle-orm';
/**
* Génère un fichier Excel avec les statistiques du questionnaire
*/
export async function exportQuestionnaireToExcel(questionnaireId: number): Promise<Buffer> {
const questionnaire = await getQuestionnaireById(questionnaireId);
const stats = await getQuestionnaireStats(questionnaireId);
const questionsData = await getQuestionsByQuestionnaireId(questionnaireId);
const reponsesData = await getReponsesDetailleesQuestionnaire(questionnaireId);
if (!questionnaire || !stats) {
throw new Error('Questionnaire introuvable');
}
const workbook = new ExcelJS.Workbook();
// Feuille 1: Statistiques globales
const statsSheet = workbook.addWorksheet('Statistiques');
statsSheet.addRow(['Questionnaire', questionnaire.nom]);
statsSheet.addRow(['Type', questionnaire.type]);
statsSheet.addRow(['']);
statsSheet.addRow(['Nombre d\'envois', stats.totalEnvois]);
statsSheet.addRow(['Nombre de réponses', stats.totalReponses]);
statsSheet.addRow(['Taux de réponse', `${stats.tauxReponse.toFixed(1)}%`]);
statsSheet.addRow(['']);
// Style pour l'en-tête
statsSheet.getColumn(1).width = 30;
statsSheet.getColumn(2).width = 40;
statsSheet.getRow(1).font = { bold: true, size: 14 };
// Feuille 2: Statistiques par question
const questionsSheet = workbook.addWorksheet('Par question');
questionsSheet.addRow(['Question', 'Type', 'Ordre']);
questionsSheet.getRow(1).font = { bold: true };
questionsSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' }
};
questionsData.forEach((q: any) => {
questionsSheet.addRow([
q.texte,
q.typeQuestion,
q.ordre
]);
});
questionsSheet.getColumn(1).width = 60;
questionsSheet.getColumn(2).width = 20;
questionsSheet.getColumn(3).width = 10;
// Feuille 3: Réponses brutes
const reponsesSheet = workbook.addWorksheet('Réponses brutes');
reponsesSheet.addRow(['Date réponse', 'Apprenant ID', 'Séquence ID', 'Question ID', 'Réponse numérique', 'Réponse texte']);
reponsesSheet.getRow(1).font = { bold: true };
reponsesSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' }
};
reponsesData.forEach((r: any) => {
reponsesSheet.addRow([
r.dateReponse ? new Date(r.dateReponse).toLocaleDateString('fr-FR') : '',
r.apprenantId,
r.sequenceId,
r.questionId,
r.reponseNumerique || '',
r.reponseTexte || ''
]);
});
reponsesSheet.getColumn(1).width = 15;
reponsesSheet.getColumn(2).width = 15;
reponsesSheet.getColumn(3).width = 15;
reponsesSheet.getColumn(4).width = 15;
reponsesSheet.getColumn(5).width = 20;
reponsesSheet.getColumn(6).width = 50;
// Générer le buffer
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
/**
* Génère un fichier PDF avec les statistiques du questionnaire
*/
export async function exportQuestionnaireToPDF(questionnaireId: number): Promise<Buffer> {
const questionnaire = await getQuestionnaireById(questionnaireId);
const stats = await getQuestionnaireStats(questionnaireId);
const questionsData = await getQuestionsByQuestionnaireId(questionnaireId);
if (!questionnaire || !stats) {
throw new Error('Questionnaire introuvable');
}
const doc = new jsPDF();
let yPos = 20;
// Titre
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text(questionnaire.nom, 20, yPos);
yPos += 10;
// Type
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.text(`Type: ${questionnaire.type}`, 20, yPos);
yPos += 15;
// Statistiques globales
doc.setFont('helvetica', 'bold');
doc.text('Statistiques globales', 20, yPos);
yPos += 8;
doc.setFont('helvetica', 'normal');
doc.text(`Nombre d'envois: ${stats.totalEnvois}`, 20, yPos);
yPos += 6;
doc.text(`Nombre de réponses: ${stats.totalReponses}`, 20, yPos);
yPos += 6;
doc.text(`Taux de réponse: ${stats.tauxReponse.toFixed(1)}%`, 20, yPos);
yPos += 15;
// Liste des questions
doc.setFont('helvetica', 'bold');
doc.text('Questions du questionnaire', 20, yPos);
yPos += 10;
questionsData.forEach((q: any, index: number) => {
// Vérifier si on doit ajouter une nouvelle page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
const questionText = `${index + 1}. ${q.texte}`;
const lines = doc.splitTextToSize(questionText, 170);
doc.text(lines, 20, yPos);
yPos += lines.length * 6;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(`Type: ${q.typeQuestion}`, 25, yPos);
yPos += 8;
});
// Pied de page
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text(
`Page ${i} sur ${pageCount} - Généré le ${new Date().toLocaleDateString('fr-FR')}`,
20,
290
);
}
return Buffer.from(doc.output('arraybuffer'));
}

View File

@@ -1073,6 +1073,28 @@ export const appRouter = router({
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id);
}),
exportExcel: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { exportQuestionnaireToExcel } = await import("./questionnaireExport");
const buffer = await exportQuestionnaireToExcel(input.id);
return {
data: buffer.toString('base64'),
filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.xlsx`,
};
}),
exportPDF: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { exportQuestionnaireToPDF } = await import("./questionnaireExport");
const buffer = await exportQuestionnaireToPDF(input.id);
return {
data: buffer.toString('base64'),
filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.pdf`,
};
}),
}),
// ===== QUESTIONS =====
@@ -1191,6 +1213,20 @@ export const appRouter = router({
dateReponse: new Date(),
});
// Envoyer une notification à l'administrateur
try {
const { notifyOwner } = await import('./_core/notification');
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
await notifyOwner({
title: 'Nouvelle réponse au questionnaire',
content: `Un apprenant a répondu au questionnaire "${questionnaire?.nom || 'Sans titre'}"`,
});
} catch (error) {
console.error('[Notification] Erreur lors de l\'envoi de la notification:', error);
// Ne pas bloquer la réponse si la notification échoue
}
return { success: true };
}),