Module Update
This commit is contained in:
343
src/modules/exams/components/exam-assembly.tsx
Normal file
343
src/modules/exams/components/exam-assembly.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user