diff --git a/src/pages/ExamStructuresPage.tsx b/src/pages/ExamStructuresPage.tsx index fd3cfc5..325a81c 100644 --- a/src/pages/ExamStructuresPage.tsx +++ b/src/pages/ExamStructuresPage.tsx @@ -1,24 +1,67 @@ import { useState } 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, DialogHeader, DialogTitle, DialogTrigger } 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 { Search, Plus, Layers, Trash2 } from "lucide-react"; +import { Search, Plus, Layers, Trash2, Loader2 } from "lucide-react"; import AiTipBanner from "@/components/ai/AiTipBanner"; -import AiCreationAssistant from "@/components/ai/AiCreationAssistant"; +import { examsService } from "@/services/exams.service"; +import { useToast } from "@/hooks/use-toast"; +import type { ExamStructure } from "@/types"; -const structures = [ - { id: 1, name: "Standard IELTS Academic", entity: "Global", industry: "General", modules: ["Reading", "Listening", "Writing", "Speaking"] }, - { id: 2, name: "Corporate English Assessment", entity: "Acme Corp", industry: "Technology", modules: ["Reading", "Writing", "Speaking"] }, - { id: 3, name: "Hospitality English Test", entity: "EduGroup", industry: "Hospitality", modules: ["Listening", "Speaking"] }, - { id: 4, name: "Medical English Proficiency", entity: "Global", industry: "Healthcare", modules: ["Reading", "Listening", "Writing", "Speaking"] }, -]; +const MODULE_OPTIONS = ["Reading", "Listening", "Writing", "Speaking"]; 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 [newName, setNewName] = useState(""); + const [newIndustry, setNewIndustry] = useState(""); + const [newModules, setNewModules] = useState([]); + + 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: (structureData: Partial) => examsService.createStructure(structureData), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["exam-structures"] }); + setCreateOpen(false); + setNewName(""); + setNewIndustry(""); + setNewModules([]); + toast({ title: "Structure created" }); + }, + onError: (err: Error) => toast({ variant: "destructive", title: "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) { + const q = search.toLowerCase(); + return s.name?.toLowerCase().includes(q) || (s as Record).industry?.toString().toLowerCase().includes(q); + } + return true; + }); return (
@@ -28,23 +71,53 @@ export default function ExamStructuresPage() {

Define exam structure templates by entity and industry.

- - + Create Exam Structure
-
-
-
-
+
+ + setNewName(e.target.value)} />
- +
+ + +
+
+ +
+ {MODULE_OPTIONS.map((m) => ( +
+ { + setNewModules((prev) => checked ? [...prev, m.toLowerCase()] : prev.filter((x) => x !== m.toLowerCase())); + }} /> + +
+ ))} +
+
+
+
@@ -55,27 +128,56 @@ export default function ExamStructuresPage() { setSearch(e.target.value)} /> - - + + {isLoading && ( +
+ +
+ )} + + {error && ( + + Failed to load structures. The backend endpoint may not be available yet. + + )} + + {!isLoading && !error && filtered.length === 0 && ( + + + No exam structures found. Create one to get started. + + + )} +
- {structures.map((s) => ( + {filtered.map((s) => (
{s.name} - +
- Entity: {s.entity} - Industry: {s.industry} + {(s as Record).entity_name && Entity: {String((s as Record).entity_name)}} + {(s as Record).industry && Industry: {String((s as Record).industry)}} +
+
+ {(Array.isArray((s as Record).modules) ? (s as Record).modules as string[] : []).map((m) => ( + {m} + ))}
-
{s.modules.map(m => {m})}
))} diff --git a/src/pages/GenerationPage.tsx b/src/pages/GenerationPage.tsx index 0058312..6cf514c 100644 --- a/src/pages/GenerationPage.tsx +++ b/src/pages/GenerationPage.tsx @@ -1,95 +1,981 @@ -import { useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useCallback, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Wand2, CheckCircle } from "lucide-react"; -import AiGeneratorModal from "@/components/ai/AiGeneratorModal"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Wand2, + BookOpen, + Headphones, + PenTool, + Mic, + Layers, + Briefcase, + ChevronDown, + Plus, + X, + Loader2, + RotateCcw, + Eye, + Send, + SkipForward, + Upload, + FileText, + Play, + Video, + Sparkles, +} from "lucide-react"; import AiTipBanner from "@/components/ai/AiTipBanner"; -import AiCreationAssistant from "@/components/ai/AiCreationAssistant"; +import { generationService } from "@/services/generation.service"; +import { mediaService, type Avatar } from "@/services/media.service"; +import { useToast } from "@/hooks/use-toast"; + +type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry"; + +interface ModuleInfo { + key: ModuleKey; + label: string; + icon: React.ReactNode; + color: string; + bgColor: string; +} + +const MODULES: ModuleInfo[] = [ + { key: "reading", label: "Reading", icon: , color: "text-blue-600", bgColor: "bg-blue-50 border-blue-200" }, + { key: "listening", label: "Listening", icon: , color: "text-orange-600", bgColor: "bg-orange-50 border-orange-200" }, + { key: "writing", label: "Writing", icon: , color: "text-green-600", bgColor: "bg-green-50 border-green-200" }, + { key: "speaking", label: "Speaking", icon: , color: "text-pink-600", bgColor: "bg-pink-50 border-pink-200" }, + { key: "level", label: "Level", icon: , color: "text-purple-600", bgColor: "bg-purple-50 border-purple-200" }, + { key: "industry", label: "Industry", icon: , color: "text-amber-700", bgColor: "bg-amber-50 border-amber-200" }, +]; + +const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]; + +const READING_EXERCISE_TYPES = [ + { key: "mcq", label: "Multiple Choice" }, + { key: "fill_blanks", label: "Fill Blanks" }, + { key: "write_blanks", label: "Write Blanks" }, + { key: "true_false", label: "True False" }, + { key: "paragraph_match", label: "Paragraph Match" }, +]; + +const LISTENING_SECTION_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_EXERCISE_TYPES = [ + { key: "mcq", label: "Multiple Choice" }, + { key: "write_blanks_questions", label: "Write Blanks: Questions" }, + { key: "true_false", label: "True False" }, + { key: "write_blanks_fill", label: "Write Blanks: Fill" }, + { key: "write_blanks_form", label: "Write Blanks: Form" }, +]; + +const DEFAULT_AVATARS: Avatar[] = [ + { id: "gia", name: "Gia", gender: "female" }, + { id: "vadim", name: "Vadim", gender: "male" }, + { id: "orhan", name: "Orhan", gender: "male" }, + { id: "flora", name: "Flora", gender: "female" }, + { id: "scarlett", name: "Scarlett", gender: "female" }, + { id: "parker", name: "Parker", gender: "male" }, + { id: "ethan", name: "Ethan", gender: "male" }, +]; + +interface PassageState { + text: string; + category: string; + type: string; + divider: string; + exerciseTypes: string[]; + exercises: unknown[]; + editing: boolean; +} + +interface ListeningSectionState { + type: string; + category: string; + divider: string; + context: string; + audioUrl: string; + exerciseTypes: string[]; + exercises: unknown[]; + editing: boolean; +} + +interface WritingTaskState { + instructions: string; + category: string; + type: string; + divider: string; + wordLimit: number; + marks: number; + editing: boolean; +} + +interface SpeakingPartState { + type: string; + category: string; + divider: string; + script: string; + videoUrl: string; + avatarId: string; + marks: number; + topics: string[]; + editing: boolean; +} + +interface ModuleState { + timer: number; + difficulty: string[]; + accessType: string; + entity: string; + approvalWorkflow: string; + rubricGroup: string; + rubricCriteria: string; + totalMarks: number; + gradingSystem: string; + shuffling: boolean; + passages: PassageState[]; + listeningSections: ListeningSectionState[]; + writingTasks: WritingTaskState[]; + speakingParts: SpeakingPartState[]; +} + +function defaultModuleState(mod: ModuleKey): ModuleState { + return { + timer: 5, + difficulty: [mod === "reading" ? "B2" : mod === "listening" ? "A2" : mod === "writing" ? "A1" : "B1"], + accessType: "private", + entity: "", + approvalWorkflow: "", + rubricGroup: "", + rubricCriteria: "", + totalMarks: 0, + gradingSystem: "", + shuffling: false, + passages: [{ text: "", category: "", type: "general", divider: "", exerciseTypes: [], exercises: [], editing: false }], + listeningSections: [{ type: "social_conversation", category: "", divider: "", context: "", audioUrl: "", exerciseTypes: [], exercises: [], editing: false }], + writingTasks: [{ instructions: "", category: "", type: "", divider: "", wordLimit: 150, marks: 0, editing: false }], + speakingParts: [{ type: "speaking_1", category: "", divider: "", script: "", videoUrl: "", avatarId: "", marks: 0, topics: ["", ""], editing: false }], + }; +} export default function GenerationPage() { - const [submitted, setSubmitted] = useState(false); + const { toast } = useToast(); + const [title, setTitle] = useState(""); + const [examLabel, setExamLabel] = useState(""); + const [examStructure, setExamStructure] = useState(""); + const [selectedModules, setSelectedModules] = useState>(new Set()); + const [activeModule, setActiveModule] = useState(null); + const [moduleStates, setModuleStates] = useState>({}); - if (submitted) { + const getModuleState = useCallback((mod: ModuleKey): ModuleState => { + return moduleStates[mod] ?? defaultModuleState(mod); + }, [moduleStates]); + + const updateModuleState = useCallback((mod: ModuleKey, patch: Partial) => { + setModuleStates((prev) => ({ + ...prev, + [mod]: { ...(prev[mod] ?? defaultModuleState(mod)), ...patch }, + })); + }, []); + + const toggleModule = (mod: ModuleKey) => { + setSelectedModules((prev) => { + const next = new Set(prev); + if (next.has(mod)) { + next.delete(mod); + if (activeModule === mod) setActiveModule(null); + } else { + next.add(mod); + setActiveModule(mod); + } + return next; + }); + }; + + const currentState = activeModule ? getModuleState(activeModule) : null; + + const generatePassageMut = useMutation({ + mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number }) => + generationService.generatePassage({ + topic: params.topic, + difficulty: params.difficulty, + word_count: params.wordCount, + }), + onSuccess: (res, vars) => { + if (!activeModule) return; + const st = getModuleState(activeModule); + const passages = [...st.passages]; + const r = res as Record; + const passageText = (r.passage as string) ?? (r.text as string) ?? JSON.stringify(res); + passages[vars.index] = { ...passages[vars.index], text: passageText }; + updateModuleState(activeModule, { passages }); + toast({ title: "Passage generated", description: `${passageText.length} characters` }); + }, + onError: (err: Error) => toast({ variant: "destructive", title: "Generation failed", description: err.message }), + }); + + const generateExercisesMut = useMutation({ + mutationFn: (params: { module: ModuleKey; passageIndex: number; types: string[]; passageText: string }) => + generationService.generate(params.module === "level" || params.module === "industry" ? "reading" : params.module, { + topic: params.passageText.slice(0, 200), + difficulty: getModuleState(params.module).difficulty[0] ?? "B2", + question_count: 5, + }), + onSuccess: (res, vars) => { + const st = getModuleState(vars.module); + const items = Array.isArray((res as Record).questions) ? (res as Record).questions as unknown[] : []; + if (vars.module === "reading") { + const passages = [...st.passages]; + passages[vars.passageIndex] = { ...passages[vars.passageIndex], exercises: items }; + updateModuleState(vars.module, { passages }); + } + toast({ title: `${items.length} exercises generated` }); + }, + onError: (err: Error) => toast({ variant: "destructive", title: "Generation failed", description: err.message }), + }); + + const generateAudioMut = useMutation({ + mutationFn: (params: { text: string; sectionIndex: number }) => + mediaService.generateListeningAudio({ text: params.text }), + onSuccess: (res, vars) => { + if (activeModule !== "listening") return; + const st = getModuleState("listening"); + const sections = [...st.listeningSections]; + sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: res.audio_url || "" }; + updateModuleState("listening", { listeningSections: sections }); + toast({ title: "Audio generated" }); + }, + onError: (err: Error) => toast({ variant: "destructive", title: "Audio generation failed", description: err.message }), + }); + + const generateWritingMut = useMutation({ + mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) => + generationService.generateWritingInstructions({ topic: params.topic, difficulty: params.difficulty, task_type: "letter" }), + onSuccess: (res, vars) => { + const st = getModuleState("writing"); + const tasks = [...st.writingTasks]; + const r = res as Record; + const instructions = (r.instructions as string) ?? JSON.stringify(r.questions ?? res); + tasks[vars.taskIndex] = { ...tasks[vars.taskIndex], instructions }; + updateModuleState("writing", { writingTasks: tasks }); + toast({ title: "Instructions generated" }); + }, + onError: (err: Error) => toast({ variant: "destructive", title: "Generation failed", description: err.message }), + }); + + const generateSpeakingMut = useMutation({ + mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) => + generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }), + onSuccess: (res, vars) => { + const st = getModuleState("speaking"); + const parts = [...st.speakingParts]; + const r = res as Record; + const script = (r.script as string) ?? JSON.stringify(r.questions ?? res); + parts[vars.partIndex] = { ...parts[vars.partIndex], script }; + updateModuleState("speaking", { speakingParts: parts }); + toast({ title: "Script generated" }); + }, + onError: (err: Error) => toast({ variant: "destructive", title: "Generation failed", description: err.message }), + }); + + const generateVideoMut = useMutation({ + mutationFn: (params: { script: string; avatarId: string; partIndex: number }) => + mediaService.createAvatarVideo({ script: params.script, avatar_id: params.avatarId, title: title || "Speaking Video" }), + onSuccess: (res, vars) => { + const st = getModuleState("speaking"); + const parts = [...st.speakingParts]; + parts[vars.partIndex] = { ...parts[vars.partIndex], videoUrl: `pending:${res.video_id}` }; + updateModuleState("speaking", { speakingParts: parts }); + toast({ title: "Video generation started", description: `Job ID: ${res.video_id}` }); + }, + onError: (err: Error) => toast({ variant: "destructive", title: "Video generation failed", description: err.message }), + }); + + const submitMut = useMutation({ + mutationFn: (skipApproval: boolean) => { + const modulesPayload: Record = {}; + for (const mod of selectedModules) { + const st = getModuleState(mod); + modulesPayload[mod] = { + timer: st.timer, + difficulty: st.difficulty, + 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, + }; + } + return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval }); + }, + onSuccess: (res) => toast({ title: "Exam submitted", description: `Exam #${res.exam_id} created (${res.status})` }), + onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }), + }); + + const anyGenerating = generatePassageMut.isPending || generateExercisesMut.isPending || + generateAudioMut.isPending || generateWritingMut.isPending || + generateSpeakingMut.isPending || generateVideoMut.isPending || submitMut.isPending; + + const renderDifficultyTags = (mod: ModuleKey) => { + const st = getModuleState(mod); return ( -
- - -
- -
-

Exam Generated!

-

Your exam has been created successfully and is ready for review.

- -
-
+
+ +
+ {st.difficulty.map((d) => ( + + {d} + + + ))} + +
); - } + }; + + const renderCommonConfig = (mod: ModuleKey) => { + const st = getModuleState(mod); + return ( +
+
+ + updateModuleState(mod, { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" /> +
+ {renderDifficultyTags(mod)} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
{st.totalMarks}
+
+
+ updateModuleState(mod, { shuffling: v })} /> + +
+
+ ); + }; + + const renderReadingModule = () => { + if (!activeModule || activeModule !== "reading") return null; + const st = getModuleState("reading"); + return ( +
+ {renderCommonConfig("reading")} + +
+ +
+ {st.passages.map((_, i) => ( +
+ + +
+ ))} + +
+
+ + + +
+ {st.passages.map((passage, pi) => ( +
+ + +

Passage {pi + 1} Settings

+ + + Category + + + { + const p = [...st.passages]; p[pi] = { ...p[pi], category: e.target.value }; + updateModuleState("reading", { passages: p }); + }} /> + + + + + Type + + + + + + + + Generate Passage + + + + + + + + + + + Add Exercises + + + {READING_EXERCISE_TYPES.map((et) => ( +
+ { + const p = [...st.passages]; + const types = checked ? [...p[pi].exerciseTypes, et.key] : p[pi].exerciseTypes.filter((t) => t !== et.key); + p[pi] = { ...p[pi], exerciseTypes: types }; + updateModuleState("reading", { passages: p }); + }} /> + +
+ ))} + +
+
+
+
+ + + +
+

Reading Passage

+
+ + + +
+
+

The reading passage that the exercises will refer to.

+