- Backend: AI generation fallbacks when OpenAI not configured, full exam submission saving all params (difficulty, rubric, entity, grading system, approval workflow) and creating linked question records per section - Backend: new exam session controller with get_session, autosave, submit, status, and results endpoints; student attempt/answer/score models - Backend: new controllers for entities, approval workflows, exam schedules - Frontend: exam session split-layout with passage panel, question types (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog - Frontend: results page with percentage score, per-answer breakdown table - Frontend: generation page dynamic dropdowns, full payload submission - Frontend: updated types for ExamSessionSection, ExamQuestion options Made-with: Cursor
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
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, Loader2 } from "lucide-react";
|
||
import { examSessionService } from "@/services/exam-session.service";
|
||
import { reportService } from "@/services/report.service";
|
||
import { useState } from "react";
|
||
|
||
interface ScoreEntry {
|
||
skill: string;
|
||
band_score: number;
|
||
raw_score: number;
|
||
max_score: number;
|
||
cefr_level: string;
|
||
}
|
||
|
||
interface FeedbackEntry {
|
||
question_id: number | null;
|
||
feedback_text: string;
|
||
source: string;
|
||
}
|
||
|
||
interface AnswerEntry {
|
||
question_id: number;
|
||
answer: string;
|
||
score: number;
|
||
is_correct: boolean;
|
||
feedback: string;
|
||
}
|
||
|
||
interface ExamResultsData {
|
||
attempt_id: number;
|
||
exam_id: number | null;
|
||
exam_title?: string;
|
||
status: string;
|
||
completed_at?: string;
|
||
released_at?: string;
|
||
started_at?: string | null;
|
||
finished_at?: string | null;
|
||
total_score?: number;
|
||
max_score?: number;
|
||
percentage?: number;
|
||
listening_band?: number;
|
||
reading_band?: number;
|
||
writing_band?: number;
|
||
speaking_band?: number;
|
||
overall_band?: number;
|
||
cefr_level?: string;
|
||
scores?: ScoreEntry[];
|
||
feedback?: FeedbackEntry[];
|
||
answers?: AnswerEntry[];
|
||
}
|
||
|
||
export default function ExamResults() {
|
||
const { examId } = useParams();
|
||
const [searchParams] = useSearchParams();
|
||
const practice = searchParams.get("mode") === "practice";
|
||
const [downloading, setDownloading] = useState(false);
|
||
|
||
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
|
||
queryKey: ["exam-results", examId],
|
||
queryFn: () => examSessionService.getResults(Number(examId)),
|
||
enabled: !!examId,
|
||
});
|
||
|
||
const handleDownloadPdf = async () => {
|
||
if (!results?.attempt_id) return;
|
||
setDownloading(true);
|
||
try {
|
||
await reportService.downloadPdf(results.attempt_id);
|
||
} catch {
|
||
// error handled silently
|
||
} finally {
|
||
setDownloading(false);
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[400px]">
|
||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isError || !results) {
|
||
return (
|
||
<div className="mx-auto max-w-5xl p-6 text-center">
|
||
<p className="text-lg font-medium text-destructive">Results not yet available</p>
|
||
<p className="text-muted-foreground mt-2">Your results may still be pending approval or grading.</p>
|
||
<Button variant="outline" className="mt-4" asChild>
|
||
<Link to={`/student/exam/${examId}/status`}>Check Status</Link>
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const hasDetailedScores = Array.isArray(results.scores) && results.scores.length > 0;
|
||
const overall = results.overall_band ?? 0;
|
||
const pct = results.percentage ?? (results.max_score ? Math.round((results.total_score ?? 0) / results.max_score * 100) : 0);
|
||
const cefr = results.cefr_level?.toUpperCase() || "N/A";
|
||
const passed = hasDetailedScores ? overall >= 7 : pct >= 70;
|
||
|
||
const skillScores = hasDetailedScores ? results.scores!.filter((s) => s.skill !== "overall") : [];
|
||
const SKILLS = skillScores.map((s) => ({
|
||
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
|
||
band: s.band_score,
|
||
cefr: s.cefr_level?.toUpperCase() || "N/A",
|
||
gap: Math.max(0, 8 - s.band_score),
|
||
target: 8,
|
||
}));
|
||
|
||
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
||
|
||
const feedbackList = results.feedback ?? [];
|
||
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
|
||
for (const fb of feedbackList) {
|
||
const key = "General";
|
||
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
|
||
feedbackBySkill[key].push(fb);
|
||
}
|
||
|
||
const answerEntries = results.answers ?? [];
|
||
|
||
return (
|
||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||
<div className="text-center">
|
||
{results.exam_title && (
|
||
<h1 className="text-xl font-semibold mb-1">{results.exam_title}</h1>
|
||
)}
|
||
<p className="text-sm text-muted-foreground">Your overall result</p>
|
||
{hasDetailedScores ? (
|
||
<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>
|
||
) : (
|
||
<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">{pct}%</span>
|
||
</div>
|
||
)}
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{results.total_score ?? 0} / {results.max_score ?? 0} marks
|
||
</p>
|
||
{hasDetailedScores && <p className="mt-1 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>}
|
||
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
||
</div>
|
||
|
||
{RADAR_DATA.length > 0 && (
|
||
<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>
|
||
|
||
{answerEntries.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Your Answers</CardTitle>
|
||
<CardDescription>
|
||
{answerEntries.filter((a) => a.is_correct).length} correct out of {answerEntries.length}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>#</TableHead>
|
||
<TableHead>Your Answer</TableHead>
|
||
<TableHead>Score</TableHead>
|
||
<TableHead>Result</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{answerEntries.map((a, i) => (
|
||
<TableRow key={a.question_id}>
|
||
<TableCell>{i + 1}</TableCell>
|
||
<TableCell className="max-w-[200px] truncate">{a.answer || "—"}</TableCell>
|
||
<TableCell>{a.score}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={a.is_correct ? "default" : "destructive"}>
|
||
{a.is_correct ? "Correct" : "Incorrect"}
|
||
</Badge>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{feedbackList.length > 0 && (
|
||
<div>
|
||
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
|
||
<Accordion type="multiple" className="w-full">
|
||
{feedbackList.map((fb, i) => (
|
||
<AccordionItem key={i} value={`fb-${i}`}>
|
||
<AccordionTrigger>
|
||
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
|
||
{fb.question_id ? `(Q${fb.question_id})` : ""}
|
||
</AccordionTrigger>
|
||
<AccordionContent className="text-muted-foreground">
|
||
{fb.feedback_text || "No detailed feedback available."}
|
||
</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">
|
||
{SKILLS.filter((s) => s.gap > 0)
|
||
.sort((a, b) => b.gap - a.gap)
|
||
.map((s) => (
|
||
<li key={s.skill}>
|
||
Focus on <strong>{s.skill}</strong> — current band {s.band}, target {s.target} (gap: {s.gap}).
|
||
</li>
|
||
))}
|
||
{SKILLS.every((s) => s.gap === 0) && <li>Excellent performance across all skills!</li>}
|
||
</ul>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button type="button" variant="outline" onClick={handleDownloadPdf} disabled={downloading}>
|
||
{downloading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <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>
|
||
);
|
||
}
|