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:
@@ -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 (
|
||||
<DashboardLayout>
|
||||
@@ -96,9 +138,29 @@ export default function AdminQuestionnaireAnalyse() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">{questionnaire.nom}</h1>
|
||||
<p className="text-muted-foreground mt-1">Analyse des réponses</p>
|
||||
<div className="mb-6 flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{questionnaire.nom}</h1>
|
||||
<p className="text-muted-foreground mt-1">Analyse des réponses</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportExcel}
|
||||
disabled={exportExcelMutation.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{exportExcelMutation.isPending ? "Export..." : "Excel"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportPDF}
|
||||
disabled={exportPDFMutation.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{exportPDFMutation.isPending ? "Export..." : "PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistiques globales */}
|
||||
|
||||
174
server/questionnaireExport.ts
Normal file
174
server/questionnaireExport.ts
Normal 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'));
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
8
todo.md
8
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
|
||||
|
||||
Reference in New Issue
Block a user