- 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
211 lines
9.4 KiB
TypeScript
211 lines
9.4 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useIeltsSkills } from "@/hooks/queries/useExamTemplates";
|
|
import type { IELTSPartConfig, IELTSSkill, IELTSSkillConfig } from "@/types";
|
|
|
|
const SKILL_LABEL: Record<IELTSSkill, string> = {
|
|
listening: "Listening",
|
|
reading: "Reading",
|
|
writing: "Writing",
|
|
speaking: "Speaking",
|
|
};
|
|
|
|
const STATIC_PARTS: Record<IELTSSkill, Omit<IELTSPartConfig, "part_number">[]> = {
|
|
listening: [
|
|
{ question_count: 10, content_type: "Audio + MCQ", time_limit_min: 7, description: "Part 1 — everyday social" },
|
|
{ question_count: 10, content_type: "Audio + MCQ", time_limit_min: 7, description: "Part 2 — monologue" },
|
|
{ question_count: 10, content_type: "Audio + MCQ", time_limit_min: 8, description: "Part 3 — conversation" },
|
|
{ question_count: 10, content_type: "Audio + MCQ", time_limit_min: 8, description: "Part 4 — lecture" },
|
|
],
|
|
reading: [
|
|
{ question_count: 13, content_type: "Passage + items", time_limit_min: 20, description: "Passage 1" },
|
|
{ question_count: 13, content_type: "Passage + items", time_limit_min: 20, description: "Passage 2" },
|
|
{ question_count: 14, content_type: "Passage + items", time_limit_min: 20, description: "Passage 3" },
|
|
],
|
|
writing: [
|
|
{ question_count: 1, content_type: "Task response", time_limit_min: 20, description: "Task 1" },
|
|
{ question_count: 1, content_type: "Essay", time_limit_min: 40, description: "Task 2" },
|
|
],
|
|
speaking: [
|
|
{ question_count: 1, content_type: "Interview", time_limit_min: 5, description: "Part 1 — introduction" },
|
|
{ question_count: 1, content_type: "Long turn", time_limit_min: 4, description: "Part 2 — cue card" },
|
|
{ question_count: 1, content_type: "Discussion", time_limit_min: 5, description: "Part 3 — abstract" },
|
|
],
|
|
};
|
|
|
|
function resolveParts(skill: IELTSSkill, cfg?: IELTSSkillConfig): IELTSPartConfig[] {
|
|
const fromApi = cfg?.parts?.length ? cfg.parts : [];
|
|
if (fromApi.length) return fromApi;
|
|
return STATIC_PARTS[skill].map((p, i) => ({
|
|
part_number: i + 1,
|
|
...p,
|
|
}));
|
|
}
|
|
|
|
function requiredQuestions(skill: IELTSSkill, parts: IELTSPartConfig[]): number {
|
|
const sum = parts.reduce((a, p) => a + p.question_count, 0);
|
|
if (sum > 0) return sum;
|
|
if (skill === "listening") return 40;
|
|
if (skill === "reading") return 40;
|
|
if (skill === "writing") return 2;
|
|
return 3;
|
|
}
|
|
|
|
export default function IeltsSkillConfig() {
|
|
const { examId: examIdParam } = useParams<{ examId: string }>();
|
|
const navigate = useNavigate();
|
|
const examId = Number(examIdParam);
|
|
const { data, isLoading, isError } = useIeltsSkills(Number.isFinite(examId) ? examId : 0);
|
|
|
|
const skills = useMemo(() => {
|
|
if (!data) return [] as IELTSSkillConfig[];
|
|
const arr = Array.isArray(data) ? data : (data as { data?: IELTSSkillConfig[] }).data ?? [];
|
|
return arr;
|
|
}, [data]);
|
|
|
|
const defaultTab = (skills.length > 0 ? skills[0]?.skill : "listening") ?? "listening";
|
|
|
|
if (!Number.isFinite(examId) || examId <= 0) {
|
|
return <p className="p-6 text-destructive">Invalid exam.</p>;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-6 max-w-6xl mx-auto space-y-4">
|
|
<Skeleton className="h-10 w-64" />
|
|
<Skeleton className="h-96 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const displaySkills: IELTSSkillConfig[] = skills.length > 0
|
|
? skills
|
|
: (["listening", "reading", "writing", "speaking"] as IELTSSkill[]).map((s) => ({
|
|
skill: s,
|
|
parts: STATIC_PARTS[s].map((p, i) => ({ part_number: i + 1, ...p })),
|
|
available_count: 0,
|
|
}));
|
|
|
|
return (
|
|
<div className="p-6 max-w-6xl mx-auto">
|
|
<div className="flex flex-col gap-6 lg:flex-row">
|
|
<div className="flex-1 space-y-4 min-w-0">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Skill layout</h1>
|
|
<p className="text-muted-foreground mt-1">Review the official structure before choosing content.</p>
|
|
</div>
|
|
|
|
<Tabs defaultValue={defaultTab} className="w-full">
|
|
<TabsList className="flex flex-wrap h-auto gap-1">
|
|
{displaySkills.map((s) => (
|
|
<TabsTrigger key={s.skill} value={s.skill} className="capitalize">
|
|
{SKILL_LABEL[s.skill]}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
{displaySkills.map((s) => {
|
|
const parts = resolveParts(s.skill, s);
|
|
const required = requiredQuestions(s.skill, parts);
|
|
const pool = s.available_count ?? 0;
|
|
const pct = required > 0 ? Math.min(100, Math.round((pool / required) * 100)) : 0;
|
|
return (
|
|
<TabsContent key={s.skill} value={s.skill} className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="capitalize">{SKILL_LABEL[s.skill]}</CardTitle>
|
|
<CardDescription>
|
|
Read-only structure. Pool coverage: {pool} items available for ~{required} required questions.
|
|
</CardDescription>
|
|
</div>
|
|
<Badge variant={pct >= 100 ? "default" : "secondary"}>{pct}% pool coverage</Badge>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Part</TableHead>
|
|
<TableHead>Questions</TableHead>
|
|
<TableHead>Content type</TableHead>
|
|
<TableHead>Time (min)</TableHead>
|
|
<TableHead className="text-right">Pool</TableHead>
|
|
<TableHead className="text-right w-[140px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{parts.map((p) => (
|
|
<TableRow key={`${s.skill}-${p.part_number}`}>
|
|
<TableCell className="font-medium">{p.part_number}</TableCell>
|
|
<TableCell>{p.question_count}</TableCell>
|
|
<TableCell>{p.content_type}</TableCell>
|
|
<TableCell>{p.time_limit_min}</TableCell>
|
|
<TableCell className="text-right text-muted-foreground">
|
|
{Math.max(0, Math.floor(pool / parts.length))}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
size="sm"
|
|
type="button"
|
|
onClick={() =>
|
|
navigate(
|
|
`/admin/exam/ielts/${examId}/content?skill=${s.skill}&part=${p.part_number}`,
|
|
)
|
|
}
|
|
>
|
|
Configure content
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
);
|
|
})}
|
|
</Tabs>
|
|
</div>
|
|
|
|
<aside className="w-full lg:w-80 shrink-0 space-y-3">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Completion</CardTitle>
|
|
<CardDescription>Per-skill readiness for assembly.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<ScrollArea className="h-[min(420px,50vh)]">
|
|
{displaySkills.map((s) => {
|
|
const parts = resolveParts(s.skill, s);
|
|
const required = requiredQuestions(s.skill, parts);
|
|
const pool = s.available_count ?? 0;
|
|
const ok = pool >= required;
|
|
return (
|
|
<div
|
|
key={s.skill}
|
|
className="flex items-center justify-between gap-2 rounded-md border p-3 mb-2 text-sm"
|
|
>
|
|
<span className="capitalize font-medium">{SKILL_LABEL[s.skill]}</span>
|
|
<Badge variant={ok ? "default" : "outline"}>{ok ? "Ready" : "Needs content"}</Badge>
|
|
</div>
|
|
);
|
|
})}
|
|
</ScrollArea>
|
|
<Button className="w-full" type="button" onClick={() => navigate(`/admin/exam/ielts/${examId}/validate`)}>
|
|
Continue to validation
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|