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, 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 { 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, ExamStructureConfig, ListeningPartConfig, ReadingPassageConfig, SpeakingPartConfig, WritingTaskConfig, } from "@/types"; /* ───────────────────────────── 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}
{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" /> {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}

{w.listening.parts.map((part, pi) => (
P{pi + 1} {sumQTypes(part.question_types)}q {w.listening.parts.length > 1 && ( )}
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}

{w.reading.passages.map((passage, pi) => (
{passageLabel} {pi + 1} {sumQTypes(passage.question_types)}q {w.reading.passages.length > 1 && ( )}
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}

{w.writing.tasks.map((task, ti) => (
updateWritingTask(ti, { label: e.target.value })} />
{w.writing.tasks.length > 1 && ( )}
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

{w.speaking.parts.map((part, i) => (
Part {i + 1} {w.speaking.parts.length > 1 && ( )}
updateSpeakingPart(i, { duration_min: Number(e.target.value) || 1 })} />
updateSpeakingPart(i, { duration_max: Number(e.target.value) || 1 })} />
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" : ""}

{w.level.map((entry, i) => (
updateLevelEntry(i, { quantity: Number(e.target.value) || 0 })} /> {w.level.length > 1 && ( )}
))}
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}

{w.industryModule.entries.map((entry, i) => (
updateIndustryEntry(i, { quantity: Number(e.target.value) || 0 })} /> {w.industryModule.entries.length > 1 && ( )}
))}
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 && (
updateW("name", e.target.value)} autoFocus />
{(["academic", "general"] as const).map((et) => ( ))}
)} {/* Step 1: Modules */} {step === 1 && (
{ALL_MODULES.map((m) => { const selected = w.modules.has(m.key); return ( ); })}
)} {/* 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()} {step < 3 ? ( ) : ( )}
); } /* ═══════════════════════════ 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 [editTarget, setEditTarget] = useState(null); const { data, isLoading, error } = useQuery({ queryKey: ["exam-structures", entityFilter], queryFn: () => examsService.listStructures(entityFilter !== "all" ? { entity_id: Number(entityFilter) } : {}), }); const structures: ExamStructure[] = data?.items ?? []; const createMut = useMutation({ mutationFn: (payload: ReturnType) => examsService.createStructure(payload as unknown as Partial), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["exam-structures"] }); setCreateOpen(false); 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: () => { queryClient.invalidateQueries({ queryKey: ["exam-structures"] }); toast({ title: "Structure deleted" }); }, onError: (err: Error) => toast({ variant: "destructive", title: "Failed", description: err.message }), }); const filtered = structures.filter((s) => { 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 (

Exam Structures

Define exam structure templates by entity and industry.

setSearch(e.target.value)} />
{isLoading && (
)} {error && ( Failed to load structures. )} {!isLoading && !error && filtered.length === 0 && ( No exam structures found. Create one to get started. )}
{filtered.map((s) => { const examType = getExamTypeBadge(s); return ( setEditTarget(s)}>
{s.name}
{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" /> )}
); }