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

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