feat(classes): optimize teacher dashboard ui and implement grade management

This commit is contained in:
SpecialX
2026-01-14 13:59:11 +08:00
parent ade8d4346c
commit 9bfc621d3f
104 changed files with 12793 additions and 2309 deletions

View File

@@ -5,8 +5,9 @@ import { ActionState } from "@/shared/types/action-state"
import { z } from "zod"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { exams, examQuestions } from "@/shared/db/schema"
import { exams, examQuestions, subjects, grades } from "@/shared/db/schema"
import { eq } from "drizzle-orm"
import { omitScheduledAtFromDescription } from "./data-access"
const ExamCreateSchema = z.object({
title: z.string().min(1),
@@ -56,9 +57,17 @@ export async function createExamAction(
const examId = createId()
const scheduled = input.scheduledAt || undefined
// Retrieve names for JSON description (to maintain compatibility)
const subjectRecord = await db.query.subjects.findFirst({
where: eq(subjects.id, input.subject),
})
const gradeRecord = await db.query.grades.findFirst({
where: eq(grades.id, input.grade),
})
const meta = {
subject: input.subject,
grade: input.grade,
subject: subjectRecord?.name ?? input.subject,
grade: gradeRecord?.name ?? input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
@@ -71,11 +80,14 @@ export async function createExamAction(
id: examId,
title: input.title,
description: JSON.stringify(meta),
creatorId: user?.id ?? "user_teacher_123",
creatorId: user?.id ?? "user_teacher_math",
subjectId: input.subject,
gradeId: input.grade,
startTime: scheduled ? new Date(scheduled) : null,
status: "draft",
})
} catch {
} catch (error) {
console.error("Failed to create exam:", error)
return {
success: false,
message: "Database error: Failed to create exam",
@@ -215,19 +227,6 @@ const ExamDuplicateSchema = z.object({
examId: z.string().min(1),
})
const omitScheduledAtFromDescription = (description: string | null) => {
if (!description) return null
try {
const parsed: unknown = JSON.parse(description)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description
const meta = parsed as Record<string, unknown>
if ("scheduledAt" in meta) delete meta.scheduledAt
return JSON.stringify(meta)
} catch {
return description
}
}
export async function duplicateExamAction(
prevState: ActionState<string> | null,
formData: FormData
@@ -271,7 +270,7 @@ export async function duplicateExamAction(
id: newExamId,
title: `${source.title} (Copy)`,
description: omitScheduledAtFromDescription(source.description),
creatorId: user?.id ?? "user_teacher_123",
creatorId: user?.id ?? "user_teacher_math",
startTime: null,
endTime: null,
status: "draft",
@@ -305,6 +304,78 @@ export async function duplicateExamAction(
}
}
async function getCurrentUser() {
return { id: "user_teacher_123", role: "teacher" }
export async function getExamPreviewAction(examId: string) {
try {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: {
question: true
}
}
}
})
if (!exam) {
return { success: false, message: "Exam not found" }
}
// Extract questions from the relation
const questions = exam.questions.map(eq => eq.question)
return {
success: true,
data: {
structure: exam.structure,
questions: questions
}
}
} catch (error) {
console.error(error)
return { success: false, message: "Failed to load exam preview" }
}
}
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
try {
const allSubjects = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
return {
success: true,
data: allSubjects.map((s) => ({ id: s.id, name: s.name })),
}
} catch (error) {
console.error("Failed to fetch subjects:", error)
return {
success: false,
message: "Failed to load subjects",
}
}
}
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
try {
const allGrades = await db.query.grades.findMany({
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
})
return {
success: true,
data: allGrades.map((g) => ({ id: g.id, name: g.name })),
}
} catch (error) {
console.error("Failed to fetch grades:", error)
return {
success: false,
message: "Failed to load grades",
}
}
}
async function getCurrentUser() {
return { id: "user_teacher_math", role: "teacher" }
}

View File

