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
1417 lines
59 KiB
TypeScript
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" : ""} · {totalQ} total questions · 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" : ""} · {totalQ} total questions · 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" : ""} · Scored by rubric · 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" : ""} · {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 · {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 · 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 · {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 · {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 · {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 · {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 · 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>
|
|
);
|
|
}
|