✅ **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.
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { useRoute } from "wouter";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
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, Download } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { toast } from "sonner";
|
|
|
|
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
|
|
|
|
export default function AdminQuestionnaireAnalyse() {
|
|
const [, params] = useRoute("/admin/questionnaires/:id/analyse");
|
|
const questionnaireId = parseInt(params?.id || "0");
|
|
|
|
const { data: questionnaire } = trpc.questionnaires.getById.useQuery({ id: questionnaireId });
|
|
const { data: stats } = trpc.questionnaires.getStats.useQuery({ id: questionnaireId });
|
|
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>
|
|
<div className="container mx-auto py-8">
|
|
<p className="text-center text-muted-foreground">Chargement...</p>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
// Analyser les réponses par question
|
|
const analyseParQuestion = questions?.map((question) => {
|
|
const reponsesQuestion = reponsesDetaillees?.filter((r) => r.questionId === question.id) || [];
|
|
|
|
if (question.typeQuestion === "echelle" || question.typeQuestion === "oui_non") {
|
|
// Calculer la moyenne et la distribution
|
|
const valeurs = reponsesQuestion
|
|
.map((r) => r.reponseNumerique)
|
|
.filter((v): v is number => v !== null);
|
|
|
|
const moyenne = valeurs.length > 0 ? valeurs.reduce((a, b) => a + b, 0) / valeurs.length : 0;
|
|
|
|
// Distribution des réponses
|
|
const distribution: Record<number, number> = {};
|
|
valeurs.forEach((v) => {
|
|
distribution[v] = (distribution[v] || 0) + 1;
|
|
});
|
|
|
|
const distributionData = Object.entries(distribution).map(([valeur, count]) => ({
|
|
valeur: parseInt(valeur),
|
|
count,
|
|
label: question.typeQuestion === "oui_non" ? (valeur === "1" ? "Oui" : "Non") : valeur,
|
|
}));
|
|
|
|
return {
|
|
question,
|
|
type: "numerique",
|
|
moyenne: Math.round(moyenne * 10) / 10,
|
|
totalReponses: valeurs.length,
|
|
distribution: distributionData,
|
|
};
|
|
} else if (question.typeQuestion === "choix_multiple") {
|
|
// Distribution des choix
|
|
const choix: Record<string, number> = {};
|
|
reponsesQuestion.forEach((r) => {
|
|
if (r.reponseTexte) {
|
|
choix[r.reponseTexte] = (choix[r.reponseTexte] || 0) + 1;
|
|
}
|
|
});
|
|
|
|
const distributionData = Object.entries(choix).map(([label, count]) => ({
|
|
label,
|
|
count,
|
|
}));
|
|
|
|
return {
|
|
question,
|
|
type: "choix",
|
|
totalReponses: reponsesQuestion.length,
|
|
distribution: distributionData,
|
|
};
|
|
} else {
|
|
// Texte libre - afficher les réponses
|
|
const reponses = reponsesQuestion
|
|
.map((r) => r.reponseTexte)
|
|
.filter((t): t is string => !!t);
|
|
|
|
return {
|
|
question,
|
|
type: "texte",
|
|
totalReponses: reponses.length,
|
|
reponses: reponses.slice(0, 10), // Limiter à 10 réponses
|
|
};
|
|
}
|
|
}) || [];
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="container mx-auto py-8">
|
|
<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 */}
|
|
<div className="grid gap-4 md:grid-cols-4 mb-8">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Envois</CardTitle>
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats?.totalEnvois || 0}</div>
|
|
<p className="text-xs text-muted-foreground">Questionnaires envoyés</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Réponses</CardTitle>
|
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats?.totalReponses || 0}</div>
|
|
<p className="text-xs text-muted-foreground">Questionnaires complétés</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Taux de réponse</CardTitle>
|
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats?.tauxReponse || 0}%</div>
|
|
<Progress value={stats?.tauxReponse || 0} className="mt-2" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Questions</CardTitle>
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{questions?.length || 0}</div>
|
|
<p className="text-xs text-muted-foreground">Questions au total</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Analyse par question */}
|
|
<div className="space-y-6">
|
|
{analyseParQuestion.map((analyse, index) => (
|
|
<Card key={index}>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">
|
|
Question {index + 1} : {analyse.question.texte}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{analyse.totalReponses} réponse(s)
|
|
{analyse.type === "numerique" && ` - Moyenne: ${analyse.moyenne}`}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{analyse.type === "numerique" && analyse.distribution && (
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={analyse.distribution}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="label" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Bar dataKey="count" fill="#3b82f6" name="Nombre de réponses" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
|
|
{analyse.type === "choix" && analyse.distribution && (
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={analyse.distribution}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={({ label, percent }) => `${label} (${(percent * 100).toFixed(0)}%)`}
|
|
outerRadius={80}
|
|
fill="#8884d8"
|
|
dataKey="count"
|
|
>
|
|
{analyse.distribution.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
|
|
{analyse.type === "texte" && analyse.reponses && (
|
|
<div className="space-y-2">
|
|
{analyse.reponses.map((reponse, i) => (
|
|
<div key={i} className="p-3 bg-muted rounded-md">
|
|
<p className="text-sm">{reponse}</p>
|
|
</div>
|
|
))}
|
|
{analyse.totalReponses > 10 && (
|
|
<p className="text-sm text-muted-foreground text-center mt-4">
|
|
... et {analyse.totalReponses - 10} autre(s) réponse(s)
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{analyseParQuestion.length === 0 && (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<p className="text-muted-foreground">Aucune réponse pour le moment</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|