Files
encoach_frontend_new_v3/frontend/src/pages/student/ExamResults.tsx
Yamen Ahmad 82ec3debcc feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
2026-04-12 14:26:39 +04:00

274 lines
9.5 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 ExamResultsData {
attempt_id: number;
exam_id: number | null;
status: string;
completed_at: string;
released_at: string;
listening_band: number;
reading_band: number;
writing_band: number;
speaking_band: number;
overall_band: number;
cefr_level: string;
scores: ScoreEntry[];
feedback: FeedbackEntry[];
}
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 overall = results.overall_band;
const cefr = results.cefr_level?.toUpperCase() || "N/A";
const passed = overall >= 7;
const skillScores = 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 feedbackBySkill: Record<string, FeedbackEntry[]> = {};
for (const fb of results.feedback) {
const key = "General";
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
feedbackBySkill[key].push(fb);
}
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>
{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>
{results.feedback.length > 0 && (
<div>
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
<Accordion type="multiple" className="w-full">
{results.feedback.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>
);
}