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
This commit is contained in:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
parent a3e12f62fa
commit f1c4953a63
731 changed files with 67205 additions and 139 deletions

View File

@@ -0,0 +1,426 @@
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>
);
}