Files
full_encoach_platform/frontend/src/pages/ExamStructuresPage.tsx
Yamen Ahmad e1f059069f feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Frontend
- i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css.
- Language toggle: localize aria-label / menu label, persist choice, update
  document dir synchronously.
- Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up
  AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass
  side = i18n.dir() === 'rtl' ? 'right' : 'left'.
- AdminLmsLayout: convert every nav item from hard-coded title to titleKey,
  translate group labels (incl. the collapsible Training), breadcrumbs,
  user menu (Profile / Settings / Logout), help button and toggle aria
  labels; replace physical mr-/right- utilities with logical me-/end-.
- AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar,
  AiAssistantDrawer): apply dir="auto" at the container level, localize
  titles, loading / error / empty states.
- Dashboards (admin / student / teacher): wrap numeric values in <bdi>,
  localize dates via ar-EG, fix flex direction for KPI and assignment cards.
- UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar,
  context-menu, pagination, sidebar): flip chevrons in RTL via a scoped
  CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-.
- Add logical-direction helpers and bidirectional isolation classes.

Locales
- Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`,
  `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity.

API client
- `api-client.ts` reads the active language from localStorage/i18n and sends
  `Accept-Language` on every request so the backend can localize AI output.

Backend (encoach_ai)
- openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware
  system prompt injection for every OpenAI call.
- coach_service + controllers (coach_controller, ai_controller): thread
  the requested language from headers / user locale down to OpenAIService.
- ai_feedback: fix latent registry error by pointing course_id at op.course
  instead of the non-existent encoach.course.

Other
- .gitignore: ignore runtime odoo logs and local caches.

Made-with: Cursor
2026-04-19 18:13:16 +04:00

