Files
encoach_backend_new_v2/frontend/src/pages/student/PlacementResults.tsx
Yamen Ahmad f1c4953a63 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
2026-04-10 17:26:42 +04:00

282 lines
11 KiB
TypeScript

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>
);
}