diff --git a/client/src/pages/AdminQuestionnaireAnalyse.tsx b/client/src/pages/AdminQuestionnaireAnalyse.tsx index 7b75f40..daa8bf5 100644 --- a/client/src/pages/AdminQuestionnaireAnalyse.tsx +++ b/client/src/pages/AdminQuestionnaireAnalyse.tsx @@ -4,7 +4,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Progress } from "@/components/ui/progress"; import DashboardLayout from "@/components/DashboardLayout"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts"; -import { FileText, Users, CheckCircle2, TrendingUp } from "lucide-react"; +import { FileText, Users, CheckCircle2, TrendingUp, Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"]; @@ -17,6 +19,46 @@ export default function AdminQuestionnaireAnalyse() { const { data: questions } = trpc.questions.list.useQuery({ questionnaireId }); const { data: reponsesDetaillees } = trpc.questionnaires.getReponsesDetaillees.useQuery({ id: questionnaireId }); + // Mutations d'export + const exportExcelMutation = trpc.questionnaires.exportExcel.useMutation(); + const exportPDFMutation = trpc.questionnaires.exportPDF.useMutation(); + + const handleExportExcel = async () => { + try { + const result = await exportExcelMutation.mutateAsync({ id: questionnaireId }); + const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.filename; + a.click(); + window.URL.revokeObjectURL(url); + toast.success('Export Excel réussi'); + } catch (error) { + toast.error('Erreur lors de l\'export Excel'); + } + }; + + const handleExportPDF = async () => { + try { + const result = await exportPDFMutation.mutateAsync({ id: questionnaireId }); + const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { + type: 'application/pdf' + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.filename; + a.click(); + window.URL.revokeObjectURL(url); + toast.success('Export PDF réussi'); + } catch (error) { + toast.error('Erreur lors de l\'export PDF'); + } + }; + if (!questionnaire) { return ( @@ -96,9 +138,29 @@ export default function AdminQuestionnaireAnalyse() { return (
-
-

{questionnaire.nom}

-

Analyse des réponses

+
+
+

{questionnaire.nom}

+

Analyse des réponses

+
+
+ + +
{/* Statistiques globales */} diff --git a/server/questionnaireExport.ts b/server/questionnaireExport.ts new file mode 100644 index 0000000..f412095 --- /dev/null +++ b/server/questionnaireExport.ts @@ -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 { + 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 { + 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')); +} diff --git a/server/routers.ts b/server/routers.ts index 8b85550..28a6b82 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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 }; }), diff --git a/todo.md b/todo.md index c5b8225..7d0e9f1 100644 --- a/todo.md +++ b/todo.md @@ -18,3 +18,11 @@ - [x] Ajouter un bouton "Prévisualiser" dans la page d'édition de questionnaire - [x] Créer un modal de prévisualisation avec le rendu exact du questionnaire + +## Exports et notifications pour les questionnaires + +- [x] Créer les fonctions backend d'export Excel des résultats +- [x] Créer les fonctions backend d'export PDF des résultats +- [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