Files
encoach_backend_new_v3/frontend/src/pages/student/ExamResults.tsx
Yamen Ahmad 50f58dc995 feat: complete exam lifecycle — AI generation, submission, student session, and results
- 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
2026-04-16 16:53:09 +04:00

342 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
);
}