Files
formation-manager-itinova/client/src/pages/AdminQuestionnaireEdit.tsx
Manus fc4df8ed36 Checkpoint: Correction des erreurs sur la page rapport-public-cible et harmonisation de la charte graphique des titres :
**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
2026-01-20 11:46:56 -05:00

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&#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="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>
);
}