-
Start Date setForm(f => ({ ...f, start_date: e.target.value }))} />
-
End Date setForm(f => ({ ...f, end_date: e.target.value }))} />
+
+
+ Schedule Exam
+ Select an exam, assign it to students, and set scheduling options.
+
+
+
+ {/* Basic info */}
+
+
+ Schedule Name *
+ updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
+
+
+ Select Exam *
+ updateF({ exam_id: v })}>
+
+
+ {publishedExams.map((e) => (
+
+ {e.title} ({e.status})
+
+ ))}
+ {publishedExams.length === 0 && (
+ No exams available
+ )}
+
+
+
+
+
+ {/* Date/Time */}
+
+
Schedule Date & Time
+
+
+
+ {/* Options checkboxes */}
+
+
Exam Options
+
+ {OPTION_FIELDS.map((opt) => (
+
+ updateF({ [opt.key]: !!checked } as Partial)}
+ className="h-4 w-4"
+ />
+ {opt.label}
+
+ ))}
+
+
+
+ {/* Assignment target */}
+
+
Assign To
+
+
+ Assignment Mode
+ updateF({ assign_mode: v as FormState["assign_mode"] })}>
+
+
+ Entire Entity
+ Class / Batch
+ Individual Students
+
+
+
+
+ Entity
+ updateF({ entity_id: v })}>
+
+
+ {entities.map((e) => (
+ {e.name}
+ ))}
+ {entities.length === 0 && (
+ No entities
+ )}
+
+
+
+
+
+ {/* Batch selection */}
+ {form.assign_mode === "batch" && (
+
+
Select Classes
+
+ {batches.map((b) => (
+
+ {
+ setForm((p) => {
+ const next = new Set(p.batch_ids);
+ checked ? next.add(b.id) : next.delete(b.id);
+ return { ...p, batch_ids: next };
+ });
+ }}
+ className="h-3.5 w-3.5"
+ />
+ {b.name}
+
+ ))}
+ {batches.length === 0 &&
No classes available
}
+
+
+ )}
+
+ {/* Individual student selection */}
+ {form.assign_mode === "individual" && (
+
+
Select Students
+
+ {students.map((s) => (
+
+ {
+ setForm((p) => {
+ const next = new Set(p.student_ids);
+ checked ? next.add(s.id) : next.delete(s.id);
+ return { ...p, student_ids: next };
+ });
+ }}
+ className="h-3.5 w-3.5"
+ />
+ {s.name}
+ {s.email && {s.email} }
+
+ ))}
+ {students.length === 0 &&
No students available
}
+
+
+ )}
+
setCreateOpen(false)}>Cancel
-
- {createMut.isPending ? "Creating..." : "Create"}
+
+ {createMut.isPending ? <> Creating...> : "Schedule Exam"}
diff --git a/src/pages/ExamStructuresPage.tsx b/src/pages/ExamStructuresPage.tsx
index 325a81c..32a1faf 100644
--- a/src/pages/ExamStructuresPage.tsx
+++ b/src/pages/ExamStructuresPage.tsx
@@ -1,30 +1,1262 @@
-import { useState } from "react";
+import { useState, useCallback, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Search, Plus, Layers, Trash2, Loader2 } from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Search,
+ Plus,
+ Layers,
+ Trash2,
+ Loader2,
+ Pencil,
+ BookOpen,
+ Headphones,
+ PenTool,
+ Mic,
+ Briefcase,
+ ChevronRight,
+ ChevronLeft,
+ Check,
+ GripVertical,
+} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { examsService } from "@/services/exams.service";
import { useToast } from "@/hooks/use-toast";
-import type { ExamStructure } from "@/types";
+import type {
+ ExamStructure,
+ ExamStructureConfig,
+ ListeningPartConfig,
+ ReadingPassageConfig,
+ SpeakingPartConfig,
+ WritingTaskConfig,
+} from "@/types";
-const MODULE_OPTIONS = ["Reading", "Listening", "Writing", "Speaking"];
+/* ───────────────────────────── constants ───────────────────────────── */
+
+const INDUSTRY_OPTIONS = ["General", "Technology", "Healthcare", "Hospitality", "Education"];
+
+type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
+
+interface ModuleMeta {
+ key: ModuleKey;
+ label: string;
+ icon: React.ReactNode;
+ color: string;
+}
+
+const ALL_MODULES: ModuleMeta[] = [
+ { key: "listening", label: "Listening", icon:
, color: "text-teal-600" },
+ { key: "reading", label: "Reading", icon:
, color: "text-blue-600" },
+ { key: "writing", label: "Writing", icon:
, color: "text-orange-600" },
+ { key: "speaking", label: "Speaking", icon:
, color: "text-purple-600" },
+ { key: "level", label: "Level", icon:
, color: "text-indigo-600" },
+ { key: "industry", label: "Industry", icon:
, color: "text-amber-700" },
+];
+
+/* ── Part / type pools ── */
+
+const ALL_LISTENING_PART_TYPES = [
+ { key: "social_conversation", label: "Social Conversation" },
+ { key: "social_monologue", label: "Social Monologue" },
+ { key: "academic_discussion", label: "Academic Discussion" },
+ { key: "academic_monologue", label: "Academic Monologue" },
+];
+
+const LISTENING_PARTS_ACADEMIC = [
+ { key: "academic_discussion", label: "P1: Academic Discussion" },
+ { key: "academic_monologue", label: "P2: Academic Monologue" },
+ { key: "social_conversation", label: "P3: Social Conversation" },
+ { key: "social_monologue", label: "P4: Social Monologue" },
+];
+const LISTENING_PARTS_GENERAL = [
+ { key: "social_conversation", label: "P1: Social Conversation" },
+ { key: "social_monologue", label: "P2: Social Monologue" },
+ { key: "academic_discussion", label: "P3: Academic Discussion" },
+ { key: "academic_monologue", label: "P4: Academic Monologue" },
+];
+
+const LISTENING_QUESTION_TYPES = [
+ { key: "mcq", label: "Multiple Choice" },
+ { key: "matching", label: "Matching" },
+ { key: "plan_map_diagram", label: "Plan/Map/Diagram Labelling" },
+ { key: "form_note_table", label: "Form/Note/Table/Summary Completion" },
+ { key: "gap_filling", label: "Gap-filling" },
+ { key: "sentence_completion", label: "Sentence Completion" },
+ { key: "true_false_ng", label: "True / False / Not Given" },
+ { key: "short_answer", label: "Short-Answer Questions" },
+];
+
+const READING_QUESTION_TYPES = [
+ { key: "mcq", label: "Multiple Choice" },
+ { key: "identifying_info", label: "Identifying Information (T/F/NG)" },
+ { key: "identifying_views", label: "Identifying Writer's Views (Y/N/NG)" },
+ { key: "matching_info", label: "Matching Information" },
+ { key: "matching_headings", label: "Matching Headings" },
+ { key: "matching_features", label: "Matching Features" },
+ { key: "matching_endings", label: "Matching Sentence Endings" },
+ { key: "sentence_completion", label: "Sentence Completion" },
+ { key: "summary_completion", label: "Summary/Note/Table/Flow-Chart Completion" },
+ { key: "diagram_label", label: "Diagram Label Completion" },
+ { key: "short_answer", label: "Short-Answer Questions" },
+];
+
+const READING_STYLES_ACADEMIC = ["Narrative", "Descriptive", "Discursive"];
+const READING_STYLES_GENERAL = ["Everyday topics", "Work topics", "General interest"];
+
+const ALL_WRITING_TASK_TYPES = [
+ { key: "descriptive", label: "Descriptive", group: "Academic" },
+ { key: "analytical", label: "Analytical", group: "Academic" },
+ { key: "persuasive", label: "Persuasive", group: "Academic" },
+ { key: "critical", label: "Critical", group: "Academic" },
+ { key: "formal_letter", label: "Formal Letter", group: "General" },
+ { key: "semi_formal_letter", label: "Semi-Formal Letter", group: "General" },
+ { key: "informal_letter", label: "Informal Letter", group: "General" },
+ { key: "scenario_based", label: "Scenario-Based Questions", group: "Both" },
+ { key: "opinion_essay", label: "Opinion Essay", group: "Essay" },
+ { key: "discussion_essay", label: "Discussion Essay", group: "Essay" },
+ { key: "advantage_disadvantage", label: "Advantage / Disadvantage", group: "Essay" },
+ { key: "solution_problem", label: "Solution / Problem", group: "Essay" },
+ { key: "direct_two_question", label: "Direct / Two-Question", group: "Essay" },
+];
+
+const ALL_SPEAKING_PART_TYPES = [
+ { key: "interview", label: "Introduction & Interview", defaultMin: 4, defaultMax: 5 },
+ { key: "long_turn", label: "Long Turn (Cue Card)", defaultMin: 3, defaultMax: 4 },
+ { key: "discussion", label: "Discussion", defaultMin: 4, defaultMax: 5 },
+];
+
+const LEVEL_EXERCISE_TYPE_POOL = [
+ { key: "mc_blank", label: "Multiple Choice: Blank Space", defaultQty: 1 },
+ { key: "mc_underline", label: "Multiple Choice: Underlined", defaultQty: 2 },
+ { key: "fill_blanks", label: "Fill Blanks", defaultQty: 5 },
+ { key: "fill_blanks_mc", label: "Fill Blanks: Multiple Choice", defaultQty: 10 },
+ { key: "verb_tense", label: "Verb Tense", defaultQty: 11 },
+];
+
+const INDUSTRY_EXERCISE_TYPE_POOL = [
+ { key: "mc_blank_space", label: "Multiple Choice: Blank Space", defaultQty: 5 },
+ { key: "mc_normal", label: "Multiple Choice: Normal", defaultQty: 5 },
+ { key: "fill_blanks", label: "Fill Blanks", defaultQty: 5 },
+ { key: "true_false", label: "True/False", defaultQty: 5 },
+ { key: "technical_writing", label: "Technical Writing", defaultQty: 2 },
+];
+
+const INDUSTRY_DIFFICULTY_OPTIONS = ["Beginner", "Intermediate", "Advanced"];
+
+/* ───────────────────────── helpers ───────────────────────── */
+
+function sumQTypes(qt: Record
): number {
+ return Object.values(qt).reduce((s, v) => s + v, 0);
+}
+
+function lookupLabel(key: string, pool: { key: string; label: string }[]): string {
+ return pool.find((p) => p.key === key)?.label ?? key;
+}
+
+/* ───────────────────────── wizard form state ───────────────────────── */
+
+interface WizardWritingTask {
+ type: string;
+ min_words: number;
+ label: string;
+}
+
+interface WizardLevelEntry {
+ type: string;
+ quantity: number;
+}
+
+interface WizardIndustryEntry {
+ type: string;
+ quantity: number;
+}
+
+interface WizardState {
+ name: string;
+ industry: string;
+ exam_type: "academic" | "general";
+ modules: Set;
+ listening: { parts: ListeningPartConfig[] };
+ reading: { passages: ReadingPassageConfig[] };
+ writing: { tasks: WizardWritingTask[] };
+ speaking: { parts: SpeakingPartConfig[] };
+ level: WizardLevelEntry[];
+ industryModule: { entries: WizardIndustryEntry[]; difficulty: string };
+}
+
+function defaultListeningParts(examType: "academic" | "general"): ListeningPartConfig[] {
+ const parts = examType === "academic" ? LISTENING_PARTS_ACADEMIC : LISTENING_PARTS_GENERAL;
+ return parts.map((p) => ({
+ key: p.key,
+ label: p.label,
+ questions: 10,
+ question_types: Object.fromEntries(LISTENING_QUESTION_TYPES.map((q) => [q.key, 0])),
+ }));
+}
+
+function defaultReadingPassages(examType: "academic" | "general"): ReadingPassageConfig[] {
+ const styles = examType === "academic" ? READING_STYLES_ACADEMIC : READING_STYLES_GENERAL;
+ return styles.map((style) => ({
+ style,
+ questions: 13,
+ question_types: Object.fromEntries(READING_QUESTION_TYPES.map((q) => [q.key, 0])),
+ }));
+}
+
+function defaultWritingTasks(examType: "academic" | "general"): WizardWritingTask[] {
+ return [
+ { label: "Task 1", type: examType === "academic" ? "descriptive" : "formal_letter", min_words: 150 },
+ { label: "Task 2", type: "opinion_essay", min_words: 250 },
+ ];
+}
+
+function emptyWizard(): WizardState {
+ return {
+ name: "",
+ industry: "",
+ exam_type: "academic",
+ modules: new Set(),
+ listening: { parts: defaultListeningParts("academic") },
+ reading: { passages: defaultReadingPassages("academic") },
+ writing: { tasks: defaultWritingTasks("academic") },
+ speaking: { parts: [...ALL_SPEAKING_PART_TYPES.map((p) => ({ key: p.key, label: p.label, duration_min: p.defaultMin, duration_max: p.defaultMax }))] },
+ level: LEVEL_EXERCISE_TYPE_POOL.map((t) => ({ type: t.key, quantity: t.defaultQty })),
+ industryModule: {
+ entries: INDUSTRY_EXERCISE_TYPE_POOL.map((t) => ({ type: t.key, quantity: t.defaultQty })),
+ difficulty: "Intermediate",
+ },
+ };
+}
+
+function wizardFromStructure(s: ExamStructure): WizardState {
+ const cfg = (s.config && typeof s.config === "object" ? s.config : {}) as ExamStructureConfig;
+ const examType = cfg.exam_type || "academic";
+ const rawModules = (Array.isArray(s.modules) ? s.modules : []) as string[];
+ const mods = new Set(
+ rawModules.filter((m): m is ModuleKey =>
+ ["reading", "listening", "writing", "speaking", "level", "industry"].includes(m),
+ ),
+ );
+
+ const base = emptyWizard();
+ base.name = s.name;
+ base.industry = s.industry || "";
+ base.exam_type = examType;
+ base.modules = mods;
+
+ if (cfg.listening?.parts?.length) {
+ base.listening.parts = cfg.listening.parts;
+ } else {
+ base.listening.parts = defaultListeningParts(examType);
+ }
+
+ if (cfg.reading?.passages?.length) {
+ base.reading.passages = cfg.reading.passages;
+ } else {
+ base.reading.passages = defaultReadingPassages(examType);
+ }
+
+ if (cfg.writing) {
+ if (cfg.writing.tasks?.length) {
+ base.writing.tasks = cfg.writing.tasks.map((t, i) => ({
+ type: t.type,
+ min_words: t.min_words,
+ label: t.label || `Task ${i + 1}`,
+ }));
+ } else if (cfg.writing.task1 || cfg.writing.task2) {
+ const tasks: WizardWritingTask[] = [];
+ if (cfg.writing.task1) tasks.push({ label: "Task 1", type: cfg.writing.task1.type, min_words: cfg.writing.task1.min_words });
+ if (cfg.writing.task2) tasks.push({ label: "Task 2", type: cfg.writing.task2.type, min_words: cfg.writing.task2.min_words });
+ base.writing.tasks = tasks;
+ }
+ }
+
+ if (cfg.speaking?.parts?.length) {
+ base.speaking.parts = cfg.speaking.parts;
+ }
+
+ if (cfg.level) {
+ if (cfg.level.entries?.length) {
+ base.level = cfg.level.entries.map((e) => ({ type: e.type, quantity: e.quantity }));
+ } else if (cfg.level.exercise_types) {
+ base.level = Object.entries(cfg.level.exercise_types).map(([type, quantity]) => ({ type, quantity }));
+ }
+ }
+
+ if (cfg.industry) {
+ if (cfg.industry.entries?.length) {
+ base.industryModule.entries = cfg.industry.entries.map((e) => ({ type: e.type, quantity: e.quantity }));
+ } else if (cfg.industry.exercise_types) {
+ base.industryModule.entries = Object.entries(cfg.industry.exercise_types).map(([type, quantity]) => ({ type, quantity }));
+ }
+ base.industryModule.difficulty = cfg.industry.difficulty || "Intermediate";
+ }
+
+ return base;
+}
+
+function wizardToPayload(w: WizardState): { name: string; industry: string; modules: string[]; config: ExamStructureConfig } {
+ const modules = [...w.modules];
+ const config: ExamStructureConfig = { exam_type: w.exam_type };
+
+ if (w.modules.has("listening")) {
+ const totalQ = w.listening.parts.reduce((s, p) => s + sumQTypes(p.question_types), 0);
+ config.listening = { parts: w.listening.parts, total_questions: totalQ };
+ }
+ if (w.modules.has("reading")) {
+ const totalQ = w.reading.passages.reduce((s, p) => s + sumQTypes(p.question_types), 0);
+ config.reading = { passages: w.reading.passages, total_questions: totalQ };
+ }
+ if (w.modules.has("writing")) {
+ config.writing = {
+ tasks: w.writing.tasks.map((t) => ({ type: t.type, min_words: t.min_words, label: t.label })),
+ rubric_id: null,
+ };
+ }
+ if (w.modules.has("speaking")) {
+ config.speaking = { parts: w.speaking.parts, rubric_id: null };
+ }
+ if (w.modules.has("level")) {
+ config.level = {
+ exercise_types: Object.fromEntries(w.level.map((e) => [e.type, e.quantity])),
+ entries: w.level,
+ };
+ }
+ if (w.modules.has("industry")) {
+ config.industry = {
+ exercise_types: Object.fromEntries(w.industryModule.entries.map((e) => [e.type, e.quantity])),
+ entries: w.industryModule.entries,
+ difficulty: w.industryModule.difficulty,
+ };
+ }
+
+ return { name: w.name.trim(), industry: w.industry, modules, config };
+}
+
+/* ───────────────────── Stepper ───────────────────── */
+
+const STEPS = ["Basics", "Modules", "Configure", "Review"] as const;
+
+function StepIndicator({ current }: { current: number }) {
+ return (
+
+ {STEPS.map((label, i) => (
+
+
+ {i < current ? : i + 1}
+
+
+ {label}
+
+ {i < STEPS.length - 1 &&
}
+
+ ))}
+
+ );
+}
+
+/* ─────────────── Reusable: question type grid with toggles ─────────────── */
+
+function QuestionTypeGrid({
+ types,
+ values,
+ onChange,
+}: {
+ types: { key: string; label: string }[];
+ values: Record;
+ onChange: (key: string, val: number) => void;
+}) {
+ const enabled = (k: string) => (values[k] ?? 0) > 0;
+ const total = sumQTypes(values);
+
+ return (
+
+
+ {types.map((qt) => {
+ const on = enabled(qt.key);
+ return (
+
+ onChange(qt.key, checked ? Math.max(values[qt.key] || 1, 1) : 0)}
+ className="h-3.5 w-3.5"
+ />
+ onChange(qt.key, on ? 0 : Math.max(values[qt.key] || 1, 1))}>
+ {qt.label}
+
+ {on && (
+ onChange(qt.key, Math.max(1, Number(e.target.value) || 1))}
+ />
+ )}
+
+ );
+ })}
+
+
+ {total} question{total !== 1 ? "s" : ""} selected
+
+
+ );
+}
+
+/* ───────────────── Wizard dialog ───────────────── */
+
+function ExamStructureWizard({
+ open,
+ onOpenChange,
+ initial,
+ onSave,
+ saving,
+ title,
+}: {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ initial: WizardState;
+ onSave: (payload: ReturnType) => void;
+ saving: boolean;
+ title: string;
+}) {
+ const [step, setStep] = useState(0);
+ const [w, setW] = useState(initial);
+
+ const resetAndClose = useCallback(() => {
+ setStep(0);
+ setW(emptyWizard());
+ onOpenChange(false);
+ }, [onOpenChange]);
+
+ const updateW = useCallback((key: K, val: WizardState[K]) => {
+ setW((prev) => ({ ...prev, [key]: val }));
+ }, []);
+
+ const handleExamTypeChange = useCallback((et: "academic" | "general") => {
+ setW((prev) => ({
+ ...prev,
+ exam_type: et,
+ listening: { parts: defaultListeningParts(et) },
+ reading: { passages: defaultReadingPassages(et) },
+ writing: { tasks: defaultWritingTasks(et) },
+ }));
+ }, []);
+
+ const toggleModule = useCallback((key: ModuleKey) => {
+ setW((prev) => {
+ const next = new Set(prev.modules);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return { ...prev, modules: next };
+ });
+ }, []);
+
+ const canNext = useMemo(() => {
+ if (step === 0) return w.name.trim().length > 0;
+ if (step === 1) return w.modules.size > 0;
+ return true;
+ }, [step, w.name, w.modules]);
+
+ const selectedModules = useMemo(
+ () => ALL_MODULES.filter((m) => w.modules.has(m.key)),
+ [w.modules],
+ );
+
+ /* ── Listening mutations ── */
+
+ const addListeningPart = () => {
+ setW((prev) => {
+ const usedKeys = new Set(prev.listening.parts.map((p) => p.key));
+ const next = ALL_LISTENING_PART_TYPES.find((t) => !usedKeys.has(t.key)) ?? ALL_LISTENING_PART_TYPES[0];
+ const newPart: ListeningPartConfig = {
+ key: next.key,
+ label: next.label,
+ questions: 10,
+ question_types: Object.fromEntries(LISTENING_QUESTION_TYPES.map((q) => [q.key, 0])),
+ };
+ return { ...prev, listening: { parts: [...prev.listening.parts, newPart] } };
+ });
+ };
+
+ const removeListeningPart = (idx: number) => {
+ setW((prev) => ({ ...prev, listening: { parts: prev.listening.parts.filter((_, i) => i !== idx) } }));
+ };
+
+ const updateListeningPartType = (idx: number, key: string) => {
+ const meta = ALL_LISTENING_PART_TYPES.find((t) => t.key === key);
+ setW((prev) => {
+ const parts = [...prev.listening.parts];
+ parts[idx] = { ...parts[idx], key, label: meta?.label ?? key };
+ return { ...prev, listening: { parts } };
+ });
+ };
+
+ const updateListeningQType = (partIdx: number, qKey: string, val: number) => {
+ setW((prev) => {
+ const parts = [...prev.listening.parts];
+ parts[partIdx] = { ...parts[partIdx], question_types: { ...parts[partIdx].question_types, [qKey]: val } };
+ return { ...prev, listening: { parts } };
+ });
+ };
+
+ /* ── Reading mutations ── */
+
+ const addReadingPassage = () => {
+ const styles = w.exam_type === "academic" ? READING_STYLES_ACADEMIC : READING_STYLES_GENERAL;
+ const usedStyles = new Set(w.reading.passages.map((p) => p.style));
+ const nextStyle = styles.find((s) => !usedStyles.has(s)) ?? styles[0];
+ setW((prev) => ({
+ ...prev,
+ reading: {
+ passages: [
+ ...prev.reading.passages,
+ { style: nextStyle, questions: 13, question_types: Object.fromEntries(READING_QUESTION_TYPES.map((q) => [q.key, 0])) },
+ ],
+ },
+ }));
+ };
+
+ const removeReadingPassage = (idx: number) => {
+ setW((prev) => ({ ...prev, reading: { passages: prev.reading.passages.filter((_, i) => i !== idx) } }));
+ };
+
+ const updateReadingStyle = (idx: number, style: string) => {
+ setW((prev) => {
+ const passages = [...prev.reading.passages];
+ passages[idx] = { ...passages[idx], style };
+ return { ...prev, reading: { passages } };
+ });
+ };
+
+ const updateReadingQType = (passageIdx: number, qKey: string, val: number) => {
+ setW((prev) => {
+ const passages = [...prev.reading.passages];
+ passages[passageIdx] = { ...passages[passageIdx], question_types: { ...passages[passageIdx].question_types, [qKey]: val } };
+ return { ...prev, reading: { passages } };
+ });
+ };
+
+ /* ── Writing mutations ── */
+
+ const addWritingTask = () => {
+ setW((prev) => ({
+ ...prev,
+ writing: {
+ tasks: [
+ ...prev.writing.tasks,
+ { label: `Task ${prev.writing.tasks.length + 1}`, type: "scenario_based", min_words: 150 },
+ ],
+ },
+ }));
+ };
+
+ const removeWritingTask = (idx: number) => {
+ setW((prev) => ({ ...prev, writing: { tasks: prev.writing.tasks.filter((_, i) => i !== idx) } }));
+ };
+
+ const updateWritingTask = (idx: number, patch: Partial) => {
+ setW((prev) => {
+ const tasks = [...prev.writing.tasks];
+ tasks[idx] = { ...tasks[idx], ...patch };
+ return { ...prev, writing: { tasks } };
+ });
+ };
+
+ /* ── Speaking mutations ── */
+
+ const addSpeakingPart = () => {
+ const usedKeys = new Set(w.speaking.parts.map((p) => p.key));
+ const next = ALL_SPEAKING_PART_TYPES.find((t) => !usedKeys.has(t.key)) ?? ALL_SPEAKING_PART_TYPES[0];
+ setW((prev) => ({
+ ...prev,
+ speaking: {
+ parts: [
+ ...prev.speaking.parts,
+ { key: next.key, label: next.label, duration_min: next.defaultMin, duration_max: next.defaultMax },
+ ],
+ },
+ }));
+ };
+
+ const removeSpeakingPart = (idx: number) => {
+ setW((prev) => ({ ...prev, speaking: { parts: prev.speaking.parts.filter((_, i) => i !== idx) } }));
+ };
+
+ const updateSpeakingPart = (idx: number, patch: Partial) => {
+ setW((prev) => {
+ const parts = [...prev.speaking.parts];
+ parts[idx] = { ...parts[idx], ...patch };
+ return { ...prev, speaking: { parts } };
+ });
+ };
+
+ const updateSpeakingPartType = (idx: number, key: string) => {
+ const meta = ALL_SPEAKING_PART_TYPES.find((t) => t.key === key);
+ if (!meta) return;
+ setW((prev) => {
+ const parts = [...prev.speaking.parts];
+ parts[idx] = { ...parts[idx], key, label: meta.label };
+ return { ...prev, speaking: { parts } };
+ });
+ };
+
+ /* ── Level mutations ── */
+
+ const addLevelEntry = () => {
+ const usedTypes = new Set(w.level.map((e) => e.type));
+ const next = LEVEL_EXERCISE_TYPE_POOL.find((t) => !usedTypes.has(t.key));
+ if (!next) return;
+ updateW("level", [...w.level, { type: next.key, quantity: next.defaultQty }]);
+ };
+
+ const removeLevelEntry = (idx: number) => {
+ updateW("level", w.level.filter((_, i) => i !== idx));
+ };
+
+ const updateLevelEntry = (idx: number, patch: Partial) => {
+ const entries = [...w.level];
+ entries[idx] = { ...entries[idx], ...patch };
+ updateW("level", entries);
+ };
+
+ /* ── Industry mutations ── */
+
+ const addIndustryEntry = () => {
+ const usedTypes = new Set(w.industryModule.entries.map((e) => e.type));
+ const next = INDUSTRY_EXERCISE_TYPE_POOL.find((t) => !usedTypes.has(t.key));
+ if (!next) return;
+ updateW("industryModule", { ...w.industryModule, entries: [...w.industryModule.entries, { type: next.key, quantity: next.defaultQty }] });
+ };
+
+ const removeIndustryEntry = (idx: number) => {
+ updateW("industryModule", { ...w.industryModule, entries: w.industryModule.entries.filter((_, i) => i !== idx) });
+ };
+
+ const updateIndustryEntry = (idx: number, patch: Partial) => {
+ const entries = [...w.industryModule.entries];
+ entries[idx] = { ...entries[idx], ...patch };
+ updateW("industryModule", { ...w.industryModule, entries });
+ };
+
+ /* ── render: listening ── */
+
+ const renderListeningConfig = () => {
+ const totalQ = w.listening.parts.reduce((s, p) => s + sumQTypes(p.question_types), 0);
+ return (
+
+
+
+ {w.listening.parts.length} part{w.listening.parts.length !== 1 ? "s" : ""} · {totalQ} total questions · Exam type: {w.exam_type}
+
+
+ Add Part
+
+
+ {w.listening.parts.map((part, pi) => (
+
+
+
+
+ P{pi + 1}
+ updateListeningPartType(pi, v)}>
+
+
+
+
+ {ALL_LISTENING_PART_TYPES.map((t) => (
+ {t.label}
+ ))}
+
+
+ {sumQTypes(part.question_types)}q
+ {w.listening.parts.length > 1 && (
+ removeListeningPart(pi)}>
+
+
+ )}
+
+
+
+ updateListeningQType(pi, qk, val)}
+ />
+
+
+ ))}
+
+ );
+ };
+
+ /* ── render: reading ── */
+
+ const renderReadingConfig = () => {
+ const styles = w.exam_type === "academic" ? READING_STYLES_ACADEMIC : READING_STYLES_GENERAL;
+ const totalQ = w.reading.passages.reduce((s, p) => s + sumQTypes(p.question_types), 0);
+ const passageLabel = w.exam_type === "academic" ? "Passage" : "Section";
+
+ return (
+
+
+
+ {w.reading.passages.length} {passageLabel.toLowerCase()}{w.reading.passages.length !== 1 ? "s" : ""} · {totalQ} total questions · Exam type: {w.exam_type}
+
+
+ Add {passageLabel}
+
+
+ {w.reading.passages.map((passage, pi) => (
+
+
+
+
+ {passageLabel} {pi + 1}
+ updateReadingStyle(pi, v)}>
+
+
+
+
+ {styles.map((s) => (
+ {s}
+ ))}
+
+
+ {sumQTypes(passage.question_types)}q
+ {w.reading.passages.length > 1 && (
+ removeReadingPassage(pi)}>
+
+
+ )}
+
+
+
+ updateReadingQType(pi, qk, val)}
+ />
+
+
+ ))}
+
+ );
+ };
+
+ /* ── render: writing ── */
+
+ const renderWritingConfig = () => (
+
+
+
+ {w.writing.tasks.length} task{w.writing.tasks.length !== 1 ? "s" : ""} · Scored by rubric · Exam type: {w.exam_type}
+
+
+ Add Task
+
+
+ {w.writing.tasks.map((task, ti) => (
+
+
+
+
+
updateWritingTask(ti, { label: e.target.value })}
+ />
+
+ {w.writing.tasks.length > 1 && (
+
removeWritingTask(ti)}>
+
+
+ )}
+
+
+
+
+ Type
+ updateWritingTask(ti, { type: v })}>
+
+
+ — Academic —
+ {ALL_WRITING_TASK_TYPES.filter((t) => t.group === "Academic").map((o) => (
+ {o.label}
+ ))}
+ — General —
+ {ALL_WRITING_TASK_TYPES.filter((t) => t.group === "General").map((o) => (
+ {o.label}
+ ))}
+ — Both —
+ {ALL_WRITING_TASK_TYPES.filter((t) => t.group === "Both").map((o) => (
+ {o.label}
+ ))}
+ — Essay —
+ {ALL_WRITING_TASK_TYPES.filter((t) => t.group === "Essay").map((o) => (
+ {o.label}
+ ))}
+
+
+
+
+ Min words
+ updateWritingTask(ti, { min_words: Number(e.target.value) || 100 })} />
+
+
+
+ ))}
+
+ );
+
+ /* ── render: speaking ── */
+
+ const renderSpeakingConfig = () => {
+ const totalMin = w.speaking.parts.reduce((s, p) => s + p.duration_min, 0);
+ const totalMax = w.speaking.parts.reduce((s, p) => s + p.duration_max, 0);
+
+ return (
+
+
+
+ {w.speaking.parts.length} part{w.speaking.parts.length !== 1 ? "s" : ""} · {totalMin}-{totalMax} minutes total
+
+
+ Add Part
+
+
+ {w.speaking.parts.map((part, i) => (
+
+
+
+
+ Part {i + 1}
+ updateSpeakingPartType(i, v)}>
+
+
+
+
+ {ALL_SPEAKING_PART_TYPES.map((t) => (
+ {t.label}
+ ))}
+
+
+ {w.speaking.parts.length > 1 && (
+ removeSpeakingPart(i)}>
+
+
+ )}
+
+
+
+
+ updateSpeakingPart(i, { label: e.target.value })}
+ />
+
+
+ ))}
+
+ );
+ };
+
+ /* ── render: level ── */
+
+ const renderLevelConfig = () => {
+ const total = w.level.reduce((s, e) => s + e.quantity, 0);
+ const usedTypes = new Set(w.level.map((e) => e.type));
+ const canAdd = LEVEL_EXERCISE_TYPE_POOL.some((t) => !usedTypes.has(t.key));
+
+ return (
+
+
+
{total} total questions · {w.level.length} exercise type{w.level.length !== 1 ? "s" : ""}
+
+ Add Type
+
+
+
+
+ {w.level.map((entry, i) => (
+
+ updateLevelEntry(i, { type: v })}>
+
+
+
+
+ {LEVEL_EXERCISE_TYPE_POOL.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+ updateLevelEntry(i, { quantity: Number(e.target.value) || 0 })} />
+ {w.level.length > 1 && (
+ removeLevelEntry(i)}>
+
+
+ )}
+
+ ))}
+
+ Total
+ {total} questions
+
+
+
+
+ );
+ };
+
+ /* ── render: industry ── */
+
+ const renderIndustryConfig = () => {
+ const total = w.industryModule.entries.reduce((s, e) => s + e.quantity, 0);
+ const usedTypes = new Set(w.industryModule.entries.map((e) => e.type));
+ const canAdd = INDUSTRY_EXERCISE_TYPE_POOL.some((t) => !usedTypes.has(t.key));
+
+ return (
+
+
+
{total} total questions · Difficulty: {w.industryModule.difficulty}
+
+ Add Type
+
+
+
+ Difficulty
+ updateW("industryModule", { ...w.industryModule, difficulty: v })}>
+
+
+ {INDUSTRY_DIFFICULTY_OPTIONS.map((d) => (
+ {d}
+ ))}
+
+
+
+
+
+ {w.industryModule.entries.map((entry, i) => (
+
+ updateIndustryEntry(i, { type: v })}>
+
+
+
+
+ {INDUSTRY_EXERCISE_TYPE_POOL.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+ updateIndustryEntry(i, { quantity: Number(e.target.value) || 0 })} />
+ {w.industryModule.entries.length > 1 && (
+ removeIndustryEntry(i)}>
+
+
+ )}
+
+ ))}
+
+ Total
+ {total} questions
+
+
+
+
+ );
+ };
+
+ const MODULE_RENDERERS: Record React.ReactNode> = {
+ listening: renderListeningConfig,
+ reading: renderReadingConfig,
+ writing: renderWritingConfig,
+ speaking: renderSpeakingConfig,
+ level: renderLevelConfig,
+ industry: renderIndustryConfig,
+ };
+
+ /* ── render review step ── */
+
+ const renderReview = () => {
+ const listeningTotal = w.listening.parts.reduce((s, p) => s + sumQTypes(p.question_types), 0);
+ const readingTotal = w.reading.passages.reduce((s, p) => s + sumQTypes(p.question_types), 0);
+ const levelTotal = w.level.reduce((s, e) => s + e.quantity, 0);
+ const industryTotal = w.industryModule.entries.reduce((s, e) => s + e.quantity, 0);
+ const speakMin = w.speaking.parts.reduce((s, p) => s + p.duration_min, 0);
+ const speakMax = w.speaking.parts.reduce((s, p) => s + p.duration_max, 0);
+
+ return (
+
+
+
+ Name {w.name}
+ {w.industry && Industry {w.industry}
}
+ Exam Type {w.exam_type}
+
+
Modules
+
{selectedModules.map((m) => {m.label} )}
+
+
+
+
+ {w.modules.has("listening") && (
+
+ Listening
+
+ {w.listening.parts.length} parts · {listeningTotal} total questions
+ {w.listening.parts.map((p, i) => (
+ P{i + 1}: {p.label} — {sumQTypes(p.question_types)} questions
+ ))}
+
+
+ )}
+ {w.modules.has("reading") && (
+
+ Reading
+
+ {w.reading.passages.length} passages · {readingTotal} total questions
+ {w.reading.passages.map((p, i) => (
+ {w.exam_type === "academic" ? "Passage" : "Section"} {i + 1}: {p.style} — {sumQTypes(p.question_types)} questions
+ ))}
+
+
+ )}
+ {w.modules.has("writing") && (
+
+ Writing
+
+ {w.writing.tasks.length} task{w.writing.tasks.length !== 1 ? "s" : ""}
+ {w.writing.tasks.map((t, i) => (
+ {t.label}: {lookupLabel(t.type, ALL_WRITING_TASK_TYPES)} (min {t.min_words} words)
+ ))}
+
+
+ )}
+ {w.modules.has("speaking") && (
+
+ Speaking
+
+ {w.speaking.parts.length} parts · {speakMin}-{speakMax} minutes
+ {w.speaking.parts.map((p, i) => (
+ Part {i + 1}: {p.label} ({p.duration_min}-{p.duration_max} min)
+ ))}
+
+
+ )}
+ {w.modules.has("level") && (
+
+ Level
+
+ {levelTotal} total questions · {w.level.length} types
+ {w.level.map((e, i) => (
+ {lookupLabel(e.type, LEVEL_EXERCISE_TYPE_POOL)}: {e.quantity}
+ ))}
+
+
+ )}
+ {w.modules.has("industry") && (
+
+ Industry
+
+ {industryTotal} total questions · Difficulty: {w.industryModule.difficulty}
+ {w.industryModule.entries.map((e, i) => (
+ {lookupLabel(e.type, INDUSTRY_EXERCISE_TYPE_POOL)}: {e.quantity}
+ ))}
+
+
+ )}
+
+ );
+ };
+
+ return (
+ {
+ if (!v) resetAndClose();
+ else onOpenChange(v);
+ }}
+ >
+
+
+ {title}
+
+ {step === 0 && "Set the basic details for this exam structure."}
+ {step === 1 && "Choose which skill modules to include."}
+ {step === 2 && "Configure each module's question types, parts, and settings."}
+ {step === 3 && "Review everything before saving."}
+
+
+
+
+
+ {/* Step 0: Basics */}
+ {step === 0 && (
+
+
+ Structure Name *
+ updateW("name", e.target.value)} autoFocus />
+
+
+ Industry
+ updateW("industry", v)}>
+
+
+ {INDUSTRY_OPTIONS.map((ind) => (
+ {ind}
+ ))}
+
+
+
+
+
Exam Type *
+
+ {(["academic", "general"] as const).map((et) => (
+
handleExamTypeChange(et)}
+ className={`rounded-lg border-2 p-4 text-center transition-all ${
+ w.exam_type === et ? "border-primary bg-primary/5 shadow-sm" : "border-muted hover:border-muted-foreground/30"
+ }`}
+ >
+ {et}
+
+ {et === "academic" ? "University / research context" : "Everyday / workplace context"}
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Step 1: Modules */}
+ {step === 1 && (
+
+ {ALL_MODULES.map((m) => {
+ const selected = w.modules.has(m.key);
+ return (
+
toggleModule(m.key)}
+ className={`rounded-lg border-2 p-4 text-center transition-all ${
+ selected ? "border-primary bg-primary/5 shadow-sm" : "border-muted hover:border-muted-foreground/30"
+ }`}
+ >
+ {m.icon}
+ {m.label}
+ {selected && }
+
+ );
+ })}
+
+ )}
+
+ {/* Step 2: Module Configuration */}
+ {step === 2 && selectedModules.length > 0 && (
+
+
+ {selectedModules.map((m) => (
+
+ {m.icon} {m.label}
+
+ ))}
+
+ {selectedModules.map((m) => (
+
+ {MODULE_RENDERERS[m.key]()}
+
+ ))}
+
+ )}
+ {step === 2 && selectedModules.length === 0 && (
+ No modules selected. Go back and select at least one.
+ )}
+
+ {/* Step 3: Review */}
+ {step === 3 && renderReview()}
+
+
+ setStep((s) => s - 1)}>
+ Back
+
+ {step < 3 ? (
+ setStep((s) => s + 1)}>
+ Next
+
+ ) : (
+ onSave(wizardToPayload(w))}>
+ {saving ? : }
+ {saving ? "Saving..." : "Save Structure"}
+
+ )}
+
+
+
+ );
+}
+
+/* ═══════════════════════════ Main page ═══════════════════════════ */
export default function ExamStructuresPage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [entityFilter, setEntityFilter] = useState("all");
+
const [createOpen, setCreateOpen] = useState(false);
- const [newName, setNewName] = useState("");
- const [newIndustry, setNewIndustry] = useState("");
- const [newModules, setNewModules] = useState([]);
+ const [editTarget, setEditTarget] = useState(null);
const { data, isLoading, error } = useQuery({
queryKey: ["exam-structures", entityFilter],
@@ -34,18 +1266,27 @@ export default function ExamStructuresPage() {
const structures: ExamStructure[] = data?.items ?? [];
const createMut = useMutation({
- mutationFn: (structureData: Partial) => examsService.createStructure(structureData),
+ mutationFn: (payload: ReturnType) =>
+ examsService.createStructure(payload as unknown as Partial),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
setCreateOpen(false);
- setNewName("");
- setNewIndustry("");
- setNewModules([]);
toast({ title: "Structure created" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Failed", description: err.message }),
});
+ const updateMut = useMutation({
+ mutationFn: ({ id, payload }: { id: number; payload: ReturnType }) =>
+ examsService.updateStructure(id, payload as unknown as Partial),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
+ setEditTarget(null);
+ toast({ title: "Structure updated" });
+ },
+ onError: (err: Error) => toast({ variant: "destructive", title: "Update failed", description: err.message }),
+ });
+
const deleteMut = useMutation({
mutationFn: (id: number) => examsService.deleteStructure(id),
onSuccess: () => {
@@ -56,13 +1297,16 @@ export default function ExamStructuresPage() {
});
const filtered = structures.filter((s) => {
- if (search) {
- const q = search.toLowerCase();
- return s.name?.toLowerCase().includes(q) || (s as Record).industry?.toString().toLowerCase().includes(q);
- }
- return true;
+ if (!search) return true;
+ const q = search.toLowerCase();
+ return s.name?.toLowerCase().includes(q) || s.industry?.toLowerCase().includes(q);
});
+ const getExamTypeBadge = (s: ExamStructure) => {
+ const cfg = s.config as ExamStructureConfig | undefined;
+ return cfg?.exam_type;
+ };
+
return (
@@ -70,55 +1314,9 @@ export default function ExamStructuresPage() {
Exam Structures
Define exam structure templates by entity and industry.
-
-
-
- Create Structure
-
-
- Create Exam Structure
-
-
- Structure Name
- setNewName(e.target.value)} />
-
-
- Industry
-
-
-
- General
- Technology
- Healthcare
- Hospitality
- Education
-
-
-
-
-
Modules
-
- {MODULE_OPTIONS.map((m) => (
-
- {
- setNewModules((prev) => checked ? [...prev, m.toLowerCase()] : prev.filter((x) => x !== m.toLowerCase()));
- }} />
- {m}
-
- ))}
-
-
-
createMut.mutate({ name: newName, industry: newIndustry, modules: newModules } as unknown as Partial)}>
- {createMut.isPending ? : null}
- Create
-
-
-
-
-
Delete
-
+
setCreateOpen(true)}>
+ Create Structure
+
@@ -142,46 +1340,77 @@ export default function ExamStructuresPage() {
{error && (
- Failed to load structures. The backend endpoint may not be available yet.
+ Failed to load structures.
)}
{!isLoading && !error && filtered.length === 0 && (
-
- No exam structures found. Create one to get started.
-
+ No exam structures found. Create one to get started.
)}
- {filtered.map((s) => (
-
-
-
-
- {s.name}
-
- deleteMut.mutate(s.id)} disabled={deleteMut.isPending}>
-
-
-
-
-
-
- {(s as Record).entity_name && Entity: {String((s as Record).entity_name)} }
- {(s as Record).industry && Industry: {String((s as Record).industry)} }
-
-
- {(Array.isArray((s as Record).modules) ? (s as Record).modules as string[] : []).map((m) => (
- {m}
- ))}
-
-
-
- ))}
+ {filtered.map((s) => {
+ const examType = getExamTypeBadge(s);
+ return (
+
setEditTarget(s)}>
+
+
+
+ {s.name}
+
+
+
{ e.stopPropagation(); setEditTarget(s); }}>
+
+
+
{ e.stopPropagation(); deleteMut.mutate(s.id); }} disabled={deleteMut.isPending}>
+
+
+
+
+
+
+
+ {s.entity_name && Entity: {s.entity_name} }
+ {s.industry && Industry: {s.industry} }
+ {examType && {examType} }
+
+
+ {(Array.isArray(s.modules) ? s.modules : []).map((m) => (
+ {m}
+ ))}
+
+
+
+ );
+ })}
+
+ {/* Create wizard */}
+ createMut.mutate(payload)}
+ saving={createMut.isPending}
+ title="Create Exam Structure"
+ />
+
+ {/* Edit wizard */}
+ {editTarget && (
+ { if (!v) setEditTarget(null); }}
+ initial={wizardFromStructure(editTarget)}
+ onSave={(payload) => updateMut.mutate({ id: editTarget.id, payload })}
+ saving={updateMut.isPending}
+ title="Edit Exam Structure"
+ />
+ )}
);
}
diff --git a/src/pages/GenerationPage.tsx b/src/pages/GenerationPage.tsx
index bfe4814..47c4679 100644
--- a/src/pages/GenerationPage.tsx
+++ b/src/pages/GenerationPage.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -52,12 +53,17 @@ import {
Sparkles,
Settings2,
ListChecks,
+ GraduationCap,
+ Dumbbell,
+ Lock,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { generationService } from "@/services/generation.service";
import { mediaService, type Avatar } from "@/services/media.service";
import { examsService } from "@/services/exams.service";
+import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
+import type { ExamStructureConfig } from "@/types";
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
@@ -222,6 +228,7 @@ interface ModuleState {
approvalWorkflow: string;
rubricGroup: string;
rubricCriteria: string;
+ rubricId: string;
totalMarks: number;
gradingSystem: string;
shuffling: boolean;
@@ -273,6 +280,7 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
approvalWorkflow: "",
rubricGroup: "",
rubricCriteria: "",
+ rubricId: "",
totalMarks: 0,
gradingSystem: "",
shuffling: false,
@@ -288,9 +296,17 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
export default function GenerationPage() {
const { toast } = useToast();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [examMode, setExamMode] = useState<"official" | "practice">("official");
const [title, setTitle] = useState("");
const [examLabel, setExamLabel] = useState("");
const [examStructure, setExamStructure] = useState("");
+ const [newStructureDialog, setNewStructureDialog] = useState<{
+ open: boolean;
+ name: string;
+ modules: Set