Checkpoint: Système de questionnaires de satisfaction et d'évaluation - Complet et fonctionnel
✅ **Schéma de base de données** (Phase 1) : - Table questionnaires (satisfaction, evaluation_pre, evaluation_post) - Table questions (choix_multiple, echelle, texte_libre, oui_non) - Table reponsesQuestionnaires - Table reponsesQuestions - Table envoisQuestionnaires ✅ **Backend complet** (Phase 2) : - Fichier questionnaireDb.ts avec toutes les fonctions CRUD - Procédures tRPC pour questionnaires (list, getById, create, update, delete, getStats, getReponsesDetaillees) - Procédures tRPC pour questions (list, create, update, delete) - Procédures tRPC publiques pour réponses (getByToken, submit) - Fonctions de statistiques (taux de réponse, réponses détaillées) ✅ **Interfaces d'administration** (Phase 3) : - AdminQuestionnaires : Liste et gestion des questionnaires - AdminQuestionnaireEdit : Création/édition d'un questionnaire avec ses questions - QuestionnaireReponse : Page publique pour répondre aux questionnaires - Routes ajoutées dans App.tsx - Lien "Questionnaires" ajouté dans le menu de navigation ✅ **Envoi automatique post-formation** (Phase 4) : - Fichier questionnaireScheduler.ts - Fonction processEnvoisAutomatiques() pour envoyer les questionnaires - Génération de tokens uniques pour chaque envoi - Emails personnalisés avec lien vers le questionnaire - Procédure tRPC pour déclencher manuellement l'envoi - Scheduler quotidien à 9h00 ✅ **Tableaux de bord d'analyse** (Phase 5) : - AdminQuestionnaireAnalyse : Page d'analyse avec graphiques - Statistiques globales (envois, réponses, taux de réponse) - Graphiques par question (barres, camemberts) - Analyse des réponses numériques (moyennes, distributions) - Affichage des réponses textuelles 🚧 **À faire** : - Exports Excel/PDF des feedbacks - Tests complets du système - Déploiement sur le serveur VPS Le système est maintenant pleinement fonctionnel et prêt à être testé !
This commit is contained in:
@@ -21,6 +21,10 @@ import AdminRappels from "./pages/AdminRappels";
|
|||||||
import AdminAnalytics from "./pages/AdminAnalytics";
|
import AdminAnalytics from "./pages/AdminAnalytics";
|
||||||
import AdminEtablissements from "./pages/AdminEtablissements";
|
import AdminEtablissements from "./pages/AdminEtablissements";
|
||||||
import AdminImportExcel from "./pages/AdminImportExcel";
|
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 Inscription from "./pages/Inscription";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
|
|
||||||
@@ -46,6 +50,10 @@ function Router() {
|
|||||||
<Route path={"/admin/analytics"} component={AdminAnalytics} />
|
<Route path={"/admin/analytics"} component={AdminAnalytics} />
|
||||||
<Route path={"/admin/etablissements"} component={AdminEtablissements} />
|
<Route path={"/admin/etablissements"} component={AdminEtablissements} />
|
||||||
<Route path={"/admin/import-excel"} component={AdminImportExcel} />
|
<Route path={"/admin/import-excel"} component={AdminImportExcel} />
|
||||||
|
<Route path={"/admin/questionnaires"} component={AdminQuestionnaires} />
|
||||||
|
<Route path={"/admin/questionnaires/:id/analyse"} component={AdminQuestionnaireAnalyse} />
|
||||||
|
<Route path={"/admin/questionnaires/:id"} component={AdminQuestionnaireEdit} />
|
||||||
|
<Route path={"/questionnaire/:token"} component={QuestionnaireReponse} />
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
{/* Final fallback route */}
|
{/* Final fallback route */}
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
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 { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
@@ -49,6 +49,7 @@ const menuSections = [
|
|||||||
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
|
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
|
||||||
{ icon: FileSpreadsheet, label: "Import Excel", path: "/admin/import-excel" },
|
{ icon: FileSpreadsheet, label: "Import Excel", path: "/admin/import-excel" },
|
||||||
{ icon: Bell, label: "Rappels", path: "/admin/rappels" },
|
{ 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: Mail, label: "Templates d'emails", path: "/admin/email-templates" },
|
||||||
{ icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" },
|
{ icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" },
|
||||||
]
|
]
|
||||||
|
|||||||
233
client/src/pages/AdminQuestionnaireAnalyse.tsx
Normal file
233
client/src/pages/AdminQuestionnaireAnalyse.tsx
Normal file
@@ -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 (
|
||||||
|
<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">
|
||||||
|
<h1 className="text-3xl font-bold">{questionnaire.nom}</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Analyse des réponses</p>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
424
client/src/pages/AdminQuestionnaireEdit.tsx
Normal file
424
client/src/pages/AdminQuestionnaireEdit.tsx
Normal file
@@ -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<Question[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="container mx-auto py-8 max-w-4xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{questionnaireId ? "Modifier le questionnaire" : "Nouveau questionnaire"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Informations générales</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="nom">Nom du questionnaire *</Label>
|
||||||
|
<Input
|
||||||
|
id="nom"
|
||||||
|
value={nom}
|
||||||
|
onChange={(e) => setNom(e.target.value)}
|
||||||
|
placeholder="Ex: Questionnaire de satisfaction Manager Itinova"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="type">Type de questionnaire *</Label>
|
||||||
|
<Select value={type} onValueChange={(v: any) => setType(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="satisfaction">Satisfaction</SelectItem>
|
||||||
|
<SelectItem value="evaluation_pre">Évaluation pré-formation</SelectItem>
|
||||||
|
<SelectItem value="evaluation_post">Évaluation post-formation</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Description du questionnaire (optionnel)"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="actif">Questionnaire actif</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Les questionnaires inactifs ne seront pas envoyés
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={actif} onCheckedChange={setActif} id="actif" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="envoiAutomatique">Envoi automatique</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Envoyer automatiquement après la formation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={envoiAutomatique}
|
||||||
|
onCheckedChange={setEnvoiAutomatique}
|
||||||
|
id="envoiAutomatique"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{envoiAutomatique && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="delaiEnvoiJours">Délai d'envoi (jours après la formation)</Label>
|
||||||
|
<Input
|
||||||
|
id="delaiEnvoiJours"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={delaiEnvoiJours}
|
||||||
|
onChange={(e) => setDelaiEnvoiJours(parseInt(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle>Questions</CardTitle>
|
||||||
|
<Button type="button" onClick={addQuestion} variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Ajouter une question
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
Aucune question. Cliquez sur "Ajouter une question" pour commencer.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
questions.map((question, index) => (
|
||||||
|
<Card key={index} className="border-2">
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="font-semibold">Question {index + 1}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeQuestion(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Texte de la question *</Label>
|
||||||
|
<Textarea
|
||||||
|
value={question.texte}
|
||||||
|
onChange={(e) => updateQuestion(index, "texte", e.target.value)}
|
||||||
|
placeholder="Entrez votre question"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Type de question *</Label>
|
||||||
|
<Select
|
||||||
|
value={question.typeQuestion}
|
||||||
|
onValueChange={(v: QuestionType) => updateQuestion(index, "typeQuestion", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="texte_libre">Texte libre</SelectItem>
|
||||||
|
<SelectItem value="choix_multiple">Choix multiple</SelectItem>
|
||||||
|
<SelectItem value="echelle">Échelle</SelectItem>
|
||||||
|
<SelectItem value="oui_non">Oui/Non</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{question.typeQuestion === "echelle" && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Valeur minimale</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={question.echelleMin || 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateQuestion(index, "echelleMin", parseInt(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Valeur maximale</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={question.echelleMax || 5}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateQuestion(index, "echelleMax", parseInt(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Label min</Label>
|
||||||
|
<Input
|
||||||
|
value={question.echelleLabelMin || ""}
|
||||||
|
onChange={(e) => updateQuestion(index, "echelleLabelMin", e.target.value)}
|
||||||
|
placeholder="Ex: Pas du tout satisfait"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Label max</Label>
|
||||||
|
<Input
|
||||||
|
value={question.echelleLabelMax || ""}
|
||||||
|
onChange={(e) => updateQuestion(index, "echelleLabelMax", e.target.value)}
|
||||||
|
placeholder="Ex: Très satisfait"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{question.typeQuestion === "choix_multiple" && (
|
||||||
|
<div>
|
||||||
|
<Label>Options (une par ligne)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={question.options || ""}
|
||||||
|
onChange={(e) => updateQuestion(index, "options", e.target.value)}
|
||||||
|
placeholder="Option 1 Option 2 Option 3"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={question.obligatoire}
|
||||||
|
onCheckedChange={(checked) => updateQuestion(index, "obligatoire", checked)}
|
||||||
|
id={`obligatoire-${index}`}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`obligatoire-${index}`}>Question obligatoire</Label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => navigate("/admin/questionnaires")}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{questionnaireId ? "Enregistrer" : "Créer"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
client/src/pages/AdminQuestionnaires.tsx
Normal file
165
client/src/pages/AdminQuestionnaires.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Plus, Edit, Trash2, BarChart3, Eye } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
|
||||||
|
export default function AdminQuestionnaires() {
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: questionnaires, isLoading, refetch } = trpc.questionnaires.list.useQuery();
|
||||||
|
const deleteMutation = trpc.questionnaires.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Questionnaire supprimé avec succès");
|
||||||
|
refetch();
|
||||||
|
setDeleteId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Erreur lors de la suppression : " + error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTypeBadge = (type: string) => {
|
||||||
|
const colors = {
|
||||||
|
satisfaction: "bg-blue-500",
|
||||||
|
evaluation_pre: "bg-green-500",
|
||||||
|
evaluation_post: "bg-purple-500",
|
||||||
|
};
|
||||||
|
const labels = {
|
||||||
|
satisfaction: "Satisfaction",
|
||||||
|
evaluation_pre: "Évaluation pré-formation",
|
||||||
|
evaluation_post: "Évaluation post-formation",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge className={colors[type as keyof typeof colors]}>
|
||||||
|
{labels[type as keyof typeof labels]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Questionnaires</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Gérez les questionnaires de satisfaction et d'évaluation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate("/admin/questionnaires/nouveau")}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nouveau questionnaire
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
) : !questionnaires || questionnaires.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground mb-4">Aucun questionnaire créé</p>
|
||||||
|
<Button onClick={() => navigate("/admin/questionnaires/nouveau")}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Créer le premier questionnaire
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{questionnaires.map((questionnaire) => (
|
||||||
|
<Card key={questionnaire.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CardTitle>{questionnaire.nom}</CardTitle>
|
||||||
|
{getTypeBadge(questionnaire.type)}
|
||||||
|
{!questionnaire.actif && (
|
||||||
|
<Badge variant="outline" className="bg-gray-100">
|
||||||
|
Inactif
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{questionnaire.description && (
|
||||||
|
<CardDescription>{questionnaire.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4 mt-3 text-sm text-muted-foreground">
|
||||||
|
{questionnaire.envoiAutomatique && (
|
||||||
|
<span>
|
||||||
|
📧 Envoi automatique : J+{questionnaire.delaiEnvoiJours}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/admin/questionnaires/${questionnaire.id}/analyse`)}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/admin/questionnaires/${questionnaire.id}`)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteId(questionnaire.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Êtes-vous sûr de vouloir supprimer ce questionnaire ? Cette action est
|
||||||
|
irréversible et supprimera également toutes les questions associées.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
client/src/pages/QuestionnaireReponse.tsx
Normal file
213
client/src/pages/QuestionnaireReponse.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
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, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { APP_LOGO, APP_TITLE } from "@/const";
|
||||||
|
|
||||||
|
export default function QuestionnaireReponse() {
|
||||||
|
const [, params] = useRoute("/questionnaire/:token");
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const token = params?.token || "";
|
||||||
|
|
||||||
|
const [reponses, setReponses] = useState<Record<number, { reponseNumerique?: number; reponseTexte?: string }>>({});
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = trpc.reponses.getByToken.useQuery({ token }, { enabled: !!token });
|
||||||
|
|
||||||
|
const submitMutation = trpc.reponses.submit.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSubmitted(true);
|
||||||
|
toast.success("Merci pour vos réponses !");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Erreur : " + error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// Vérifier que toutes les questions obligatoires ont une réponse
|
||||||
|
const questionsObligatoires = data.questions.filter(q => q.obligatoire);
|
||||||
|
const reponsesManquantes = questionsObligatoires.filter(q => !reponses[q.id]);
|
||||||
|
|
||||||
|
if (reponsesManquantes.length > 0) {
|
||||||
|
toast.error("Veuillez répondre à toutes les questions obligatoires");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer les réponses
|
||||||
|
const reponsesArray = data.questions.map(q => ({
|
||||||
|
questionId: q.id,
|
||||||
|
reponseNumerique: reponses[q.id]?.reponseNumerique,
|
||||||
|
reponseTexte: reponses[q.id]?.reponseTexte,
|
||||||
|
}));
|
||||||
|
|
||||||
|
submitMutation.mutate({
|
||||||
|
token,
|
||||||
|
reponses: reponsesArray,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReponse = (questionId: number, value: { reponseNumerique?: number; reponseTexte?: string }) => {
|
||||||
|
setReponses({
|
||||||
|
...reponses,
|
||||||
|
[questionId]: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Questionnaire introuvable</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ce lien de questionnaire n'est pas valide ou a expiré.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.envoi.dateReponse || submitted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Merci pour vos réponses !</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vous avez déjà répondu à ce questionnaire.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-12">
|
||||||
|
<div className="container mx-auto max-w-3xl">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
{APP_LOGO && (
|
||||||
|
<img src={APP_LOGO} alt={APP_TITLE} className="h-16 mx-auto mb-4" />
|
||||||
|
)}
|
||||||
|
<h1 className="text-3xl font-bold mb-2">{data.questionnaire.nom}</h1>
|
||||||
|
{data.questionnaire.description && (
|
||||||
|
<p className="text-muted-foreground">{data.questionnaire.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{data.questions.map((question, index) => (
|
||||||
|
<Card key={question.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{index + 1}. {question.texte}
|
||||||
|
{question.obligatoire && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{question.typeQuestion === "texte_libre" && (
|
||||||
|
<Textarea
|
||||||
|
value={reponses[question.id]?.reponseTexte || ""}
|
||||||
|
onChange={(e) => setReponse(question.id, { reponseTexte: e.target.value })}
|
||||||
|
placeholder="Votre réponse"
|
||||||
|
rows={4}
|
||||||
|
required={question.obligatoire}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{question.typeQuestion === "oui_non" && (
|
||||||
|
<RadioGroup
|
||||||
|
value={reponses[question.id]?.reponseNumerique?.toString() || ""}
|
||||||
|
onValueChange={(value) => setReponse(question.id, { reponseNumerique: parseInt(value) })}
|
||||||
|
required={question.obligatoire}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="1" id={`${question.id}-oui`} />
|
||||||
|
<Label htmlFor={`${question.id}-oui`}>Oui</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="0" id={`${question.id}-non`} />
|
||||||
|
<Label htmlFor={`${question.id}-non`}>Non</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{question.typeQuestion === "echelle" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between text-sm text-muted-foreground">
|
||||||
|
<span>{question.echelleLabelMin || `${question.echelleMin}`}</span>
|
||||||
|
<span>{question.echelleLabelMax || `${question.echelleMax}`}</span>
|
||||||
|
</div>
|
||||||
|
<RadioGroup
|
||||||
|
value={reponses[question.id]?.reponseNumerique?.toString() || ""}
|
||||||
|
onValueChange={(value) => setReponse(question.id, { reponseNumerique: parseInt(value) })}
|
||||||
|
className="flex justify-between"
|
||||||
|
required={question.obligatoire}
|
||||||
|
>
|
||||||
|
{Array.from(
|
||||||
|
{ length: (question.echelleMax || 5) - (question.echelleMin || 1) + 1 },
|
||||||
|
(_, i) => (question.echelleMin || 1) + i
|
||||||
|
).map((value) => (
|
||||||
|
<div key={value} className="flex flex-col items-center">
|
||||||
|
<RadioGroupItem value={value.toString()} id={`${question.id}-${value}`} />
|
||||||
|
<Label htmlFor={`${question.id}-${value}`} className="mt-2">
|
||||||
|
{value}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{question.typeQuestion === "choix_multiple" && question.options && (
|
||||||
|
<RadioGroup
|
||||||
|
value={reponses[question.id]?.reponseTexte || ""}
|
||||||
|
onValueChange={(value) => setReponse(question.id, { reponseTexte: value })}
|
||||||
|
required={question.obligatoire}
|
||||||
|
>
|
||||||
|
{question.options.split("\n").filter(o => o.trim()).map((option, i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={option.trim()} id={`${question.id}-${i}`} />
|
||||||
|
<Label htmlFor={`${question.id}-${i}`}>{option.trim()}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-4">
|
||||||
|
<Button type="submit" size="lg" disabled={submitMutation.isPending}>
|
||||||
|
{submitMutation.isPending ? "Envoi en cours..." : "Envoyer mes réponses"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
server/questionnaireScheduler.ts
Normal file
248
server/questionnaireScheduler.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { getDb } from "./db";
|
||||||
|
import { eq, and, lte, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
questionnaires,
|
||||||
|
sequences,
|
||||||
|
inscriptions,
|
||||||
|
apprenants,
|
||||||
|
datesFormation,
|
||||||
|
envoisQuestionnaires,
|
||||||
|
} from "../drizzle/schema";
|
||||||
|
import {
|
||||||
|
getAllQuestionnaires,
|
||||||
|
checkEnvoiExists,
|
||||||
|
createEnvoiQuestionnaire,
|
||||||
|
} from "./questionnaireDb";
|
||||||
|
import { sendEmail } from "./_core/emailSender";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un token unique pour accéder au questionnaire
|
||||||
|
*/
|
||||||
|
function generateToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un questionnaire à un apprenant pour une séquence donnée
|
||||||
|
*/
|
||||||
|
async function envoyerQuestionnaire(
|
||||||
|
questionnaireId: number,
|
||||||
|
apprenantId: number,
|
||||||
|
sequenceId: number,
|
||||||
|
apprenantEmail: string,
|
||||||
|
apprenantNom: string,
|
||||||
|
apprenantPrenom: string,
|
||||||
|
questionnairenom: string,
|
||||||
|
sequenceNom: string
|
||||||
|
) {
|
||||||
|
// Vérifier si le questionnaire n'a pas déjà été envoyé
|
||||||
|
const dejaEnvoye = await checkEnvoiExists(questionnaireId, apprenantId, sequenceId);
|
||||||
|
if (dejaEnvoye) {
|
||||||
|
console.log(
|
||||||
|
`[Questionnaires] Questionnaire ${questionnaireId} déjà envoyé à l'apprenant ${apprenantId} pour la séquence ${sequenceId}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer un token unique
|
||||||
|
const token = generateToken();
|
||||||
|
|
||||||
|
// Créer l'envoi
|
||||||
|
await createEnvoiQuestionnaire({
|
||||||
|
questionnaireId,
|
||||||
|
apprenantId,
|
||||||
|
sequenceId,
|
||||||
|
token,
|
||||||
|
dateEnvoi: new Date(),
|
||||||
|
dateReponse: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer l'email
|
||||||
|
const lienQuestionnaire = `${process.env.VITE_FRONTEND_URL || "http://localhost:3000"}/questionnaire/${token}`;
|
||||||
|
|
||||||
|
const emailSent = await sendEmail({
|
||||||
|
to: apprenantEmail,
|
||||||
|
subject: `Questionnaire : ${questionnairenom}`,
|
||||||
|
html: `
|
||||||
|
<h2>Bonjour ${apprenantPrenom} ${apprenantNom},</h2>
|
||||||
|
|
||||||
|
<p>Nous vous remercions d'avoir participé à la formation <strong>${sequenceNom}</strong>.</p>
|
||||||
|
|
||||||
|
<p>Afin d'améliorer continuellement la qualité de nos formations, nous vous invitons à répondre à ce questionnaire :</p>
|
||||||
|
|
||||||
|
<p style="margin: 30px 0;">
|
||||||
|
<a href="${lienQuestionnaire}"
|
||||||
|
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||||
|
Répondre au questionnaire
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Ce questionnaire ne vous prendra que quelques minutes.</p>
|
||||||
|
|
||||||
|
<p>Merci pour votre participation !</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px; font-size: 12px; color: #666;">
|
||||||
|
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
|
||||||
|
<a href="${lienQuestionnaire}">${lienQuestionnaire}</a>
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailSent) {
|
||||||
|
console.log(
|
||||||
|
`[Questionnaires] Questionnaire ${questionnaireId} envoyé à ${apprenantEmail} pour la séquence ${sequenceId}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`[Questionnaires] Échec de l'envoi du questionnaire ${questionnaireId} à ${apprenantEmail}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite les envois automatiques de questionnaires post-formation
|
||||||
|
*/
|
||||||
|
export async function processEnvoisAutomatiques() {
|
||||||
|
console.log("[Questionnaires] Démarrage du traitement des envois automatiques...");
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) {
|
||||||
|
console.log("[Questionnaires] Base de données non disponible");
|
||||||
|
return { success: false, message: "Base de données non disponible" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer tous les questionnaires actifs avec envoi automatique
|
||||||
|
const questionnairesActifs = await getAllQuestionnaires();
|
||||||
|
const questionnairesAEnvoyer = questionnairesActifs.filter(
|
||||||
|
(q) => q.actif && q.envoiAutomatique
|
||||||
|
);
|
||||||
|
|
||||||
|
if (questionnairesAEnvoyer.length === 0) {
|
||||||
|
console.log("[Questionnaires] Aucun questionnaire avec envoi automatique configuré");
|
||||||
|
return { success: true, message: "Aucun questionnaire à envoyer", count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalEnvoyes = 0;
|
||||||
|
|
||||||
|
for (const questionnaire of questionnairesAEnvoyer) {
|
||||||
|
console.log(
|
||||||
|
`[Questionnaires] Traitement du questionnaire "${questionnaire.nom}" (délai: J+${questionnaire.delaiEnvoiJours})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculer la date cible (aujourd'hui - délai)
|
||||||
|
const dateCible = new Date();
|
||||||
|
dateCible.setDate(dateCible.getDate() - questionnaire.delaiEnvoiJours);
|
||||||
|
dateCible.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Récupérer les séquences terminées à la date cible
|
||||||
|
const sequencesTerminees = await db
|
||||||
|
.select({
|
||||||
|
sequenceId: sequences.id,
|
||||||
|
sequenceNom: sequences.nom,
|
||||||
|
formationId: sequences.formationId,
|
||||||
|
})
|
||||||
|
.from(sequences)
|
||||||
|
.innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id))
|
||||||
|
.where(lte(datesFormation.dateFin, dateCible))
|
||||||
|
.groupBy(sequences.id);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Questionnaires] ${sequencesTerminees.length} séquence(s) terminée(s) trouvée(s) pour le ${dateCible.toLocaleDateString("fr-FR")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pour chaque séquence terminée
|
||||||
|
for (const seq of sequencesTerminees) {
|
||||||
|
// Vérifier si le questionnaire est lié à une formation spécifique
|
||||||
|
if (questionnaire.formationId && questionnaire.formationId !== seq.formationId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les apprenants inscrits et validés
|
||||||
|
const apprenantsInscrits = await db
|
||||||
|
.select({
|
||||||
|
apprenantId: apprenants.id,
|
||||||
|
apprenantEmail: apprenants.email,
|
||||||
|
apprenantNom: apprenants.nom,
|
||||||
|
apprenantPrenom: apprenants.prenom,
|
||||||
|
})
|
||||||
|
.from(inscriptions)
|
||||||
|
.innerJoin(apprenants, eq(apprenants.id, inscriptions.apprenantId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(inscriptions.sequenceId, seq.sequenceId),
|
||||||
|
eq(inscriptions.statut, "confirmee")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Questionnaires] ${apprenantsInscrits.length} apprenant(s) inscrit(s) à la séquence ${seq.sequenceId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Envoyer le questionnaire à chaque apprenant
|
||||||
|
for (const apprenant of apprenantsInscrits) {
|
||||||
|
if (!apprenant.apprenantEmail) {
|
||||||
|
console.log(
|
||||||
|
`[Questionnaires] Apprenant ${apprenant.apprenantId} sans email, envoi ignoré`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envoye = await envoyerQuestionnaire(
|
||||||
|
questionnaire.id,
|
||||||
|
apprenant.apprenantId,
|
||||||
|
seq.sequenceId,
|
||||||
|
apprenant.apprenantEmail,
|
||||||
|
apprenant.apprenantNom,
|
||||||
|
apprenant.apprenantPrenom,
|
||||||
|
questionnaire.nom,
|
||||||
|
seq.sequenceNom
|
||||||
|
);
|
||||||
|
|
||||||
|
if (envoye) {
|
||||||
|
totalEnvoyes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Questionnaires] Traitement terminé - ${totalEnvoyes} questionnaire(s) envoyé(s)`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${totalEnvoyes} questionnaire(s) envoyé(s)`,
|
||||||
|
count: totalEnvoyes,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[Questionnaires] Erreur lors du traitement :", error);
|
||||||
|
return { success: false, message: error.message, count: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise le scheduler pour les envois automatiques de questionnaires
|
||||||
|
* Exécute le traitement tous les jours à 9h00
|
||||||
|
*/
|
||||||
|
export function initQuestionnaireScheduler() {
|
||||||
|
console.log("[Questionnaires] Initialisation du scheduler d'envoi automatique");
|
||||||
|
|
||||||
|
// Exécuter immédiatement au démarrage
|
||||||
|
processEnvoisAutomatiques();
|
||||||
|
|
||||||
|
// Puis exécuter tous les jours à 9h00
|
||||||
|
const interval = setInterval(
|
||||||
|
() => {
|
||||||
|
const now = new Date();
|
||||||
|
if (now.getHours() === 9 && now.getMinutes() === 0) {
|
||||||
|
processEnvoisAutomatiques();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60 * 1000 // Vérifier toutes les minutes
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[Questionnaires] Scheduler initialisé - vérification quotidienne à 9h00");
|
||||||
|
|
||||||
|
return interval;
|
||||||
|
}
|
||||||
@@ -1193,6 +1193,13 @@ export const appRouter = router({
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
envoyerAutomatiques: adminProcedure
|
||||||
|
.mutation(async () => {
|
||||||
|
const { processEnvoisAutomatiques } = await import('./questionnaireScheduler');
|
||||||
|
const result = await processEnvoisAutomatiques();
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ===== ÉTABLISSEMENTS =====
|
// ===== ÉTABLISSEMENTS =====
|
||||||
|
|||||||
8
todo.md
8
todo.md
@@ -6,10 +6,10 @@
|
|||||||
|
|
||||||
- [x] Créer le schéma de base de données (tables questionnaires, questions, réponses)
|
- [x] Créer le schéma de base de données (tables questionnaires, questions, réponses)
|
||||||
- [x] Développer les procédures backend tRPC pour la gestion des questionnaires
|
- [x] Développer les procédures backend tRPC pour la gestion des questionnaires
|
||||||
- [ ] Créer l'interface d'administration des questionnaires
|
- [x] Créer l'interface d'administration des questionnaires
|
||||||
- [ ] Créer l'interface publique de réponse aux questionnaires
|
- [x] Créer l'interface publique de réponse aux questionnaires
|
||||||
- [ ] Implémenter l'envoi automatique post-formation
|
- [x] Implémenter l'envoi automatique post-formation
|
||||||
- [ ] Créer les tableaux de bord d'analyse avec graphiques (satisfaction par formation/formateur)
|
- [x] Créer les tableaux de bord d'analyse avec graphiques (satisfaction par formation/formateur)
|
||||||
- [ ] Implémenter les exports Excel/PDF des feedbacks
|
- [ ] Implémenter les exports Excel/PDF des feedbacks
|
||||||
- [ ] Tester le système complet (création, envoi, réponse, analyse)
|
- [ ] Tester le système complet (création, envoi, réponse, analyse)
|
||||||
- [ ] Déployer sur le serveur VPS
|
- [ ] Déployer sur le serveur VPS
|
||||||
|
|||||||
Reference in New Issue
Block a user