feat: complete exam lifecycle — AI generation, submission, student session, and results

- Backend: AI generation fallbacks when OpenAI not configured, full exam
  submission saving all params (difficulty, rubric, entity, grading system,
  approval workflow) and creating linked question records per section
- Backend: new exam session controller with get_session, autosave, submit,
  status, and results endpoints; student attempt/answer/score models
- Backend: new controllers for entities, approval workflows, exam schedules
- Frontend: exam session split-layout with passage panel, question types
  (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog
- Frontend: results page with percentage score, per-answer breakdown table
- Frontend: generation page dynamic dropdowns, full payload submission
- Frontend: updated types for ExamSessionSection, ExamQuestion options

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-16 16:53:09 +04:00
parent 1d7ebea2af
commit b78124bb7b
15 changed files with 3961 additions and 433 deletions

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -52,12 +53,17 @@ import {
Sparkles,
Settings2,
ListChecks,
GraduationCap,
Dumbbell,
Lock,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { generationService } from "@/services/generation.service";
import { mediaService, type Avatar } from "@/services/media.service";
import { examsService } from "@/services/exams.service";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import type { ExamStructureConfig } from "@/types";
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
@@ -222,6 +228,7 @@ interface ModuleState {
approvalWorkflow: string;
rubricGroup: string;
rubricCriteria: string;
rubricId: string;
totalMarks: number;
gradingSystem: string;
shuffling: boolean;
@@ -273,6 +280,7 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
approvalWorkflow: "",
rubricGroup: "",
rubricCriteria: "",
rubricId: "",
totalMarks: 0,
gradingSystem: "",
shuffling: false,
@@ -288,9 +296,17 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
export default function GenerationPage() {
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [examMode, setExamMode] = useState<"official" | "practice">("official");
const [title, setTitle] = useState("");
const [examLabel, setExamLabel] = useState("");
const [examStructure, setExamStructure] = useState("");
const [newStructureDialog, setNewStructureDialog] = useState<{
open: boolean;
name: string;
modules: Set<ModuleKey>;
}>({ open: false, name: "", modules: new Set() });
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
@@ -352,6 +368,47 @@ export default function GenerationPage() {
});
const structures = structuresQ.data?.items ?? [];
const rubricsQ = useQuery({
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
const rubrics = rubricsQ.data?.items ?? [];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],
queryFn: () => examsService.listRubricGroups({}),
});
const rubricGroups = rubricGroupsQ.data?.items ?? [];
const entitiesQ = useQuery({
queryKey: ["entities"],
queryFn: () => api.get<{ items: { id: number; name: string; code: string; type: string }[] }>("/entities"),
});
const entities = entitiesQ.data?.items ?? [];
const workflowsQ = useQuery({
queryKey: ["approval-workflows"],
queryFn: () => api.get<{ items: { id: number; name: string; type: string }[] }>("/approval-workflows"),
});
const approvalWorkflows = workflowsQ.data?.items ?? [];
const selectedStructure = structures.find((s) => String(s.id) === examStructure);
const selectedStructureConfig = (selectedStructure?.config && typeof selectedStructure.config === "object" ? selectedStructure.config : null) as ExamStructureConfig | null;
const isOfficialLocked = examMode === "official" && !!selectedStructure;
const createStructureMut = useMutation({
mutationFn: (data: { name: string; modules: string[] }) =>
examsService.createStructure(data),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
setExamStructure(String(created.id));
setNewStructureDialog({ open: false, name: "", modules: new Set() });
toast({ title: "Structure created", description: `"${created.name}" has been added.` });
},
onError: (err: Error) =>
toast({ variant: "destructive", title: "Failed to create structure", description: err.message }),
});
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
return moduleStates[mod] ?? defaultModuleState(mod);
}, [moduleStates]);
@@ -579,13 +636,44 @@ export default function GenerationPage() {
accessType: st.accessType,
shuffling: st.shuffling,
gradingSystem: st.gradingSystem,
passages: mod === "reading" ? st.passages.map((p) => ({ text: p.text, category: p.category, type: p.type, exercises: p.exercises })) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({ type: s.type, context: s.context, audioUrl: s.audioUrl })) : undefined,
tasks: mod === "writing" ? st.writingTasks.map((t) => ({ instructions: t.instructions, wordLimit: t.wordLimit, marks: t.marks })) : undefined,
parts: mod === "speaking" ? st.speakingParts.map((p) => ({ type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks })) : undefined,
rubricId: st.rubricId,
entity: st.entity,
approvalWorkflow: st.approvalWorkflow,
totalMarks: st.totalMarks,
passages: mod === "reading" ? st.passages.map((p) => ({
text: p.text, category: p.category, type: p.type,
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
difficulty: ex.difficulty || st.difficulty?.[0] || "B2",
})),
})) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({
type: s.type, context: s.context, audioUrl: s.audioUrl,
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
difficulty: ex.difficulty || st.difficulty?.[0] || "B2",
})),
})) : undefined,
tasks: mod === "writing" ? st.writingTasks.map((t) => ({
instructions: t.instructions, wordLimit: t.wordLimit,
marks: t.marks, category: t.category, type: t.type,
})) : undefined,
parts: mod === "speaking" ? st.speakingParts.map((p) => ({
type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks,
})) : undefined,
};
}
return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval });
return generationService.submitExam({
title, label: examLabel,
exam_mode: examMode,
structure_id: examStructure ? Number(examStructure) : undefined,
modules: modulesPayload,
skip_approval: skipApproval,
});
},
onSuccess: (res) => toast({
title: "Exam submitted successfully",
@@ -599,27 +687,6 @@ export default function GenerationPage() {
generateAudioMut.isPending || generateWritingMut.isPending ||
generateSpeakingMut.isPending || generateVideoMut.isPending || submitMut.isPending;
const renderDifficultyTags = (mod: ModuleKey) => {
const st = getModuleState(mod);
return (
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState(mod, { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState(mod, { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
};
const renderCommonConfig = (mod: ModuleKey) => {
const st = getModuleState(mod);
return (
@@ -628,7 +695,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState(mod, { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
{renderDifficultyTags(mod)}
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Access Type</Label>
<Select value={st.accessType} onValueChange={(v) => updateModuleState(mod, { accessType: v })}>
@@ -643,21 +737,36 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState(mod, { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Approval Workflow</Label>
<Select value={st.approvalWorkflow} onValueChange={(v) => updateModuleState(mod, { approvalWorkflow: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="default">Default</SelectItem><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{approvalWorkflows.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Grading System</Label>
<Select value={st.gradingSystem} onValueChange={(v) => updateModuleState(mod, { gradingSystem: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="System Select" /></SelectTrigger>
<SelectContent><SelectItem value="ielts">IELTS Band</SelectItem><SelectItem value="percentage">Percentage</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="ielts">IELTS Band (1-9)</SelectItem>
<SelectItem value="percentage">Percentage (0-100%)</SelectItem>
<SelectItem value="pass_fail">Pass / Fail</SelectItem>
<SelectItem value="cefr">CEFR Level (A1-C2)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
@@ -1589,23 +1698,6 @@ export default function GenerationPage() {
const renderLevelModule = () => {
if (activeModule !== "level") return null;
const st = getModuleState("level");
const renderLevelDifficulty = () => (
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState("level", { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState("level", { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
return (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
@@ -1613,7 +1705,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("level", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
{renderLevelDifficulty()}
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("level", { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Number of Parts</Label>
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
@@ -1638,7 +1757,12 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState("level", { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
@@ -1797,21 +1921,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("industry", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState("industry", { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState("industry", { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{INDUSTRY_DIFFICULTY_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("industry", { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Number of Parts</Label>
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
@@ -1836,7 +1973,12 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState("industry", { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
@@ -1946,6 +2088,110 @@ export default function GenerationPage() {
);
};
const handleStructureSelect = (val: string) => {
setExamStructure(val);
const s = structures.find((st) => String(st.id) === val);
if (s) {
const mods = (Array.isArray(s.modules) ? s.modules : []) as string[];
if (mods.length) {
const next = new Set<ModuleKey>();
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
setSelectedModules(next);
if (next.size > 0) setActiveModule([...next][0]);
}
}
};
const handleModeSwitch = (mode: "official" | "practice") => {
setExamMode(mode);
if (mode === "practice") {
setExamStructure("");
}
setSelectedModules(new Set());
setActiveModule(null);
};
const renderOfficialStructurePreview = () => {
if (!selectedStructure || !selectedStructureConfig) return null;
const cfg = selectedStructureConfig;
const mods = (Array.isArray(selectedStructure.modules) ? selectedStructure.modules : []) as string[];
return (
<Card className="border bg-muted/30">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">{selectedStructure.name}</span>
{cfg.exam_type && <Badge variant="outline" className="capitalize text-xs">{cfg.exam_type}</Badge>}
{selectedStructure.industry && <Badge variant="secondary" className="text-xs">{selectedStructure.industry}</Badge>}
</div>
<span className="text-xs text-muted-foreground">Read-only structure</span>
</div>
<div className="flex gap-1.5 flex-wrap">
{mods.map((m) => (
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-1">
{cfg.listening && mods.includes("listening") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Headphones className="h-3.5 w-3.5 text-teal-600" /> Listening</div>
<p className="text-xs text-muted-foreground">{cfg.listening.parts?.length ?? 0} parts &middot; {cfg.listening.total_questions ?? 0} questions</p>
{cfg.listening.parts?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">P{i + 1}: {p.label}</p>
))}
</div>
)}
{cfg.reading && mods.includes("reading") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><BookOpen className="h-3.5 w-3.5 text-blue-600" /> Reading</div>
<p className="text-xs text-muted-foreground">{cfg.reading.passages?.length ?? 0} passages &middot; {cfg.reading.total_questions ?? 0} questions</p>
{cfg.reading.passages?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">{cfg.exam_type === "academic" ? "Passage" : "Section"} {i + 1}: {p.style}</p>
))}
</div>
)}
{cfg.writing && mods.includes("writing") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><PenTool className="h-3.5 w-3.5 text-orange-600" /> Writing</div>
{cfg.writing.tasks?.map((t, i) => (
<p key={i} className="text-xs text-muted-foreground">{t.label || `Task ${i + 1}`}: {t.type} (min {t.min_words} words)</p>
))}
{!cfg.writing.tasks?.length && cfg.writing.task1 && (
<p className="text-xs text-muted-foreground">Task 1: {cfg.writing.task1.type} &middot; Task 2: {cfg.writing.task2?.type}</p>
)}
</div>
)}
{cfg.speaking && mods.includes("speaking") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Mic className="h-3.5 w-3.5 text-purple-600" /> Speaking</div>
{cfg.speaking.parts?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">Part {i + 1}: {p.label} ({p.duration_min}-{p.duration_max} min)</p>
))}
</div>
)}
{cfg.level && mods.includes("level") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Layers className="h-3.5 w-3.5 text-indigo-600" /> Level</div>
<p className="text-xs text-muted-foreground">
{Object.values(cfg.level.exercise_types || {}).reduce((s, v) => s + v, 0)} total questions
</p>
</div>
)}
{cfg.industry && mods.includes("industry") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Briefcase className="h-3.5 w-3.5 text-amber-700" /> Industry</div>
<p className="text-xs text-muted-foreground">Difficulty: {cfg.industry.difficulty}</p>
</div>
)}
</div>
</CardContent>
</Card>
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -1956,9 +2202,34 @@ export default function GenerationPage() {
</div>
</div>
{/* Mode tabs */}
<div className="flex gap-2 border-b pb-0">
<button
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
examMode === "official"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
}`}
onClick={() => handleModeSwitch("official")}
>
<GraduationCap className="h-4 w-4" /> Official Exam
</button>
<button
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
examMode === "practice"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
}`}
onClick={() => handleModeSwitch("practice")}
>
<Dumbbell className="h-4 w-4" /> Practice Exam
</button>
</div>
<AiTipBanner context="generation" variant="recommendation" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Title, Label, Structure row */}
<div className={`grid grid-cols-1 gap-4 ${examMode === "official" ? "md:grid-cols-3" : "md:grid-cols-2"}`}>
<div className="space-y-1">
<Label>Title <span className="text-destructive">*</span></Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Insert title" />
@@ -1967,52 +2238,87 @@ export default function GenerationPage() {
<Label>Exam Label</Label>
<Input value={examLabel} onChange={(e) => setExamLabel(e.target.value)} placeholder="Exam Label" />
</div>
<div className="space-y-1">
<Label>Exam Structure</Label>
<Select value={examStructure} onValueChange={(val) => {
setExamStructure(val);
const s = structures.find((st) => String(st.id) === val);
if (s) {
const mods = (s as Record<string, unknown>).modules as string[] | undefined;
if (Array.isArray(mods) && mods.length) {
const next = new Set<ModuleKey>();
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
setSelectedModules(next);
if (next.size > 0) setActiveModule([...next][0]);
{examMode === "official" && (
<div className="space-y-1">
<Label>Exam Structure <span className="text-destructive">*</span></Label>
<Select value={examStructure} onValueChange={(val) => {
if (val === "__add_new__") {
navigate("/admin/exam-structures");
return;
}
}
}}>
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
<SelectContent>
{structures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
{structures.length === 0 && (
<SelectItem value="_none" disabled>No structures available</SelectItem>
)}
</SelectContent>
</Select>
</div>
handleStructureSelect(val);
}}>
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
<SelectContent>
{structures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
{structures.length === 0 && (
<SelectItem value="_none" disabled>No structures available</SelectItem>
)}
<div className="border-t my-1" />
<SelectItem value="__add_new__" className="text-primary font-medium">
<span className="flex items-center gap-1.5"><Plus className="h-3.5 w-3.5" /> Add New</span>
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div>
<p className="text-sm text-muted-foreground mb-3">Please select which subjects the exam is about</p>
<div className="flex flex-wrap gap-3">
{MODULES.map((m) => (
<button key={m.key}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
selectedModules.has(m.key) ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
}`}
onClick={() => toggleModule(m.key)}>
<span className={m.color}>{m.icon}</span>
<span className="text-sm font-medium">{m.label}</span>
{selectedModules.has(m.key) && (
<Badge variant="default" className="ml-1 h-5 text-[10px]">Select</Badge>
)}
</button>
))}
{/* Official mode: read-only structure preview */}
{examMode === "official" && selectedStructure && renderOfficialStructurePreview()}
{examMode === "official" && !selectedStructure && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
<GraduationCap className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Select an Exam Structure above to view its configuration and start generating.</p>
</CardContent>
</Card>
)}
{/* Practice mode: free module selection */}
{examMode === "practice" && (
<div>
<p className="text-sm text-muted-foreground mb-3">Select which modules you want to practice</p>
<div className="flex flex-wrap gap-3">
{MODULES.map((m) => (
<button key={m.key}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
selectedModules.has(m.key) ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
}`}
onClick={() => toggleModule(m.key)}>
<span className={m.color}>{m.icon}</span>
<span className="text-sm font-medium">{m.label}</span>
{selectedModules.has(m.key) && (
<Badge variant="default" className="ml-1 h-5 text-[10px]">Select</Badge>
)}
</button>
))}
</div>
</div>
</div>
)}
{/* Official mode: module selector from structure's modules (locked, non-toggleable) */}
{examMode === "official" && isOfficialLocked && selectedModules.size > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-3 flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" /> Modules defined by structure &mdash; select one to configure
</p>
<div className="flex flex-wrap gap-3">
{MODULES.filter((m) => selectedModules.has(m.key)).map((m) => (
<button key={m.key}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
activeModule === m.key ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
}`}
onClick={() => setActiveModule(m.key)}>
<span className={m.color}>{m.icon}</span>
<span className="text-sm font-medium">{m.label}</span>
</button>
))}
</div>
</div>
)}
{activeModule && selectedModules.has(activeModule) && (
<div className="flex gap-3">
@@ -2530,6 +2836,67 @@ export default function GenerationPage() {
)}
</DialogContent>
</Dialog>
<Dialog open={newStructureDialog.open} onOpenChange={(open) => {
if (!open) setNewStructureDialog({ open: false, name: "", modules: new Set() });
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New Exam Structure</DialogTitle>
<DialogDescription>Create a new exam structure with selected modules.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Name <span className="text-destructive">*</span></Label>
<Input
value={newStructureDialog.name}
onChange={(e) => setNewStructureDialog((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. Business English Assessment"
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Modules</Label>
<div className="grid grid-cols-2 gap-2">
{MODULES.map((m) => (
<label key={m.key} className={`flex items-center gap-2 rounded-lg border px-3 py-2 cursor-pointer transition-all ${
newStructureDialog.modules.has(m.key) ? `${m.bgColor} ${m.color}` : "hover:bg-muted/50"
}`}>
<Checkbox
checked={newStructureDialog.modules.has(m.key)}
onCheckedChange={(checked) => {
setNewStructureDialog((prev) => {
const next = new Set(prev.modules);
if (checked) next.add(m.key); else next.delete(m.key);
return { ...prev, modules: next };
});
}}
/>
{m.icon}
<span className="text-sm font-medium">{m.label}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewStructureDialog({ open: false, name: "", modules: new Set() })}>
Cancel
</Button>
<Button
disabled={!newStructureDialog.name.trim() || newStructureDialog.modules.size === 0 || createStructureMut.isPending}
onClick={() => {
createStructureMut.mutate({
name: newStructureDialog.name.trim(),
modules: [...newStructureDialog.modules],
});
}}
>
{createStructureMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Creating...</> : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}