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:
291
frontend/src/pages/student/AiEnglishCourse.tsx
Normal file
291
frontend/src/pages/student/AiEnglishCourse.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Lock,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
Percent,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useAiCourse, useAiCourseTracks, useCreateEnglishCourse } from "@/hooks/queries/useAiCourse";
|
||||
import { queryKeys } from "@/hooks/queries/keys";
|
||||
import type { AICourseTrack, AIGenerationStep, AITrackModule } from "@/types";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const DEFAULT_STEPS: AIGenerationStep[] = [
|
||||
{ name: "Profile Analysis", status: "complete" },
|
||||
{ name: "Reading Content", status: "in_progress" },
|
||||
{ name: "Grammar Exercises", status: "pending" },
|
||||
{ name: "Speaking Prompts", status: "pending" },
|
||||
{ name: "Vocabulary Sets", status: "pending" },
|
||||
];
|
||||
|
||||
const COLORS = ["hsl(var(--primary))", "hsl(var(--chart-2))", "hsl(var(--chart-3))"];
|
||||
|
||||
export default function AiEnglishCourse() {
|
||||
const { courseId: cid } = useParams<{ courseId: string }>();
|
||||
const courseId = Number(cid);
|
||||
const qc = useQueryClient();
|
||||
const { data: course, isLoading } = useAiCourse(Number.isFinite(courseId) ? courseId : undefined);
|
||||
const { data: tracksRaw } = useAiCourseTracks(Number.isFinite(courseId) ? courseId : undefined);
|
||||
const createEnglish = useCreateEnglishCourse();
|
||||
|
||||
const [target, setTarget] = useState("");
|
||||
const [learningStyle, setLearningStyle] = useState("");
|
||||
const [levelUpOpen, setLevelUpOpen] = useState(false);
|
||||
|
||||
const steps = course?.generation_steps?.length ? course.generation_steps : DEFAULT_STEPS;
|
||||
const stepProgress = useMemo(() => {
|
||||
const done = steps.filter((s) => s.status === "complete").length;
|
||||
return Math.round((done / Math.max(steps.length, 1)) * 100);
|
||||
}, [steps]);
|
||||
|
||||
const tracks: AICourseTrack[] = useMemo(() => {
|
||||
if (tracksRaw?.length) return tracksRaw;
|
||||
return [
|
||||
{ name: "Grammar Track", progress_percent: 40, modules: placeholderModules("grammar") },
|
||||
{ name: "Skills Track", progress_percent: 55, modules: placeholderModules("skills") },
|
||||
{ name: "Vocabulary Track", progress_percent: 20, modules: placeholderModules("vocab") },
|
||||
];
|
||||
}, [tracksRaw]);
|
||||
|
||||
const cefrChart = [
|
||||
{ label: "Start", value: 3.2 },
|
||||
{ label: "Now", value: 4.1 },
|
||||
{ label: "Target", value: 5.0 },
|
||||
];
|
||||
|
||||
const timePerTrack = tracks.map((t) => ({ name: t.name.replace(" Track", ""), hours: t.progress_percent / 10 }));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[320px] text-muted-foreground">
|
||||
Loading course…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ls = learningStyle || course?.learning_style?.join(", ") || "visual, mixed";
|
||||
const tgt = target || course?.target_level || "B2";
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-6 max-w-7xl mx-auto">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">AI English course</h1>
|
||||
<p className="text-muted-foreground text-sm">Personalized pathway to your target level</p>
|
||||
</div>
|
||||
<Button onClick={() => setLevelUpOpen(true)} variant="secondary" size="sm" className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Preview celebration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile confirmation</CardTitle>
|
||||
<CardDescription>Adjust targets before continuing.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Current CEFR</Label>
|
||||
<p className="text-lg font-semibold mt-1">{course?.current_level ?? "B1"}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target">Target CEFR</Label>
|
||||
<Input id="target" value={tgt} onChange={(e) => setTarget(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="style">Learning style</Label>
|
||||
<Input id="style" value={ls} onChange={(e) => setLearningStyle(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Duration (weeks)</Label>
|
||||
<p className="text-lg font-semibold mt-1">{course?.estimated_duration_weeks ?? 8}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
disabled={createEnglish.isPending}
|
||||
onClick={() => {
|
||||
if (!course) return;
|
||||
const styles =
|
||||
ls.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean).length > 0
|
||||
? ls.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
: course?.learning_style ?? ["visual"];
|
||||
createEnglish.mutate(
|
||||
{ current_level: course?.current_level ?? "B1", target_level: tgt, learning_style: styles },
|
||||
{
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.tracks(courseId) });
|
||||
toast.success("AI course started.");
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : "Could not start"),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Start AI Course
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generation progress</CardTitle>
|
||||
<CardDescription>Content pipeline for this course</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Overall</span>
|
||||
<span>{stepProgress}%</span>
|
||||
</div>
|
||||
<Progress value={stepProgress} />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{steps.map((s) => (
|
||||
<div key={s.name} className="rounded-lg border p-3 text-sm">
|
||||
<div className="font-medium leading-tight">{s.name}</div>
|
||||
<div className="text-muted-foreground capitalize mt-1">{s.status.replace("_", " ")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Learning tracks</h2>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{tracks.map((track) => (
|
||||
<Card key={track.name}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
{track.name}
|
||||
</CardTitle>
|
||||
<CardDescription>{track.progress_percent}% complete</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Progress value={track.progress_percent} />
|
||||
<Separator className="my-2" />
|
||||
<ul className="space-y-2">
|
||||
{track.modules.map((m) => (
|
||||
<li key={m.id} className="flex items-start gap-2 text-sm">
|
||||
<ModuleIcon status={m.status} />
|
||||
<div>
|
||||
<div className="font-medium">{m.title}</div>
|
||||
<div className="text-xs text-muted-foreground">~{m.estimated_hours}h</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Progress report</CardTitle>
|
||||
<CardDescription>CEFR trajectory and effort</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-8 lg:grid-cols-2">
|
||||
<div className="h-64">
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
CEFR gain
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={cefrChart}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="label" />
|
||||
<YAxis domain={[0, 6]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="h-64">
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Time per track (relative)
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={timePerTrack} dataKey="hours" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
|
||||
{timePerTrack.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="lg:col-span-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Percent className="h-4 w-4" />
|
||||
Completion rates follow each track's module checklist above.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={levelUpOpen} onOpenChange={setLevelUpOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
Level up!
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You reached <strong>{course?.target_level ?? tgt}</strong>. The next modules unlock with richer tasks and feedback.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setLevelUpOpen(false)}>Continue</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleIcon({ status }: { status: AITrackModule["status"] }) {
|
||||
if (status === "completed") return <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0 mt-0.5" />;
|
||||
if (status === "in_progress") return <CircleDot className="h-4 w-4 text-primary shrink-0 mt-0.5" />;
|
||||
return <Lock className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />;
|
||||
}
|
||||
|
||||
function placeholderModules(prefix: string): AITrackModule[] {
|
||||
return [
|
||||
{ id: 1, title: `${prefix} — Foundations`, status: "completed", estimated_hours: 2 },
|
||||
{ id: 2, title: `${prefix} — Practice set`, status: "in_progress", estimated_hours: 3 },
|
||||
{ id: 3, title: `${prefix} — Mastery`, status: "locked", estimated_hours: 4 },
|
||||
];
|
||||
}
|
||||
317
frontend/src/pages/student/AiIeltsCourse.tsx
Normal file
317
frontend/src/pages/student/AiIeltsCourse.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Headphones,
|
||||
Mic,
|
||||
BookMarked,
|
||||
PenLine,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Lock,
|
||||
ClipboardCheck,
|
||||
Award,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useAiCourse, useAiCourseTracks, useCreateIeltsCourse } from "@/hooks/queries/useAiCourse";
|
||||
import { queryKeys } from "@/hooks/queries/keys";
|
||||
import type { AICourseTrack, AIGenerationStep, AITrackModule } from "@/types";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
} from "recharts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const SKILL_ICONS = [PenLine, BookMarked, Mic, Headphones];
|
||||
|
||||
export default function AiIeltsCourse() {
|
||||
const { courseId: cid } = useParams<{ courseId: string }>();
|
||||
const courseId = Number(cid);
|
||||
const qc = useQueryClient();
|
||||
const { data: course, isLoading } = useAiCourse(Number.isFinite(courseId) ? courseId : undefined);
|
||||
const { data: tracksRaw } = useAiCourseTracks(Number.isFinite(courseId) ? courseId : undefined);
|
||||
const createIelts = useCreateIeltsCourse();
|
||||
|
||||
const [targetBand, setTargetBand] = useState("");
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
|
||||
const skillsRanked =
|
||||
course?.skills_ranked_by_gap && course.skills_ranked_by_gap.length > 0
|
||||
? course.skills_ranked_by_gap
|
||||
: [
|
||||
{ skill: "Writing", gap: 1.2 },
|
||||
{ skill: "Speaking", gap: 0.9 },
|
||||
{ skill: "Reading", gap: 0.4 },
|
||||
{ skill: "Listening", gap: 0.3 },
|
||||
];
|
||||
|
||||
const weakTypes = course?.weak_question_types ?? {
|
||||
Writing: ["Task 2 argument depth"],
|
||||
Reading: ["True/False/Not Given"],
|
||||
Speaking: ["Part 2 long turn"],
|
||||
Listening: ["Form completion"],
|
||||
};
|
||||
|
||||
const skillPanels: { skill: string; steps: AIGenerationStep[]; progress_percent: number }[] =
|
||||
course?.ielts_generation_by_skill?.length
|
||||
? course.ielts_generation_by_skill
|
||||
: ["Writing", "Reading", "Speaking", "Listening"].map((skill, i) => ({
|
||||
skill,
|
||||
steps: [
|
||||
{ name: "Blueprint", status: i === 0 ? ("in_progress" as const) : ("pending" as const) },
|
||||
{ name: "Items", status: "pending" as const },
|
||||
{ name: "Rubric alignment", status: "pending" as const },
|
||||
],
|
||||
progress_percent: [35, 20, 10, 5][i] ?? 0,
|
||||
}));
|
||||
|
||||
const tracks: AICourseTrack[] = useMemo(() => {
|
||||
if (tracksRaw?.length) return tracksRaw;
|
||||
return ["Writing", "Reading", "Speaking", "Listening"].map((name, ti) => ({
|
||||
name: `${name} skill`,
|
||||
progress_percent: 15 + ti * 8,
|
||||
modules: ieltsModules(name),
|
||||
}));
|
||||
}, [tracksRaw]);
|
||||
|
||||
const bandSeries = [
|
||||
{ label: "L", v: course?.current_bands?.Listening ?? 6.0 },
|
||||
{ label: "R", v: course?.current_bands?.Reading ?? 6.5 },
|
||||
{ label: "W", v: course?.current_bands?.Writing ?? 5.5 },
|
||||
{ label: "S", v: course?.current_bands?.Speaking ?? 6.0 },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center py-24 text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
const tgt = targetBand || String(course?.target_level ?? "6.5");
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-6 max-w-7xl mx-auto">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">AI IELTS course</h1>
|
||||
<p className="text-muted-foreground text-sm">Band-focused practice generated for you</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setReadinessOpen(true)}>
|
||||
<ClipboardCheck className="h-4 w-4 mr-2" />
|
||||
Practice exam readiness
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IELTS gap profile</CardTitle>
|
||||
<CardDescription>Exam context and targets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Exam type</Label>
|
||||
<p className="text-lg font-semibold mt-1">{course?.exam_type ?? "Academic"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Current bands</Label>
|
||||
<p className="text-sm mt-1">
|
||||
{Object.entries(course?.current_bands ?? { L: 6, R: 6.5, W: 5.5, S: 6 })
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tgt">Target band</Label>
|
||||
<Input id="tgt" value={tgt} onChange={(e) => setTargetBand(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Ranked by gap</Label>
|
||||
<ol className="mt-2 text-sm list-decimal list-inside space-y-1">
|
||||
{skillsRanked.map((s) => (
|
||||
<li key={s.skill}>
|
||||
{s.skill} (Δ {s.gap.toFixed(1)})
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
<Label className="text-muted-foreground">Weak question types</Label>
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
{Object.entries(weakTypes).map(([skill, items]) => (
|
||||
<div key={skill} className="rounded-md border p-3">
|
||||
<div className="font-medium mb-1">{skill}</div>
|
||||
<ul className="list-disc list-inside text-muted-foreground">
|
||||
{items.map((x) => (
|
||||
<li key={x}>{x}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
disabled={createIelts.isPending}
|
||||
onClick={() => {
|
||||
const band = Number(targetBand || course?.target_level || 7);
|
||||
createIelts.mutate(
|
||||
{
|
||||
exam_type: course?.exam_type ?? "academic",
|
||||
target_band: Number.isFinite(band) ? band : 7,
|
||||
skills: skillsRanked.map((s) => s.skill),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.tracks(courseId) });
|
||||
toast.success("AI IELTS course started.");
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : "Could not start"),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Start AI IELTS Course
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Per-skill generation</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{skillPanels.map((panel, idx) => {
|
||||
const Icon = SKILL_ICONS[idx % SKILL_ICONS.length];
|
||||
return (
|
||||
<Card key={panel.skill}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{panel.skill}
|
||||
</CardTitle>
|
||||
<CardDescription>{panel.progress_percent}%</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Progress value={panel.progress_percent} />
|
||||
<ul className="text-sm space-y-1">
|
||||
{panel.steps.map((s) => (
|
||||
<li key={s.name} className="flex justify-between gap-2">
|
||||
<span>{s.name}</span>
|
||||
<span className="text-muted-foreground capitalize">{s.status.replace("_", " ")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Skill columns</h2>
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
{tracks.map((track) => (
|
||||
<Card key={track.name}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{track.name}</CardTitle>
|
||||
<CardDescription>{track.progress_percent}%</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Progress value={track.progress_percent} />
|
||||
<Separator />
|
||||
<ul className="space-y-2 text-sm">
|
||||
{track.modules.map((m) => (
|
||||
<li key={m.id} className="flex gap-2">
|
||||
<ModIcon st={m.status} />
|
||||
<span>{m.title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Award className="h-5 w-5" />
|
||||
Band improvement report
|
||||
</CardTitle>
|
||||
<CardDescription>Current skill snapshot vs trajectory</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-8 lg:grid-cols-2">
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={bandSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="label" />
|
||||
<YAxis domain={[0, 9]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="v" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={[
|
||||
{ w: 1, b: 5.6 },
|
||||
{ w: 2, b: 6.1 },
|
||||
{ w: 3, b: 6.4 },
|
||||
{ w: 4, b: 6.8 },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="w" />
|
||||
<YAxis domain={[5, 8]} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="b" stroke="hsl(var(--primary))" strokeWidth={2} dot />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={readinessOpen} onOpenChange={setReadinessOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Practice exam readiness</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When each skill column reaches the practice threshold, you unlock a full timed paper with authentic pacing and
|
||||
AI feedback.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setReadinessOpen(false)}>Got it</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModIcon({ st }: { st: AITrackModule["status"] }) {
|
||||
if (st === "completed") return <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />;
|
||||
if (st === "in_progress") return <CircleDot className="h-4 w-4 text-primary shrink-0" />;
|
||||
return <Lock className="h-4 w-4 text-muted-foreground shrink-0" />;
|
||||
}
|
||||
|
||||
function ieltsModules(skill: string): AITrackModule[] {
|
||||
return [
|
||||
{ id: 1, title: `${skill} — Drill set A`, status: "completed", estimated_hours: 2 },
|
||||
{ id: 2, title: `${skill} — Drill set B`, status: "in_progress", estimated_hours: 2 },
|
||||
{ id: 3, title: `${skill} — Exam-style pack`, status: "locked", estimated_hours: 3 },
|
||||
];
|
||||
}
|
||||
204
frontend/src/pages/student/CourseDelivery.tsx
Normal file
204
frontend/src/pages/student/CourseDelivery.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useCourseDetail, useTrackProgress } from "@/hooks/queries/useCourseGeneration";
|
||||
import type { ModuleConfig, ModuleResource } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Headphones,
|
||||
Lock,
|
||||
Sparkles,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResourceIcon({ type }: { type: ModuleResource["type"] }) {
|
||||
switch (type) {
|
||||
case "pdf":
|
||||
return <FileText className="h-4 w-4" />;
|
||||
case "video":
|
||||
return <Video className="h-4 w-4" />;
|
||||
case "audio":
|
||||
return <Headphones className="h-4 w-4" />;
|
||||
default:
|
||||
return <BookOpen className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function CourseDelivery() {
|
||||
const { courseId: courseIdParam } = useParams();
|
||||
const courseId = Number(courseIdParam);
|
||||
const { data: course, isLoading } = useCourseDetail(courseId);
|
||||
const track = useTrackProgress();
|
||||
|
||||
const [activeResource, setActiveResource] = useState<{ module: ModuleConfig; resource: ModuleResource } | null>(null);
|
||||
|
||||
const bySkill = useMemo(() => {
|
||||
const map = new Map<string, ModuleConfig[]>();
|
||||
course?.modules.forEach((m) => {
|
||||
const k = m.skill || "General";
|
||||
if (!map.has(k)) map.set(k, []);
|
||||
map.get(k)!.push(m);
|
||||
});
|
||||
return map;
|
||||
}, [course]);
|
||||
|
||||
const overallProgress = useMemo(() => {
|
||||
if (!course?.modules.length) return 0;
|
||||
const sum = course.modules.reduce((s, m) => s + (m.progress_percent ?? 0), 0);
|
||||
return Math.round(sum / course.modules.length);
|
||||
}, [course]);
|
||||
|
||||
const weeksLeft = course ? Math.max(0, course.duration_weeks - 2) : 0;
|
||||
|
||||
const markResource = (mod: ModuleConfig, res: ModuleResource) => {
|
||||
track.mutate({
|
||||
courseId,
|
||||
progress: {
|
||||
resource_id: res.id,
|
||||
completion_percent: 100,
|
||||
time_spent_ms: 60000,
|
||||
},
|
||||
});
|
||||
setActiveResource({ module: mod, resource: res });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Skeleton className="mb-4 h-12 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{course?.title ?? `Course #${courseId}`}</CardTitle>
|
||||
<CardDescription>
|
||||
{course?.exam_type ?? "IELTS"} · Target {course?.target_band ?? course?.target_level ?? "—"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">{course?.progression_model ?? "linear"}</Badge>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Overall progress</span>
|
||||
<span>{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} />
|
||||
<p className="text-sm text-muted-foreground">About {weeksLeft} weeks remaining at your current pace</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{Array.from(bySkill.entries()).map(([skill, mods]) => (
|
||||
<Collapsible key={skill} defaultOpen>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="flex cursor-pointer flex-row items-center justify-between py-3">
|
||||
<CardTitle className="text-base">{skill}</CardTitle>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
{mods.map((mod) => (
|
||||
<div key={mod.id} className="rounded-lg border p-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold">{mod.title}</h3>
|
||||
{mod.status === "locked" ? <Lock className="h-4 w-4 text-muted-foreground" /> : null}
|
||||
{mod.id === course?.modules.find((m) => m.status === "in_progress")?.id ? (
|
||||
<Badge>Recommended Next</Badge>
|
||||
) : null}
|
||||
{mod.status === "skipped" ? <Badge variant="outline">Skipped</Badge> : null}
|
||||
</div>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
{mod.estimated_hours}h · {mod.cefr_target ?? "CEFR target"}
|
||||
</div>
|
||||
<Progress value={mod.progress_percent ?? 0} className="mb-3 h-2" />
|
||||
<div className="space-y-2">
|
||||
{mod.resources.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => markResource(mod, r)}
|
||||
className="flex w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ResourceIcon type={r.type} />
|
||||
{r.title}
|
||||
</span>
|
||||
{r.completed ? <Check className="h-4 w-4 text-green-600" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button type="button" size="sm" disabled={mod.status === "locked"}>
|
||||
{mod.status === "in_progress" || mod.progress_percent ? "Continue" : "Start"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="min-h-[320px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Resource viewer</CardTitle>
|
||||
<CardDescription>
|
||||
{activeResource ? activeResource.resource.title : "Select a resource to open the viewer"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!activeResource ? (
|
||||
<p className="text-sm text-muted-foreground">PDF, video, audio, and exercise viewers render here.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[200px] items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-sm text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{activeResource.resource.type === "pdf" && "PDF viewer placeholder"}
|
||||
{activeResource.resource.type === "video" && "Video player placeholder"}
|
||||
{activeResource.resource.type === "audio" && "Audio player placeholder"}
|
||||
{activeResource.resource.type === "exercise" && "Interactive exercise placeholder"}
|
||||
{activeResource.resource.type === "ai_generated" && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" /> AI-generated content placeholder
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className="h-32 text-xs text-muted-foreground">
|
||||
Module: {activeResource.module.title}. Completion: {activeResource.module.completion_criteria}.
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
frontend/src/pages/student/DiagnosticTest.tsx
Normal file
127
frontend/src/pages/student/DiagnosticTest.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, CheckCircle2, ArrowRight, Clock } from "lucide-react";
|
||||
import { useStartDiagnostic, useAnswerDiagnostic } from "@/hooks/queries";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { DiagnosticQuestion } from "@/types";
|
||||
|
||||
export default function DiagnosticTest() {
|
||||
const { subjectId } = useParams<{ subjectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const startMutation = useStartDiagnostic();
|
||||
const answerMutation = useAnswerDiagnostic();
|
||||
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const [currentQuestion, setCurrentQuestion] = useState<DiagnosticQuestion | null>(null);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState("");
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [totalQuestions, setTotalQuestions] = useState(0);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const handleStart = () => {
|
||||
startMutation.mutate(Number(subjectId), {
|
||||
onSuccess: (data) => {
|
||||
setSessionId(data.session.id);
|
||||
setCurrentQuestion(data.first_question);
|
||||
setTotalQuestions(data.session.total_questions);
|
||||
setQuestionIndex(1);
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Failed to start diagnostic", variant: "destructive" }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAnswer = () => {
|
||||
if (!sessionId || !currentQuestion || !selectedAnswer) return;
|
||||
answerMutation.mutate(
|
||||
{ session_id: sessionId, question_id: currentQuestion.id, answer: selectedAnswer },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setSelectedAnswer("");
|
||||
if (data.completed) {
|
||||
setCompleted(true);
|
||||
} else if (data.next_question) {
|
||||
setCurrentQuestion(data.next_question);
|
||||
setQuestionIndex((i) => i + 1);
|
||||
}
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Failed to submit answer", variant: "destructive" }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (completed) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500 mx-auto" />
|
||||
<h1 className="text-2xl font-bold">Diagnostic Complete!</h1>
|
||||
<p className="text-muted-foreground">Your proficiency profile has been generated.</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button onClick={() => navigate(`/student/proficiency/${subjectId}`)}>View Proficiency</Button>
|
||||
<Button variant="outline" onClick={() => navigate(`/student/plan/${subjectId}`)}>See Learning Plan</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
|
||||
<Clock className="h-16 w-16 text-primary mx-auto opacity-60" />
|
||||
<h1 className="text-2xl font-bold">Diagnostic Assessment</h1>
|
||||
<p className="text-muted-foreground">This adaptive test will assess your current knowledge across all topics. Questions adapt to your performance in real time.</p>
|
||||
<Button size="lg" onClick={handleStart} disabled={startMutation.isPending}>
|
||||
{startMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Begin Assessment
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>Question {questionIndex} of ~{totalQuestions}</span>
|
||||
<Badge variant="outline">{currentQuestion?.difficulty}</Badge>
|
||||
</div>
|
||||
<Progress value={(questionIndex / totalQuestions) * 100} className="h-2" />
|
||||
</div>
|
||||
|
||||
{currentQuestion && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<span>{currentQuestion.domain_name}</span>
|
||||
<span>·</span>
|
||||
<span>{currentQuestion.topic_name}</span>
|
||||
</div>
|
||||
<CardTitle className="text-lg">{currentQuestion.question_text}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{currentQuestion.options && (
|
||||
<RadioGroup value={selectedAnswer} onValueChange={setSelectedAnswer}>
|
||||
{currentQuestion.options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center space-x-3 rounded-lg border p-3 hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value={opt} id={`opt-${i}`} />
|
||||
<Label htmlFor={`opt-${i}`} className="flex-1 cursor-pointer">{opt}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}>
|
||||
{answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Submit Answer <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
frontend/src/pages/student/ExamResults.tsx
Normal file
178
frontend/src/pages/student/ExamResults.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Radar,
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Award, BookOpen, Download, RefreshCw } from "lucide-react";
|
||||
|
||||
const SKILLS = [
|
||||
{ skill: "Listening", band: 7.5, cefr: "C1", gap: 0.5, target: 8 },
|
||||
{ skill: "Reading", band: 8, cefr: "C1", gap: 0, target: 8 },
|
||||
{ skill: "Writing", band: 6.5, cefr: "B2", gap: 1.5, target: 8 },
|
||||
{ skill: "Speaking", band: 7, cefr: "C1", gap: 1, target: 8 },
|
||||
];
|
||||
|
||||
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
||||
|
||||
export default function ExamResults() {
|
||||
const { examId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const practice = searchParams.get("mode") === "practice";
|
||||
const overall = 7.5;
|
||||
const cefr = "C1";
|
||||
const passed = overall >= 7;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">Your overall result</p>
|
||||
<div className="mt-2 flex items-center justify-center gap-3">
|
||||
<Award className="h-12 w-12 text-primary" />
|
||||
<span className="text-5xl font-bold tabular-nums">{overall}</span>
|
||||
<Badge variant="secondary" className="text-lg">
|
||||
Band
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>
|
||||
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skill profile</CardTitle>
|
||||
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="skill" />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
||||
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skills breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Skill</TableHead>
|
||||
<TableHead>Band</TableHead>
|
||||
<TableHead>CEFR</TableHead>
|
||||
<TableHead>Gap to Target</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SKILLS.map((s) => (
|
||||
<TableRow key={s.skill}>
|
||||
<TableCell className="font-medium">{s.skill}</TableCell>
|
||||
<TableCell>{s.band}</TableCell>
|
||||
<TableCell>{s.cefr}</TableCell>
|
||||
<TableCell>{s.gap > 0 ? `−${s.gap}` : "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Section feedback</h2>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{["Listening", "Reading", "Writing", "Speaking"].map((name) => (
|
||||
<AccordionItem key={name} value={name}>
|
||||
<AccordionTrigger>{name}</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
Detailed feedback for {name} will appear here once released by your instructor.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Areas to improve</CardTitle>
|
||||
<CardDescription>Focus recommendations based on this attempt</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="list-inside list-disc space-y-2 text-sm">
|
||||
<li>Strengthen task response structure in Writing Task 2.</li>
|
||||
<li>Extend range of cohesive devices in argumentative essays.</li>
|
||||
<li>Maintain fluency while reducing hesitation in Speaking Part 2.</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF Report
|
||||
</Button>
|
||||
<Button type="button" asChild>
|
||||
<Link to={`/student/course/generate?from=exam&examId=${examId ?? ""}`}>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
View Recommended Course
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" asChild>
|
||||
<Link to={`/student/exam/${examId}/session?mode=practice`}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Retake Exam
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className={passed ? "border-green-600/40 bg-green-500/5" : ""}>
|
||||
<CardHeader>
|
||||
<CardTitle>Pass path</CardTitle>
|
||||
<CardDescription>Congratulations — you are on track for your goal.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm">Consider registering for the official test while skills are strong.</p>
|
||||
<Button type="button" size="sm">
|
||||
Register for official exam
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={!passed ? "border-amber-600/40 bg-amber-500/5" : ""}>
|
||||
<CardHeader>
|
||||
<CardTitle>Below threshold</CardTitle>
|
||||
<CardDescription>Close the gap with targeted training.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm">Start adaptive training tailored to your weakest skills.</p>
|
||||
<Button type="button" size="sm" variant="secondary" asChild>
|
||||
<Link to={`/student/course/generate?from=exam&examId=${examId ?? ""}`}>Start adaptive training</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
458
frontend/src/pages/student/ExamSession.tsx
Normal file
458
frontend/src/pages/student/ExamSession.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
|
||||
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function normalizeType(t: string) {
|
||||
return t.toLowerCase().replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
function countWords(s: string) {
|
||||
return s.trim() ? s.trim().split(/\s+/).length : 0;
|
||||
}
|
||||
|
||||
function buildAnswerMap(sections: ExamSessionSection[]) {
|
||||
const map = new Map<number, ExamAnswer>();
|
||||
for (const sec of sections) {
|
||||
for (const q of sec.questions) {
|
||||
map.set(q.id, {
|
||||
question_id: q.id,
|
||||
answer: null,
|
||||
flagged: false,
|
||||
time_spent_ms: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export default function ExamSession() {
|
||||
const { examId: examIdParam } = useParams();
|
||||
const examId = Number(examIdParam);
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isLoading, isError } = useExamSession(examId);
|
||||
const autoSave = useExamAutoSave();
|
||||
const submitMut = useExamSubmit();
|
||||
|
||||
const [sectionIdx, setSectionIdx] = useState(0);
|
||||
const [questionIdx, setQuestionIdx] = useState(0);
|
||||
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
||||
const [sectionGate, setSectionGate] = useState<{ open: boolean; sec: number; remaining: number } | null>(null);
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const totalSecRef = useRef(0);
|
||||
const qStartRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
setAnswers(buildAnswerMap(session.sections));
|
||||
totalSecRef.current = session.total_time_min * 60;
|
||||
setTick(0);
|
||||
}, [session]);
|
||||
|
||||
const section = session?.sections[sectionIdx];
|
||||
const question = section?.questions[questionIdx];
|
||||
const totalQuestions = useMemo(
|
||||
() => session?.sections.reduce((n, s) => n + s.questions.length, 0) ?? 0,
|
||||
[session],
|
||||
);
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
qStartRef.current = Date.now();
|
||||
}, [question?.id]);
|
||||
|
||||
const answeredCount = useMemo(() => {
|
||||
let n = 0;
|
||||
answers.forEach((a) => {
|
||||
if (a.answer === null || a.answer === "") return;
|
||||
if (Array.isArray(a.answer) && a.answer.length === 0) return;
|
||||
n += 1;
|
||||
});
|
||||
return n;
|
||||
}, [answers]);
|
||||
|
||||
const progressPct = totalQuestions ? (answeredCount / totalQuestions) * 100 : 0;
|
||||
|
||||
const updateAnswer = useCallback(
|
||||
(questionId: number, patch: Partial<ExamAnswer>) => {
|
||||
setAnswers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const cur = next.get(questionId);
|
||||
if (!cur) return prev;
|
||||
const spent = Date.now() - qStartRef.current;
|
||||
next.set(questionId, { ...cur, ...patch, time_spent_ms: cur.time_spent_ms + spent });
|
||||
qStartRef.current = Date.now();
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const currentSectionAnswers = useCallback((): ExamAnswer[] => {
|
||||
if (!section) return [];
|
||||
return section.questions.map((q) => answers.get(q.id)!).filter(Boolean);
|
||||
}, [section, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !section) return;
|
||||
const id = window.setInterval(() => {
|
||||
autoSave.mutate({
|
||||
examId,
|
||||
payload: { section_id: section.id, answers: currentSectionAnswers() },
|
||||
});
|
||||
}, 10000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [session, section, examId, autoSave, currentSectionAnswers]);
|
||||
|
||||
const handleFlag = () => {
|
||||
if (!question) return;
|
||||
const cur = answers.get(question.id);
|
||||
if (!cur) return;
|
||||
updateAnswer(question.id, { flagged: !cur.flagged });
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (!session || !section) return;
|
||||
if (questionIdx < section.questions.length - 1) {
|
||||
setQuestionIdx((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
if (sectionIdx < session.sections.length - 1) {
|
||||
setSectionGate({ open: true, sec: sectionIdx + 1, remaining: 5 });
|
||||
return;
|
||||
}
|
||||
setReviewOpen(true);
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
if (questionIdx > 0) {
|
||||
setQuestionIdx((i) => i - 1);
|
||||
return;
|
||||
}
|
||||
if (sectionIdx > 0) {
|
||||
const prevSec = session!.sections[sectionIdx - 1];
|
||||
setSectionIdx((s) => s - 1);
|
||||
setQuestionIdx(prevSec.questions.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionGate?.open) return;
|
||||
if (sectionGate.remaining <= 0) {
|
||||
setSectionIdx(sectionGate.sec);
|
||||
setQuestionIdx(0);
|
||||
setSectionGate(null);
|
||||
return;
|
||||
}
|
||||
const t = window.setTimeout(
|
||||
() => setSectionGate((g) => (g ? { ...g, remaining: g.remaining - 1 } : null)),
|
||||
1000,
|
||||
);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [sectionGate]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let answered = 0;
|
||||
let unanswered = 0;
|
||||
let flagged = 0;
|
||||
session?.sections.forEach((sec) => {
|
||||
sec.questions.forEach((q) => {
|
||||
const a = answers.get(q.id);
|
||||
if (!a) {
|
||||
unanswered += 1;
|
||||
return;
|
||||
}
|
||||
if (a.flagged) flagged += 1;
|
||||
const empty =
|
||||
a.answer === null ||
|
||||
a.answer === "" ||
|
||||
(Array.isArray(a.answer) && a.answer.length === 0);
|
||||
if (empty) unanswered += 1;
|
||||
else answered += 1;
|
||||
});
|
||||
});
|
||||
return { answered, unanswered, flagged, total: totalQuestions };
|
||||
}, [session, answers, totalQuestions]);
|
||||
|
||||
const renderQuestion = (q: ExamQuestion) => {
|
||||
const a = answers.get(q.id);
|
||||
if (!a) return null;
|
||||
const nt = normalizeType(q.type);
|
||||
|
||||
if (nt.includes("listen") || q.audio_url) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
|
||||
<Button type="button" variant="outline" size="icon" onClick={() => setPlaying((p) => !p)}>
|
||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</Button>
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div className="h-2 w-1/3 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">0:00 / 3:42</span>
|
||||
</div>
|
||||
{q.options?.length ? (
|
||||
<RadioGroup
|
||||
value={typeof a.answer === "string" ? a.answer : ""}
|
||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||
className="space-y-2"
|
||||
>
|
||||
{q.options.map((o) => (
|
||||
<div key={o.label} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nt.includes("multi") || nt === "mcq_multi") {
|
||||
const selected = Array.isArray(a.answer) ? a.answer : [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{q.options?.map((o) => (
|
||||
<div key={o.label} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${q.id}-${o.label}`}
|
||||
checked={selected.includes(o.label)}
|
||||
onCheckedChange={(ck) => {
|
||||
const on = ck === true;
|
||||
const next = on ? [...selected, o.label] : selected.filter((x) => x !== o.label);
|
||||
updateAnswer(q.id, { answer: next });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nt.includes("gap")) {
|
||||
return (
|
||||
<Input
|
||||
value={typeof a.answer === "string" ? a.answer : ""}
|
||||
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
|
||||
placeholder="Your answer"
|
||||
className="max-w-md"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
|
||||
const opts = q.options?.length
|
||||
? q.options
|
||||
: [
|
||||
{ label: "T", text: "True" },
|
||||
{ label: "F", text: "False" },
|
||||
{ label: "NG", text: "Not Given" },
|
||||
];
|
||||
return (
|
||||
<RadioGroup
|
||||
value={typeof a.answer === "string" ? a.answer : ""}
|
||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||
className="space-y-2"
|
||||
>
|
||||
{opts.map((o) => (
|
||||
<div key={o.label} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (nt.includes("writing") || nt.includes("essay")) {
|
||||
const text = typeof a.answer === "string" ? a.answer : "";
|
||||
const wc = countWords(text);
|
||||
const min = q.min_words ?? 150;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
<p className={cn("text-sm", wc < min ? "text-amber-600" : "text-muted-foreground")}>
|
||||
{wc} words · minimum {min}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
Recording interface will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
value={typeof a.answer === "string" ? a.answer : ""}
|
||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||
className="space-y-2"
|
||||
>
|
||||
{q.options?.map((o) => (
|
||||
<div key={o.label} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !session) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background p-6">
|
||||
<p className="text-destructive text-lg font-medium">Unable to load this exam session.</p>
|
||||
<p className="text-muted-foreground text-sm">The exam may not exist or the server is unreachable.</p>
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const remainOverall = Math.max(0, totalSecRef.current - tick);
|
||||
const mm = Math.floor(remainOverall / 60);
|
||||
const ss = remainOverall % 60;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col bg-background">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-6 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">{session.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{section?.title}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<span className="font-mono text-lg tabular-nums">
|
||||
{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="w-48 space-y-1">
|
||||
<Progress value={progressPct} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">Overall progress</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{question ? (
|
||||
<Card className="border-none shadow-none">
|
||||
<CardContent className="space-y-6 p-0">
|
||||
{question.passage_text ? (
|
||||
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
|
||||
) : null}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="font-medium">{question.stem}</p>
|
||||
</div>
|
||||
{renderQuestion(question)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-wrap items-center justify-between gap-3 border-t px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={goPrev} disabled={sectionIdx === 0 && questionIdx === 0}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleFlag}>
|
||||
<Flag className="mr-1 h-4 w-4" />
|
||||
Flag for Review
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" onClick={goNext}>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<Dialog open={!!sectionGate?.open} onOpenChange={() => setSectionGate(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Next section</DialogTitle>
|
||||
<DialogDescription>
|
||||
The next section begins in{" "}
|
||||
<span className="font-mono font-semibold">{sectionGate?.remaining ?? 0}</span>s.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={reviewOpen} onOpenChange={setReviewOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review and submit</DialogTitle>
|
||||
<DialogDescription>
|
||||
{summary.answered} answered · {summary.unanswered} unanswered · {summary.flagged} flagged ·{" "}
|
||||
{summary.total} total
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setReviewOpen(false)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
submitMut.mutate(examId, {
|
||||
onSuccess: () => {
|
||||
setReviewOpen(false);
|
||||
navigate(`/student/exam/${examId}/status`);
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={submitMut.isPending}
|
||||
>
|
||||
Submit exam
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/pages/student/ExamStatus.tsx
Normal file
111
frontend/src/pages/student/ExamStatus.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useExamStatus } from "@/hooks/queries/useExamSession";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function ExamStatus() {
|
||||
const { examId: examIdParam } = useParams();
|
||||
const examId = Number(examIdParam);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading, isFetching } = useExamStatus(examId, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const s = (data.status ?? "").toLowerCase();
|
||||
if (data.scores_available || s.includes("complete") || s.includes("released")) {
|
||||
navigate(`/student/exam/${examId}/results`, { replace: true });
|
||||
}
|
||||
}, [data, examId, navigate]);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading status…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = (data?.status ?? "").toLowerCase();
|
||||
let view: "scoring" | "partial" | "manual" | "generic" = "generic";
|
||||
if (status.includes("scoring") || status === "processing") view = "scoring";
|
||||
else if (status.includes("manual") || status.includes("approval") || status.includes("under_review"))
|
||||
view = "manual";
|
||||
else if (
|
||||
status.includes("partial") ||
|
||||
status.includes("pending_ws") ||
|
||||
status.includes("lr_scored") ||
|
||||
(status.includes("listening") && status.includes("reading") && status.includes("pending"))
|
||||
)
|
||||
view = "partial";
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-lg flex-col justify-center gap-6 p-6">
|
||||
{view === "scoring" ? (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<CardTitle>Scores are being calculated</CardTitle>
|
||||
<CardDescription>This page refreshes every few seconds.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-xs text-muted-foreground">
|
||||
{isFetching ? "Checking…" : "Waiting for next update…"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{view === "partial" ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partial results</CardTitle>
|
||||
<CardDescription>Listening and Reading are scored; Writing and Speaking are still pending review.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Listening</span>
|
||||
<Badge variant="secondary">Band 7.5</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Reading</span>
|
||||
<Badge variant="secondary">Band 8.0</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Writing</span>
|
||||
<Badge>Pending Review</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Speaking</span>
|
||||
<Badge>Pending Review</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{view === "manual" ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Results under review</CardTitle>
|
||||
<CardDescription>Your institution will release Writing and Speaking results after approval.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button type="button" variant="outline" onClick={() => navigate("/student/dashboard")}>
|
||||
Back to dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{view === "generic" ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status</CardTitle>
|
||||
<CardDescription>{data?.status ?? "Unknown"}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
frontend/src/pages/student/GapAnalysis.tsx
Normal file
210
frontend/src/pages/student/GapAnalysis.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useGapAnalysis, useAutoGenerateCourse } from "@/hooks/queries/useCourseGeneration";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
export default function GapAnalysis() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
const fromParam = searchParams.get("from");
|
||||
const source = fromParam === "placement" ? "placement" : "exam";
|
||||
const examId = Number(searchParams.get("examId"));
|
||||
const sessionId = Number(searchParams.get("sessionId"));
|
||||
const sourceId = source === "exam" ? examId : sessionId;
|
||||
const entityStudent = searchParams.get("entity") === "1";
|
||||
|
||||
const enabled = source === "exam" ? Number.isFinite(examId) && examId > 0 : Number.isFinite(sessionId) && sessionId > 0;
|
||||
|
||||
const { data, isLoading, isError } = useGapAnalysis(source, sourceId);
|
||||
const generate = useAutoGenerateCourse();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.skill_gaps?.length) return [];
|
||||
return [...data.skill_gaps]
|
||||
.sort((a, b) => b.gap - a.gap)
|
||||
.map((g) => ({ skill: g.skill, gap: g.gap }));
|
||||
}, [data]);
|
||||
|
||||
const canGenerate = user?.user_type === "student" && !entityStudent;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Skill gap analysis</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{source === "exam" ? `From exam ${examId || "—"}` : `Placement session ${sessionId || "—"}`}
|
||||
</p>
|
||||
</div>
|
||||
{canGenerate ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => generate.mutate(sourceId)}
|
||||
disabled={!enabled || generate.isPending}
|
||||
>
|
||||
Generate Course
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{entityStudent ? (
|
||||
<Alert>
|
||||
<AlertTitle>Entity-managed students</AlertTitle>
|
||||
<AlertDescription>
|
||||
Gap analysis is view-only for your organization. Contact your coordinator to assign a course.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!enabled ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Missing source</AlertTitle>
|
||||
<AlertDescription>
|
||||
Add <code className="rounded bg-muted px-1">?from=exam&examId=…</code> or{" "}
|
||||
<code className="rounded bg-muted px-1">?from=placement&sessionId=…</code> to the URL.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-96 w-full" />
|
||||
) : isError || !data ? (
|
||||
<p className="text-destructive">Could not load gap analysis.</p>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skills ranked by gap</CardTitle>
|
||||
<CardDescription>Larger bars need more attention</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" domain={[0, "dataMax"]} />
|
||||
<YAxis type="category" dataKey="skill" width={100} tickLine={false} axisLine={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="gap" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gap table</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Skill</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Gap</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Est. Hours</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.skill_gaps.map((g) => (
|
||||
<TableRow key={g.skill}>
|
||||
<TableCell className="font-medium">{g.skill}</TableCell>
|
||||
<TableCell>{g.current_band ?? g.current_level}</TableCell>
|
||||
<TableCell>{g.target_band ?? g.target_level}</TableCell>
|
||||
<TableCell>{g.gap}</TableCell>
|
||||
<TableCell className="capitalize">{g.priority}</TableCell>
|
||||
<TableCell>{g.estimated_hours}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Question-type weaknesses</h2>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{Object.entries(data.question_type_weaknesses).map(([skill, rows]) => (
|
||||
<AccordionItem key={skill} value={skill}>
|
||||
<AccordionTrigger>{skill}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Accuracy</TableHead>
|
||||
<TableHead>Correct</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow key={r.question_type}>
|
||||
<TableCell>{r.question_type}</TableCell>
|
||||
<TableCell>{Math.round(r.accuracy * 100)}%</TableCell>
|
||||
<TableCell>{r.correct}</TableCell>
|
||||
<TableCell>{r.total}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Topic weaknesses</h2>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(data.topic_weaknesses).map(([cat, topics]) => (
|
||||
<Card key={cat}>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base">{cat}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Topic</TableHead>
|
||||
<TableHead>Errors</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topics.map((t) => (
|
||||
<TableRow key={t.topic}>
|
||||
<TableCell>{t.topic}</TableCell>
|
||||
<TableCell>{t.errors}</TableCell>
|
||||
<TableCell>{t.total}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
frontend/src/pages/student/LearningPlan.tsx
Normal file
122
frontend/src/pages/student/LearningPlan.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, Lock, CheckCircle2, PlayCircle, Circle, ArrowRight, Sparkles } from "lucide-react";
|
||||
import { useLearningPlan, useGenerateLearningPlan } from "@/hooks/queries";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { PlanItemStatus } from "@/types";
|
||||
|
||||
const statusIcons: Record<PlanItemStatus, ReactNode> = {
|
||||
locked: <Lock className="h-4 w-4 text-muted-foreground" />,
|
||||
available: <Circle className="h-4 w-4 text-blue-500" />,
|
||||
in_progress: <PlayCircle className="h-4 w-4 text-yellow-500" />,
|
||||
completed: <CheckCircle2 className="h-4 w-4 text-green-500" />,
|
||||
};
|
||||
|
||||
const statusColors: Record<PlanItemStatus, string> = {
|
||||
locked: "bg-muted text-muted-foreground",
|
||||
available: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
in_progress: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
completed: "bg-green-50 text-green-700 border-green-200",
|
||||
};
|
||||
|
||||
export default function LearningPlanPage() {
|
||||
const { subjectId } = useParams<{ subjectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data: plan, isLoading } = useLearningPlan({ subject_id: Number(subjectId) });
|
||||
const generateMutation = useGenerateLearningPlan();
|
||||
|
||||
const handleGenerate = () => {
|
||||
generateMutation.mutate(
|
||||
{ subject_id: Number(subjectId) },
|
||||
{
|
||||
onSuccess: () => toast({ title: "Plan Generated", description: "Your personalized learning plan is ready." }),
|
||||
onError: () => toast({ title: "Error", description: "Failed to generate plan", variant: "destructive" }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
|
||||
<Sparkles className="h-16 w-16 text-primary mx-auto opacity-60" />
|
||||
<h1 className="text-2xl font-bold">No Learning Plan Yet</h1>
|
||||
<p className="text-muted-foreground">Complete a diagnostic assessment first, then generate your personalized learning plan.</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Take Diagnostic</Button>
|
||||
<Button onClick={handleGenerate} disabled={generateMutation.isPending}>
|
||||
{generateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate Plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = plan.items.sort((a, b) => a.sequence - b.sequence);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Learning Plan: {plan.subject_name}</h1>
|
||||
<p className="text-muted-foreground">{plan.ai_summary}</p>
|
||||
</div>
|
||||
<Badge variant={plan.status === "active" ? "default" : "secondary"}>{plan.status}</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">Overall Progress</span>
|
||||
<span className="text-sm font-medium">{Math.round(plan.overall_progress)}%</span>
|
||||
</div>
|
||||
<Progress value={plan.overall_progress} className="h-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<Card key={item.id} className={`border ${statusColors[item.status]}`}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="text-xs text-muted-foreground font-mono">{index + 1}</div>
|
||||
{statusIcons[item.status]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.topic_name}</span>
|
||||
<Badge variant="outline" className="text-xs">{item.domain_name}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
|
||||
<span>~{item.estimated_hours}h estimated</span>
|
||||
{item.actual_hours > 0 && <span>{item.actual_hours}h spent</span>}
|
||||
<span>Mastery: {Math.round(item.mastery)}%</span>
|
||||
</div>
|
||||
{item.status !== "locked" && (
|
||||
<Progress value={item.mastery} className="h-1 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
{(item.status === "available" || item.status === "in_progress") && (
|
||||
<Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>
|
||||
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{item.status === "completed" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>Review</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
frontend/src/pages/student/PlacementAccess.tsx
Normal file
135
frontend/src/pages/student/PlacementAccess.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { placementService } from "@/services/placement.service";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
|
||||
const fullFeatures = [
|
||||
"Unlimited adaptive lessons",
|
||||
"Speaking feedback & analytics",
|
||||
"Full placement history",
|
||||
"Priority instructor Q&A",
|
||||
];
|
||||
|
||||
const trialFeatures = ["7 days full access", "Placement results saved", "Email reminders before trial ends"];
|
||||
|
||||
const skipFeatures = ["Dashboard access", "Limited preview content", "Upgrade any time"];
|
||||
|
||||
export default function PlacementAccess() {
|
||||
const navigate = useNavigate();
|
||||
const [trialLoading, setTrialLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startTrial = async () => {
|
||||
setError(null);
|
||||
setTrialLoading(true);
|
||||
try {
|
||||
await placementService.startTrial();
|
||||
navigate("/student/dashboard");
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Could not start trial");
|
||||
} finally {
|
||||
setTrialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto py-12 px-4">
|
||||
<div className="text-center mb-10 space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Choose how you want to continue</h1>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Unlock the full adaptive pathway or explore on your own terms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-center text-sm text-destructive mb-6 bg-destructive/10 rounded-lg py-2 px-4 max-w-xl mx-auto">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3 items-stretch">
|
||||
<Card className="border-2 border-primary shadow-lg flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Full access</CardTitle>
|
||||
<CardDescription>Best value for serious exam prep</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
<ul className="space-y-2">
|
||||
{fullFeatures.map((f) => (
|
||||
<li key={f} className="flex gap-2 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0 text-primary mt-0.5" />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" size="lg" onClick={() => navigate("/student/subscription")}>
|
||||
Subscribe now
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-md bg-muted/40 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Free trial</CardTitle>
|
||||
<CardDescription>Try everything for seven days</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
<ul className="space-y-2">
|
||||
{trialFeatures.map((f) => (
|
||||
<li key={f} className="flex gap-2 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0 text-primary mt-0.5" />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
disabled={trialLoading}
|
||||
onClick={() => void startTrial()}
|
||||
>
|
||||
{trialLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Starting
|
||||
</>
|
||||
) : (
|
||||
"Try free for 7 days"
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-dashed flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Skip for now</CardTitle>
|
||||
<CardDescription>Head straight to your dashboard</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
<ul className="space-y-2">
|
||||
{skipFeatures.map((f) => (
|
||||
<li key={f} className="flex gap-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" size="lg" variant="outline" onClick={() => navigate("/student/dashboard")}>
|
||||
Continue to dashboard
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/pages/student/PlacementBriefing.tsx
Normal file
132
frontend/src/pages/student/PlacementBriefing.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, Clock, Layers, Sparkles } from "lucide-react";
|
||||
import { usePlacementStart } from "@/hooks/queries/usePlacement";
|
||||
import type { PlacementSubject } from "@/types";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
const dimensions = [
|
||||
{ name: "Grammar", detail: "~30 MCQ", time: "~8 min" },
|
||||
{ name: "Vocabulary", detail: "~20 items", time: "~5 min" },
|
||||
{ name: "Reading", detail: "~10 Q / 2 passages", time: "~10 min" },
|
||||
{ name: "Speaking", detail: "3 prompts", time: "~5 min" },
|
||||
];
|
||||
|
||||
export default function PlacementBriefing() {
|
||||
const navigate = useNavigate();
|
||||
const start = usePlacementStart();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleBegin = async () => {
|
||||
setError(null);
|
||||
const subject: PlacementSubject = "english";
|
||||
try {
|
||||
const res = await start.mutateAsync(subject);
|
||||
sessionStorage.setItem(
|
||||
`placement_session_${res.session_id}`,
|
||||
JSON.stringify({ question: res.first_question }),
|
||||
);
|
||||
navigate(`/student/placement/test?session=${encodeURIComponent(res.session_id)}`, {
|
||||
state: { question: res.first_question, sessionId: res.session_id },
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Could not start the test");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-4xl mx-auto py-6 px-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Placement test</h1>
|
||||
<p className="text-muted-foreground mt-1">Adaptive English placement across four skills.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-0 shadow-md bg-gradient-to-br from-primary/10 to-transparent">
|
||||
<CardHeader className="pb-2">
|
||||
<Clock className="h-5 w-5 text-primary mb-1" />
|
||||
<CardTitle className="text-base">Duration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-semibold tabular-nums">20–30 min</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Pace yourself; timer tracks elapsed time.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<Layers className="h-5 w-5 text-primary mb-1" />
|
||||
<CardTitle className="text-base">Sections</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-semibold">4</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Grammar, vocabulary, reading, speaking.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<Sparkles className="h-5 w-5 text-primary mb-1" />
|
||||
<CardTitle className="text-base">Adaptive</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="secondary" className="mb-1">
|
||||
CAT-style
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">Difficulty adjusts based on your responses.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>What to expect</CardTitle>
|
||||
<CardDescription>Approximate structure per dimension</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Dimension</TableHead>
|
||||
<TableHead>Format</TableHead>
|
||||
<TableHead className="text-right">Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dimensions.map((row) => (
|
||||
<TableRow key={row.name}>
|
||||
<TableCell className="font-medium">{row.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.detail}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.time}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="lg" className="min-w-[160px]" onClick={() => void handleBegin()} disabled={start.isPending}>
|
||||
{start.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Starting
|
||||
</>
|
||||
) : (
|
||||
"Begin Test"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
frontend/src/pages/student/PlacementResults.tsx
Normal file
281
frontend/src/pages/student/PlacementResults.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Radar,
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown, Loader2 } from "lucide-react";
|
||||
import { usePlacementResults, useLearningPath, useSpeakingStatus } from "@/hooks/queries/usePlacement";
|
||||
import type { CEFRLevel } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function cefrBadgeClass(level: CEFRLevel): string {
|
||||
switch (level) {
|
||||
case "c2":
|
||||
case "c1":
|
||||
return "bg-violet-600 hover:bg-violet-600 text-white border-0";
|
||||
case "b2":
|
||||
case "b1":
|
||||
return "bg-emerald-600 hover:bg-emerald-600 text-white border-0";
|
||||
case "a2":
|
||||
case "a1":
|
||||
case "pre_a1":
|
||||
return "bg-amber-600 hover:bg-amber-600 text-white border-0";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const CEFR_LABEL: Record<CEFRLevel, string> = {
|
||||
pre_a1: "Pre-A1",
|
||||
a1: "A1",
|
||||
a2: "A2",
|
||||
b1: "B1",
|
||||
b2: "B2",
|
||||
c1: "C1",
|
||||
c2: "C2",
|
||||
};
|
||||
|
||||
function formatCefr(level: CEFRLevel): string {
|
||||
return CEFR_LABEL[level] ?? level;
|
||||
}
|
||||
|
||||
export default function PlacementResults() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const sessionId = searchParams.get("session") || "";
|
||||
const navigate = useNavigate();
|
||||
|
||||
const results = usePlacementResults(sessionId);
|
||||
const learningPath = useLearningPath(sessionId);
|
||||
const speakingPending = results.data?.speaking_status === "pending";
|
||||
const speakingPoll = useSpeakingStatus(sessionId, speakingPending);
|
||||
|
||||
const radarData = useMemo(() => {
|
||||
if (!results.data?.dimensions.length) return [];
|
||||
return results.data.dimensions.map((d) => ({
|
||||
dimension: d.dimension,
|
||||
score: d.score,
|
||||
fullMark: 100,
|
||||
}));
|
||||
}, [results.data]);
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto py-16 px-4 text-center">
|
||||
<p className="text-muted-foreground mb-4">Missing session.</p>
|
||||
<Button variant="outline" onClick={() => navigate("/student/placement")}>
|
||||
Back to placement
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 space-y-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Your placement results</h1>
|
||||
<p className="text-muted-foreground mt-2">Personalized snapshot before your course begins.</p>
|
||||
</div>
|
||||
|
||||
{results.isLoading && (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-destructive text-sm">{results.error.message}</p>
|
||||
<Button className="mt-4" variant="outline" onClick={() => results.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{results.data && (
|
||||
<>
|
||||
<Card className="border-0 shadow-lg overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-primary/15 via-primary/5 to-transparent p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Overall level</p>
|
||||
<p className="text-4xl md:text-5xl font-bold mt-2 tabular-nums">
|
||||
{formatCefr(results.data.overall_cefr)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={cn("text-lg px-4 py-2", cefrBadgeClass(results.data.overall_cefr))}>
|
||||
CEFR {formatCefr(results.data.overall_cefr)}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
<Card className="border-0 shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Skill profile</CardTitle>
|
||||
<CardDescription>Relative strength across dimensions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[320px]">
|
||||
{radarData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={radarData}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 11 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} />
|
||||
<Radar
|
||||
name="Score"
|
||||
dataKey="score"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="hsl(var(--primary))"
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No dimension data.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Detailed scores</CardTitle>
|
||||
<CardDescription>Gap to target highlights where to focus first</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Dimension</TableHead>
|
||||
<TableHead className="text-right">Score</TableHead>
|
||||
<TableHead>CEFR</TableHead>
|
||||
<TableHead>Gap</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.data.dimensions.map((row) => (
|
||||
<TableRow key={row.dimension}>
|
||||
<TableCell className="font-medium">{row.dimension}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.score.toFixed(1)}</TableCell>
|
||||
<TableCell>
|
||||
{row.cefr_level ? (
|
||||
<Badge variant="outline">{formatCefr(row.cefr_level)}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{row.gap_to_target}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Speaking:</span>
|
||||
{(speakingPoll.data?.status === "scored" || results.data.speaking_status === "scored") && (
|
||||
<Badge variant="secondary">Scored</Badge>
|
||||
)}
|
||||
{speakingPending && speakingPoll.data?.status !== "scored" && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Learning path preview</CardTitle>
|
||||
<CardDescription>Aligned modules based on your gaps</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{learningPath.isLoading && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-2/3" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{learningPath.error && (
|
||||
<p className="text-sm text-destructive">{learningPath.error.message}</p>
|
||||
)}
|
||||
{learningPath.data && (
|
||||
<>
|
||||
<div className="rounded-xl border bg-muted/30 p-6 space-y-2">
|
||||
<h3 className="text-lg font-semibold">{learningPath.data.course_title}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
~{learningPath.data.estimated_duration_weeks} weeks · {learningPath.data.module_count} modules ·{" "}
|
||||
{learningPath.data.study_hours_per_week} h/week suggested
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">Skill gap modules</h4>
|
||||
<ul className="space-y-3">
|
||||
{[...learningPath.data.modules]
|
||||
.sort((a, b) => {
|
||||
const pr = { high: 0, medium: 1, low: 2 };
|
||||
return pr[a.priority] - pr[b.priority];
|
||||
})
|
||||
.map((m) => (
|
||||
<li
|
||||
key={m.name}
|
||||
className="flex flex-wrap items-baseline justify-between gap-2 rounded-lg border p-3"
|
||||
>
|
||||
<span className="font-medium">{m.name}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant={
|
||||
m.priority === "high" ? "destructive" : m.priority === "medium" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{m.priority}
|
||||
</Badge>
|
||||
<span>{m.estimated_hours}h</span>
|
||||
<span className="capitalize">{m.difficulty}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-primary hover:underline">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
View full course plan
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 rounded-lg border bg-card p-4 text-sm text-muted-foreground leading-relaxed">
|
||||
{learningPath.data.modules.map((m) => (
|
||||
<p key={`${m.name}-detail`} className="mb-2 last:mb-0">
|
||||
<span className="font-medium text-foreground">{m.name}</span> — {m.resource_types.join(", ")}
|
||||
</p>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="lg" onClick={() => navigate("/student/placement/access")}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
frontend/src/pages/student/PlacementTest.tsx
Normal file
364
frontend/src/pages/student/PlacementTest.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Mic, Loader2 } from "lucide-react";
|
||||
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
|
||||
import type { CATQuestion, CATSection } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LocationState = { question?: CATQuestion; sessionId?: string };
|
||||
|
||||
function sectionTitle(s: CATSection): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export default function PlacementTest() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const sessionId = searchParams.get("session") || "";
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const locState = location.state as LocationState | undefined;
|
||||
|
||||
const answerMut = usePlacementAnswer();
|
||||
const autoSave = usePlacementAutoSave();
|
||||
|
||||
const [question, setQuestion] = useState<CATQuestion | null>(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [betweenQuestions, setBetweenQuestions] = useState(false);
|
||||
const [progressInfo, setProgressInfo] = useState<{ current: number; estimated_total: number } | null>(null);
|
||||
const [sectionBridge, setSectionBridge] = useState<{
|
||||
from: CATSection;
|
||||
to: CATSection;
|
||||
nextQuestion: CATQuestion;
|
||||
} | null>(null);
|
||||
|
||||
const [singleAnswer, setSingleAnswer] = useState("");
|
||||
const [multiAnswer, setMultiAnswer] = useState<string[]>([]);
|
||||
const questionStartedAt = useRef<number>(Date.now());
|
||||
const draftRef = useRef<{ question_id: number; answer: string | string[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
navigate("/student/placement", { replace: true });
|
||||
return;
|
||||
}
|
||||
const fromState = locState?.question;
|
||||
const fromStorage = sessionStorage.getItem(`placement_session_${sessionId}`);
|
||||
let parsed: CATQuestion | null = null;
|
||||
if (fromStorage) {
|
||||
try {
|
||||
const j = JSON.parse(fromStorage) as { question?: CATQuestion };
|
||||
parsed = j.question ?? null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
const q = fromState ?? parsed;
|
||||
if (q) {
|
||||
setQuestion(q);
|
||||
questionStartedAt.current = Date.now();
|
||||
}
|
||||
setInitializing(false);
|
||||
}, [sessionId, navigate, locState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
setSingleAnswer("");
|
||||
setMultiAnswer([]);
|
||||
questionStartedAt.current = Date.now();
|
||||
}, [question?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
const id = question.id;
|
||||
if (question.type === "mcq_multi") {
|
||||
draftRef.current = { question_id: id, answer: multiAnswer };
|
||||
} else {
|
||||
draftRef.current = { question_id: id, answer: singleAnswer };
|
||||
}
|
||||
}, [question, singleAnswer, multiAnswer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !draftRef.current) return;
|
||||
const tick = window.setInterval(() => {
|
||||
const d = draftRef.current;
|
||||
if (!d) return;
|
||||
void autoSave.mutate({ session_id: sessionId, current_answer: d });
|
||||
}, 10000);
|
||||
return () => window.clearInterval(tick);
|
||||
}, [sessionId, autoSave]);
|
||||
|
||||
const elapsedRef = useRef(0);
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => {
|
||||
elapsedRef.current += 1;
|
||||
setTick((x) => x + 1);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const formatElapsed = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const buildAnswerPayload = (): string | string[] => {
|
||||
if (!question) return "";
|
||||
if (question.type === "mcq_multi") return multiAnswer;
|
||||
if (question.type === "gap_fill") return singleAnswer.trim();
|
||||
return singleAnswer;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!question || !sessionId) return;
|
||||
const timeSpent = Date.now() - questionStartedAt.current;
|
||||
setBetweenQuestions(true);
|
||||
try {
|
||||
const res = await answerMut.mutateAsync({
|
||||
session_id: sessionId,
|
||||
question_id: question.id,
|
||||
answer: buildAnswerPayload(),
|
||||
time_spent_ms: timeSpent,
|
||||
});
|
||||
if (res.progress) setProgressInfo(res.progress);
|
||||
if (res.test_complete) {
|
||||
navigate(`/student/placement/results?session=${encodeURIComponent(sessionId)}`);
|
||||
return;
|
||||
}
|
||||
if (res.section_complete && res.next_question) {
|
||||
setSectionBridge({
|
||||
from: question.section,
|
||||
to: res.next_question.section,
|
||||
nextQuestion: res.next_question,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res.next_question) {
|
||||
setQuestion(res.next_question);
|
||||
sessionStorage.setItem(`placement_session_${sessionId}`, JSON.stringify({ question: res.next_question }));
|
||||
}
|
||||
} finally {
|
||||
setBetweenQuestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const continueAfterSection = () => {
|
||||
if (!sectionBridge || !sessionId) return;
|
||||
setQuestion(sectionBridge.nextQuestion);
|
||||
sessionStorage.setItem(
|
||||
`placement_session_${sessionId}`,
|
||||
JSON.stringify({ question: sectionBridge.nextQuestion }),
|
||||
);
|
||||
setSectionBridge(null);
|
||||
};
|
||||
|
||||
const toggleMulti = (label: string) => {
|
||||
setMultiAnswer((prev) => (prev.includes(label) ? prev.filter((x) => x !== label) : [...prev, label]));
|
||||
};
|
||||
|
||||
const displayProgress = progressInfo ?? {
|
||||
current: question?.estimated_total ? 1 : 0,
|
||||
estimated_total: question?.estimated_total ?? 0,
|
||||
};
|
||||
|
||||
const approxFinal =
|
||||
displayProgress.estimated_total > 0 && displayProgress.current >= displayProgress.estimated_total;
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
if (!initializing && !question && !sectionBridge) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 bg-background">
|
||||
<Card className="max-w-md w-full border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Session unavailable</CardTitle>
|
||||
<CardDescription>Return to the briefing and start the test again.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => navigate("/student/placement")}>Back</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sectionBridge) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<header className="border-b px-6 py-4 flex items-center justify-between bg-card/80 backdrop-blur">
|
||||
<span className="text-sm font-medium text-muted-foreground">Section complete</span>
|
||||
<span className="text-sm tabular-nums">{formatElapsed(elapsedRef.current)}</span>
|
||||
</header>
|
||||
<main className="flex-1 flex items-center justify-center p-6">
|
||||
<Card className="max-w-lg w-full border-0 shadow-xl text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{sectionTitle(sectionBridge.from)} complete. Next: {sectionTitle(sectionBridge.to)}
|
||||
</CardTitle>
|
||||
<CardDescription>Take a short breath — continue when you're ready.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button size="lg" className="w-full" onClick={continueAfterSection}>
|
||||
Continue
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-b from-muted/20 to-background">
|
||||
<header className="sticky top-0 z-10 border-b px-4 md:px-8 py-3 flex flex-wrap items-center gap-4 justify-between bg-card/90 backdrop-blur supports-[backdrop-filter]:bg-card/80">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<span className="text-sm font-semibold tabular-nums text-primary">{formatElapsed(elapsedRef.current)}</span>
|
||||
<span className="text-sm font-medium truncate">{question ? sectionTitle(question.section) : "…"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-1 max-w-md min-w-[200px]">
|
||||
<Progress
|
||||
value={
|
||||
displayProgress.estimated_total
|
||||
? Math.min(100, (displayProgress.current / displayProgress.estimated_total) * 100)
|
||||
: 0
|
||||
}
|
||||
className="h-2 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
Question {displayProgress.current} of ~{displayProgress.estimated_total || "?"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex items-stretch justify-center p-4 md:p-8">
|
||||
<div className="w-full max-w-3xl">
|
||||
{initializing || betweenQuestions || !question ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="pt-8 space-y-8">
|
||||
{question.passage_text && (
|
||||
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{question.passage_text}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-lg font-medium leading-snug">{question.stem}</p>
|
||||
|
||||
{(question.type === "mcq" || question.type === "definition_match") && question.options && (
|
||||
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
|
||||
{question.options.map((o) => (
|
||||
<label
|
||||
key={o.label}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors",
|
||||
singleAnswer === o.label && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value={o.label} id={`opt-${o.label}`} />
|
||||
<span className="text-sm leading-snug">{o.text}</span>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{question.type === "mcq_multi" && question.options && (
|
||||
<div className="space-y-3">
|
||||
{question.options.map((o) => (
|
||||
<label
|
||||
key={o.label}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<Checkbox
|
||||
checked={multiAnswer.includes(o.label)}
|
||||
onCheckedChange={() => toggleMulti(o.label)}
|
||||
/>
|
||||
<span className="text-sm">{o.text}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.type === "gap_fill" && (
|
||||
<Input
|
||||
value={singleAnswer}
|
||||
onChange={(e) => setSingleAnswer(e.target.value)}
|
||||
placeholder="Your answer"
|
||||
className="text-base"
|
||||
/>
|
||||
)}
|
||||
|
||||
{question.type === "tfng" && (
|
||||
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
|
||||
{(question.options?.length
|
||||
? question.options
|
||||
: [
|
||||
{ label: "T", text: "True" },
|
||||
{ label: "F", text: "False" },
|
||||
{ label: "NG", text: "Not Given" },
|
||||
]
|
||||
).map((o) => (
|
||||
<label
|
||||
key={o.label}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border p-4",
|
||||
singleAnswer === o.label && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value={o.label} id={`tfng-${o.label}`} />
|
||||
<span className="text-sm">{o.text}</span>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{question.type === "audio_recording" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
||||
<Mic className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
Recording will be available in a future update. Use the button below to proceed for now.
|
||||
</p>
|
||||
<Button type="button" variant="secondary" size="lg" disabled>
|
||||
Record (placeholder)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="min-w-[160px]"
|
||||
disabled={answerMut.isPending || betweenQuestions}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{answerMut.isPending || betweenQuestions ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving
|
||||
</>
|
||||
) : approxFinal ? (
|
||||
"Finish Test"
|
||||
) : (
|
||||
"Submit answer"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
frontend/src/pages/student/ProficiencyProfile.tsx
Normal file
117
frontend/src/pages/student/ProficiencyProfile.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowRight, Target, TrendingUp } from "lucide-react";
|
||||
import { useProficiency } from "@/hooks/queries";
|
||||
|
||||
function getMasteryColor(mastery: number): string {
|
||||
if (mastery >= 80) return "text-green-600";
|
||||
if (mastery >= 60) return "text-blue-600";
|
||||
if (mastery >= 40) return "text-yellow-600";
|
||||
if (mastery >= 20) return "text-orange-600";
|
||||
return "text-red-600";
|
||||
}
|
||||
|
||||
function getMasteryBg(mastery: number): string {
|
||||
if (mastery >= 80) return "bg-green-100";
|
||||
if (mastery >= 60) return "bg-blue-100";
|
||||
if (mastery >= 40) return "bg-yellow-100";
|
||||
if (mastery >= 20) return "bg-orange-100";
|
||||
return "bg-red-100";
|
||||
}
|
||||
|
||||
export default function ProficiencyProfile() {
|
||||
const { subjectId } = useParams<{ subjectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: proficiencies = [], isLoading } = useProficiency({ subject_id: Number(subjectId) });
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const domains = [...new Set(proficiencies.map(p => p.domain_name))];
|
||||
const overallMastery = proficiencies.length > 0 ? proficiencies.reduce((sum, p) => sum + p.mastery, 0) / proficiencies.length : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Proficiency Profile</h1>
|
||||
<p className="text-muted-foreground">Your knowledge map across all topics.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button>
|
||||
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ml-1 h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<Target className="h-8 w-8 mx-auto text-primary mb-2" />
|
||||
<div className="text-3xl font-bold">{Math.round(overallMastery)}%</div>
|
||||
<p className="text-sm text-muted-foreground">Overall Mastery</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<TrendingUp className="h-8 w-8 mx-auto text-green-500 mb-2" />
|
||||
<div className="text-3xl font-bold">{proficiencies.filter(p => p.mastery >= 80).length}</div>
|
||||
<p className="text-sm text-muted-foreground">Topics Mastered</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="h-8 w-8 mx-auto text-orange-500 mb-2 text-2xl font-bold">{proficiencies.filter(p => p.mastery < 40).length}</div>
|
||||
<p className="text-sm text-muted-foreground">Topics Needing Work</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{domains.map((domain) => {
|
||||
const topics = proficiencies.filter(p => p.domain_name === domain);
|
||||
const domainMastery = topics.reduce((s, t) => s + t.mastery, 0) / topics.length;
|
||||
|
||||
return (
|
||||
<Card key={domain}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">{domain}</CardTitle>
|
||||
<Badge variant="outline" className={getMasteryColor(domainMastery)}>{Math.round(domainMastery)}%</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{topics.map((topic) => (
|
||||
<div
|
||||
key={topic.topic_id}
|
||||
className={`p-3 rounded-lg border ${getMasteryBg(topic.mastery)} cursor-pointer hover:shadow-sm transition-shadow`}
|
||||
onClick={() => navigate(`/student/topic/${topic.topic_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium truncate">{topic.topic_name}</span>
|
||||
<span className={`text-sm font-semibold ${getMasteryColor(topic.mastery)}`}>{Math.round(topic.mastery)}%</span>
|
||||
</div>
|
||||
<Progress value={topic.mastery} className="h-1.5" />
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<Badge variant="secondary" className="text-xs">{topic.mastery_level}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{proficiencies.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12 text-muted-foreground">
|
||||
<p>No proficiency data yet. Take a diagnostic assessment first.</p>
|
||||
<Button className="mt-4" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Start Diagnostic</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/pages/student/StudentAnnouncements.tsx
Normal file
65
frontend/src/pages/student/StudentAnnouncements.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Megaphone, AlertTriangle, AlertCircle, Info } from "lucide-react";
|
||||
import { useAnnouncements } from "@/hooks/queries";
|
||||
import type { Announcement } from "@/types/communication";
|
||||
|
||||
const priorityConfig: Record<Announcement["priority"], { variant: "destructive" | "default" | "secondary"; icon: React.ReactNode }> = {
|
||||
urgent: { variant: "destructive", icon: <AlertTriangle className="h-4 w-4" /> },
|
||||
important: { variant: "default", icon: <AlertCircle className="h-4 w-4" /> },
|
||||
normal: { variant: "secondary", icon: <Info className="h-4 w-4" /> },
|
||||
};
|
||||
|
||||
export default function StudentAnnouncements() {
|
||||
const { data: announcements = [], isLoading } = useAnnouncements();
|
||||
|
||||
const sorted = [...announcements]
|
||||
.filter(a => a.is_published)
|
||||
.sort((a, b) => new Date(b.published_at ?? b.expires_at ?? "").getTime() - new Date(a.published_at ?? a.expires_at ?? "").getTime());
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Announcements</h1>
|
||||
<p className="text-muted-foreground">Stay up to date with the latest announcements from your courses.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sorted.map(a => {
|
||||
const cfg = priorityConfig[a.priority];
|
||||
return (
|
||||
<Card key={a.id} className={a.priority === "urgent" ? "border-destructive/30" : ""}>
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className={`h-10 w-10 rounded-lg flex items-center justify-center shrink-0 ${a.priority === "urgent" ? "bg-destructive/10 text-destructive" : a.priority === "important" ? "bg-yellow-500/10 text-yellow-600" : "bg-primary/10 text-primary"}`}>
|
||||
{cfg.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{a.title}</span>
|
||||
<Badge variant={cfg.variant}>{a.priority}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{a.content}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
|
||||
<span>{a.author_name}</span>
|
||||
{a.course_name && <><span>·</span><span>{a.course_name}</span></>}
|
||||
{a.published_at && <><span>·</span><span>{new Date(a.published_at).toLocaleDateString()}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{sorted.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Megaphone className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>No announcements right now.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/student/StudentAssignments.tsx
Normal file
93
frontend/src/pages/student/StudentAssignments.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAssignments } from "@/hooks/queries";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import AiWritingHelper from "@/components/ai/AiWritingHelper";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
export default function StudentAssignments() {
|
||||
const [submitOpen, setSubmitOpen] = useState(false);
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<string | null>(null);
|
||||
const [draftText, setDraftText] = useState("");
|
||||
const { toast } = useToast();
|
||||
const { data: assignmentsData, isLoading } = useAssignments();
|
||||
const assignments = assignmentsData?.items ?? [];
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const handleSubmit = () => {
|
||||
setSubmitOpen(false);
|
||||
toast({ title: "Assignment Submitted", description: "Your work has been submitted successfully." });
|
||||
};
|
||||
|
||||
const statusFilter = (status: string) => assignments.filter(a => status === "all" ? true : a.status === status);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Assignments</h1>
|
||||
<p className="text-muted-foreground">View and submit your assignments.</p>
|
||||
</div>
|
||||
|
||||
<AiTipBanner context="student-assignments" variant="recommendation" />
|
||||
|
||||
<Tabs defaultValue="all">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All ({assignments.length})</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pending ({statusFilter("pending").length})</TabsTrigger>
|
||||
<TabsTrigger value="submitted">Submitted ({statusFilter("submitted").length})</TabsTrigger>
|
||||
<TabsTrigger value="graded">Graded ({statusFilter("graded").length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{["all", "pending", "submitted", "graded", "overdue"].map(tab => (
|
||||
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
|
||||
{statusFilter(tab).map(a => (
|
||||
<Card key={a.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm">{a.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.courseName} · Due: {a.dueDate}</p>
|
||||
{a.grade !== undefined && <p className="text-xs font-medium text-primary mt-1">Grade: {a.grade}/{a.maxGrade}</p>}
|
||||
{a.feedback && <p className="text-xs text-muted-foreground mt-0.5">"{a.feedback}"</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant={a.status === "graded" ? "default" : a.status === "overdue" ? "destructive" : "secondary"}>{a.status}</Badge>
|
||||
{a.status === "pending" && (
|
||||
<Button size="sm" onClick={() => { setSelectedAssignment(a.id); setSubmitOpen(true); }}>Submit</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={submitOpen} onOpenChange={setSubmitOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Submit Assignment</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">Drag & drop your file here or click to browse</p>
|
||||
<p className="text-xs mt-1">PDF, DOC, DOCX up to 10MB</p>
|
||||
</div>
|
||||
<Textarea placeholder="Add a comment (optional)..." rows={3} value={draftText} onChange={(e) => setDraftText(e.target.value)} />
|
||||
<AiWritingHelper text={draftText} task_type="ielts_writing" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSubmitOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit}>Submit</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/pages/student/StudentAttendance.tsx
Normal file
75
frontend/src/pages/student/StudentAttendance.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useAttendance } from "@/hooks/queries";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
const badgeVariant = (s: string) => s === "present" ? "default" as const : s === "absent" ? "destructive" as const : "secondary" as const;
|
||||
|
||||
export default function StudentAttendance() {
|
||||
const { data: attendanceRecords = [], isLoading } = useAttendance();
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const counts = { present: 0, absent: 0, late: 0, excused: 0 };
|
||||
attendanceRecords.forEach(a => counts[a.status]++);
|
||||
const total = attendanceRecords.length;
|
||||
const rate = total > 0 ? ((counts.present + counts.late) / total * 100).toFixed(1) : "0";
|
||||
|
||||
const pieData = [
|
||||
{ name: "Present", value: counts.present, color: "hsl(142, 71%, 45%)" },
|
||||
{ name: "Late", value: counts.late, color: "hsl(38, 92%, 50%)" },
|
||||
{ name: "Absent", value: counts.absent, color: "hsl(0, 72%, 51%)" },
|
||||
{ name: "Excused", value: counts.excused, color: "hsl(199, 89%, 48%)" },
|
||||
].filter(d => d.value > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Attendance</h1>
|
||||
<p className="text-muted-foreground">Your attendance record across all courses.</p>
|
||||
</div>
|
||||
|
||||
<AiTipBanner context="student-attendance" variant="insight" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader><CardTitle className="text-lg">Summary</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-4xl font-bold text-primary">{rate}%</p>
|
||||
<p className="text-sm text-muted-foreground">Attendance Rate</p>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" cx="50%" cy="50%" innerRadius={50} outerRadius={80}>
|
||||
{pieData.map((d, i) => <Cell key={i} fill={d.color} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader><CardTitle className="text-lg">Attendance Records</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Date</TableHead><TableHead>Course</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{attendanceRecords.map(a => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell>{a.date}</TableCell>
|
||||
<TableCell>{a.courseName}</TableCell>
|
||||
<TableCell><Badge variant={badgeVariant(a.status)} className="capitalize">{a.status}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/student/StudentChapterView.tsx
Normal file
126
frontend/src/pages/student/StudentChapterView.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { FileText, Video, Image, Link2, Music, Download, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import { useChapter, useChapterMaterials, useChapterProgress, useCompleteChapter, useMarkMaterialViewed } from "@/hooks/queries";
|
||||
import { coursewareService } from "@/services/courseware.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { MaterialType } from "@/types/courseware";
|
||||
|
||||
const typeIcons: Record<MaterialType, React.ReactNode> = {
|
||||
pdf: <FileText className="h-5 w-5" />,
|
||||
document: <FileText className="h-5 w-5" />,
|
||||
video: <Video className="h-5 w-5" />,
|
||||
audio: <Music className="h-5 w-5" />,
|
||||
image: <Image className="h-5 w-5" />,
|
||||
link: <Link2 className="h-5 w-5" />,
|
||||
article: <FileText className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
export default function StudentChapterView() {
|
||||
const { courseId, chapterId } = useParams<{ courseId: string; chapterId: string }>();
|
||||
const chId = Number(chapterId);
|
||||
const { toast } = useToast();
|
||||
const { data: chapter, isLoading: lc } = useChapter(chId);
|
||||
const { data: materials = [], isLoading: lm } = useChapterMaterials(chId);
|
||||
const { data: progress, isLoading: lp } = useChapterProgress(chId);
|
||||
const completeChapter = useCompleteChapter();
|
||||
const markViewed = useMarkMaterialViewed();
|
||||
|
||||
const completionPct = progress ? Math.round((progress.materials_completed / Math.max(progress.materials_total, 1)) * 100) : 0;
|
||||
|
||||
const handleDownload = async (materialId: number, name: string) => {
|
||||
try {
|
||||
const blob = await coursewareService.downloadMaterial(materialId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast({ title: "Error", description: "Download failed", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkComplete = () => {
|
||||
completeChapter.mutate(chId, {
|
||||
onSuccess: () => toast({ title: "Chapter Completed!" }),
|
||||
onError: () => toast({ title: "Error", description: "Failed to mark complete", variant: "destructive" }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenMaterial = (materialId: number) => {
|
||||
markViewed.mutate({ id: materialId, chapterId: chId });
|
||||
};
|
||||
|
||||
if (lc || lm || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{chapter && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{chapter.name}</h1>
|
||||
{chapter.description && <p className="text-muted-foreground mt-1">{chapter.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Progress</span>
|
||||
<span className="text-sm text-muted-foreground">{progress.materials_completed} / {progress.materials_total} materials</span>
|
||||
</div>
|
||||
<Progress value={completionPct} className="h-2" />
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<span className="text-sm text-muted-foreground">{completionPct}% complete</span>
|
||||
{progress.status !== "completed" && (
|
||||
<Button size="sm" onClick={handleMarkComplete} disabled={completeChapter.isPending}>
|
||||
{completeChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" /> Mark Complete
|
||||
</Button>
|
||||
)}
|
||||
{progress.status === "completed" && (
|
||||
<Badge variant="default"><CheckCircle2 className="mr-1 h-3 w-3" /> Completed</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{materials
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.map(m => (
|
||||
<Card key={m.id} className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => handleOpenMaterial(m.id)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 text-primary">
|
||||
{typeIcons[m.type]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{m.name}</p>
|
||||
<Badge variant="outline" className="mt-1 text-xs">{m.type}</Badge>
|
||||
</div>
|
||||
{m.allow_download && (
|
||||
<Button variant="ghost" size="icon" className="shrink-0" onClick={(e) => { e.stopPropagation(); handleDownload(m.id, m.name); }}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{materials.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>No materials available in this chapter.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/student/StudentCourseDetail.tsx
Normal file
126
frontend/src/pages/student/StudentCourseDetail.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCourses, useAssignments, useAttendance, useGrades } from "@/hooks/queries";
|
||||
import { ArrowLeft, CheckCircle, Circle, FileText, Video, HelpCircle } from "lucide-react";
|
||||
|
||||
const iconMap = { video: Video, reading: FileText, quiz: HelpCircle };
|
||||
|
||||
export default function StudentCourseDetail() {
|
||||
const { id } = useParams();
|
||||
const { data: coursesData, isLoading: lc } = useCourses();
|
||||
const { data: assignmentsData, isLoading: la } = useAssignments();
|
||||
const { data: attendanceData, isLoading: lat } = useAttendance();
|
||||
const { data: gradesData, isLoading: lg } = useGrades();
|
||||
const courses = coursesData?.items ?? [];
|
||||
const assignments = assignmentsData?.items ?? [];
|
||||
const attendanceRecords = attendanceData ?? [];
|
||||
const gradeRecords = gradesData ?? [];
|
||||
if (lc || la || lat || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const course = courses.find(c => c.id === id);
|
||||
|
||||
if (!course) return <div className="p-8 text-center text-muted-foreground">Course not found.</div>;
|
||||
|
||||
const courseAssignments = assignments.filter(a => a.courseId === id);
|
||||
const courseAttendance = attendanceRecords.filter(a => a.courseId === id);
|
||||
const courseGrades = gradeRecords.filter(g => g.courseId === id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4" /></Link></Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{course.title}</h1>
|
||||
<p className="text-muted-foreground">{course.instructor} · {course.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">Overall Progress</span>
|
||||
<span className="text-sm font-bold">{course.progress}%</span>
|
||||
</div>
|
||||
<Progress value={course.progress} className="h-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="materials">
|
||||
<TabsList>
|
||||
<TabsTrigger value="materials">Materials</TabsTrigger>
|
||||
<TabsTrigger value="assignments">Assignments ({courseAssignments.length})</TabsTrigger>
|
||||
<TabsTrigger value="attendance">Attendance</TabsTrigger>
|
||||
<TabsTrigger value="grades">Grades</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="materials" className="space-y-4 mt-4">
|
||||
{course.modules.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm py-8 text-center">No modules available yet.</p>
|
||||
) : course.modules.map(mod => (
|
||||
<Card key={mod.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{mod.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{mod.lessons.map(lesson => {
|
||||
const Icon = iconMap[lesson.type];
|
||||
return (
|
||||
<div key={lesson.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50">
|
||||
{lesson.completed ? <CheckCircle className="h-4 w-4 text-success shrink-0" /> : <Circle className="h-4 w-4 text-muted-foreground shrink-0" />}
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm flex-1">{lesson.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{lesson.duration}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="assignments" className="mt-4 space-y-3">
|
||||
{courseAssignments.map(a => (
|
||||
<Card key={a.id}>
|
||||
<CardContent className="pt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{a.title}</p>
|
||||
<p className="text-xs text-muted-foreground">Due: {a.dueDate}</p>
|
||||
</div>
|
||||
<Badge variant={a.status === "graded" ? "default" : a.status === "overdue" ? "destructive" : "secondary"}>
|
||||
{a.status}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="attendance" className="mt-4 space-y-3">
|
||||
{courseAttendance.map(a => (
|
||||
<div key={a.id} className="flex items-center justify-between p-3 rounded border">
|
||||
<span className="text-sm">{a.date}</span>
|
||||
<Badge variant={a.status === "present" ? "default" : a.status === "absent" ? "destructive" : "secondary"}>
|
||||
{a.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="grades" className="mt-4 space-y-3">
|
||||
{courseGrades.map(g => (
|
||||
<div key={g.id} className="flex items-center justify-between p-3 rounded border">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{g.assignmentTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">{g.date}</p>
|
||||
</div>
|
||||
<span className="font-bold text-primary">{g.grade}/{g.maxGrade}</span>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/src/pages/student/StudentCourses.tsx
Normal file
58
frontend/src/pages/student/StudentCourses.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useCourses } from "@/hooks/queries";
|
||||
import { BookOpen, Users, Clock } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
export default function StudentCourses() {
|
||||
const { data: coursesData, isLoading } = useCourses();
|
||||
const courses = coursesData?.items ?? [];
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">My Courses</h1>
|
||||
<p className="text-muted-foreground">Browse and continue your enrolled courses.</p>
|
||||
</div>
|
||||
|
||||
<AiTipBanner context="student-courses" variant="recommendation" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{myCourses.map((c) => (
|
||||
<Link to={`/student/courses/${c.id}`} key={c.id}>
|
||||
<Card className="hover:shadow-md transition-shadow h-full">
|
||||
<div className="h-2 rounded-t-lg bg-primary" />
|
||||
<CardContent className="pt-5 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge variant="secondary" className="text-xs">{c.level}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{c.code}</Badge>
|
||||
</div>
|
||||
<h3 className="font-semibold mt-2">{c.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{c.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.modules.length} modules</span>
|
||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.enrolled} students</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-medium">{c.progress}%</span>
|
||||
</div>
|
||||
<Progress value={c.progress} className="h-2" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1"><Clock className="h-3 w-3" /> {c.instructor}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/src/pages/student/StudentDashboard.tsx
Normal file
115
frontend/src/pages/student/StudentDashboard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useCourses, useAssignments, useGrades } from "@/hooks/queries";
|
||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
export default function StudentDashboard() {
|
||||
const { data: coursesData, isLoading: lc } = useCourses();
|
||||
const { data: assignmentsData, isLoading: la } = useAssignments();
|
||||
const { data: gradesData, isLoading: lg } = useGrades();
|
||||
const courses = coursesData?.items ?? [];
|
||||
const assignments = assignmentsData?.items ?? [];
|
||||
const gradeRecords = gradesData ?? [];
|
||||
if (lc || la || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
|
||||
const upcomingAssignments = assignments.filter(a => a.status === "pending").slice(0, 3);
|
||||
const recentGrades = gradeRecords.slice(0, 3);
|
||||
const stats = [
|
||||
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
||||
{ label: "Pending Assignments", value: String(assignments.filter(a => a.status === "pending").length), icon: ClipboardList, color: "text-warning" },
|
||||
{ label: "Average Grade", value: "78%", icon: BarChart3, color: "text-success" },
|
||||
{ label: "Attendance Rate", value: "94%", icon: Calendar, color: "text-info" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Welcome back, Sarah!</h1>
|
||||
<p className="text-muted-foreground">Here's an overview of your learning progress.</p>
|
||||
</div>
|
||||
|
||||
<AiTipBanner context="student-dashboard" variant="tip" />
|
||||
|
||||
<AiStudyCoach />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-bold">{s.value}</p>
|
||||
</div>
|
||||
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">My Courses</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild><Link to="/student/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{myCourses.map((c) => (
|
||||
<Link to={`/student/courses/${c.id}`} key={c.id} className="block">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{c.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{c.instructor}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="w-24"><Progress value={c.progress} className="h-2" /></div>
|
||||
<span className="text-xs font-medium w-8 text-right">{c.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">Upcoming Assignments</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild><Link to="/student/assignments">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{upcomingAssignments.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between p-2 rounded border">
|
||||
<div><p className="text-sm font-medium">{a.title}</p><p className="text-xs text-muted-foreground">{a.courseName}</p></div>
|
||||
<Badge variant="outline" className="text-xs">{a.dueDate}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">Recent Grades</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild><Link to="/student/grades">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recentGrades.map((g) => (
|
||||
<div key={g.id} className="flex items-center justify-between p-2 rounded border">
|
||||
<div><p className="text-sm font-medium">{g.assignmentTitle}</p><p className="text-xs text-muted-foreground">{g.courseName}</p></div>
|
||||
<span className="text-sm font-bold text-primary">{g.grade}/{g.maxGrade}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
frontend/src/pages/student/StudentDiscussionBoard.tsx
Normal file
176
frontend/src/pages/student/StudentDiscussionBoard.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MessageSquare, Pin, CheckCircle2, Loader2, ArrowLeft, Send, Users } from "lucide-react";
|
||||
import { useDiscussionBoards, usePosts, useCreatePost } from "@/hooks/queries";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { DiscussionPost } from "@/types/communication";
|
||||
|
||||
function PostItem({ post, boardId }: { post: DiscussionPost; boardId: number }) {
|
||||
const [showReply, setShowReply] = useState(false);
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
const createPost = useCreatePost();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleReply = () => {
|
||||
createPost.mutate(
|
||||
{ boardId, data: { board_id: boardId, parent_id: post.id, content: replyContent } },
|
||||
{
|
||||
onSuccess: () => { setShowReply(false); setReplyContent(""); },
|
||||
onError: () => toast({ title: "Error", description: "Failed to reply", variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className={`p-4 rounded-lg border ${post.is_pinned ? "border-primary/30 bg-primary/5" : ""}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{post.title && <p className="font-medium">{post.title}</p>}
|
||||
<p className="text-sm mt-1">{post.content}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
|
||||
<span>{post.author_name}</span>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-xs">{post.author_role}</Badge>
|
||||
<span>·</span>
|
||||
<span>{new Date(post.created_at).toLocaleDateString()}</span>
|
||||
{post.is_pinned && <Badge variant="secondary" className="text-xs"><Pin className="h-3 w-3 mr-1" />Pinned</Badge>}
|
||||
{post.is_resolved && <Badge variant="default" className="text-xs"><CheckCircle2 className="h-3 w-3 mr-1" />Resolved</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowReply(!showReply)}>Reply</Button>
|
||||
</div>
|
||||
{showReply && (
|
||||
<div className="flex gap-2 mt-3 pt-3 border-t">
|
||||
<Input placeholder="Write a reply..." value={replyContent} onChange={e => setReplyContent(e.target.value)} className="flex-1" />
|
||||
<Button size="sm" onClick={handleReply} disabled={createPost.isPending || !replyContent}>
|
||||
{createPost.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{post.replies && post.replies.length > 0 && (
|
||||
<div className="ml-6 space-y-2">
|
||||
{post.replies.map(reply => (
|
||||
<PostItem key={reply.id} post={reply} boardId={boardId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudentDiscussionBoard() {
|
||||
const { toast } = useToast();
|
||||
const { data: boards = [], isLoading } = useDiscussionBoards();
|
||||
const [selectedBoardId, setSelectedBoardId] = useState<number | null>(null);
|
||||
const { data: postsData, isLoading: loadingPosts } = usePosts(selectedBoardId ?? 0);
|
||||
const createPost = useCreatePost();
|
||||
|
||||
const [showNewPost, setShowNewPost] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newContent, setNewContent] = useState("");
|
||||
|
||||
const posts = postsData?.results ?? [];
|
||||
|
||||
const handleCreatePost = () => {
|
||||
if (!selectedBoardId) return;
|
||||
createPost.mutate(
|
||||
{ boardId: selectedBoardId, data: { board_id: selectedBoardId, title: newTitle, content: newContent } },
|
||||
{
|
||||
onSuccess: () => { toast({ title: "Post Created" }); setShowNewPost(false); setNewTitle(""); setNewContent(""); },
|
||||
onError: () => toast({ title: "Error", description: "Failed to create post", variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Discussion Boards</h1>
|
||||
<p className="text-muted-foreground">Participate in course discussions with your classmates.</p>
|
||||
</div>
|
||||
|
||||
{!selectedBoardId ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{boards.map(board => (
|
||||
<Card key={board.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedBoardId(board.id)}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{board.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{board.course_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>{board.post_count} posts</span>
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{boards.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>No discussion boards available for your courses.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
|
||||
</Button>
|
||||
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
|
||||
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Post</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Title</Label>
|
||||
<Input placeholder="Post title" value={newTitle} onChange={e => setNewTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content</Label>
|
||||
<Textarea placeholder="Write your post..." rows={4} value={newContent} onChange={e => setNewContent(e.target.value)} />
|
||||
</div>
|
||||
<Button className="w-full" onClick={handleCreatePost} disabled={createPost.isPending || !newContent}>
|
||||
{createPost.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{loadingPosts ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{posts.map(post => (
|
||||
<PostItem key={post.id} post={post} boardId={selectedBoardId} />
|
||||
))}
|
||||
{posts.length === 0 && (
|
||||
<Card><CardContent className="py-8 text-center text-muted-foreground">No posts yet. Start the conversation!</CardContent></Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/pages/student/StudentGrades.tsx
Normal file
86
frontend/src/pages/student/StudentGrades.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useGrades } from "@/hooks/queries";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
|
||||
export default function StudentGrades() {
|
||||
const { data: gradeRecords = [], isLoading } = useGrades();
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const avgGrade = gradeRecords.length
|
||||
? (gradeRecords.reduce((sum, g) => sum + (g.grade / g.maxGrade) * 100, 0) / gradeRecords.length).toFixed(1)
|
||||
: "0";
|
||||
const highest = gradeRecords.length
|
||||
? Math.max(...gradeRecords.map(g => (g.grade / g.maxGrade) * 100)).toFixed(1)
|
||||
: "0";
|
||||
|
||||
const chartData = gradeRecords.map(g => ({
|
||||
name: g.assignmentTitle.length > 15 ? g.assignmentTitle.slice(0, 15) + "…" : g.assignmentTitle,
|
||||
grade: Math.round((g.grade / g.maxGrade) * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Grades</h1>
|
||||
<p className="text-muted-foreground">Track your academic performance.</p>
|
||||
</div>
|
||||
|
||||
<AiReportNarrative narrative={`Your average grade is ${avgGrade}%. Your strongest area is essay writing with consistent scores above 80%. Focus on improving speaking scores — your last mock test scored 72%, which is below your average. AI recommends practicing with the IELTS Speaking Masterclass materials.`} />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Average</p><p className="text-3xl font-bold text-primary">{avgGrade}%</p></CardContent></Card>
|
||||
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Highest</p><p className="text-3xl font-bold text-success">{highest}%</p></CardContent></Card>
|
||||
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Graded Items</p><p className="text-3xl font-bold">{gradeRecords.length}</p></CardContent></Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Grade Distribution</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} className="fill-muted-foreground" />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} className="fill-muted-foreground" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="grade" fill="hsl(192, 82%, 32%)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">All Grades</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Grade</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{gradeRecords.map(g => (
|
||||
<TableRow key={g.id}>
|
||||
<TableCell className="font-medium">{g.assignmentTitle}</TableCell>
|
||||
<TableCell>{g.courseName}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-xs capitalize">{g.type}</Badge></TableCell>
|
||||
<TableCell>{g.date}</TableCell>
|
||||
<TableCell className="text-right font-bold">{g.grade}/{g.maxGrade}</TableCell>
|
||||
<TableCell><AiGradeExplainer studentName="Sarah Johnson" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/pages/student/StudentJourney.tsx
Normal file
111
frontend/src/pages/student/StudentJourney.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BookOpen, TrendingUp, Activity, CheckCircle2, FileText, ClipboardList } from "lucide-react";
|
||||
import { useProficiencySummary } from "@/hooks/queries";
|
||||
import { analyticsService } from "@/services/analytics.service";
|
||||
import { adaptiveService } from "@/services/adaptive.service";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function StudentJourney() {
|
||||
const { data: summaries = [], isLoading: ls } = useProficiencySummary();
|
||||
|
||||
const { data: analyticsData, isLoading: la } = useQuery({
|
||||
queryKey: ["analytics", "student", "journey"],
|
||||
queryFn: () => analyticsService.getStudentAnalytics(),
|
||||
});
|
||||
|
||||
const { data: planData, isLoading: lp } = useQuery({
|
||||
queryKey: ["adaptive", "plan", "all"],
|
||||
queryFn: () => adaptiveService.getLearningPlan({}),
|
||||
});
|
||||
|
||||
const analytics = analyticsData as { recent_activity?: { action: string; description: string; date: string }[] } | undefined;
|
||||
const recentActivity = analytics?.recent_activity?.slice(0, 10) ?? [];
|
||||
|
||||
if (ls || la || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const activityIcons: Record<string, React.ReactNode> = {
|
||||
exam: <ClipboardList className="h-4 w-4" />,
|
||||
assignment: <FileText className="h-4 w-4" />,
|
||||
chapter: <BookOpen className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">My Learning Journey</h1>
|
||||
<p className="text-muted-foreground">Track your progress and proficiency across all subjects.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summaries.map(s => {
|
||||
const mastery = Math.round(s.overall_mastery ?? 0);
|
||||
return (
|
||||
<Card key={s.subject_id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{s.subject_name ?? `Subject ${s.subject_id}`}</CardTitle>
|
||||
<CardDescription>{s.topics_mastered ?? 0} / {s.topics_total ?? 0} topics mastered</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Overall Mastery</span>
|
||||
<span className="font-medium">{mastery}%</span>
|
||||
</div>
|
||||
<Progress value={mastery} className="h-2" />
|
||||
</div>
|
||||
<Badge variant={mastery >= 80 ? "default" : mastery >= 50 ? "secondary" : "outline"}>
|
||||
{mastery >= 80 ? "Proficient" : mastery >= 50 ? "Developing" : "Beginner"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{summaries.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<TrendingUp className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>Take a diagnostic assessment to start tracking your journey.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Your last 10 actions across all courses.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentActivity.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentActivity.map((act, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
{activityIcons[act.action] ?? <CheckCircle2 className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{act.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(act.date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs shrink-0">{act.action}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No recent activity to show.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
frontend/src/pages/student/StudentMessages.tsx
Normal file
171
frontend/src/pages/student/StudentMessages.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Mail, Send, Loader2, Inbox, PenSquare } from "lucide-react";
|
||||
import { useMessages, useSentMessages, useUnreadMessageCount, useSendMessage } from "@/hooks/queries";
|
||||
import { communicationService } from "@/services/communication.service";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { Message } from "@/types/communication";
|
||||
|
||||
export default function StudentMessages() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: inboxData, isLoading: li } = useMessages();
|
||||
const { data: sentData, isLoading: ls } = useSentMessages();
|
||||
const { data: unreadData } = useUnreadMessageCount();
|
||||
const sendMessage = useSendMessage();
|
||||
|
||||
const inbox = inboxData?.results ?? [];
|
||||
const sent = sentData?.results ?? [];
|
||||
const unreadCount = unreadData?.count ?? 0;
|
||||
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const [recipientId, setRecipientId] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: (id: number) => communicationService.getMessage(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["messages"] });
|
||||
qc.invalidateQueries({ queryKey: ["messages", "unread-count"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenMessage = (msg: Message) => {
|
||||
setSelectedMessage(msg);
|
||||
setShowDetail(true);
|
||||
if (!msg.is_read) markRead.mutate(msg.id);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
sendMessage.mutate(
|
||||
{ recipient_id: Number(recipientId), subject, content },
|
||||
{
|
||||
onSuccess: () => { toast({ title: "Message Sent" }); setShowCompose(false); setRecipientId(""); setSubject(""); setContent(""); },
|
||||
onError: () => toast({ title: "Error", description: "Failed to send message", variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (li || ls) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Messages</h1>
|
||||
<p className="text-muted-foreground">Communicate with your classmates and teachers.</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCompose(true)}>
|
||||
<PenSquare className="mr-2 h-4 w-4" /> New Message
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="inbox">
|
||||
<TabsList>
|
||||
<TabsTrigger value="inbox" className="gap-2">
|
||||
<Inbox className="h-4 w-4" /> Inbox
|
||||
{unreadCount > 0 && <Badge variant="destructive" className="ml-1 text-xs px-1.5">{unreadCount}</Badge>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sent" className="gap-2">
|
||||
<Send className="h-4 w-4" /> Sent
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inbox" className="space-y-2 mt-4">
|
||||
{inbox.map(msg => (
|
||||
<Card key={msg.id} className={`cursor-pointer hover:shadow-sm transition-shadow ${!msg.is_read ? "border-primary/30 bg-primary/5" : ""}`} onClick={() => handleOpenMessage(msg)}>
|
||||
<CardContent className="flex items-center gap-4 py-3">
|
||||
<Mail className={`h-5 w-5 shrink-0 ${!msg.is_read ? "text-primary" : "text-muted-foreground"}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm truncate ${!msg.is_read ? "font-semibold" : ""}`}>{msg.subject}</span>
|
||||
{!msg.is_read && <Badge variant="default" className="text-xs">New</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">From: {msg.sender_name}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{new Date(msg.created_at).toLocaleDateString()}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{inbox.length === 0 && (
|
||||
<Card><CardContent className="py-8 text-center text-muted-foreground">Your inbox is empty.</CardContent></Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="sent" className="space-y-2 mt-4">
|
||||
{sent.map(msg => (
|
||||
<Card key={msg.id} className="cursor-pointer hover:shadow-sm transition-shadow" onClick={() => handleOpenMessage(msg)}>
|
||||
<CardContent className="flex items-center gap-4 py-3">
|
||||
<Send className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate">{msg.subject}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">To: {msg.recipient_name}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{new Date(msg.created_at).toLocaleDateString()}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{sent.length === 0 && (
|
||||
<Card><CardContent className="py-8 text-center text-muted-foreground">No sent messages.</CardContent></Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Sheet open={showDetail} onOpenChange={setShowDetail}>
|
||||
<SheetContent className="sm:max-w-lg">
|
||||
{selectedMessage && (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{selectedMessage.subject}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>From: {selectedMessage.sender_name}</span>
|
||||
<span>·</span>
|
||||
<span>To: {selectedMessage.recipient_name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{new Date(selectedMessage.created_at).toLocaleString()}</span>
|
||||
<div className="p-4 rounded-lg border bg-muted/30 text-sm whitespace-pre-wrap">{selectedMessage.content}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Dialog open={showCompose} onOpenChange={setShowCompose}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Message</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Recipient ID</Label>
|
||||
<Input type="number" placeholder="Enter recipient user ID" value={recipientId} onChange={e => setRecipientId(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subject</Label>
|
||||
<Input placeholder="Message subject" value={subject} onChange={e => setSubject(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content</Label>
|
||||
<Textarea placeholder="Write your message..." rows={4} value={content} onChange={e => setContent(e.target.value)} />
|
||||
</div>
|
||||
<Button className="w-full" onClick={handleSend} disabled={sendMessage.isPending || !recipientId || !subject || !content}>
|
||||
{sendMessage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Send className="mr-2 h-4 w-4" /> Send
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
frontend/src/pages/student/StudentProfile.tsx
Normal file
61
frontend/src/pages/student/StudentProfile.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { User } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function StudentProfile() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Profile</h1>
|
||||
<p className="text-muted-foreground">Manage your account information.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Personal Information</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-primary">{(user?.name?.split(" ") ?? []).map(w => w[0]).join("").slice(0, 2).toUpperCase() || "??"}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">Change Avatar</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>First Name</Label>
|
||||
<Input defaultValue={user?.name.split(" ")[0]} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Last Name</Label>
|
||||
<Input defaultValue={user?.name.split(" ")[1]} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input defaultValue={user?.email} type="email" />
|
||||
</div>
|
||||
<Button onClick={() => toast({ title: "Profile Updated", description: "Your information has been saved." })}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Change Password</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2"><Label>Current Password</Label><Input type="password" /></div>
|
||||
<div className="space-y-2"><Label>New Password</Label><Input type="password" /></div>
|
||||
<div className="space-y-2"><Label>Confirm Password</Label><Input type="password" /></div>
|
||||
<Button onClick={() => toast({ title: "Password Updated" })}>Update Password</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/pages/student/StudentTimetable.tsx
Normal file
74
frontend/src/pages/student/StudentTimetable.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTimetable } from "@/hooks/queries";
|
||||
|
||||
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] as const;
|
||||
const hours = ["08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00", "17:00"];
|
||||
|
||||
export default function StudentTimetable() {
|
||||
const { data: timetableSessions = [], isLoading } = useTimetable();
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const mySessions = timetableSessions.filter(s => ["c1", "c2", "c5", "c8"].includes(s.courseId));
|
||||
|
||||
function getSessionAt(day: string, hour: string) {
|
||||
return mySessions.find(s => s.day === day && s.startTime <= hour && s.endTime > hour);
|
||||
}
|
||||
|
||||
function isStart(day: string, hour: string) {
|
||||
return mySessions.find(s => s.day === day && s.startTime === hour);
|
||||
}
|
||||
|
||||
function getSpan(session: (typeof mySessions)[0]) {
|
||||
const start = parseInt(session.startTime.split(":")[0]);
|
||||
const end = parseInt(session.endTime.split(":")[0]);
|
||||
const startMin = parseInt(session.startTime.split(":")[1]);
|
||||
const endMin = parseInt(session.endTime.split(":")[1]);
|
||||
return (end - start) + (endMin > 0 ? 0.5 : 0) - (startMin > 0 ? 0.5 : 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Timetable</h1>
|
||||
<p className="text-muted-foreground">Your weekly class schedule.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 overflow-x-auto">
|
||||
<div className="min-w-[700px]">
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
<div className="text-xs font-medium text-muted-foreground p-2" />
|
||||
{days.map(d => (
|
||||
<div key={d} className="text-xs font-semibold text-center p-2 bg-muted rounded">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
{hours.map(hour => (
|
||||
<div key={hour} className="grid grid-cols-6 gap-1 min-h-[48px]">
|
||||
<div className="text-xs text-muted-foreground p-2 flex items-start">{hour}</div>
|
||||
{days.map(day => {
|
||||
const session = isStart(day, hour);
|
||||
const occupied = getSessionAt(day, hour);
|
||||
if (session) {
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className="rounded-md p-2 text-xs text-white"
|
||||
style={{ backgroundColor: session.color, minHeight: `${getSpan(session) * 48}px` }}
|
||||
>
|
||||
<p className="font-semibold">{session.courseName}</p>
|
||||
<p className="opacity-80">{session.startTime}-{session.endTime}</p>
|
||||
<p className="opacity-70">{session.room}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (occupied && !session) return <div key={day} />;
|
||||
return <div key={day} className="border border-dashed border-border/50 rounded-md" />;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
frontend/src/pages/student/SubjectRegistrationPage.tsx
Normal file
145
frontend/src/pages/student/SubjectRegistrationPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { institutionalExamService } from "@/services/institutional-exam.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { SubjectRegistration } from "@/types/institutional-exam";
|
||||
|
||||
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "outline",
|
||||
confirm: "default",
|
||||
reject: "destructive",
|
||||
done: "secondary",
|
||||
};
|
||||
|
||||
export default function SubjectRegistrationPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [selectedSubjects, setSelectedSubjects] = useState<number[]>([]);
|
||||
|
||||
const { data: registrationsData, isLoading } = useQuery({
|
||||
queryKey: ["subject-registrations", "list"],
|
||||
queryFn: () => institutionalExamService.listSubjectRegistrations(),
|
||||
});
|
||||
const registrations = registrationsData?.items ?? [];
|
||||
|
||||
const { data: available = [], isLoading: loadingAvailable } = useQuery({
|
||||
queryKey: ["subject-registrations", "available"],
|
||||
queryFn: () => institutionalExamService.getAvailableSubjects(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => institutionalExamService.createSubjectRegistration({
|
||||
subject_ids: selectedSubjects,
|
||||
} as Parameters<typeof institutionalExamService.createSubjectRegistration>[0]),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["subject-registrations"] });
|
||||
toast({ title: "Registration submitted" });
|
||||
setSelectedSubjects([]);
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Registration failed", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const toggleSubject = (id: number) => {
|
||||
setSelectedSubjects(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Subject Registration</h1>
|
||||
<p className="text-muted-foreground">Register for subjects within your enrolled courses.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available Subjects</CardTitle>
|
||||
<CardDescription>Select the subjects you want to register for this term.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingAvailable ? (
|
||||
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : available.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{available.map((subj: SubjectRegistration) => (
|
||||
<label
|
||||
key={subj.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedSubjects.includes(subj.id)}
|
||||
onCheckedChange={() => toggleSubject(subj.id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{subj.subject_names?.join(", ") || subj.course_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{subj.course_name} · {subj.batch_name}
|
||||
{subj.min_unit_load > 0 && ` · Load: ${subj.min_unit_load}–${subj.max_unit_load}`}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending || selectedSubjects.length === 0}>
|
||||
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Submit Registration ({selectedSubjects.length} selected)
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No subjects available for registration.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>My Registrations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Subjects</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{registrations.map((reg: SubjectRegistration) => (
|
||||
<TableRow key={reg.id}>
|
||||
<TableCell className="font-medium">{reg.course_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{reg.batch_name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{reg.subject_names.slice(0, 3).map((name, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">{name}</Badge>
|
||||
))}
|
||||
{reg.subject_names.length > 3 && <Badge variant="secondary" className="text-xs">+{reg.subject_names.length - 3}</Badge>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={stateBadgeVariant[reg.state] ?? "outline"} className="capitalize">{reg.state}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{registrations.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">No registrations yet.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/pages/student/SubjectSelection.tsx
Normal file
86
frontend/src/pages/student/SubjectSelection.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BookOpen, ArrowRight } from "lucide-react";
|
||||
import { useSubjects, useProficiencySummary } from "@/hooks/queries";
|
||||
|
||||
export default function SubjectSelection() {
|
||||
const navigate = useNavigate();
|
||||
const { data: subjects = [], isLoading: ls } = useSubjects();
|
||||
const { data: summaries = [], isLoading: lp } = useProficiencySummary();
|
||||
|
||||
if (ls || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const getSummary = (subjectId: number) => summaries.find(s => s.subject_id === subjectId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">My Subjects</h1>
|
||||
<p className="text-muted-foreground">Select a subject to view your progress or start a diagnostic assessment.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{subjects.map((subject) => {
|
||||
const summary = getSummary(subject.id);
|
||||
const mastery = summary?.overall_mastery ?? 0;
|
||||
const hasTaken = !!summary;
|
||||
|
||||
return (
|
||||
<Card key={subject.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{subject.name}</CardTitle>
|
||||
<CardDescription>{subject.code}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasTaken ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Overall Mastery</span>
|
||||
<span className="font-medium">{Math.round(mastery)}%</span>
|
||||
</div>
|
||||
<Progress value={mastery} className="h-2" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{summary?.topics_mastered ?? 0} / {summary?.topics_total ?? 0} topics mastered</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => navigate(`/student/proficiency/${subject.id}`)}>
|
||||
View Profile
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1" onClick={() => navigate(`/student/plan/${subject.id}`)}>
|
||||
Learning Plan <ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Take a diagnostic assessment to discover your proficiency level and get a personalized learning plan.</p>
|
||||
<Button className="w-full" onClick={() => navigate(`/student/diagnostic/${subject.id}`)}>
|
||||
Start Diagnostic <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{subjects.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>No subjects available yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/student/TopicLearning.tsx
Normal file
126
frontend/src/pages/student/TopicLearning.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FileText, Video, Link2, BookOpen, MessageSquare, Trophy, ExternalLink } from "lucide-react";
|
||||
import { useTopicContent } from "@/hooks/queries";
|
||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||
|
||||
const resourceIcons: Record<string, ReactNode> = {
|
||||
pdf: <FileText className="h-4 w-4" />,
|
||||
video: <Video className="h-4 w-4" />,
|
||||
link: <Link2 className="h-4 w-4" />,
|
||||
document: <BookOpen className="h-4 w-4" />,
|
||||
interactive: <MessageSquare className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
export default function TopicLearning() {
|
||||
const { topicId } = useParams<{ topicId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: topicContent, isLoading } = useTopicContent(Number(topicId));
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
if (!topicContent) {
|
||||
return (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>Topic content not found.</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>Go Back</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{topicContent.topic_name}</h1>
|
||||
{topicContent.has_content_gap && (
|
||||
<Badge variant="secondary" className="mt-2">AI-supplemented content</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{topicContent.ai_content && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
AI Explanation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm leading-relaxed">{topicContent.ai_content.explanation}</p>
|
||||
{topicContent.ai_content.key_points.length > 0 && (
|
||||
<>
|
||||
<h4 className="font-medium text-sm">Key Points</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-sm text-muted-foreground">
|
||||
{topicContent.ai_content.key_points.map((point, i) => (
|
||||
<li key={i}>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{topicContent.ai_content.examples.length > 0 && (
|
||||
<>
|
||||
<h4 className="font-medium text-sm">Examples</h4>
|
||||
{topicContent.ai_content.examples.map((ex, i) => (
|
||||
<div key={i} className="p-3 bg-accent/50 rounded-lg text-sm">{ex}</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{topicContent.resources.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Resources</CardTitle>
|
||||
<CardDescription>{topicContent.resources.length} resources available</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{topicContent.resources.map((resource) => (
|
||||
<div key={resource.id} className="flex items-center justify-between p-3 rounded-lg border hover:bg-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{resourceIcons[resource.resource_type] ?? <FileText className="h-4 w-4" />}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{resource.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{resource.resource_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
{resource.url && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href={resource.url} target="_blank" rel="noopener noreferrer">
|
||||
Open <ExternalLink className="ml-1 h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" className="flex-1">
|
||||
<BookOpen className="mr-2 h-4 w-4" /> Practice Questions
|
||||
</Button>
|
||||
<Button className="flex-1">
|
||||
<Trophy className="mr-2 h-4 w-4" /> Mastery Quiz
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<AiStudyCoach />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user