560 lines
23 KiB
TypeScript
560 lines
23 KiB
TypeScript
"use client"
|
|
|
|
import { fetchWithAuth } from "@/lib/apiClient";
|
|
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 { Loader2, Upload, FileText, Clock, CalendarIcon, CheckCircle2, AlertCircle, X, Trash2 } from "lucide-react"
|
|
import DatePicker from "react-datepicker"
|
|
import "react-datepicker/dist/react-datepicker.css"
|
|
import { z } from "zod"
|
|
|
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
|
|
|
// Permissive, auto-normalizing Zod schema for client-side question parsing
|
|
const QuestionSchema = z.preprocess((val: any) => {
|
|
if (typeof val !== "object" || val === null) return val;
|
|
const q = { ...val };
|
|
|
|
// Normalize question text
|
|
if (!q.question_text && q.question) {
|
|
q.question_text = q.question;
|
|
}
|
|
|
|
// Normalize solution text
|
|
if (!q.solution_text && (q.solution || q.explanation)) {
|
|
q.solution_text = q.solution || q.explanation;
|
|
}
|
|
if (!q.solution_text) {
|
|
q.solution_text = "Detailed solution will be available after review.";
|
|
}
|
|
|
|
// Normalize topic / subtopic / subject / difficulty
|
|
if (!q.subject) q.subject = "General";
|
|
if (!q.topic) q.topic = q.subject || "General";
|
|
if (!q.subtopic) q.subtopic = q.topic || "General";
|
|
if (!q.difficulty) q.difficulty = "Medium";
|
|
if (!q.section) q.section = q.subject || "General";
|
|
|
|
// Normalize question type
|
|
const typeStr = String(q.type || "").trim();
|
|
if (["Single_Choice", "MCQ", "Single", "MCQ_Single"].includes(typeStr)) {
|
|
q.type = "MCQ_Single";
|
|
} else if (["Multiple_Choice", "Multiple", "MCQ_Multiple"].includes(typeStr)) {
|
|
q.type = "MCQ_Multiple";
|
|
} else if (["Numerical", "Subjective", "Integer"].includes(typeStr)) {
|
|
q.type = "Numerical";
|
|
} else {
|
|
q.type = "MCQ_Single";
|
|
}
|
|
|
|
// Normalize language
|
|
const langStr = String(q.language || "").trim().toLowerCase();
|
|
if (langStr === "hindi") q.language = "Hindi";
|
|
else if (langStr === "bilingual") q.language = "Bilingual";
|
|
else q.language = "English";
|
|
|
|
// Normalize options if passed as an array
|
|
if (Array.isArray(q.options)) {
|
|
const optsObj: Record<string, string> = {};
|
|
q.options.forEach((opt: any, idx: number) => {
|
|
const letter = String.fromCharCode(65 + idx);
|
|
optsObj[letter] = String(opt);
|
|
});
|
|
q.options = optsObj;
|
|
}
|
|
|
|
// Normalize correct_answer format
|
|
if (typeof q.correct_answer === "string" || typeof q.correct_answer === "number") {
|
|
q.correct_answer = { value: String(q.correct_answer) };
|
|
} else if (Array.isArray(q.correct_answer)) {
|
|
q.correct_answer = { value: q.correct_answer.map(String) };
|
|
} else if (typeof q.correct_answer === "object" && q.correct_answer !== null) {
|
|
const val = q.correct_answer.value ?? q.correct_answer.correct ?? q.correct_answer.answer ?? "A";
|
|
q.correct_answer = { value: val };
|
|
} else {
|
|
q.correct_answer = { value: "A" };
|
|
}
|
|
|
|
return q;
|
|
}, z.object({
|
|
id: z.string().optional(),
|
|
section: z.string().default("General"),
|
|
subject: z.string().default("General"),
|
|
topic: z.string().default("General"),
|
|
subtopic: z.string().default("General"),
|
|
difficulty: z.string().default("Medium"),
|
|
type: z.enum(["MCQ_Single", "MCQ_Multiple", "Numerical"]),
|
|
question_text: z.string().min(1, "Question text is required"),
|
|
options: z.record(z.string(), z.any()).nullable().optional(),
|
|
correct_answer: z.object({
|
|
value: z.any()
|
|
}),
|
|
solution_text: z.string(),
|
|
language: z.enum(["English", "Hindi", "Bilingual"])
|
|
}).passthrough());
|
|
|
|
const TestSchema = z.array(QuestionSchema);
|
|
|
|
export default function AdminTestsPage() {
|
|
const [tests, setTests] = useState<any[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [uploading, setUploading] = useState(false)
|
|
const { userId } = useActiveUser()
|
|
|
|
// Upload Form State
|
|
const [title, setTitle] = useState("")
|
|
const [testType, setTestType] = useState("Mock")
|
|
const [examPattern, setExamPattern] = useState("JEE_Main")
|
|
const [timeLimit, setTimeLimit] = useState("180")
|
|
const [scheduledDate, setScheduledDate] = useState<Date | null>(null)
|
|
const [endDate, setEndDate] = useState<Date | null>(null)
|
|
|
|
// Drag and Drop State
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const [validQuestions, setValidQuestions] = useState<Record<string, unknown>[] | null>(null)
|
|
const [validationErrors, setValidationErrors] = useState<{ path: string, message: string }[]>([])
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const fetchTests = async () => {
|
|
try {
|
|
const res = await fetchWithAuth("/api/admin/tests")
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setTests(data)
|
|
}
|
|
} catch (e) {
|
|
console.error(e)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
fetchTests()
|
|
}, [])
|
|
|
|
const handleToggle = async (testId: string) => {
|
|
try {
|
|
const res = await fetchWithAuth(`/api/admin/tests/${testId}/toggle`, {
|
|
method: "PUT"
|
|
})
|
|
if (res.ok) {
|
|
fetchTests()
|
|
}
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (testId: string) => {
|
|
if (!confirm("Are you sure you want to delete this test and all its questions? This action cannot be undone.")) return
|
|
try {
|
|
const res = await fetchWithAuth(`/api/admin/tests/${testId}`, {
|
|
method: "DELETE"
|
|
})
|
|
if (res.ok) {
|
|
alert("Test deleted successfully!")
|
|
fetchTests()
|
|
} else {
|
|
alert("Failed to delete test.")
|
|
}
|
|
} catch (e) {
|
|
console.error(e)
|
|
alert("Error deleting test")
|
|
}
|
|
}
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragging(true)
|
|
}
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragging(false)
|
|
}
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragging(false)
|
|
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
processFile(e.dataTransfer.files[0])
|
|
}
|
|
}
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
processFile(e.target.files[0])
|
|
}
|
|
}
|
|
|
|
const processFile = (file: File) => {
|
|
if (!file.name.endsWith('.json')) {
|
|
setValidationErrors([{ path: "File", message: "Only .json files are allowed." }])
|
|
setValidQuestions(null)
|
|
return
|
|
}
|
|
|
|
const reader = new FileReader()
|
|
reader.onload = (e) => {
|
|
try {
|
|
const json = JSON.parse(e.target?.result as string)
|
|
|
|
// Zod validation
|
|
const result = TestSchema.safeParse(json)
|
|
if (result.success) {
|
|
setValidQuestions(result.data)
|
|
setValidationErrors([])
|
|
} else {
|
|
setValidQuestions(null)
|
|
const errors = (result as any).error.errors.map((err: any) => ({
|
|
path: err.path.join('.'),
|
|
message: err.message
|
|
}))
|
|
setValidationErrors(errors)
|
|
}
|
|
} catch {
|
|
setValidQuestions(null)
|
|
setValidationErrors([{ path: "JSON Parsing", message: "Invalid JSON format. Check for syntax errors." }])
|
|
}
|
|
}
|
|
reader.readAsText(file)
|
|
}
|
|
|
|
const removeFile = () => {
|
|
setValidQuestions(null)
|
|
setValidationErrors([])
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ""
|
|
}
|
|
}
|
|
|
|
const handleUpload = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
const effectiveAdminId = userId && userId !== "admin" ? userId : "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
|
|
if (testType === 'DPP_PDF') {
|
|
const file = fileInputRef.current?.files?.[0]
|
|
if (!file) {
|
|
alert("Please select a PDF file to upload to Minio.")
|
|
return
|
|
}
|
|
setUploading(true)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append("file", file)
|
|
formData.append("title", title)
|
|
formData.append("exam_pattern", examPattern)
|
|
formData.append("time_limit", timeLimit)
|
|
formData.append("admin_id", effectiveAdminId)
|
|
if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString())
|
|
|
|
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
|
method: "POST",
|
|
body: formData
|
|
})
|
|
if (res.ok) {
|
|
alert("PDF uploaded successfully to Minio and connected!")
|
|
setTitle("")
|
|
removeFile()
|
|
fetchTests()
|
|
} else {
|
|
const err = await res.json()
|
|
alert(`Upload failed: ${JSON.stringify(err)}`)
|
|
}
|
|
} catch (err) {
|
|
alert("Upload failed")
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!validQuestions || validQuestions.length === 0) {
|
|
alert("Please upload a valid JSON file with questions first.")
|
|
return
|
|
}
|
|
|
|
setUploading(true)
|
|
|
|
const payload = {
|
|
title,
|
|
type: testType,
|
|
exam_pattern: examPattern,
|
|
time_limit: parseInt(timeLimit),
|
|
admin_id: effectiveAdminId,
|
|
questions_json: validQuestions,
|
|
scheduled_date: scheduledDate ? scheduledDate.toISOString() : null,
|
|
end_date: endDate ? endDate.toISOString() : null,
|
|
};
|
|
|
|
try {
|
|
const res = await fetchWithAuth("/api/admin/tests/create_manual", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
|
|
if (res.ok) {
|
|
alert("Test created successfully!")
|
|
setTitle("")
|
|
removeFile()
|
|
fetchTests()
|
|
} else {
|
|
const err = await res.json()
|
|
alert(`Creation failed: ${JSON.stringify(err)}`)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
alert("Creation failed")
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-8 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 h-fit">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Upload className="w-5 h-5 text-primary" />
|
|
Create Manual Test
|
|
</CardTitle>
|
|
<CardDescription>Upload a structured JSON array of questions in LaTeX format to create a deterministic test.</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>
|
|
|
|
{/* TEST TYPE — KEY DIFFERENTIATOR */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium flex items-center gap-2">
|
|
Test Type
|
|
<span className="text-[10px] text-muted-foreground font-normal">(determines which section it appears in)</span>
|
|
</label>
|
|
<select
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
value={testType}
|
|
onChange={(e) => setTestType(e.target.value)}
|
|
>
|
|
<option value="Mock">🎯 CBT Test (JSON) — appears in Mock Tests section</option>
|
|
<option value="DPS">🤖 Prep Pilot Test (as CBT JSON) — appears in AI Coach</option>
|
|
<option value="DPP_PDF">📋 DPP PDF (Minio Upload) — Class/Plan aligned</option>
|
|
</select>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{testType === 'Mock' && '✅ Purely for CBT. Appears under Dashboard → Mock Tests.'}
|
|
{testType === 'DPS' && '✅ Purely for CBT. Appears under AI Coach → PrepPilot AI.'}
|
|
{testType === 'DPP_PDF' && '✅ Uploads a PDF to Minio storage for Class/Plan aligned DPP.'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Exam Pattern</label>
|
|
<select
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
value={examPattern}
|
|
onChange={(e) => setExamPattern(e.target.value)}
|
|
>
|
|
<option value="JEE_Main">JEE Main</option>
|
|
<option value="JEE_Advanced">JEE Advanced</option>
|
|
<option value="NEET">NEET</option>
|
|
</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="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2 flex flex-col">
|
|
<label className="text-sm font-medium">Scheduled Start</label>
|
|
<DatePicker
|
|
selected={scheduledDate}
|
|
onChange={(date: Date | null) => setScheduledDate(date)}
|
|
showTimeSelect
|
|
timeFormat="HH:mm"
|
|
timeIntervals={15}
|
|
timeCaption="time"
|
|
dateFormat="MMMM d, yyyy h:mm aa"
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
placeholderText="Optional"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2 flex flex-col">
|
|
<label className="text-sm font-medium">Scheduled End</label>
|
|
<DatePicker
|
|
selected={endDate}
|
|
onChange={(date: Date | null) => setEndDate(date)}
|
|
showTimeSelect
|
|
timeFormat="HH:mm"
|
|
timeIntervals={15}
|
|
timeCaption="time"
|
|
dateFormat="MMMM d, yyyy h:mm aa"
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
placeholderText="Optional"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 pt-2">
|
|
<label className="text-sm font-medium">Questions JSON File</label>
|
|
|
|
{testType === 'DPP_PDF' ? (
|
|
<div className="space-y-2 pt-2">
|
|
<label className="text-sm font-medium">PDF File (Minio Upload)</label>
|
|
<input
|
|
type="file"
|
|
accept=".pdf"
|
|
ref={fileInputRef}
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer file:border-0 file:bg-primary file:text-primary-foreground file:h-full file:px-4 file:mr-4 file:font-semibold hover:file:bg-primary/90"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if(file) setValidationErrors([])
|
|
}}
|
|
/>
|
|
</div>
|
|
) : validQuestions ? (
|
|
<div className="flex items-center justify-between p-3 border border-green-500/50 bg-green-500/10 rounded-md">
|
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
|
<CheckCircle2 className="w-5 h-5" />
|
|
<span className="text-sm font-medium">{validQuestions.length} questions validated</span>
|
|
</div>
|
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" onClick={removeFile}>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`relative flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-md transition-colors ${isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"}`}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
|
|
<p className="text-sm font-medium text-center">Drag and drop your .json file here</p>
|
|
<p className="text-xs text-muted-foreground text-center mt-1">or click to browse files</p>
|
|
<input
|
|
type="file"
|
|
accept=".json"
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
onChange={handleFileChange}
|
|
ref={fileInputRef}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{validationErrors.length > 0 && (
|
|
<div className="mt-2 p-3 border border-destructive/50 bg-destructive/10 rounded-md max-h-48 overflow-y-auto">
|
|
<div className="flex items-center gap-2 text-destructive mb-2">
|
|
<AlertCircle className="w-4 h-4" />
|
|
<span className="text-sm font-bold">Validation Errors Found</span>
|
|
</div>
|
|
<ul className="space-y-1">
|
|
{validationErrors.slice(0, 50).map((err, i) => (
|
|
<li key={i} className="text-xs text-destructive/90 flex gap-2">
|
|
<span className="font-mono bg-destructive/20 px-1 rounded">{err.path}</span>
|
|
<span>{err.message}</span>
|
|
</li>
|
|
))}
|
|
{validationErrors.length > 50 && (
|
|
<li className="text-xs text-muted-foreground italic mt-2">...and {validationErrors.length - 50} more errors</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full" disabled={uploading || (!validQuestions && testType !== 'DPP_PDF')}>
|
|
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
|
{uploading ? "Creating Test..." : (testType === 'DPP_PDF' ? "Upload PDF to Minio" : "Create Test from JSON")}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Existing Tests List */}
|
|
<Card className="lg:col-span-2 border-muted bg-card shadow-sm h-fit">
|
|
<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("_", " ") || "All"}</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" />
|
|
{new Date(test.scheduled_date).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{test.is_active ? "Published" : "Hidden"}</span>
|
|
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} />
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
onClick={() => handleDelete(test.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|