diff --git a/client/src/App.tsx b/client/src/App.tsx index cf92bd0..c1e49cd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -21,6 +21,10 @@ import AdminRappels from "./pages/AdminRappels"; import AdminAnalytics from "./pages/AdminAnalytics"; import AdminEtablissements from "./pages/AdminEtablissements"; import AdminImportExcel from "./pages/AdminImportExcel"; +import AdminQuestionnaires from "./pages/AdminQuestionnaires"; +import AdminQuestionnaireEdit from "./pages/AdminQuestionnaireEdit"; +import AdminQuestionnaireAnalyse from "./pages/AdminQuestionnaireAnalyse"; +import QuestionnaireReponse from "./pages/QuestionnaireReponse"; import Inscription from "./pages/Inscription"; import Login from "./pages/Login"; @@ -46,6 +50,10 @@ function Router() { + + + + {/* Final fallback route */} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 45664e2..fc3cb41 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/sidebar"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -49,6 +49,7 @@ const menuSections = [ { icon: UserCog, label: "Utilisateurs", path: "/admin/users" }, { icon: FileSpreadsheet, label: "Import Excel", path: "/admin/import-excel" }, { icon: Bell, label: "Rappels", path: "/admin/rappels" }, + { icon: ClipboardList, label: "Questionnaires", path: "/admin/questionnaires" }, { icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" }, { icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" }, ] diff --git a/client/src/pages/AdminQuestionnaireAnalyse.tsx b/client/src/pages/AdminQuestionnaireAnalyse.tsx new file mode 100644 index 0000000..7b75f40 --- /dev/null +++ b/client/src/pages/AdminQuestionnaireAnalyse.tsx @@ -0,0 +1,233 @@ +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 } from "lucide-react"; + +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 }); + + if (!questionnaire) { + return ( + +
+

Chargement...

+
+
+ ); + } + + // 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 = {}; + 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 = {}; + 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 ( + +
+
+

{questionnaire.nom}

+

Analyse des réponses

+
+ + {/* Statistiques globales */} +
+ + + Envois + + + +
{stats?.totalEnvois || 0}
+

Questionnaires envoyés

+
+
+ + + + Réponses + + + +
{stats?.totalReponses || 0}
+

Questionnaires complétés

+
+
+ + + + Taux de réponse + + + +
{stats?.tauxReponse || 0}%
+ +
+
+ + + + Questions + + + +
{questions?.length || 0}
+

Questions au total

