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:
@@ -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 · {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 · {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} · 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 — 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user