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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user