@@ -1,9 +1,5 @@
"use client"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { Eye, Printer } from "lucide-react"
import type { ExamNode } from "./selected-question-list"
type ChoiceOption = {
@@ -86,55 +82,33 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" size="sm" className="gap-2">
<Eye className="h-4 w-4" />
Preview Exam
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-6 pb-2 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle>Exam Preview</DialogTitle>
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
<Printer className="h-4 w-4 mr-2" />
Print
</Button>
</div>
</DialogHeader>
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
{/* Header */}
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
<span>Subject: {subject}</span>
<span>Grade: {grade}</span>
<span>Time: {durationMin} mins</span>
<span>Total: {totalScore} pts</span>
</div>
<div className="flex justify-center gap-12 text-sm pt-4">
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
</div>
</div>
<div className="bg-card shadow-sm border p-12 print:shadow-none print:border-none">
{/* Header */}
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
<span>Subject: {subject}</span>
<span>Grade: {grade}</span>
<span>Time: {durationMin} mins</span>
<span>Total: {totalScore} pts</span>
</div>
<div className="flex justify-center gap-12 text-sm pt-4">
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
</div>
</div>
{/* Content */}
<div className="space-y-2">
{nodes.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
Empty Exam Paper
</div>
) : (
nodes.map(node => renderNode(node))
)}
</div>
{/* Content */}
<div className="space-y-2">
{nodes.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
Empty Exam Paper
</div>
</ScrollArea>
</DialogContent>
</Dialog>
) : (
nodes.map(node => renderNode(node))
)}
</div>
</div>
)
}

View File

