- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
427 lines
17 KiB
TypeScript
427 lines
17 KiB
TypeScript
import { useEffect, useMemo } from "react";
|
||
import { useForm } from "react-hook-form";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { z } from "zod";
|
||
import { format } from "date-fns";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { Info } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { useCreateIeltsExam } from "@/hooks/queries/useExamTemplates";
|
||
import type { IELTSSkill, IELTSExamType, AssemblyMode, ExamDifficulty } from "@/types";
|
||
|
||
const skillOptions: { id: IELTSSkill; label: string }[] = [
|
||
{ id: "listening", label: "Listening" },
|
||
{ id: "reading", label: "Reading" },
|
||
{ id: "writing", label: "Writing" },
|
||
{ id: "speaking", label: "Speaking" },
|
||
];
|
||
|
||
const formSchema = z.object({
|
||
exam_type: z.enum(["academic", "general_training"]),
|
||
skills: z.array(z.enum(["listening", "reading", "writing", "speaking"])).min(1),
|
||
title: z.string().min(2, "Title is required"),
|
||
target_band: z.number().min(4).max(9),
|
||
difficulty: z.enum(["easy", "medium", "hard"]),
|
||
randomize: z.boolean(),
|
||
assembly_mode: z.enum(["auto", "manual", "hybrid"]),
|
||
results_release_mode: z.enum(["auto", "manual_approval"]),
|
||
});
|
||
|
||
type FormValues = z.infer<typeof formSchema>;
|
||
|
||
function bandSteps(): number[] {
|
||
const out: number[] = [];
|
||
for (let v = 4; v <= 9; v += 0.5) out.push(Math.round(v * 10) / 10);
|
||
return out;
|
||
}
|
||
|
||
const BAND_STEPS = bandSteps();
|
||
|
||
function nearestBand(x: number): number {
|
||
let best = BAND_STEPS[0];
|
||
let d = Math.abs(x - best);
|
||
for (const b of BAND_STEPS) {
|
||
const nd = Math.abs(x - b);
|
||
if (nd < d) {
|
||
d = nd;
|
||
best = b;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
export default function IeltsExamCreate() {
|
||
const navigate = useNavigate();
|
||
const createExam = useCreateIeltsExam();
|
||
|
||
const form = useForm<FormValues>({
|
||
resolver: zodResolver(formSchema),
|
||
defaultValues: {
|
||
exam_type: "academic",
|
||
skills: ["listening", "reading", "writing", "speaking"],
|
||
title: "",
|
||
target_band: 7,
|
||
difficulty: "medium",
|
||
randomize: true,
|
||
assembly_mode: "hybrid",
|
||
results_release_mode: "auto",
|
||
},
|
||
});
|
||
|
||
const examType = form.watch("exam_type");
|
||
const targetBand = form.watch("target_band");
|
||
|
||
const suggestedTitle = useMemo(() => {
|
||
const label = examType === "academic" ? "IELTS Academic" : "IELTS General Training";
|
||
const band = targetBand.toFixed(1);
|
||
const dateStr = format(new Date(), "yyyy-MM-dd");
|
||
return `${label} — Band ${band} — ${dateStr}`;
|
||
}, [examType, targetBand]);
|
||
|
||
useEffect(() => {
|
||
const cur = form.getValues("title");
|
||
if (!cur.trim()) {
|
||
form.setValue("title", suggestedTitle);
|
||
}
|
||
}, [form, suggestedTitle]);
|
||
|
||
const onSuggestTitle = () => {
|
||
form.setValue("title", suggestedTitle);
|
||
};
|
||
|
||
const onSubmit = async (values: FormValues) => {
|
||
const payload = {
|
||
exam_type: values.exam_type as IELTSExamType,
|
||
skills: values.skills as IELTSSkill[],
|
||
title: values.title,
|
||
target_band: values.target_band,
|
||
difficulty: values.difficulty as ExamDifficulty,
|
||
randomize: values.randomize,
|
||
assembly_mode: values.assembly_mode as AssemblyMode,
|
||
results_release_mode: values.results_release_mode,
|
||
};
|
||
const raw = await createExam.mutateAsync(payload);
|
||
const res = raw as { exam_id?: number; data?: { exam_id?: number } };
|
||
const examId = res.exam_id ?? res.data?.exam_id;
|
||
if (examId != null) {
|
||
sessionStorage.setItem(`ielts_assembly_${examId}`, values.assembly_mode);
|
||
navigate(`/admin/exam/ielts/${examId}/skills`);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="p-6 max-w-4xl mx-auto space-y-8">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold tracking-tight">IELTS exam — Phase 1</h1>
|
||
<p className="text-muted-foreground mt-1">Initialization and assembly preferences.</p>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
{["Initialization", "Skills", "Content", "Validate"].map((label, i) => (
|
||
<div
|
||
key={label}
|
||
className={`flex-1 rounded-md border px-3 py-2 text-center text-sm ${
|
||
i === 0 ? "border-primary bg-primary/5 font-medium" : "border-muted text-muted-foreground"
|
||
}`}
|
||
>
|
||
{i + 1}. {label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Exam type</CardTitle>
|
||
<CardDescription>Select the official IELTS paper type.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FormField
|
||
control={form.control}
|
||
name="exam_type"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormControl>
|
||
<RadioGroup
|
||
onValueChange={(v) => field.onChange(v as IELTSExamType)}
|
||
value={field.value}
|
||
className="grid gap-3 sm:grid-cols-2"
|
||
>
|
||
<Label
|
||
htmlFor="et-academic"
|
||
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary"
|
||
>
|
||
<RadioGroupItem value="academic" id="et-academic" />
|
||
<span className="font-medium">IELTS Academic</span>
|
||
</Label>
|
||
<Label
|
||
htmlFor="et-gt"
|
||
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary"
|
||
>
|
||
<RadioGroupItem value="general_training" id="et-gt" />
|
||
<span className="font-medium">General Training</span>
|
||
</Label>
|
||
</RadioGroup>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Target skills</CardTitle>
|
||
<CardDescription>Include at least one skill in this build.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FormField
|
||
control={form.control}
|
||
name="skills"
|
||
render={() => (
|
||
<FormItem>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{skillOptions.map((s) => (
|
||
<FormField
|
||
key={s.id}
|
||
control={form.control}
|
||
name="skills"
|
||
render={({ field }) => (
|
||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||
<FormControl>
|
||
<Checkbox
|
||
checked={field.value?.includes(s.id)}
|
||
onCheckedChange={(checked) => {
|
||
const next = new Set(field.value ?? []);
|
||
if (checked) next.add(s.id);
|
||
else next.delete(s.id);
|
||
field.onChange(Array.from(next) as IELTSSkill[]);
|
||
}}
|
||
/>
|
||
</FormControl>
|
||
<FormLabel className="font-normal cursor-pointer">{s.label}</FormLabel>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Exam details</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<FormField
|
||
control={form.control}
|
||
name="title"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Exam title</FormLabel>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||
<FormControl>
|
||
<Input placeholder={suggestedTitle} {...field} />
|
||
</FormControl>
|
||
<Button type="button" variant="secondary" onClick={onSuggestTitle}>
|
||
Apply suggested
|
||
</Button>
|
||
</div>
|
||
<FormDescription>Uses type, band, and today’s date.</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="target_band"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Target band ({field.value.toFixed(1)})</FormLabel>
|
||
<FormControl>
|
||
<Slider
|
||
min={4}
|
||
max={9}
|
||
step={0.5}
|
||
value={[field.value]}
|
||
onValueChange={(v) => field.onChange(nearestBand(v[0] ?? 7))}
|
||
className="max-w-md"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="difficulty"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Difficulty</FormLabel>
|
||
<Select onValueChange={field.onChange} value={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger className="max-w-xs">
|
||
<SelectValue placeholder="Select" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
<SelectItem value="easy">Easy</SelectItem>
|
||
<SelectItem value="medium">Medium</SelectItem>
|
||
<SelectItem value="hard">Hard</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="randomize"
|
||
render={({ field }) => (
|
||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||
<div className="space-y-0.5">
|
||
<FormLabel>Randomization</FormLabel>
|
||
<FormDescription>Shuffle order within allowed constraints.</FormDescription>
|
||
</div>
|
||
<FormControl>
|
||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Assembly mode</CardTitle>
|
||
<CardDescription>How items are chosen from the content pool.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FormField
|
||
control={form.control}
|
||
name="assembly_mode"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormControl>
|
||
<RadioGroup
|
||
onValueChange={field.onChange}
|
||
value={field.value}
|
||
className="grid gap-4"
|
||
>
|
||
<Label className="flex cursor-pointer gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
|
||
<RadioGroupItem value="auto" id="am-auto" />
|
||
<div>
|
||
<span className="font-medium">Auto</span>
|
||
<p className="text-sm text-muted-foreground">
|
||
The system fills every slot using rules and pool metadata.
|
||
</p>
|
||
</div>
|
||
</Label>
|
||
<Label className="flex cursor-pointer gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
|
||
<RadioGroupItem value="manual" id="am-manual" />
|
||
<div>
|
||
<span className="font-medium">Manual</span>
|
||
<p className="text-sm text-muted-foreground">You select each question from the pool.</p>
|
||
</div>
|
||
</Label>
|
||
<Label className="flex cursor-pointer gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
|
||
<RadioGroupItem value="hybrid" id="am-hybrid" />
|
||
<div>
|
||
<span className="font-medium">Hybrid</span>
|
||
<p className="text-sm text-muted-foreground">
|
||
Suggested sets with per-item accept or reject.
|
||
</p>
|
||
</div>
|
||
</Label>
|
||
</RadioGroup>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Score release</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<FormField
|
||
control={form.control}
|
||
name="results_release_mode"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormControl>
|
||
<RadioGroup
|
||
onValueChange={field.onChange}
|
||
value={field.value}
|
||
className="grid gap-3 sm:grid-cols-2"
|
||
>
|
||
<Label className="flex cursor-pointer items-center gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
|
||
<RadioGroupItem value="auto" id="sr-auto" />
|
||
<span>Auto-release</span>
|
||
</Label>
|
||
<Label className="flex cursor-pointer items-center gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
|
||
<RadioGroupItem value="manual_approval" id="sr-manual" />
|
||
<span>Manual approval</span>
|
||
</Label>
|
||
</RadioGroup>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Alert>
|
||
<Info className="h-4 w-4" />
|
||
<AlertTitle>IELTS structure (reference)</AlertTitle>
|
||
<AlertDescription className="space-y-2 mt-2">
|
||
<ul className="list-disc pl-4 text-sm space-y-1">
|
||
<li>Listening: 4 parts, 10 questions each (approx. 30 minutes plus transfer time).</li>
|
||
<li>Reading: 3 passages, about 13–14 questions each (60 minutes).</li>
|
||
<li>Writing: 2 tasks (60 minutes).</li>
|
||
<li>Speaking: 3 parts (11–14 minutes).</li>
|
||
</ul>
|
||
</AlertDescription>
|
||
</Alert>
|
||
|
||
<Separator />
|
||
|
||
<div className="flex justify-end gap-3">
|
||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
|
||
Back
|
||
</Button>
|
||
<Button type="submit" disabled={createExam.isPending}>
|
||
{createExam.isPending ? "Creating…" : "Next"}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Form>
|
||
</div>
|
||
);
|
||
}
|