+
+
+
+ + {/* Analyse par question */} +
+ {analyseParQuestion.map((analyse, index) => ( + + + + Question {index + 1} : {analyse.question.texte} + + + {analyse.totalReponses} réponse(s) + {analyse.type === "numerique" && ` - Moyenne: ${analyse.moyenne}`} + + + + {analyse.type === "numerique" && analyse.distribution && ( +
+ + + + + + + + + + +
+ )} + + {analyse.type === "choix" && analyse.distribution && ( +
+ + + `${label} (${(percent * 100).toFixed(0)}%)`} + outerRadius={80} + fill="#8884d8" + dataKey="count" + > + {analyse.distribution.map((entry, index) => ( + + ))} + + + + +
+ )} + + {analyse.type === "texte" && analyse.reponses && ( +
+ {analyse.reponses.map((reponse, i) => ( +
+

{reponse}

+
+ ))} + {analyse.totalReponses > 10 && ( +

+ ... et {analyse.totalReponses - 10} autre(s) réponse(s) +

+ )} +
+ )} +
+
+ ))} +
+ + {analyseParQuestion.length === 0 && ( + + +

Aucune réponse pour le moment

+
+
+ )} +
+
+ ); +} diff --git a/client/src/pages/AdminQuestionnaireEdit.tsx b/client/src/pages/AdminQuestionnaireEdit.tsx new file mode 100644 index 0000000..58a8226 --- /dev/null +++ b/client/src/pages/AdminQuestionnaireEdit.tsx @@ -0,0 +1,424 @@ +import { useState, useEffect } from "react"; +import { useRoute, useLocation } from "wouter"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Plus, Trash2, GripVertical, Save } from "lucide-react"; +import { toast } from "sonner"; +import DashboardLayout from "@/components/DashboardLayout"; + +type QuestionType = "choix_multiple" | "echelle" | "texte_libre" | "oui_non"; + +interface Question { + id?: number; + ordre: number; + texte: string; + typeQuestion: QuestionType; + options?: string; + echelleMin?: number; + echelleMax?: number; + echelleLabelMin?: string; + echelleLabelMax?: string; + obligatoire: boolean; +} + +export default function AdminQuestionnaireEdit() { + const [, params] = useRoute("/admin/questionnaires/:id"); + const [, navigate] = useLocation(); + const questionnaireId = params?.id === "nouveau" ? null : parseInt(params?.id || "0"); + + const [nom, setNom] = useState(""); + const [type, setType] = useState<"satisfaction" | "evaluation_pre" | "evaluation_post">("satisfaction"); + const [description, setDescription] = useState(""); + const [actif, setActif] = useState(true); + const [envoiAutomatique, setEnvoiAutomatique] = useState(false); + const [delaiEnvoiJours, setDelaiEnvoiJours] = useState(1); + const [questions, setQuestions] = useState([]); + + const { data: questionnaire } = trpc.questionnaires.getById.useQuery( + { id: questionnaireId! }, + { enabled: !!questionnaireId } + ); + + const { data: existingQuestions } = trpc.questions.list.useQuery( + { questionnaireId: questionnaireId! }, + { enabled: !!questionnaireId } + ); + + const createMutation = trpc.questionnaires.create.useMutation({ + onSuccess: async (data) => { + // Créer les questions + for (const question of questions) { + await createQuestionMutation.mutateAsync({ + questionnaireId: data.id, + ...question, + }); + } + toast.success("Questionnaire créé avec succès"); + navigate("/admin/questionnaires"); + }, + onError: (error) => { + toast.error("Erreur : " + error.message); + }, + }); + + const updateMutation = trpc.questionnaires.update.useMutation({ + onSuccess: async () => { + // Mettre à jour les questions + for (const question of questions) { + if (question.id) { + await updateQuestionMutation.mutateAsync({ + id: question.id, + ...question, + }); + } else { + await createQuestionMutation.mutateAsync({ + questionnaireId: questionnaireId!, + ...question, + }); + } + } + toast.success("Questionnaire mis à jour avec succès"); + navigate("/admin/questionnaires"); + }, + onError: (error) => { + toast.error("Erreur : " + error.message); + }, + }); + + const createQuestionMutation = trpc.questions.create.useMutation(); + const updateQuestionMutation = trpc.questions.update.useMutation(); + const deleteQuestionMutation = trpc.questions.delete.useMutation(); + + useEffect(() => { + if (questionnaire) { + setNom(questionnaire.nom); + setType(questionnaire.type); + setDescription(questionnaire.description || ""); + setActif(questionnaire.actif); + setEnvoiAutomatique(questionnaire.envoiAutomatique); + setDelaiEnvoiJours(questionnaire.delaiEnvoiJours); + } + }, [questionnaire]); + + useEffect(() => { + if (existingQuestions) { + setQuestions(existingQuestions.map(q => ({ + id: q.id, + ordre: q.ordre, + texte: q.texte, + typeQuestion: q.typeQuestion, + options: q.options || undefined, + echelleMin: q.echelleMin || undefined, + echelleMax: q.echelleMax || undefined, + echelleLabelMin: q.echelleLabelMin || undefined, + echelleLabelMax: q.echelleLabelMax || undefined, + obligatoire: q.obligatoire, + }))); + } + }, [existingQuestions]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!nom.trim()) { + toast.error("Le nom du questionnaire est obligatoire"); + return; + } + + if (questions.length === 0) { + toast.error("Ajoutez au moins une question"); + return; + } + + const data = { + nom, + type, + description: description || undefined, + actif, + envoiAutomatique, + delaiEnvoiJours, + }; + + if (questionnaireId) { + updateMutation.mutate({ id: questionnaireId, ...data }); + } else { + createMutation.mutate(data); + } + }; + + const addQuestion = () => { + setQuestions([ + ...questions, + { + ordre: questions.length + 1, + texte: "", + typeQuestion: "texte_libre", + obligatoire: false, + }, + ]); + }; + + const updateQuestion = (index: number, field: keyof Question, value: any) => { + const newQuestions = [...questions]; + newQuestions[index] = { ...newQuestions[index], [field]: value }; + setQuestions(newQuestions); + }; + + const removeQuestion = async (index: number) => { + const question = questions[index]; + if (question.id) { + await deleteQuestionMutation.mutateAsync({ id: question.id }); + } + setQuestions(questions.filter((_, i) => i !== index)); + }; + + const getQuestionTypeLabel = (type: QuestionType) => { + const labels = { + texte_libre: "Texte libre", + choix_multiple: "Choix multiple", + echelle: "Échelle", + oui_non: "Oui/Non", + }; + return labels[type]; + }; + + return ( + +
+
+

+ {questionnaireId ? "Modifier le questionnaire" : "Nouveau questionnaire"} +

+
+ +
+ + + Informations générales + + +
+ + setNom(e.target.value)} + placeholder="Ex: Questionnaire de satisfaction Manager Itinova" + required + /> +
+ +
+ + +
+ +
+ +