feat: complete pragativeer v2 frontend and backend, docker setup, and cbt engine
This commit is contained in:
168
src/app/dashboard/cbt/history/[attemptId]/page.tsx
Normal file
168
src/app/dashboard/cbt/history/[attemptId]/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, use } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, CheckCircle2, XCircle, Clock, Award } from "lucide-react";
|
||||
|
||||
export default function AttemptHistoryPage({ params }: { params: Promise<{ attemptId: string }> }) {
|
||||
const { attemptId } = use(params);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [attempt, setAttempt] = useState<any>(null);
|
||||
const [test, setTest] = useState<any>(null);
|
||||
const [questions, setQuestions] = useState<Record<string, any>>({});
|
||||
|
||||
const userId = "b0e17378-864d-439e-8806-708dc48a891b";
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchHistoryData() {
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
// 1. Fetch all attempts to find this specific one
|
||||
const attemptsRes = await fetch(`${apiUrl}/api/users/${userId}/attempts`);
|
||||
const attemptsData = await attemptsRes.json();
|
||||
const foundAttempt = attemptsData.find((a: any) => a.id === attemptId);
|
||||
|
||||
if (!foundAttempt) {
|
||||
throw new Error("Attempt not found");
|
||||
}
|
||||
setAttempt(foundAttempt);
|
||||
|
||||
// 2. Fetch the test details
|
||||
const testRes = await fetch(`${apiUrl}/api/tests/${foundAttempt.source_id}?user_id=${userId}`);
|
||||
const testData = await testRes.json();
|
||||
setTest(testData);
|
||||
|
||||
// 3. Fetch all questions for this attempt to build the answer key
|
||||
const qs: Record<string, any> = {};
|
||||
await Promise.all(
|
||||
foundAttempt.answers.map(async (ans: any) => {
|
||||
const qRes = await fetch(`${apiUrl}/api/questions/${ans.question_id}`);
|
||||
if (qRes.ok) {
|
||||
const qData = await qRes.json();
|
||||
qs[ans.question_id] = qData;
|
||||
}
|
||||
})
|
||||
);
|
||||
setQuestions(qs);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to load attempt history:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchHistoryData();
|
||||
}, [attemptId, userId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!attempt) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
Attempt not found. <Link href="/dashboard/cbt" className="text-primary underline">Go back</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scoreCalc = (attempt.score * 4) - ((attempt.total_questions - attempt.score) * 1);
|
||||
const maxScore = attempt.total_questions * 4;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<Link href="/dashboard/cbt" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-6 transition-colors">
|
||||
<ArrowLeft size={16} className="mr-2" /> Back to Tests
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 mb-10">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{test?.title || 'Practice Test'} Solutions</h1>
|
||||
<p className="text-muted-foreground">Review your performance, detailed solutions, and identify your weak spots.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Card className="bg-secondary/30 border-border min-w-32">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||
<Award className="text-[#00F0FF] mb-2" size={24} />
|
||||
<div className="text-sm text-muted-foreground font-bold uppercase">Score</div>
|
||||
<div className="text-2xl font-bold">{scoreCalc} <span className="text-sm text-muted-foreground">/ {maxScore}</span></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-secondary/30 border-border min-w-32">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||
<Clock className="text-primary mb-2" size={24} />
|
||||
<div className="text-sm text-muted-foreground font-bold uppercase">Time Taken</div>
|
||||
<div className="text-2xl font-bold">{Math.floor(attempt.time_taken / 60)}m {attempt.time_taken % 60}s</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-bold border-b border-border pb-2">Answer Key & Explanations</h2>
|
||||
|
||||
{attempt.answers.map((ans: any, idx: number) => {
|
||||
const q = questions[ans.question_id];
|
||||
if (!q) return null;
|
||||
|
||||
return (
|
||||
<Card key={ans.question_id} className={`border-l-4 ${ans.is_correct ? 'border-l-green-500' : 'border-l-red-500'}`}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-secondary flex items-center justify-center font-bold">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-lg leading-relaxed">{q.text}</h3>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="outline" className="text-xs">{q.topic}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{q.difficulty}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ans.is_correct ? (
|
||||
<Badge className="bg-green-500/20 text-green-500 border-green-500/30 flex items-center gap-1"><CheckCircle2 size={14}/> Correct</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/20 text-red-500 border-red-500/30 flex items-center gap-1"><XCircle size={14}/> Incorrect</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6 ml-12">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-muted-foreground uppercase">Your Answer</p>
|
||||
<div className={`p-3 rounded-lg border ${ans.is_correct ? 'bg-green-500/10 border-green-500/30 text-green-500' : 'bg-red-500/10 border-red-500/30 text-red-500'}`}>
|
||||
{q.options[ans.selected_answer] ? `${ans.selected_answer}) ${q.options[ans.selected_answer]}` : 'Not Attempted'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-muted-foreground uppercase">Correct Answer</p>
|
||||
<div className="p-3 rounded-lg border bg-green-500/10 border-green-500/30 text-green-500">
|
||||
{q.correct_answer?.correct}) {q.options[q.correct_answer?.correct]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{q.correct_answer?.explanation && (
|
||||
<div className="mt-6 ml-12 p-4 bg-secondary/30 rounded-xl border border-border">
|
||||
<p className="text-sm font-bold mb-2">Explanation:</p>
|
||||
<p className="text-muted-foreground">{q.correct_answer.explanation}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,95 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Activity, Clock, PlayCircle, Search } from "lucide-react"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Activity, Clock, PlayCircle, Search } from "lucide-react";
|
||||
|
||||
interface TestData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
exam_pattern: string;
|
||||
total_time: number;
|
||||
difficulty: string;
|
||||
}
|
||||
|
||||
export default function CBTPage() {
|
||||
const upcomingExams = [
|
||||
{ title: "AITS Full Syllabus Test 4", date: "Tomorrow, 10:00 AM", duration: "180 Min", marks: 720 },
|
||||
{ title: "Part Test 3: Mechanics", date: "Oct 24, 2026", duration: "180 Min", marks: 720 },
|
||||
{ title: "Weekly Revision Test", date: "Oct 28, 2026", duration: "90 Min", marks: 360 },
|
||||
]
|
||||
const [exams, setExams] = useState<TestData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [targetExam, setTargetExam] = useState("");
|
||||
const [attempts, setAttempts] = useState<any[]>([]);
|
||||
|
||||
const pastExams = [
|
||||
{ title: "AITS Full Syllabus Test 3", date: "Oct 15, 2026", score: 645, total: 720, percentile: "99.4" },
|
||||
{ title: "Part Test 2: Electromagnetism", date: "Oct 08, 2026", score: 680, total: 720, percentile: "99.8" },
|
||||
]
|
||||
const userId = "b0e17378-864d-439e-8806-708dc48a891b";
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCBTExams() {
|
||||
try {
|
||||
// 1. Fetch user target exam
|
||||
const userRes = await fetch(`http://localhost:8000/api/users/${userId}`);
|
||||
if (!userRes.ok) throw new Error("Failed to load user target exam");
|
||||
const userData = await userRes.json();
|
||||
const pattern = userData.target_exam;
|
||||
setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main");
|
||||
|
||||
// 2. Fetch tests filtered by exam
|
||||
const testRes = await fetch(`http://localhost:8000/api/tests?exam_pattern=${pattern}`);
|
||||
if (!testRes.ok) throw new Error("Failed to fetch tests");
|
||||
const testsData = await testRes.json();
|
||||
setExams(testsData);
|
||||
|
||||
// 3. Fetch user attempts
|
||||
const attemptsRes = await fetch(`http://localhost:8000/api/users/${userId}/attempts`);
|
||||
if (attemptsRes.ok) {
|
||||
const attemptsData = await attemptsRes.json();
|
||||
setAttempts(attemptsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading exams:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadCBTExams();
|
||||
}, []);
|
||||
|
||||
const filteredExams = exams.filter(exam =>
|
||||
exam.title.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// Get the highest score for each test if attempted multiple times
|
||||
const attemptsByTest = attempts.reduce((acc, curr) => {
|
||||
if (!acc[curr.source_id] || acc[curr.source_id].score < curr.score) {
|
||||
acc[curr.source_id] = curr;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Computer Based Tests (CBT)</h1>
|
||||
<p className="text-muted-foreground mt-1 font-medium">Real exam environment simulations to conquer NEET/JEE.</p>
|
||||
<p className="text-muted-foreground mt-1 font-medium">
|
||||
Real exam environment simulations to conquer {targetExam || "NEET/JEE"}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<input type="text" placeholder="Search exams..." className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[#00F0FF] outline-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search exams..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[#00F0FF] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,23 +97,49 @@ export default function CBTPage() {
|
||||
<h2 className="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<Clock size={20} className="text-[#00F0FF]" /> Upcoming & Active Exams
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{upcomingExams.map((exam, i) => (
|
||||
<Card key={i} className="hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group">
|
||||
<CardContent className="p-6">
|
||||
<Badge variant="outline" className="mb-4 bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">Mock Test</Badge>
|
||||
<h3 className="font-bold text-lg leading-tight mb-2 group-hover:text-[#00F0FF] transition-colors">{exam.title}</h3>
|
||||
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
||||
<div className="font-medium text-foreground">{exam.date}</div>
|
||||
<div>{exam.duration} • {exam.marks} Marks</div>
|
||||
</div>
|
||||
<Button className="w-full gap-2 rounded-xl bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background transition-colors font-bold">
|
||||
<PlayCircle size={16} /> Start Exam
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-muted-foreground">Loading test catalog...</div>
|
||||
) : filteredExams.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground bg-secondary/10 border border-dashed border-border rounded-2xl">
|
||||
No active exams found for {targetExam || "your target exam"}. Try switching it in settings!
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredExams.map((exam) => {
|
||||
const attempt = attemptsByTest[exam.id];
|
||||
const isAttempted = !!attempt;
|
||||
|
||||
return (
|
||||
<Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''}`}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">
|
||||
{exam.type} Test
|
||||
</Badge>
|
||||
{isAttempted && (
|
||||
<Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
Attempted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-bold text-lg leading-tight mb-2 group-hover:text-[#00F0FF] transition-colors">
|
||||
{exam.title}
|
||||
</h3>
|
||||
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
||||
<div className="font-medium text-foreground">Available Now</div>
|
||||
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
||||
</div>
|
||||
<Link href={`/cbt/${exam.id}`}>
|
||||
<Button className={`w-full gap-2 rounded-xl transition-colors font-bold ${isAttempted ? 'bg-secondary border border-border text-foreground' : 'bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background'}`}>
|
||||
<PlayCircle size={16} /> {isAttempted ? 'Retake Exam' : 'Start Exam'}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 pt-8">
|
||||
@@ -56,29 +147,44 @@ export default function CBTPage() {
|
||||
<Activity size={20} className="text-muted-foreground" /> Past Attempts
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{pastExams.map((exam, i) => (
|
||||
{attempts.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground bg-secondary/10 border border-dashed border-border rounded-2xl">
|
||||
You haven't attempted any CBT exams yet.
|
||||
</div>
|
||||
) : attempts.map((attempt, i) => {
|
||||
const exam = exams.find(e => e.id === attempt.source_id);
|
||||
const scoreCalc = (attempt.score * 4) - ((attempt.total_questions - attempt.score) * 1); // rough calc for display
|
||||
|
||||
return (
|
||||
<Card key={i} className="bg-secondary/20">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold">{exam.title}</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">{exam.date}</p>
|
||||
<CardContent className="p-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="w-full md:w-auto">
|
||||
<h3 className="font-bold">{exam?.title || 'Unknown Test'}</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">{formatDate(attempt.submitted_at)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center gap-4 md:gap-8 w-full md:w-auto justify-between md:justify-end">
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-muted-foreground font-bold uppercase tracking-wider">Score</div>
|
||||
<div className="font-bold text-lg"><span className="text-foreground">{exam.score}</span><span className="text-muted-foreground">/{exam.total}</span></div>
|
||||
<div className="text-xs md:text-sm text-muted-foreground font-bold uppercase tracking-wider">Score</div>
|
||||
<div className="font-bold text-base md:text-lg">
|
||||
<span className="text-foreground">{scoreCalc}</span>
|
||||
<span className="text-muted-foreground">/{attempt.total_questions * 4}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-muted-foreground font-bold uppercase tracking-wider">Percentile</div>
|
||||
<div className="font-bold text-lg text-[#00F0FF]">{exam.percentile}</div>
|
||||
<div className="text-xs md:text-sm text-muted-foreground font-bold uppercase tracking-wider">Accuracy</div>
|
||||
<div className="font-bold text-base md:text-lg text-[#00F0FF]">{Math.round(attempt.accuracy)}%</div>
|
||||
</div>
|
||||
<Button variant="outline" className="rounded-xl border-border hover:bg-secondary">Review Analysis</Button>
|
||||
<Link href={`/dashboard/cbt/history/${attempt.id}`}>
|
||||
<Button variant="outline" className="rounded-xl border-border hover:bg-secondary">
|
||||
View Solutions
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user