Feat: Complete CBT PDF upload, AI parsing, and MinIO storage integration
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode } from "react"
|
||||
import { LayoutDashboard, Users, Database, Terminal, Settings } from "lucide-react"
|
||||
import { LayoutDashboard, Users, Database, Terminal, Settings, FileText } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Logo } from "@/components/Logo"
|
||||
@@ -19,6 +19,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
<nav className="flex-1 px-4 space-y-1.5 mt-6">
|
||||
<NavItem href="/admin" icon={<LayoutDashboard size={18} />} label="Command Center" active={pathname === "/admin"} />
|
||||
<NavItem href="/admin/tests" icon={<FileText size={18} />} label="Tests & DPPs" active={pathname.startsWith("/admin/tests")} />
|
||||
<NavItem href="/admin/questions" icon={<Database size={18} />} label="Question Bank" active={pathname.startsWith("/admin/questions")} />
|
||||
<NavItem href="/admin/agents" icon={<Terminal size={18} />} label="AI Agents" active={pathname.startsWith("/admin/agents")} />
|
||||
<NavItem href="/admin/students" icon={<Users size={18} />} label="Students" active={pathname.startsWith("/admin/students")} />
|
||||
|
||||
213
src/app/admin/tests/page.tsx
Normal file
213
src/app/admin/tests/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Loader2, Upload, FileText, Clock, CalendarIcon } from "lucide-react"
|
||||
import { format } from "date-fns"
|
||||
|
||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||
|
||||
export default function AdminTestsPage() {
|
||||
const [tests, setTests] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const { userId } = useActiveUser()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Upload Form State
|
||||
const [title, setTitle] = useState("")
|
||||
const [examPattern, setExamPattern] = useState("JEE_Main")
|
||||
const [timeLimit, setTimeLimit] = useState("180")
|
||||
const [scheduledDate, setScheduledDate] = useState("")
|
||||
|
||||
const fetchTests = async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/admin/tests")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTests(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTests()
|
||||
}, [])
|
||||
|
||||
const handleToggle = async (testId: string) => {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/admin/tests/${testId}/toggle`, {
|
||||
method: "PUT"
|
||||
})
|
||||
if (res.ok) {
|
||||
fetchTests()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!fileInputRef.current?.files?.[0]) return
|
||||
if (!userId) return
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileInputRef.current.files[0])
|
||||
formData.append("title", title)
|
||||
formData.append("exam_pattern", examPattern)
|
||||
formData.append("time_limit", timeLimit)
|
||||
formData.append("admin_id", userId)
|
||||
if (scheduledDate) {
|
||||
formData.append("scheduled_date", new Date(scheduledDate).toISOString())
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/admin/tests/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
alert("Test uploaded and sent to AI for processing!")
|
||||
setTitle("")
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
fetchTests()
|
||||
} else {
|
||||
alert("Upload failed")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert("Upload failed")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs</h1>
|
||||
<p className="text-muted-foreground mt-2">Manage CBT examinations and daily practice sheets</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Upload Form */}
|
||||
<Card className="lg:col-span-1 border-muted bg-card shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-primary" />
|
||||
Upload New Test
|
||||
</CardTitle>
|
||||
<CardDescription>Upload a PDF containing questions. AI will automatically extract and format them.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleUpload} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Test Title</label>
|
||||
<Input required value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. JEE Main Mock Test 1" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Exam Pattern</label>
|
||||
<Select value={examPattern} onValueChange={setExamPattern}>
|
||||
<SelectTrigger><SelectValue placeholder="Select Exam" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="JEE_Main">JEE Main</SelectItem>
|
||||
<SelectItem value="JEE_Advanced">JEE Advanced</SelectItem>
|
||||
<SelectItem value="NEET">NEET</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Time Limit (mins)</label>
|
||||
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Scheduled Date (Optional)</label>
|
||||
<Input type="datetime-local" value={scheduledDate} onChange={e => setScheduledDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Test PDF File</label>
|
||||
<Input required type="file" accept=".pdf" ref={fileInputRef} className="cursor-pointer" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Make sure it contains clear questions and options.</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={uploading}>
|
||||
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Upload className="w-4 h-4 mr-2" />}
|
||||
{uploading ? "Processing PDF via AI..." : "Upload & Parse"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Existing Tests List */}
|
||||
<Card className="lg:col-span-2 border-muted bg-card shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
Test Repository
|
||||
</CardTitle>
|
||||
<CardDescription>Manage visibility and schedule for parsed tests.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /></div>
|
||||
) : tests.length === 0 ? (
|
||||
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-lg">
|
||||
No tests uploaded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tests.map(test => (
|
||||
<div key={test.id} className="flex items-center justify-between p-4 border rounded-lg bg-background">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{test.title}</h3>
|
||||
<Badge variant="outline">{test.exam_pattern.replace("_", " ")}</Badge>
|
||||
<Badge variant={test.is_active ? "default" : "secondary"}>
|
||||
{test.is_active ? "Active" : "Inactive / Processing"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{test.total_time} mins
|
||||
</div>
|
||||
{test.scheduled_date && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
{format(new Date(test.scheduled_date), "PP p")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">{test.is_active ? "Published" : "Hidden"}</span>
|
||||
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ interface TestData {
|
||||
exam_pattern: string;
|
||||
total_time: number;
|
||||
difficulty: string;
|
||||
scheduled_date?: string;
|
||||
}
|
||||
|
||||
export default function CBTPage() {
|
||||
@@ -116,6 +117,11 @@ export default function CBTPage() {
|
||||
const isAttempted = !!attempt;
|
||||
const isLocked = !hasAccessTo("weekly-test-series");
|
||||
|
||||
// Date logic
|
||||
const now = new Date();
|
||||
const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now;
|
||||
const isUpcoming = scheduledTime > now;
|
||||
|
||||
return (
|
||||
<Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''} ${isLocked ? 'opacity-80' : ''}`}>
|
||||
<CardContent className="p-6">
|
||||
@@ -123,7 +129,11 @@ export default function CBTPage() {
|
||||
<Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">
|
||||
{exam.type} Test
|
||||
</Badge>
|
||||
{isLocked ? (
|
||||
{isUpcoming ? (
|
||||
<Badge variant="secondary" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
||||
Upcoming
|
||||
</Badge>
|
||||
) : isLocked ? (
|
||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1">
|
||||
<Lock size={12} /> Premium
|
||||
</Badge>
|
||||
@@ -137,10 +147,17 @@ export default function CBTPage() {
|
||||
{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 className="font-medium text-foreground">
|
||||
{isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : 'Available Now'}
|
||||
</div>
|
||||
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
||||
</div>
|
||||
{isLocked ? (
|
||||
|
||||
{isUpcoming ? (
|
||||
<Button disabled className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground font-bold">
|
||||
<Clock size={16} /> Starts Soon
|
||||
</Button>
|
||||
) : isLocked ? (
|
||||
<Button
|
||||
onClick={() => setShowUpgradeModal(true)}
|
||||
className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground border border-dashed border-border hover:border-[#00F0FF]/50 hover:text-foreground font-bold"
|
||||
|
||||
Reference in New Issue
Block a user