@@ -10,10 +10,13 @@ type QuestionBankListProps = {
questions: Question[]
onAdd: (question: Question) => void
isAdded: (id: string) => boolean
onLoadMore?: () => void
hasMore?: boolean
isLoading?: boolean
}
export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) {
if (questions.length === 0) {
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
if (questions.length === 0 && !isLoading) {
return (
<div className="text-center py-8 text-muted-foreground">
No questions found matching your filters.
@@ -22,7 +25,7 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
}
return (
<div className="space-y-3">
<div className="space-y-3 pb-4">
{questions.map((q) => {
const added = isAdded(q.id)
const content = q.content as { text?: string }
@@ -60,6 +63,28 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
</Card>
)
})}
{hasMore && (
<div className="pt-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={onLoadMore}
disabled={isLoading}
className="w-full text-muted-foreground"
>
{isLoading ? "Loading..." : "Load More"}
</Button>
</div>
)}
{isLoading && questions.length === 0 && (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="h-20 bg-muted/20 rounded-lg animate-pulse" />
))}
</div>
)}
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy }
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,13 +28,14 @@ import {
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreviewAction } from "../actions"
import { Exam } from "../types"
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
import type { ExamNode } from "./assembly/selected-question-list"
import type { Question } from "@/modules/questions/types"
interface ExamActionsProps {
exam: Exam
@@ -44,6 +46,46 @@ export function ExamActions({ exam }: ExamActionsProps) {
const [showViewDialog, setShowViewDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const [previewNodes, setPreviewNodes] = useState<ExamNode[] | null>(null)
const [loadingPreview, setLoadingPreview] = useState(false)
const handleView = async () => {
setLoadingPreview(true)
setShowViewDialog(true)
try {
const result = await getExamPreviewAction(exam.id)
if (result.success && result.data) {
const { structure, questions } = result.data
const questionById = new Map<string, Question>()
for (const q of questions) questionById.set(q.id, q as unknown as Question)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hydrate = (nodes: any[]): ExamNode[] => {
return nodes.map((node) => {
if (node.type === "question") {
const q = node.questionId ? questionById.get(node.questionId) : undefined
return { ...node, question: q }
}
if (node.type === "group") {
return { ...node, children: hydrate(node.children || []) }
}
return node
})
}
const nodes = Array.isArray(structure) ? hydrate(structure) : []
setPreviewNodes(nodes)
} else {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
}
} catch (e) {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
} finally {
setLoadingPreview(false)
}
}
const copyId = () => {
navigator.clipboard.writeText(exam.id)
@@ -112,25 +154,35 @@ export function ExamActions({ exam }: ExamActionsProps) {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={copyId}>
<Copy className="mr-2 h-4 w-4" /> Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
<Eye className="mr-2 h-4 w-4" /> View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
handleView()
}}
title="Preview Exam"
>
<Eye className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={copyId}>
<Copy className="mr-2 h-4 w-4" /> Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
</DropdownMenuItem>
@@ -166,49 +218,21 @@ export function ExamActions({ exam }: ExamActionsProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Exam Details</DialogTitle>
<DialogDescription>ID: {exam.id}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Title:</span>
<span className="col-span-3">{exam.title}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Subject:</span>
<span className="col-span-3">{exam.subject}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Grade:</span>
<span className="col-span-3">{exam.grade}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Total Score:</span>
<span className="col-span-3">{exam.totalScore}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Duration:</span>
<span className="col-span-3">{exam.durationMin} min</span>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete exam?</AlertDialogTitle>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the exam.
This action cannot be undone. This will permanently delete the exam
&quot;{exam.title}&quot; and remove all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={(e) => {
e.preventDefault()
handleDelete()
@@ -220,6 +244,34 @@ export function ExamActions({ exam }: ExamActionsProps) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<div className="p-4 border-b shrink-0 flex items-center justify-between">
<DialogTitle className="text-lg font-semibold tracking-tight">{exam.title}</DialogTitle>
</div>
<ScrollArea className="flex-1">
{loadingPreview ? (
<div className="py-20 text-center text-muted-foreground">Loading preview...</div>
) : previewNodes && previewNodes.length > 0 ? (
<div className="max-w-3xl mx-auto py-8 px-6">
<ExamPaperPreview
title={exam.title}
subject={exam.subject}
grade={exam.grade}
durationMin={exam.durationMin}
totalScore={exam.totalScore}
nodes={previewNodes}
/>
</div>
) : (
<div className="py-20 text-center text-muted-foreground">
No questions in this exam.
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -1,19 +1,20 @@
"use client"
import { useDeferredValue, useMemo, useState } from "react"
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useFormStatus } from "react-dom"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Search } from "lucide-react"
import { Search, Eye } from "lucide-react"
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import type { Question } from "@/modules/questions/types"
import { updateExamAction } from "@/modules/exams/actions"
import { getQuestionsAction } from "@/modules/questions/actions"
import { StructureEditor } from "./assembly/structure-editor"
import { QuestionBankList } from "./assembly/question-bank-list"
import type { ExamNode } from "./assembly/selected-question-list"
@@ -49,6 +50,12 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
const deferredSearch = useDeferredValue(search)
// Bank state
const [bankQuestions, setBankQuestions] = useState<Question[]>(props.questionOptions)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(props.questionOptions.length >= 20)
const [isBankLoading, startBankTransition] = useTransition()
// Initialize structure state
const [structure, setStructure] = useState<ExamNode[]>(() => {
const questionById = new Map<string, Question>()
@@ -76,26 +83,47 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return []
})
const filteredQuestions = useMemo(() => {
let list: Question[] = [...props.questionOptions]
if (deferredSearch) {
const lower = deferredSearch.toLowerCase()
list = list.filter(q => {
const content = q.content as { text?: string }
return content.text?.toLowerCase().includes(lower)
})
}
const fetchQuestions = (reset: boolean = false) => {
startBankTransition(async () => {
const nextPage = reset ? 1 : page + 1
try {
const result = await getQuestionsAction({
q: deferredSearch,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: typeFilter === 'all' ? undefined : typeFilter as any,
difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
page: nextPage,
pageSize: 20
})
if (result && result.data) {
setBankQuestions(prev => {
if (reset) return result.data
// Deduplicate just in case
const existingIds = new Set(prev.map(q => q.id))
const newQuestions = result.data.filter(q => !existingIds.has(q.id))
return [...prev, ...newQuestions]
})
setHasMore(result.data.length === 20)
setPage(nextPage)
}
} catch (error) {
toast.error("Failed to load questions")
}
})
}
if (typeFilter !== "all") {
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
if (deferredSearch === "" && typeFilter === "all" && difficultyFilter === "all") {
return
}
}
if (difficultyFilter !== "all") {
const d = parseInt(difficultyFilter)
list = list.filter((q) => q.difficulty === d)
}
return list
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
fetchQuestions(true)
}, [deferredSearch, typeFilter, difficultyFilter])
// Recursively calculate total score
const assignedTotal = useMemo(() => {
@@ -231,6 +259,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return clean(structure)
}
const [previewOpen, setPreviewOpen] = useState(false)
const handleSave = async (formData: FormData) => {
formData.set("examId", props.examId)
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
@@ -238,7 +268,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Saved draft")
toast.success("Exam draft saved")
} else {
toast.error(result.message || "Save failed")
}
@@ -260,47 +290,76 @@ export function ExamAssembly(props: ExamAssemblyProps) {
}
return (
<div className="grid h-[calc(100vh-12rem)] gap-6 lg:grid-cols-5">
{/* Left: Preview (3 cols) */}
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
<CardHeader className="bg-muted/30 pb-4">
<div className="grid h-[calc(100vh-8rem)] gap-4 lg:grid-cols-12">
{/* Left: Preview (8 cols) */}
<Card className="lg:col-span-8 flex flex-col overflow-hidden border-2 border-primary/10 shadow-sm">
<CardHeader className="bg-muted/30 pb-4 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle>Exam Structure</CardTitle>
<ExamPaperPreview
title={props.title}
subject={props.subject}
grade={props.grade}
durationMin={props.durationMin}
totalScore={props.totalScore}
nodes={structure}
/>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-col items-end">
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
<span className="text-xs text-muted-foreground">Total Score</span>
<CardTitle className="text-lg">Exam Structure</CardTitle>
<div className="h-4 w-[1px] bg-border mx-1" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{props.subject}</span>
<span></span>
<span>{props.grade}</span>
<span></span>
<span>{props.durationMin} min</span>
</div>
<div className="h-2 w-24 rounded-full bg-secondary">
<div
className={`h-full rounded-full transition-all ${
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
}`}
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-6">
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
<Eye className="h-4 w-4" />
Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<div className="p-4 border-b shrink-0 flex items-center justify-between">
<DialogTitle className="text-lg font-semibold tracking-tight">{props.title}</DialogTitle>
</div>
<div className="flex-1 min-h-0 relative">
<ScrollArea className="h-full">
<div className="max-w-3xl mx-auto py-8 px-6">
<ExamPaperPreview
title={props.title}
subject={props.subject}
grade={props.grade}
durationMin={props.durationMin}
totalScore={props.totalScore}
nodes={structure}
/>
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
<div className="flex items-center gap-3 text-sm">
<div className="flex flex-col items-end">
<div className="flex items-baseline gap-1">
<span className={`text-lg font-bold ${assignedTotal > props.totalScore ? "text-destructive" : "text-primary"}`}>
{assignedTotal}
</span>
<span className="text-muted-foreground">/ {props.totalScore}</span>
</div>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Total Score</span>
</div>
<div className="h-10 w-2 rounded-full bg-secondary overflow-hidden flex flex-col-reverse">
<div
className={`w-full transition-all ${
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
}`}
style={{ height: `${Math.min(progress, 100)}%` }}
/>
</div>
</div>
</div>
</div>
</CardHeader>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
<div className="grid grid-cols-3 gap-4 text-sm text-muted-foreground bg-muted/20 p-3 rounded-md">
<div><span className="font-medium text-foreground">{props.subject}</span></div>
<div><span className="font-medium text-foreground">{props.grade}</span></div>
<div>Duration: <span className="font-medium text-foreground">{props.durationMin} min</span></div>
</div>
<ScrollArea className="flex-1 bg-muted/5">
<div className="max-w-4xl mx-auto p-6 space-y-8">
<StructureEditor
items={structure}
onChange={setStructure}
@@ -312,32 +371,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
</div>
</ScrollArea>
<div className="border-t p-4 bg-muted/30 flex gap-3 justify-end">
<form action={handleSave} className="flex-1">
<SubmitButton label="Save Draft" />
<div className="border-t p-4 bg-background flex gap-3 justify-end items-center shadow-[0_-1px_2px_rgba(0,0,0,0.03)]">
<div className="mr-auto text-xs text-muted-foreground">
{structure.length === 0 ? "Start by adding questions from the right panel" : `${structure.length} items in structure`}
</div>
<form action={handleSave}>
<Button variant="outline" size="sm" type="submit" className="w-24">Save Draft</Button>
</form>
<form action={handlePublish} className="flex-1">
<SubmitButton label="Publish Exam" />
<form action={handlePublish}>
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">Publish</Button>
</form>
</div>
</Card>
{/* Right: Question Bank (2 cols) */}
<Card className="lg:col-span-2 flex flex-col overflow-hidden">
<CardHeader className="pb-3 space-y-3">
<CardTitle className="text-base">Question Bank</CardTitle>
{/* Right: Question Bank (4 cols) */}
<Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
<CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">Question Bank</CardTitle>
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{bankQuestions.length}{hasMore ? "+" : ""} loaded
</span>
</div>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search questions..."
className="pl-8"
placeholder="Search by content..."
className="pl-9 h-9 text-sm"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="flex-1 h-8 text-xs"><SelectValue placeholder="Type" /></SelectTrigger>
<SelectTrigger className="flex-1 h-8 text-xs bg-background"><SelectValue placeholder="Type" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="single_choice">Single Choice</SelectItem>
@@ -347,7 +414,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
</SelectContent>
</Select>
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
<SelectTrigger className="w-[80px] h-8 text-xs"><SelectValue placeholder="Diff" /></SelectTrigger>
<SelectTrigger className="w-[80px] h-8 text-xs bg-background"><SelectValue placeholder="Diff" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="1">Lvl 1</SelectItem>
@@ -360,14 +427,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
</div>
</CardHeader>
<Separator />
<ScrollArea className="flex-1 p-4 bg-muted/10">
<QuestionBankList
questions={filteredQuestions}
onAdd={handleAdd}
isAdded={(id) => addedQuestionIds.has(id)}
/>
<ScrollArea className="flex-1 p-0 bg-muted/5">
<div className="p-3">
<QuestionBankList
questions={bankQuestions}
onAdd={handleAdd}
isAdded={(id) => addedQuestionIds.has(id)}
onLoadMore={() => fetchQuestions(false)}
hasMore={hasMore}
isLoading={isBankLoading}
/>
</div>
</ScrollArea>
</Card>
</div>

View File

@@ -0,0 +1,103 @@
"use client"
import Link from "next/link"
import { Book, Clock, GraduationCap, Trophy, HelpCircle } from "lucide-react"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/shared/components/ui/card"
import { Badge, BadgeProps } from "@/shared/components/ui/badge"
import { cn, formatDate } from "@/shared/lib/utils"
import { Exam } from "../types"
import { ExamActions } from "./exam-actions"
interface ExamCardProps {
exam: Exam
hrefBase?: string
}
const subjectColorMap: Record<string, string> = {
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
}
export function ExamCard({ exam, hrefBase }: ExamCardProps) {
const base = hrefBase || "/teacher/exams"
const colorClass = subjectColorMap[exam.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800"
const statusVariant: BadgeProps["variant"] =
exam.status === "published"
? "secondary"
: exam.status === "archived"
? "destructive"
: "outline"
return (
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
<Link href={`${base}/${exam.id}/build`} className="flex-1">
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
<div className="relative z-10 flex h-full flex-col justify-between">
<div className="flex justify-between items-start">
<Badge variant={statusVariant} className="bg-background/50 backdrop-blur-sm shadow-none border-transparent">
{exam.status}
</Badge>
{exam.difficulty && (
<Badge variant="outline" className="bg-background/50 backdrop-blur-sm border-transparent shadow-none">
Lvl {exam.difficulty}
</Badge>
)}
</div>
<Book className="h-8 w-8 opacity-50" />
</div>
</div>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg leading-tight line-clamp-2 group-hover:text-primary transition-colors">
{exam.title}
</h3>
</div>
</CardHeader>
<CardContent className="p-4 pt-1 pb-2">
<div className="flex flex-wrap gap-y-2 gap-x-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<GraduationCap className="h-3.5 w-3.5" />
<span>{exam.grade}</span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
<span>{exam.durationMin} min</span>
</div>
<div className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
<span>{exam.totalScore} pts</span>
</div>
</div>
</CardContent>
</Link>
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<HelpCircle className="h-3.5 w-3.5" />
<span>{exam.questionCount || 0} Questions</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground/60 mr-2">
{formatDate(exam.updatedAt || exam.createdAt)}
</span>
<ExamActions exam={exam} />
</div>
</CardFooter>
</Card>
)
}

View File

@@ -30,109 +30,126 @@ export const examColumns: ColumnDef<Exam>[] = [
},
{
accessorKey: "title",
header: "Title",
header: "Exam Info",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.title}</span>
{row.original.tags && row.original.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{row.original.tags.slice(0, 2).map((t, idx) => (
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
{t}
</Badge>
))}
{row.original.tags.length > 2 && (
<Badge variant="outline" className="text-xs">+{row.original.tags.length - 2}</Badge>
)}
</div>
)}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-base">{row.original.title}</span>
{row.original.tags && row.original.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{row.original.tags.slice(0, 2).map((t, idx) => (
<Badge key={`${t}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
{t}
</Badge>
))}
{row.original.tags.length > 2 && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">+{row.original.tags.length - 2}</Badge>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground/80">{row.original.subject}</span>
<span></span>
<span>{row.original.grade}</span>
</div>
</div>
),
},
{
accessorKey: "subject",
header: "Subject",
},
{
accessorKey: "grade",
header: "Grade",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">{row.original.grade}</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.original.status
// Use 'default' as base for published/success to ensure type safety,
// but override with className below
const variant: BadgeProps["variant"] =
status === "published"
? "secondary"
? "default"
: status === "archived"
? "destructive"
? "secondary"
: "outline"
return (
<Badge variant={variant} className="capitalize">
<Badge
variant={variant}
className={cn(
"capitalize",
status === "published" && "bg-green-600 hover:bg-green-700 border-transparent",
status === "draft" && "bg-amber-100 text-amber-800 hover:bg-amber-200 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800"
)}
>
{status}
</Badge>
)
},
},
{
id: "stats",
header: "Stats",
cell: ({ row }) => (
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{row.original.questionCount} Qs</span>
<span></span>
<span>{row.original.totalScore} Pts</span>
</div>
<div className="flex items-center gap-1">
<span>{row.original.durationMin} min</span>
</div>
</div>
),
},
{
accessorKey: "difficulty",
header: "Difficulty",
cell: ({ row }) => {
const diff = row.original.difficulty
return (
<div className="flex items-center">
<span
className={cn(
"font-medium",
diff <= 2 ? "text-green-600" : diff === 3 ? "text-yellow-600" : "text-red-600"
)}
>
{diff === 1
? "Easy"
: diff === 2
? "Easy-Med"
: diff === 3
? "Medium"
: diff === 4
? "Med-Hard"
: "Hard"}
<div className="flex flex-col gap-1">
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((level) => (
<div
key={level}
className={cn(
"h-1.5 w-3 rounded-full",
level <= diff
? diff <= 2 ? "bg-green-500" : diff === 3 ? "bg-yellow-500" : "bg-red-500"
: "bg-muted"
)}
/>
))}
</div>
<span className="text-[10px] text-muted-foreground font-medium">
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
</span>
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
</div>
)
},
},
{
accessorKey: "durationMin",
header: "Duration",
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.durationMin} min</span>,
},
{
accessorKey: "totalScore",
header: "Total",
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.totalScore}</span>,
},
{
accessorKey: "scheduledAt",
header: "Scheduled",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{row.original.scheduledAt ? formatDate(row.original.scheduledAt) : "-"}
</span>
),
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{formatDate(row.original.createdAt)}
</span>
),
id: "dates",
header: "Date",
cell: ({ row }) => {
const scheduled = row.original.scheduledAt
const created = row.original.createdAt
return (
<div className="flex flex-col gap-0.5 text-xs">
{scheduled ? (
<>
<span className="font-medium text-blue-600 dark:text-blue-400">Scheduled</span>
<span className="text-muted-foreground">{formatDate(scheduled)}</span>
</>
) : (
<>
<span className="text-muted-foreground">Created</span>
<span>{formatDate(created)}</span>
</>
)}
</div>
)
},
},
{
id: "actions",

View File

@@ -52,7 +52,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableHeader className="bg-muted/40">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
@@ -88,20 +88,38 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex items-center justify-between px-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</div>
<div className="space-x-2">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
<ChevronRight className="h-4 w-4" />
</Button>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Page</p>
<span className="text-sm font-medium">
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>

View File

@@ -19,57 +19,59 @@ export function ExamFilters() {
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
return (
<div className="flex items-center gap-2">
<div className="relative w-full md:w-[260px]">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative w-full md:w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
<Input
placeholder="Search exams..."
className="pl-7"
className="pl-9 bg-background border-muted-foreground/20"
value={search || ""}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Status</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Status</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Difficulty" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Difficulty</SelectItem>
<SelectItem value="1">Easy (1)</SelectItem>
<SelectItem value="2">Easy-Med (2)</SelectItem>
<SelectItem value="3">Medium (3)</SelectItem>
<SelectItem value="4">Med-Hard (4)</SelectItem>
<SelectItem value="5">Hard (5)</SelectItem>
</SelectContent>
</Select>
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Difficulty" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Difficulty</SelectItem>
<SelectItem value="1">Easy (1)</SelectItem>
<SelectItem value="2">Easy-Med (2)</SelectItem>
<SelectItem value="3">Medium (3)</SelectItem>
<SelectItem value="4">Med-Hard (4)</SelectItem>
<SelectItem value="5">Hard (5)</SelectItem>
</SelectContent>
</Select>
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setStatus(null)
setDifficulty(null)
}}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setStatus(null)
setDifficulty(null)
}}
className="h-10 px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}

View File

@@ -1,99 +1,357 @@
"use client"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useTransition, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { toast } from "sonner"
import { Loader2, Sparkles, BookOpen } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { createExamAction } from "../actions"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { createExamAction, getSubjectsAction, getGradesAction } from "../actions"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Exam"}
</Button>
)
export const formSchema = z.object({
title: z.string().min(2, "Title must be at least 2 characters."),
subject: z.string().min(1, "Subject is required."),
grade: z.string().min(1, "Grade is required."),
difficulty: z.string(),
totalScore: z.coerce.number().min(1, "Total score must be at least 1."),
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes."),
scheduledAt: z.string().optional(),
mode: z.enum(["manual", "ai"]),
})
type ExamFormValues = z.infer<typeof formSchema>
const defaultValues: Partial<ExamFormValues> = {
title: "",
subject: "",
grade: "",
difficulty: "3",
totalScore: 100,
durationMin: 90,
mode: "manual",
scheduledAt: "",
}
export function ExamForm() {
const router = useRouter()
const [difficulty, setDifficulty] = useState<string>("3")
const [isPending, startTransition] = useTransition()
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
const [loadingSubjects, setLoadingSubjects] = useState(true)
const [grades, setGrades] = useState<{ id: string; name: string }[]>([])
const [loadingGrades, setLoadingGrades] = useState(true)
const handleSubmit = async (formData: FormData) => {
const result = await createExamAction(null, formData)
if (result.success) {
toast.success(result.message)
if (result.data) {
router.push(`/teacher/exams/${result.data}/build`)
const form = useForm<ExamFormValues>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(formSchema) as any,
defaultValues: defaultValues as unknown as ExamFormValues,
})
useEffect(() => {
const fetchMetadata = async () => {
try {
const [subjectsResult, gradesResult] = await Promise.all([
getSubjectsAction(),
getGradesAction()
])
if (subjectsResult.success && subjectsResult.data) {
setSubjects(subjectsResult.data)
} else {
toast.error("Failed to load subjects")
}
if (gradesResult.success && gradesResult.data) {
setGrades(gradesResult.data)
} else {
toast.error("Failed to load grades")
}
} catch (error) {
console.error(error)
toast.error("Failed to load form data")
} finally {
setLoadingSubjects(false)
setLoadingGrades(false)
}
} else {
toast.error(result.message)
}
fetchMetadata()
}, [])
function onSubmit(data: ExamFormValues) {
const formData = new FormData()
formData.append("title", data.title)
formData.append("subject", data.subject)
formData.append("grade", data.grade)
formData.append("difficulty", data.difficulty)
formData.append("totalScore", data.totalScore.toString())
formData.append("durationMin", data.durationMin.toString())
if (data.scheduledAt) {
formData.append("scheduledAt", data.scheduledAt)
}
startTransition(async () => {
const result = await createExamAction(null, formData)
if (result.success && result.data) {
toast.success("Exam draft created", {
description: "Redirecting to exam builder...",
})
router.push(`/teacher/exams/${result.data}/build`)
} else {
toast.error(result.message || "Failed to create exam")
}
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSubmit = (e: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form.handleSubmit(onSubmit as any)(e);
}
return (
<Card>
<CardHeader>
<CardTitle>Exam Creator</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="title">Title</Label>
<Input id="title" name="title" placeholder="e.g. Algebra Midterm" required />
</div>
<div className="grid gap-2">
<Label htmlFor="subject">Subject</Label>
<Input id="subject" name="subject" placeholder="e.g. Mathematics" required />
</div>
<div className="grid gap-2">
<Label htmlFor="grade">Grade</Label>
<Input id="grade" name="grade" placeholder="e.g. Grade 10" required />
</div>
<div className="grid gap-2">
<Label>Difficulty</Label>
<Select value={difficulty} onValueChange={(val) => setDifficulty(val)} name="difficulty">
<SelectTrigger>
<SelectValue placeholder="Select difficulty" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Easy (1)</SelectItem>
<SelectItem value="2">Easy-Med (2)</SelectItem>
<SelectItem value="3">Medium (3)</SelectItem>
<SelectItem value="4">Med-Hard (4)</SelectItem>
<SelectItem value="5">Hard (5)</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="difficulty" value={difficulty} />
</div>
<div className="grid gap-2">
<Label htmlFor="totalScore">Total Score</Label>
<Input id="totalScore" name="totalScore" type="number" min={1} placeholder="e.g. 100" required />
</div>
<div className="grid gap-2">
<Label htmlFor="durationMin">Duration (min)</Label>
<Input id="durationMin" name="durationMin" type="number" min={10} placeholder="e.g. 90" required />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="scheduledAt">Scheduled At (optional)</Label>
<Input id="scheduledAt" name="scheduledAt" type="datetime-local" />
</div>
</div>
<Form {...form}>
<form onSubmit={handleSubmit} className="grid gap-8 lg:grid-cols-3">
{/* Left Column: Exam Details */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Exam Details</CardTitle>
<CardDescription>
Define the core information for your exam.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{subjects.map((subject) => (
<SelectItem key={subject.id} value={subject.id}>
{subject.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="grade"
render={({ field }) => (
<FormItem>
<FormLabel>Grade Level</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingGrades}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{grades.map((grade) => (
<SelectItem key={grade.id} value={grade.id}>
{grade.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField
control={form.control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1">Level 1 (Easy)</SelectItem>
<SelectItem value="2">Level 2</SelectItem>
<SelectItem value="3">Level 3 (Medium)</SelectItem>
<SelectItem value="4">Level 4</SelectItem>
<SelectItem value="5">Level 5 (Hard)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="totalScore"
render={({ field }) => (
<FormItem>
<FormLabel>Total Score</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="durationMin"
render={({ field }) => (
<FormItem>
<FormLabel>Duration (min)</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<CardFooter className="justify-end">
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
<FormField
control={form.control}
name="scheduledAt"
render={({ field }) => (
<FormItem>
<FormLabel>Schedule Start Time (Optional)</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormDescription>
If set, this exam will be scheduled for a specific time.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Right Column: Mode & Actions */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Assembly Mode</CardTitle>
<CardDescription>
Choose how to build the exam structure.
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<div className="flex flex-col space-y-3">
{/* Manual Mode */}
<div
className={cn(
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground",
field.value === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
)}
onClick={() => field.onChange("manual")}
>
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">Manual Assembly</span>
</div>
<span className="mt-1 text-xs text-muted-foreground">
Manually select questions from the bank and organize structure.
</span>
</div>
{/* AI Mode (Disabled) */}
<div
className={cn(
"relative flex cursor-not-allowed flex-col rounded-lg border p-4 shadow-sm outline-none opacity-50 bg-muted/20"
)}
>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
<span className="font-medium">AI Generation</span>
</div>
<span className="mt-1 text-xs text-muted-foreground">
Automatically generate exam structure based on topics. (Coming Soon)
</span>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isPending ? "Creating Draft..." : "Create & Start Building"}
</Button>
</CardFooter>
</Card>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,16 @@
import { Exam } from "../types"
import { ExamCard } from "./exam-card"
interface ExamGridProps {
exams: Exam[]
}
export function ExamGrid({ exams }: ExamGridProps) {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{exams.map((exam) => (
<ExamCard key={exam.id} exam={exam} />
))}
</div>
)
}

View File

@@ -68,6 +68,10 @@ export const getExams = cache(async (params: GetExamsParams) => {
const data = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(exams.createdAt)],
with: {
subject: true,
gradeEntity: true,
}
})
// Transform and Filter (especially for JSON fields)
@@ -78,8 +82,8 @@ export const getExams = cache(async (params: GetExamsParams) => {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
subject: getString(meta, "subject") || "General",
grade: getString(meta, "grade") || "General",
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60,
@@ -103,6 +107,8 @@ export const getExamById = cache(async (id: string) => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, id),
with: {
subject: true,
gradeEntity: true,
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: {
@@ -120,8 +126,8 @@ export const getExamById = cache(async (id: string) => {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
subject: getString(meta, "subject") || "General",
grade: getString(meta, "grade") || "General",
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60,
@@ -137,3 +143,18 @@ export const getExamById = cache(async (id: string) => {
})),
}
})
export const omitScheduledAtFromDescription = (description: string | null): string => {
if (!description) return "{}"
try {
const meta = JSON.parse(description)
if (typeof meta === "object" && meta !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { scheduledAt, ...rest } = meta as any
return JSON.stringify(rest)
}
return description
} catch {
return description || "{}"
}
}