feat(classes): optimize teacher dashboard ui and implement grade management
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
"{exam.title}" 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
103
src/modules/exams/components/exam-card.tsx
Normal file
103
src/modules/exams/components/exam-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
16
src/modules/exams/components/exam-grid.tsx
Normal file
16
src/modules/exams/components/exam-grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 || "{}"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user