1417 lines
59 KiB
TypeScript

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: <Headphones className="h-4 w-4" />, color: "text-teal-600" },
{ key: "reading", label: "Reading", icon: <BookOpen className="h-4 w-4" />, color: "text-blue-600" },
{ key: "writing", label: "Writing", icon: <PenTool className="h-4 w-4" />, color: "text-orange-600" },
{ key: "speaking", label: "Speaking", icon: <Mic className="h-4 w-4" />, color: "text-purple-600" },
{ key: "level", label: "Level", icon: <Layers className="h-4 w-4" />, color: "text-indigo-600" },
{ key: "industry", label: "Industry", icon: <Briefcase className="h-4 w-4" />, 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<string, number>): 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<ModuleKey>;
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<ModuleKey>(
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 (
<div className="flex items-center gap-1 mb-6">
{STEPS.map((label, i) => (
<div key={label} className="flex items-center gap-1">
<div
className={`flex items-center justify-center h-7 w-7 rounded-full text-xs font-semibold transition-colors ${
i < current ? "bg-primary text-primary-foreground" : i === current ? "bg-primary text-primary-foreground ring-2 ring-primary/30" : "bg-muted text-muted-foreground"
}`}
>
{i < current ? <Check className="h-3.5 w-3.5" /> : i + 1}
</div>
<span className={`text-xs font-medium hidden sm:inline ${i === current ? "text-foreground" : "text-muted-foreground"}`}>
{label}
</span>
{i < STEPS.length - 1 && <ChevronRight className="h-3.5 w-3.5 text-muted-foreground mx-1" />}
</div>
))}
</div>
);
}
/* ─────────────── Reusable: question type grid with toggles ─────────────── */
function QuestionTypeGrid({
types,
values,
onChange,
}: {
types: { key: string; label: string }[];
values: Record<string, number>;
onChange: (key: string, val: number) => void;
}) {
const enabled = (k: string) => (values[k] ?? 0) > 0;
const total = sumQTypes(values);
return (
<div className="space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5">
{types.map((qt) => {
const on = enabled(qt.key);
return (
<div key={qt.key} className={`flex items-center gap-2 rounded-md px-2 py-1 transition-colors ${on ? "bg-primary/5" : ""}`}>
<Checkbox
checked={on}
onCheckedChange={(checked) => onChange(qt.key, checked ? Math.max(values[qt.key] || 1, 1) : 0)}
className="h-3.5 w-3.5"
/>
<Label className="text-xs truncate flex-1 cursor-pointer" onClick={() => onChange(qt.key, on ? 0 : Math.max(values[qt.key] || 1, 1))}>
{qt.label}
</Label>
{on && (
<Input
type="number" min={1} max={40}
className="w-14 h-6 text-xs text-center"
value={values[qt.key]}
onChange={(e) => onChange(qt.key, Math.max(1, Number(e.target.value) || 1))}
/>
)}
</div>
);
})}
</div>
<div className="text-xs text-muted-foreground text-right">
{total} question{total !== 1 ? "s" : ""} selected
</div>
</div>
);
}
/* ───────────────── Wizard dialog ───────────────── */
function ExamStructureWizard({
open,
onOpenChange,
initial,
onSave,
saving,
title,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
initial: WizardState;
onSave: (payload: ReturnType<typeof wizardToPayload>) => void;
saving: boolean;
title: string;
}) {
const [step, setStep] = useState(0);
const [w, setW] = useState<WizardState>(initial);
const resetAndClose = useCallback(() => {
setStep(0);
setW(emptyWizard());
onOpenChange(false);
}, [onOpenChange]);
const updateW = useCallback(<K extends keyof WizardState>(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<WizardWritingTask>) => {
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<SpeakingPartConfig>) => {
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<WizardLevelEntry>) => {
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<WizardIndustryEntry>) => {
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{w.listening.parts.length} part{w.listening.parts.length !== 1 ? "s" : ""} &middot; {totalQ} total questions &middot; Exam type: <strong className="text-foreground capitalize">{w.exam_type}</strong>
</p>
<Button type="button" variant="outline" size="sm" onClick={addListeningPart}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Part
</Button>
</div>
{w.listening.parts.map((part, pi) => (
<Card key={`${part.key}-${pi}`} className="border">
<CardHeader className="py-3 px-4">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground/50" />
<span className="text-xs text-muted-foreground font-medium shrink-0">P{pi + 1}</span>
<Select value={part.key} onValueChange={(v) => updateListeningPartType(pi, v)}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_LISTENING_PART_TYPES.map((t) => (
<SelectItem key={t.key} value={t.key}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="secondary" className="text-[10px] shrink-0">{sumQTypes(part.question_types)}q</Badge>
{w.listening.parts.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive shrink-0" onClick={() => removeListeningPart(pi)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="px-4 pb-3">
<QuestionTypeGrid
types={LISTENING_QUESTION_TYPES}
values={part.question_types}
onChange={(qk, val) => updateListeningQType(pi, qk, val)}
/>
</CardContent>
</Card>
))}
</div>
);
};
/* ── 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{w.reading.passages.length} {passageLabel.toLowerCase()}{w.reading.passages.length !== 1 ? "s" : ""} &middot; {totalQ} total questions &middot; Exam type: <strong className="text-foreground capitalize">{w.exam_type}</strong>
</p>
<Button type="button" variant="outline" size="sm" onClick={addReadingPassage}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add {passageLabel}
</Button>
</div>
{w.reading.passages.map((passage, pi) => (
<Card key={pi} className="border">
<CardHeader className="py-3 px-4">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground/50" />
<span className="text-xs text-muted-foreground font-medium shrink-0">{passageLabel} {pi + 1}</span>
<Select value={passage.style} onValueChange={(v) => updateReadingStyle(pi, v)}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{styles.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="secondary" className="text-[10px] shrink-0">{sumQTypes(passage.question_types)}q</Badge>
{w.reading.passages.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive shrink-0" onClick={() => removeReadingPassage(pi)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="px-4 pb-3">
<QuestionTypeGrid
types={READING_QUESTION_TYPES}
values={passage.question_types}
onChange={(qk, val) => updateReadingQType(pi, qk, val)}
/>
</CardContent>
</Card>
))}
</div>
);
};
/* ── render: writing ── */
const renderWritingConfig = () => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{w.writing.tasks.length} task{w.writing.tasks.length !== 1 ? "s" : ""} &middot; Scored by rubric &middot; Exam type: <strong className="text-foreground capitalize">{w.exam_type}</strong>
</p>
<Button type="button" variant="outline" size="sm" onClick={addWritingTask}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Task
</Button>
</div>
{w.writing.tasks.map((task, ti) => (
<Card key={ti} className="border">
<CardHeader className="py-3 px-4">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground/50" />
<Input
className="h-7 text-xs font-semibold w-28"
value={task.label}
onChange={(e) => updateWritingTask(ti, { label: e.target.value })}
/>
<div className="flex-1" />
{w.writing.tasks.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive shrink-0" onClick={() => removeWritingTask(ti)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="px-4 pb-3 space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Type</Label>
<Select value={task.type} onValueChange={(v) => updateWritingTask(ti, { type: v })}>
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_header_academic" disabled className="text-xs font-semibold text-muted-foreground"> Academic </SelectItem>
{ALL_WRITING_TASK_TYPES.filter((t) => t.group === "Academic").map((o) => (
<SelectItem key={o.key} value={o.key}>{o.label}</SelectItem>
))}
<SelectItem value="_header_general" disabled className="text-xs font-semibold text-muted-foreground"> General </SelectItem>
{ALL_WRITING_TASK_TYPES.filter((t) => t.group === "General").map((o) => (
<SelectItem key={o.key} value={o.key}>{o.label}</SelectItem>
))}
<SelectItem value="_header_both" disabled className="text-xs font-semibold text-muted-foreground"> Both </SelectItem>
{ALL_WRITING_TASK_TYPES.filter((t) => t.group === "Both").map((o) => (
<SelectItem key={o.key} value={o.key}>{o.label}</SelectItem>
))}
<SelectItem value="_header_essay" disabled className="text-xs font-semibold text-muted-foreground"> Essay </SelectItem>
{ALL_WRITING_TASK_TYPES.filter((t) => t.group === "Essay").map((o) => (
<SelectItem key={o.key} value={o.key}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Min words</Label>
<Input type="number" min={50} className="h-8 w-28 text-sm" value={task.min_words}
onChange={(e) => updateWritingTask(ti, { min_words: Number(e.target.value) || 100 })} />
</div>
</CardContent>
</Card>
))}
</div>
);
/* ── 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{w.speaking.parts.length} part{w.speaking.parts.length !== 1 ? "s" : ""} &middot; {totalMin}-{totalMax} minutes total
</p>
<Button type="button" variant="outline" size="sm" onClick={addSpeakingPart}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Part
</Button>
</div>
{w.speaking.parts.map((part, i) => (
<Card key={`${part.key}-${i}`} className="border">
<CardHeader className="py-3 px-4">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground/50" />
<span className="text-xs text-muted-foreground font-medium shrink-0">Part {i + 1}</span>
<Select value={part.key} onValueChange={(v) => updateSpeakingPartType(i, v)}>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_SPEAKING_PART_TYPES.map((t) => (
<SelectItem key={t.key} value={t.key}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
{w.speaking.parts.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive shrink-0" onClick={() => removeSpeakingPart(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="flex items-center gap-4">
<div className="space-y-1">
<Label className="text-xs">Duration min (min)</Label>
<Input type="number" min={1} max={15} className="w-20 h-7 text-xs text-center"
value={part.duration_min}
onChange={(e) => updateSpeakingPart(i, { duration_min: Number(e.target.value) || 1 })} />
</div>
<div className="space-y-1">
<Label className="text-xs">Duration max (min)</Label>
<Input type="number" min={1} max={15} className="w-20 h-7 text-xs text-center"
value={part.duration_max}
onChange={(e) => updateSpeakingPart(i, { duration_max: Number(e.target.value) || 1 })} />
</div>
</div>
<Input
className="mt-2 h-7 text-xs"
placeholder="Custom label (optional)"
value={part.label}
onChange={(e) => updateSpeakingPart(i, { label: e.target.value })}
/>
</CardContent>
</Card>
))}
</div>
);
};
/* ── 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{total} total questions &middot; {w.level.length} exercise type{w.level.length !== 1 ? "s" : ""}</p>
<Button type="button" variant="outline" size="sm" onClick={addLevelEntry} disabled={!canAdd}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Type
</Button>
</div>
<Card className="border">
<CardContent className="p-4 space-y-3">
{w.level.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Select value={entry.type} onValueChange={(v) => updateLevelEntry(i, { type: v })}>
<SelectTrigger className="h-8 text-sm flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVEL_EXERCISE_TYPE_POOL.map((t) => (
<SelectItem key={t.key} value={t.key} disabled={usedTypes.has(t.key) && t.key !== entry.type}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input type="number" min={0} max={50} className="w-20 h-8 text-sm text-center"
value={entry.quantity}
onChange={(e) => updateLevelEntry(i, { quantity: Number(e.target.value) || 0 })} />
{w.level.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 text-destructive shrink-0" onClick={() => removeLevelEntry(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
<div className="border-t pt-2 flex justify-between text-sm font-medium">
<span>Total</span>
<span>{total} questions</span>
</div>
</CardContent>
</Card>
</div>
);
};
/* ── 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{total} total questions &middot; Difficulty: {w.industryModule.difficulty}</p>
<Button type="button" variant="outline" size="sm" onClick={addIndustryEntry} disabled={!canAdd}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Type
</Button>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Difficulty</Label>
<Select value={w.industryModule.difficulty}
onValueChange={(v) => updateW("industryModule", { ...w.industryModule, difficulty: v })}>
<SelectTrigger className="h-8 text-sm w-44"><SelectValue /></SelectTrigger>
<SelectContent>
{INDUSTRY_DIFFICULTY_OPTIONS.map((d) => (
<SelectItem key={d} value={d}>{d}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className="border">
<CardContent className="p-4 space-y-3">
{w.industryModule.entries.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Select value={entry.type} onValueChange={(v) => updateIndustryEntry(i, { type: v })}>
<SelectTrigger className="h-8 text-sm flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INDUSTRY_EXERCISE_TYPE_POOL.map((t) => (
<SelectItem key={t.key} value={t.key} disabled={usedTypes.has(t.key) && t.key !== entry.type}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input type="number" min={0} max={50} className="w-20 h-8 text-sm text-center"
value={entry.quantity}
onChange={(e) => updateIndustryEntry(i, { quantity: Number(e.target.value) || 0 })} />
{w.industryModule.entries.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 text-destructive shrink-0" onClick={() => removeIndustryEntry(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
<div className="border-t pt-2 flex justify-between text-sm font-medium">
<span>Total</span>
<span>{total} questions</span>
</div>
</CardContent>
</Card>
</div>
);
};
const MODULE_RENDERERS: Record<ModuleKey, () => 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 (
<div className="space-y-4">
<Card className="border">
<CardContent className="p-4 space-y-2">
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Name</span><span className="font-medium">{w.name}</span></div>
{w.industry && <div className="flex justify-between text-sm"><span className="text-muted-foreground">Industry</span><span className="font-medium">{w.industry}</span></div>}
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Exam Type</span><Badge variant="outline" className="capitalize">{w.exam_type}</Badge></div>
<div className="flex justify-between text-sm items-center">
<span className="text-muted-foreground">Modules</span>
<div className="flex gap-1 flex-wrap">{selectedModules.map((m) => <Badge key={m.key} variant="secondary" className="capitalize text-xs">{m.label}</Badge>)}</div>
</div>
</CardContent>
</Card>
{w.modules.has("listening") && (
<Card className="border">
<CardHeader className="py-2 px-4"><CardTitle className="text-sm flex items-center gap-1.5"><Headphones className="h-3.5 w-3.5" /> Listening</CardTitle></CardHeader>
<CardContent className="px-4 pb-3 space-y-1">
<p className="text-xs text-muted-foreground">{w.listening.parts.length} parts &middot; {listeningTotal} total questions</p>
{w.listening.parts.map((p, i) => (
<p key={i} className="text-xs">P{i + 1}: {p.label} {sumQTypes(p.question_types)} questions</p>
))}
</CardContent>
</Card>
)}
{w.modules.has("reading") && (
<Card className="border">
<CardHeader className="py-2 px-4"><CardTitle className="text-sm flex items-center gap-1.5"><BookOpen className="h-3.5 w-3.5" /> Reading</CardTitle></CardHeader>
<CardContent className="px-4 pb-3 space-y-1">
<p className="text-xs text-muted-foreground">{w.reading.passages.length} passages &middot; {readingTotal} total questions</p>
{w.reading.passages.map((p, i) => (
<p key={i} className="text-xs">{w.exam_type === "academic" ? "Passage" : "Section"} {i + 1}: {p.style} {sumQTypes(p.question_types)} questions</p>
))}
</CardContent>
</Card>
)}
{w.modules.has("writing") && (
<Card className="border">
<CardHeader className="py-2 px-4"><CardTitle className="text-sm flex items-center gap-1.5"><PenTool className="h-3.5 w-3.5" /> Writing</CardTitle></CardHeader>
<CardContent className="px-4 pb-3 space-y-1">
<p className="text-xs text-muted-foreground">{w.writing.tasks.length} task{w.writing.tasks.length !== 1 ? "s" : ""}</p>
{w.writing.tasks.map((t, i) => (
<p key={i} className="text-xs">{t.label}: {lookupLabel(t.type, ALL_WRITING_TASK_TYPES)} (min {t.min_words} words)</p>
))}
</CardContent>
</Card>
)}
{w.modules.has("speaking") && (
<Card className="border">
<CardHeader className="py-2 px-4"><CardTitle className="text-sm flex items-center gap-1.5"><Mic className="h-3.5 w-3.5" /> Speaking</CardTitle></CardHeader>
<CardContent className="px-4 pb-3 space-y-1">
<p className="text-xs text-muted-foreground">{w.speaking.parts.length} parts &middot; {speakMin}-{speakMax} minutes</p>
{w.speaking.parts.map((p, i) => (
<p key={i} className="text-xs">Part {i + 1}: {p.label} ({p.duration_min}-{p.duration_max} min)</p>
))}
</CardContent>
</Card>
)}
{w.modules.has("level") && (
<Card className="border">
<CardHeader className="py-2 px-4"><CardTitle className="text-sm flex items-center gap-1.5"><Layers className="h-3.5 w-3.5" /> Level</CardTitle></CardHeader>
<CardContent className="px-4 pb-3 space-y-1">
<p className="text-xs text-muted-foreground">{levelTotal} total questions &middot; {w.level.length} types</p>
{w.level.map((e, i) => (
<p key={i} className="text-xs">{lookupLabel(e.type, LEVEL_EXERCISE_TYPE_POOL)}: {e.quantity}</p>
))}
</CardContent>
</Card>
)}
{w.modules.has("industry") && (
<Card className="border">
<CardHeader className="py-2 px-4"><CardTitle className="text-sm flex items-center gap-1.5"><Briefcase className="h-3.5 w-3.5" /> Industry</CardTitle></CardHeader>
<CardContent className="px-4 pb-3 space-y-1">
<p className="text-xs text-muted-foreground">{industryTotal} total questions &middot; Difficulty: {w.industryModule.difficulty}</p>
{w.industryModule.entries.map((e, i) => (
<p key={i} className="text-xs">{lookupLabel(e.type, INDUSTRY_EXERCISE_TYPE_POOL)}: {e.quantity}</p>
))}
</CardContent>
</Card>
)}
</div>
);
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) resetAndClose();
else onOpenChange(v);
}}
>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
<StepIndicator current={step} />
{/* Step 0: Basics */}
{step === 0 && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Structure Name <span className="text-destructive">*</span></Label>
<Input placeholder="e.g. IELTS Academic Full Test" value={w.name} onChange={(e) => updateW("name", e.target.value)} autoFocus />
</div>
<div className="space-y-1.5">
<Label>Industry</Label>
<Select value={w.industry} onValueChange={(v) => updateW("industry", v)}>
<SelectTrigger><SelectValue placeholder="Select industry" /></SelectTrigger>
<SelectContent>
{INDUSTRY_OPTIONS.map((ind) => (
<SelectItem key={ind} value={ind}>{ind}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Exam Type <span className="text-destructive">*</span></Label>
<div className="grid grid-cols-2 gap-3">
{(["academic", "general"] as const).map((et) => (
<button
key={et}
type="button"
onClick={() => 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"
}`}
>
<p className="font-semibold capitalize">{et}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{et === "academic" ? "University / research context" : "Everyday / workplace context"}
</p>
</button>
))}
</div>
</div>
</div>
)}
{/* Step 1: Modules */}
{step === 1 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{ALL_MODULES.map((m) => {
const selected = w.modules.has(m.key);
return (
<button
key={m.key}
type="button"
onClick={() => 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"
}`}
>
<div className={`mx-auto mb-1.5 ${m.color}`}>{m.icon}</div>
<p className="text-sm font-medium">{m.label}</p>
{selected && <Check className="h-3.5 w-3.5 text-primary mx-auto mt-1" />}
</button>
);
})}
</div>
)}
{/* Step 2: Module Configuration */}
{step === 2 && selectedModules.length > 0 && (
<Tabs defaultValue={selectedModules[0].key} className="w-full">
<TabsList className="w-full flex-wrap h-auto gap-1 bg-muted/50 p-1">
{selectedModules.map((m) => (
<TabsTrigger key={m.key} value={m.key} className="text-xs capitalize gap-1">
{m.icon} {m.label}
</TabsTrigger>
))}
</TabsList>
{selectedModules.map((m) => (
<TabsContent key={m.key} value={m.key} className="mt-4">
{MODULE_RENDERERS[m.key]()}
</TabsContent>
))}
</Tabs>
)}
{step === 2 && selectedModules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No modules selected. Go back and select at least one.</p>
)}
{/* Step 3: Review */}
{step === 3 && renderReview()}
<DialogFooter className="flex justify-between sm:justify-between gap-2 pt-2">
<Button variant="outline" disabled={step === 0} onClick={() => setStep((s) => s - 1)}>
<ChevronLeft className="h-4 w-4 mr-1" /> Back
</Button>
{step < 3 ? (
<Button disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
Next <ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button disabled={saving} onClick={() => onSave(wizardToPayload(w))}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Check className="h-4 w-4 mr-1" />}
{saving ? "Saving..." : "Save Structure"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/* ═══════════════════════════ 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<ExamStructure | null>(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<typeof wizardToPayload>) =>
examsService.createStructure(payload as unknown as Partial<ExamStructure>),
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<typeof wizardToPayload> }) =>
examsService.updateStructure(id, payload as unknown as Partial<ExamStructure>),
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Exam Structures</h1>
<p className="text-muted-foreground">Define exam structure templates by entity and industry.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Structure
</Button>
</div>
<AiTipBanner context="exam-structures" variant="insight" />
<div className="flex gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search structures..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Entity" /></SelectTrigger>
<SelectContent><SelectItem value="all">All Entities</SelectItem></SelectContent>
</Select>
</div>
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<Card className="border-destructive">
<CardContent className="p-4 text-sm text-destructive">Failed to load structures.</CardContent>
</Card>
)}
{!isLoading && !error && filtered.length === 0 && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">No exam structures found. Create one to get started.</CardContent>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((s) => {
const examType = getExamTypeBadge(s);
return (
<Card key={s.id} className="border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer" onClick={() => setEditTarget(s)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />{s.name}
</CardTitle>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={(e) => { e.stopPropagation(); setEditTarget(s); }}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
onClick={(e) => { e.stopPropagation(); deleteMut.mutate(s.id); }} disabled={deleteMut.isPending}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3 flex-wrap">
{s.entity_name && <span>Entity: <span className="text-foreground font-medium">{s.entity_name}</span></span>}
{s.industry && <span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>}
{examType && <Badge variant="outline" className="capitalize text-xs">{examType}</Badge>}
</div>
<div className="flex gap-1.5 flex-wrap">
{(Array.isArray(s.modules) ? s.modules : []).map((m) => (
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Create wizard */}
<ExamStructureWizard
open={createOpen}
onOpenChange={setCreateOpen}
initial={emptyWizard()}
onSave={(payload) => createMut.mutate(payload)}
saving={createMut.isPending}
title="Create Exam Structure"
/>
{/* Edit wizard */}
{editTarget && (
<ExamStructureWizard
key={editTarget.id}
open={!!editTarget}
onOpenChange={(v) => { if (!v) setEditTarget(null); }}
initial={wizardFromStructure(editTarget)}
onSave={(payload) => updateMut.mutate({ id: editTarget.id, payload })}
saving={updateMut.isPending}
title="Edit Exam Structure"
/>
)}
</div>
);
}