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:
Manus Sandbox
2025-12-05 07:04:13 -05:00
parent 3521c55297
commit cc8f634c16
9 changed files with 1304 additions and 5 deletions

View File

@@ -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} />

View File

@@ -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" },
]

View 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>
);
}

View 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&#10;Option 2&#10;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>
);
}

View 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>
);
}

View 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>
);
}