Files
full_encoach_platform/frontend/src/pages/admin/IeltsExamCreate.tsx
Yamen Ahmad f1c4953a63 feat(v3): restructure project + add complete frontend
- 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
2026-04-10 17:26:42 +04:00

427 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 todays 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 1314 questions each (60 minutes).</li>
<li>Writing: 2 tasks (60 minutes).</li>
<li>Speaking: 3 parts (1114 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>
);
}