344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import { useFormStatus } from "react-dom"
|
|
import { useRouter } from "next/navigation"
|
|
import { toast } from "sonner"
|
|
import { Search } from "lucide-react"
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
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 { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
import { Separator } from "@/shared/components/ui/separator"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import type { Question } from "@/modules/questions/types"
|
|
import { updateExamAction } from "@/modules/exams/actions"
|
|
import { StructureEditor } from "./assembly/structure-editor"
|
|
import { QuestionBankList } from "./assembly/question-bank-list"
|
|
import type { ExamNode } from "./assembly/selected-question-list"
|
|
import { createId } from "@paralleldrive/cuid2"
|
|
|
|
type ExamAssemblyProps = {
|
|
examId: string
|
|
title: string
|
|
subject: string
|
|
grade: string
|
|
difficulty: number
|
|
totalScore: number
|
|
durationMin: number
|
|
initialSelected?: Array<{ id: string; score: number }>
|
|
initialStructure?: ExamNode[] // New prop
|
|
questionOptions: Question[]
|
|
}
|
|
|
|
function SubmitButton({ label }: { label: string }) {
|
|
const { pending } = useFormStatus()
|
|
return (
|
|
<Button type="submit" disabled={pending} className="w-full">
|
|
{pending ? "Saving..." : label}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export function ExamAssembly(props: ExamAssemblyProps) {
|
|
const router = useRouter()
|
|
const [search, setSearch] = useState("")
|
|
const [typeFilter, setTypeFilter] = useState<string>("all")
|
|
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
|
|
|
// Initialize structure state
|
|
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
|
// Hydrate structure with full question objects
|
|
const hydrate = (nodes: ExamNode[]): ExamNode[] => {
|
|
return nodes.map(node => {
|
|
if (node.type === 'question') {
|
|
const q = props.questionOptions.find(opt => opt.id === node.questionId)
|
|
return { ...node, question: q }
|
|
}
|
|
if (node.type === 'group') {
|
|
return { ...node, children: hydrate(node.children || []) }
|
|
}
|
|
return node
|
|
})
|
|
}
|
|
|
|
// Use initialStructure if provided (Server generated or DB stored)
|
|
if (props.initialStructure && props.initialStructure.length > 0) {
|
|
return hydrate(props.initialStructure)
|
|
}
|
|
|
|
// Fallback logic removed as Server Component handles initial migration
|
|
return []
|
|
})
|
|
|
|
const filteredQuestions = useMemo(() => {
|
|
let list: Question[] = [...props.questionOptions]
|
|
|
|
if (search) {
|
|
const lower = search.toLowerCase()
|
|
list = list.filter(q => {
|
|
const content = q.content as { text?: string }
|
|
return content.text?.toLowerCase().includes(lower)
|
|
})
|
|
}
|
|
|
|
if (typeFilter !== "all") {
|
|
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
|
|
}
|
|
if (difficultyFilter !== "all") {
|
|
const d = parseInt(difficultyFilter)
|
|
list = list.filter((q) => q.difficulty === d)
|
|
}
|
|
return list
|
|
}, [search, typeFilter, difficultyFilter, props.questionOptions])
|
|
|
|
// Recursively calculate total score
|
|
const assignedTotal = useMemo(() => {
|
|
const calc = (nodes: ExamNode[]): number => {
|
|
return nodes.reduce((sum, node) => {
|
|
if (node.type === 'question') return sum + (node.score || 0)
|
|
if (node.type === 'group') return sum + calc(node.children || [])
|
|
return sum
|
|
}, 0)
|
|
}
|
|
return calc(structure)
|
|
}, [structure])
|
|
|
|
const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
|
|
|
|
const handleAdd = (question: Question) => {
|
|
setStructure(prev => [
|
|
...prev,
|
|
{
|
|
id: createId(),
|
|
type: 'question',
|
|
questionId: question.id,
|
|
score: 10,
|
|
question
|
|
}
|
|
])
|
|
}
|
|
|
|
const handleAddGroup = () => {
|
|
setStructure(prev => [
|
|
...prev,
|
|
{
|
|
id: createId(),
|
|
type: 'group',
|
|
title: 'New Section',
|
|
children: []
|
|
}
|
|
])
|
|
}
|
|
|
|
const handleRemove = (id: string) => {
|
|
const removeRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
|
return nodes.filter(n => n.id !== id).map(n => {
|
|
if (n.type === 'group') {
|
|
return { ...n, children: removeRecursive(n.children || []) }
|
|
}
|
|
return n
|
|
})
|
|
}
|
|
setStructure(prev => removeRecursive(prev))
|
|
}
|
|
|
|
const handleScoreChange = (id: string, score: number) => {
|
|
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
|
return nodes.map(n => {
|
|
if (n.id === id) return { ...n, score }
|
|
if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) }
|
|
return n
|
|
})
|
|
}
|
|
setStructure(prev => updateRecursive(prev))
|
|
}
|
|
|
|
const handleGroupTitleChange = (id: string, title: string) => {
|
|
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
|
return nodes.map(n => {
|
|
if (n.id === id) return { ...n, title }
|
|
if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) }
|
|
return n
|
|
})
|
|
}
|
|
setStructure(prev => updateRecursive(prev))
|
|
}
|
|
|
|
// Helper to extract flat list for DB examQuestions table
|
|
const getFlatQuestions = () => {
|
|
const list: Array<{ id: string; score: number }> = []
|
|
const traverse = (nodes: ExamNode[]) => {
|
|
nodes.forEach(n => {
|
|
if (n.type === 'question' && n.questionId) {
|
|
list.push({ id: n.questionId, score: n.score || 0 })
|
|
}
|
|
if (n.type === 'group') {
|
|
traverse(n.children || [])
|
|
}
|
|
})
|
|
}
|
|
traverse(structure)
|
|
return list
|
|
}
|
|
|
|
// Helper to strip runtime question objects for DB structure storage
|
|
const getCleanStructure = () => {
|
|
const clean = (nodes: ExamNode[]): any[] => {
|
|
return nodes.map(n => {
|
|
const { question, ...rest } = n
|
|
if (n.type === 'group') {
|
|
return { ...rest, children: clean(n.children || []) }
|
|
}
|
|
return rest
|
|
})
|
|
}
|
|
return clean(structure)
|
|
}
|
|
|
|
const handleSave = async (formData: FormData) => {
|
|
formData.set("examId", props.examId)
|
|
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
|
formData.set("structureJson", JSON.stringify(getCleanStructure()))
|
|
|
|
const result = await updateExamAction(null, formData)
|
|
if (result.success) {
|
|
toast.success("Saved draft")
|
|
} else {
|
|
toast.error(result.message || "Save failed")
|
|
}
|
|
}
|
|
|
|
const handlePublish = async (formData: FormData) => {
|
|
formData.set("examId", props.examId)
|
|
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
|
formData.set("structureJson", JSON.stringify(getCleanStructure()))
|
|
formData.set("status", "published")
|
|
|
|
const result = await updateExamAction(null, formData)
|
|
if (result.success) {
|
|
toast.success("Published exam")
|
|
router.push("/teacher/exams/all")
|
|
} else {
|
|
toast.error(result.message || "Publish failed")
|
|
}
|
|
}
|
|
|
|
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="flex items-center justify-between">
|
|
<CardTitle>Exam Structure</CardTitle>
|
|
<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>
|
|
</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>
|
|
</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>
|
|
|
|
<StructureEditor
|
|
items={structure}
|
|
onChange={setStructure}
|
|
onScoreChange={handleScoreChange}
|
|
onGroupTitleChange={handleGroupTitleChange}
|
|
onRemove={handleRemove}
|
|
onAddGroup={handleAddGroup}
|
|
/>
|
|
</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" />
|
|
</form>
|
|
<form action={handlePublish} className="flex-1">
|
|
<SubmitButton label="Publish Exam" />
|
|
</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>
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search questions..."
|
|
className="pl-8"
|
|
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>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Types</SelectItem>
|
|
<SelectItem value="single_choice">Single Choice</SelectItem>
|
|
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
|
<SelectItem value="judgment">True/False</SelectItem>
|
|
<SelectItem value="text">Short Answer</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
|
<SelectTrigger className="w-[80px] h-8 text-xs"><SelectValue placeholder="Diff" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
<SelectItem value="1">Lvl 1</SelectItem>
|
|
<SelectItem value="2">Lvl 2</SelectItem>
|
|
<SelectItem value="3">Lvl 3</SelectItem>
|
|
<SelectItem value="4">Lvl 4</SelectItem>
|
|
<SelectItem value="5">Lvl 5</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<Separator />
|
|
|
|
<ScrollArea className="flex-1 p-4 bg-muted/10">
|
|
<QuestionBankList
|
|
questions={filteredQuestions}
|
|
onAdd={handleAdd}
|
|
isAdded={(id) => {
|
|
// Check if question is added anywhere in the structure
|
|
const isAddedRecursive = (nodes: ExamNode[]): boolean => {
|
|
return nodes.some(n => {
|
|
if (n.type === 'question' && n.questionId === id) return true
|
|
if (n.type === 'group' && n.children) return isAddedRecursive(n.children)
|
|
return false
|
|
})
|
|
}
|
|
return isAddedRecursive(structure)
|
|
}}
|
|
/>
|
|
</ScrollArea>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|