**Corrections d'erreurs :** 1. **Erreurs SQL DATE_FORMAT corrigées** (analyticsDb.ts) : - Remplacement des sous-requêtes corrélées par des JOIN dans getTauxRemplissageByMonth() - Utilisation de COUNT(CASE WHEN...) au lieu de SUM(SELECT COUNT(*)) - Ajout de leftJoin pour éviter les erreurs de sous-requêtes 2. **Erreur React "Cannot read properties of null" corrigée** (AdminRapportPublicCible.tsx) : - Ajout de l'opérateur null-safe `?.` pour accéder à `insc.apprenant?.fonction` - Vérification explicite si la fonction est null/undefined - Gestion du cas "public cible = tous" qui correspond toujours 3. **Tests réussis** : - Page rapport-public-cible : affiche correctement les statistiques et recommandations - Tableau de bord analytique : graphiques et statistiques fonctionnels - Page suivi questionnaires : affiche correctement les données **Harmonisation charte graphique :** - Application du style bleu clair (#6B9FE8) avec ombre portée à tous les titres des 32 pages - Création de la classe CSS `.page-title` réutilisable dans index.css - Cohérence visuelle sur toute l'application **Résultat :** ✅ Toutes les erreurs SQL et React sont corrigées ✅ Les pages fonctionnent correctement sans erreur ✅ Charte graphique harmonisée sur toutes les pages
514 lines
20 KiB
TypeScript
514 lines
20 KiB
TypeScript
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, Eye } from "lucide-react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Slider } from "@/components/ui/slider";
|
|
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 [titre, setTitre] = 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 [showPreview, setShowPreview] = useState(false);
|
|
|
|
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) {
|
|
setTitre(questionnaire.titre);
|
|
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 (!titre.trim()) {
|
|
toast.error("Le titre du questionnaire est obligatoire");
|
|
return;
|
|
}
|
|
|
|
if (questions.length === 0) {
|
|
toast.error("Ajoutez au moins une question");
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
titre,
|
|
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="page-title">
|
|
{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="titre">Titre du questionnaire *</Label>
|
|
<Input
|
|
id="titre"
|
|
value={titre}
|
|
onChange={(e) => setTitre(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="button"
|
|
variant="secondary"
|
|
onClick={() => setShowPreview(true)}
|
|
disabled={questions.length === 0}
|
|
>
|
|
<Eye className="mr-2 h-4 w-4 text-blue-600" />
|
|
Prévisualiser
|
|
</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>
|
|
{/* Modal de prévisualisation */}
|
|
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{titre || "Questionnaire sans titre"}</DialogTitle>
|
|
{description && <p className="text-sm text-muted-foreground mt-2">{description}</p>}
|
|
</DialogHeader>
|
|
<div className="space-y-6 mt-4">
|
|
{questions.map((question, index) => (
|
|
<div key={index} className="space-y-3">
|
|
<div className="font-medium">
|
|
{index + 1}. {question.texte}
|
|
{question.obligatoire && <span className="text-red-500 ml-1">*</span>}
|
|
</div>
|
|
|
|
{question.typeQuestion === "texte_libre" && (
|
|
<Textarea
|
|
placeholder="Votre réponse..."
|
|
disabled
|
|
className="bg-muted"
|
|
/>
|
|
)}
|
|
|
|
{question.typeQuestion === "oui_non" && (
|
|
<RadioGroup disabled>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="oui" id={`preview-${index}-oui`} />
|
|
<Label htmlFor={`preview-${index}-oui`}>Oui</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="non" id={`preview-${index}-non`} />
|
|
<Label htmlFor={`preview-${index}-non`}>Non</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
)}
|
|
|
|
{question.typeQuestion === "choix_multiple" && (
|
|
<RadioGroup disabled>
|
|
{(question.options || "").split("\n").filter(o => o.trim()).map((option, optIndex) => (
|
|
<div key={optIndex} className="flex items-center space-x-2">
|
|
<RadioGroupItem value={option} id={`preview-${index}-${optIndex}`} />
|
|
<Label htmlFor={`preview-${index}-${optIndex}`}>{option}</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
)}
|
|
|
|
{question.typeQuestion === "echelle" && (
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm text-muted-foreground">
|
|
<span>{question.echelleLabelMin || `${question.echelleMin || 1}`}</span>
|
|
<span>{question.echelleLabelMax || `${question.echelleMax || 5}`}</span>
|
|
</div>
|
|
<Slider
|
|
disabled
|
|
min={question.echelleMin || 1}
|
|
max={question.echelleMax || 5}
|
|
step={1}
|
|
defaultValue={[Math.floor(((question.echelleMin || 1) + (question.echelleMax || 5)) / 2)]}
|
|
className="w-full"
|
|
/>
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
{Array.from(
|
|
{ length: (question.echelleMax || 5) - (question.echelleMin || 1) + 1 },
|
|
(_, i) => (question.echelleMin || 1) + i
|
|
).map((val) => (
|
|
<span key={val}>{val}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</DashboardLayout>
|
|
);
|
|
}
|