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 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() {
|
||||
<Route path={"/admin/analytics"} component={AdminAnalytics} />
|
||||
<Route path={"/admin/etablissements"} component={AdminEtablissements} />
|
||||
<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} />
|
||||
{/* Final fallback route */}
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
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 };
|
||||
}),
|
||||
|
||||
envoyerAutomatiques: adminProcedure
|
||||
.mutation(async () => {
|
||||
const { processEnvoisAutomatiques } = await import('./questionnaireScheduler');
|
||||
const result = await processEnvoisAutomatiques();
|
||||
return result;
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== É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] Développer les procédures backend tRPC pour la gestion des questionnaires
|
||||
- [ ] Créer l'interface d'administration des questionnaires
|
||||
- [ ] Créer l'interface publique de réponse aux questionnaires
|
||||
- [ ] Implémenter l'envoi automatique post-formation
|
||||
- [ ] Créer les tableaux de bord d'analyse avec graphiques (satisfaction par formation/formateur)
|
||||
- [x] Créer l'interface d'administration des questionnaires
|
||||
- [x] Créer l'interface publique de réponse aux questionnaires
|
||||
- [x] Implémenter l'envoi automatique post-formation
|
||||
- [x] Créer les tableaux de bord d'analyse avec graphiques (satisfaction par formation/formateur)
|
||||
- [ ] Implémenter les exports Excel/PDF des feedbacks
|
||||
- [ ] Tester le système complet (création, envoi, réponse, analyse)
|
||||
- [ ] Déployer sur le serveur VPS
|
||||
|
||||
Reference in New Issue
Block a user