Module Update
Some checks failed
CI / build-and-test (push) Failing after 1m31s
CI / deploy (push) Has been skipped

This commit is contained in:
SpecialX
2025-12-30 14:42:30 +08:00
parent f1797265b2
commit e7c902e8e1
148 changed files with 19317 additions and 113 deletions

View File

@@ -0,0 +1,244 @@
"use server"
import { revalidatePath } from "next/cache"
import { ActionState } from "@/shared/types/action-state"
import { z } from "zod"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { exams, examQuestions, submissionAnswers, examSubmissions } from "@/shared/db/schema"
import { eq } from "drizzle-orm"
const ExamCreateSchema = z.object({
title: z.string().min(1),
subject: z.string().min(1),
grade: z.string().min(1),
difficulty: z.coerce.number().int().min(1).max(5),
totalScore: z.coerce.number().int().min(1),
durationMin: z.coerce.number().int().min(1),
scheduledAt: z.string().optional().nullable(),
questions: z
.array(
z.object({
id: z.string(),
score: z.coerce.number().int().min(0),
})
)
.optional(),
})
export async function createExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawQuestions = formData.get("questionsJson") as string | null
const parsed = ExamCreateSchema.safeParse({
title: formData.get("title"),
subject: formData.get("subject"),
grade: formData.get("grade"),
difficulty: formData.get("difficulty"),
totalScore: formData.get("totalScore"),
durationMin: formData.get("durationMin"),
scheduledAt: formData.get("scheduledAt"),
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const input = parsed.data
const examId = createId()
const scheduled = input.scheduledAt || undefined
const meta = {
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: scheduled ?? undefined,
}
try {
const user = await getCurrentUser()
await db.insert(exams).values({
id: examId,
title: input.title,
description: JSON.stringify(meta),
creatorId: user?.id ?? "user_teacher_123",
startTime: scheduled ? new Date(scheduled) : null,
status: "draft",
})
} catch (error) {
console.error("Failed to create exam:", error)
return {
success: false,
message: "Database error: Failed to create exam",
}
}
revalidatePath("/teacher/exams/all")
return {
success: true,
message: "Exam created successfully.",
data: examId,
}
}
const ExamUpdateSchema = z.object({
examId: z.string().min(1),
questions: z
.array(
z.object({
id: z.string(),
score: z.coerce.number().int().min(0),
})
)
.default([]),
structure: z.any().optional(), // Accept structure JSON
status: z.enum(["draft", "published", "archived"]).optional(),
})
export async function updateExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawQuestions = formData.get("questionsJson") as string | null
const rawStructure = formData.get("structureJson") as string | null
const parsed = ExamUpdateSchema.safeParse({
examId: formData.get("examId"),
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
structure: rawStructure ? JSON.parse(rawStructure) : undefined,
status: formData.get("status") ?? undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid update data",
errors: parsed.error.flatten().fieldErrors,
}
}
const { examId, questions, structure, status } = parsed.data
try {
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
if (questions.length > 0) {
await db.insert(examQuestions).values(
questions.map((q, idx) => ({
examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
}
// Prepare update object
const updateData: any = {}
if (status) updateData.status = status
if (structure) updateData.structure = structure
if (Object.keys(updateData).length > 0) {
await db.update(exams).set(updateData).where(eq(exams.id, examId))
}
} catch (error) {
console.error("Failed to update exam:", error)
return {
success: false,
message: "Database error: Failed to update exam",
}
}
revalidatePath("/teacher/exams/all")
return {
success: true,
message: "Exam updated",
data: examId,
}
}
const GradingSchema = z.object({
submissionId: z.string().min(1),
answers: z.array(z.object({
id: z.string(), // answer id
score: z.coerce.number().min(0),
feedback: z.string().optional()
}))
})
export async function gradeSubmissionAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawAnswers = formData.get("answersJson") as string | null
const parsed = GradingSchema.safeParse({
submissionId: formData.get("submissionId"),
answers: rawAnswers ? JSON.parse(rawAnswers) : []
})
if (!parsed.success) {
return {
success: false,
message: "Invalid grading data",
errors: parsed.error.flatten().fieldErrors
}
}
const { submissionId, answers } = parsed.data
try {
let totalScore = 0
// Update each answer
for (const ans of answers) {
await db.update(submissionAnswers)
.set({
score: ans.score,
feedback: ans.feedback,
updatedAt: new Date()
})
.where(eq(submissionAnswers.id, ans.id))
totalScore += ans.score
}
// Update submission total score and status
await db.update(examSubmissions)
.set({
score: totalScore,
status: "graded",
updatedAt: new Date()
})
.where(eq(examSubmissions.id, submissionId))
} catch (error) {
console.error("Grading failed:", error)
return {
success: false,
message: "Database error during grading"
}
}
revalidatePath(`/teacher/exams/grading`)
return {
success: true,
message: "Grading saved successfully"
}
}
async function getCurrentUser() {
return { id: "user_teacher_123", role: "teacher" }
}

View File

@@ -0,0 +1,65 @@
"use client"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Card } from "@/shared/components/ui/card"
import { Plus } from "lucide-react"
import type { Question } from "@/modules/questions/types"
type QuestionBankListProps = {
questions: Question[]
onAdd: (question: Question) => void
isAdded: (id: string) => boolean
}
export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) {
if (questions.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No questions found matching your filters.
</div>
)
}
return (
<div className="space-y-3">
{questions.map((q) => {
const added = isAdded(q.id)
const content = q.content as { text?: string }
return (
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] uppercase">
{q.type.replace("_", " ")}
</Badge>
<Badge variant="secondary" className="text-[10px]">
Lvl {q.difficulty}
</Badge>
{q.knowledgePoints?.slice(0, 1).map((kp) => (
<Badge key={kp.id} variant="outline" className="text-[10px] truncate max-w-[100px]">
{kp.name}
</Badge>
))}
</div>
<p className="text-sm line-clamp-2 text-muted-foreground">
{content.text || "No content preview"}
</p>
</div>
<div className="flex items-center">
<Button
size="sm"
variant={added ? "secondary" : "default"}
disabled={added}
onClick={() => onAdd(q)}
className="h-8 w-8 p-0"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</Card>
)
})}
</div>
)
}

View File

@@ -0,0 +1,181 @@
"use client"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { ArrowUp, ArrowDown, Trash2 } from "lucide-react"
import type { Question } from "@/modules/questions/types"
export type ExamNode = {
id: string
type: 'group' | 'question'
title?: string // For group
questionId?: string // For question
score?: number
children?: ExamNode[] // For group
question?: Question // Populated for rendering
}
type SelectedQuestionListProps = {
items: ExamNode[]
onRemove: (id: string, parentId?: string) => void
onMove: (id: string, direction: 'up' | 'down', parentId?: string) => void
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
onAddGroup: () => void
}
export function SelectedQuestionList({
items,
onRemove,
onMove,
onScoreChange,
onGroupTitleChange,
onAddGroup
}: SelectedQuestionListProps) {
if (items.length === 0) {
return (
<div className="border border-dashed rounded-lg p-8 text-center text-muted-foreground text-sm flex flex-col gap-4">
<p>No questions selected. Add questions from the bank or create a group.</p>
<Button variant="outline" onClick={onAddGroup}>Create Section</Button>
</div>
)
}
return (
<div className="space-y-4">
{items.map((node, idx) => {
if (node.type === 'group') {
return (
<div key={node.id} className="rounded-lg border bg-muted/10 p-4 space-y-4">
<div className="flex items-center gap-3">
<Input
value={node.title || "Untitled Section"}
onChange={(e) => onGroupTitleChange(node.id, e.target.value)}
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background"
/>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'up')} disabled={idx === 0}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'down')} disabled={idx === items.length - 1}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => onRemove(node.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="pl-4 border-l-2 border-muted space-y-3">
{node.children?.length === 0 ? (
<div className="text-xs text-muted-foreground italic py-2">Drag questions here or add from bank</div>
) : (
node.children?.map((child, cIdx) => (
<QuestionItem
key={child.id}
item={child}
index={cIdx}
total={node.children?.length || 0}
onRemove={() => onRemove(child.id, node.id)}
onMove={(dir) => onMove(child.id, dir, node.id)}
onScoreChange={(score) => onScoreChange(child.id, score)}
/>
))
)}
</div>
</div>
)
}
return (
<QuestionItem
key={node.id}
item={node}
index={idx}
total={items.length}
onRemove={() => onRemove(node.id)}
onMove={(dir) => onMove(node.id, dir)}
onScoreChange={(score) => onScoreChange(node.id, score)}
/>
)
})}
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={onAddGroup} className="w-full border-dashed">
+ Add Section
</Button>
</div>
</div>
)
}
function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
item: ExamNode
index: number
total: number
onRemove: () => void
onMove: (dir: 'up' | 'down') => void
onScoreChange: (score: number) => void
}) {
const content = item.question?.content as { text?: string }
return (
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
{index + 1}
</span>
<p className="text-sm line-clamp-2 pt-0.5">
{content?.text || "Question content"}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center justify-between pl-8">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={index === 0}
onClick={() => onMove('up')}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={index === total - 1}
onClick={() => onMove('down')}
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
Score
</Label>
<Input
id={`score-${item.id}`}
type="number"
min={0}
className="h-7 w-16 text-right"
value={item.score}
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,570 @@
"use client"
import React, { useMemo, useState } from "react"
import {
DndContext,
pointerWithin,
rectIntersection,
getFirstCollision,
CollisionDetection,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
defaultDropAnimationSideEffects,
DragStartEvent,
DragOverEvent,
DragEndEvent,
DropAnimation,
MeasuringStrategy,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import type { ExamNode } from "./selected-question-list"
import type { Question } from "@/modules/questions/types"
// --- Types ---
type StructureEditorProps = {
items: ExamNode[]
onChange: (items: ExamNode[]) => void
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
onRemove: (id: string) => void
onAddGroup: () => void
}
// --- Components ---
function SortableItem({
id,
item,
onRemove,
onScoreChange
}: {
id: string
item: ExamNode
onRemove: () => void
onScoreChange: (val: number) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const content = item.question?.content as { text?: string }
return (
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
<div className="flex items-start justify-between gap-3">
<div className="flex gap-2 items-start flex-1">
<button {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
<GripVertical className="h-4 w-4" />
</button>
<p className="text-sm line-clamp-2 pt-0.5 select-none">
{content?.text || "Question content"}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center justify-end pl-8">
<div className="flex items-center gap-2">
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
Score
</Label>
<Input
id={`score-${item.id}`}
type="number"
min={0}
className="h-7 w-16 text-right"
value={item.score}
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
/>
</div>
</div>
</div>
)
}
function SortableGroup({
id,
item,
children,
onRemove,
onTitleChange
}: {
id: string
item: ExamNode
children: React.ReactNode
onRemove: () => void
onTitleChange: (val: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const [isOpen, setIsOpen] = useState(true)
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const totalScore = useMemo(() => {
const calc = (nodes: ExamNode[]): number => {
return nodes.reduce((acc, node) => {
if (node.type === 'question') return acc + (node.score || 0)
if (node.type === 'group') return acc + calc(node.children || [])
return acc
}, 0)
}
return calc(item.children || [])
}, [item])
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border bg-muted/10 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
<div className="flex items-center gap-3">
<button {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
<GripVertical className="h-5 w-5" />
</button>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 h-6 w-6 hover:bg-transparent">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<Input
value={item.title || ""}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Section Title"
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1"
/>
<div className="flex items-center gap-1 text-muted-foreground text-xs bg-background/50 px-2 py-1 rounded">
<Calculator className="h-3 w-3" />
<span>{totalScore} pts</span>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onRemove}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent className="pl-4 border-l-2 border-muted space-y-3 min-h-[50px] animate-in slide-in-from-top-2 fade-in duration-200">
{children}
</CollapsibleContent>
</Collapsible>
)
}
function StructureRenderer({ nodes, ...props }: {
nodes: ExamNode[]
onRemove: (id: string) => void
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
}) {
return (
<SortableContext items={nodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
{nodes.map(node => (
<React.Fragment key={node.id}>
{node.type === 'group' ? (
<SortableGroup
id={node.id}
item={node}
onRemove={() => props.onRemove(node.id)}
onTitleChange={(val) => props.onGroupTitleChange(node.id, val)}
>
<StructureRenderer
nodes={node.children || []}
onRemove={props.onRemove}
onScoreChange={props.onScoreChange}
onGroupTitleChange={props.onGroupTitleChange}
/>
{(!node.children || node.children.length === 0) && (
<div className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed border-muted/50 rounded">
Drag items here
</div>
)}
</SortableGroup>
) : (
<SortableItem
id={node.id}
item={node}
onRemove={() => props.onRemove(node.id)}
onScoreChange={(val) => props.onScoreChange(node.id, val)}
/>
)}
</React.Fragment>
))}
</SortableContext>
)
}
// --- Main Component ---
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0.5',
},
},
}),
}
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup }: StructureEditorProps) {
const [activeId, setActiveId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// Recursively find item
const findItem = (id: string, nodes: ExamNode[] = items): ExamNode | null => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findItem(id, node.children)
if (found) return found
}
}
return null
}
const activeItem = activeId ? findItem(activeId) : null
// DND Handlers
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string)
}
// Custom collision detection for nested sortables
const customCollisionDetection: CollisionDetection = (args) => {
// 1. First check pointer within for precise container detection
const pointerCollisions = pointerWithin(args)
// If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container)
if (pointerCollisions.length > 0) {
return pointerCollisions
}
// 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container
return rectIntersection(args)
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event
if (!over) return
const activeId = active.id as string
const overId = over.id as string
if (activeId === overId) return
// Find if we are moving over a Group container
// "overId" could be a SortableItem (Question) OR a SortableGroup (Group)
const activeNode = findItem(activeId)
const overNode = findItem(overId)
if (!activeNode || !overNode) return
// CRITICAL FIX: Prevent dragging a node onto its own descendant
// This happens when dragging a group and hovering over its own children.
// If we proceed, we would remove the group (and its children) and then fail to find the child to insert next to.
const isDescendantOfActive = (childId: string): boolean => {
const check = (node: ExamNode): boolean => {
if (!node.children) return false
return node.children.some(c => c.id === childId || check(c))
}
return check(activeNode)
}
if (isDescendantOfActive(overId)) return
// Find which list the `over` item belongs to
const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => {
if (list.some(i => i.id === id)) return parentId
for (const node of list) {
if (node.children) {
const res = findContainerId(id, node.children, node.id)
if (res) return res
}
}
return undefined
}
const activeContainerId = findContainerId(activeId, items)
const overContainerId = findContainerId(overId, items)
// Scenario 1: Moving item into a Group by hovering over the Group itself
// If overNode is a Group, we might want to move INTO it
if (overNode.type === 'group') {
// Logic: If active item is NOT in this group already
// AND we are not trying to move a group into its own descendant (circular check)
const isDescendant = (parent: ExamNode, childId: string): boolean => {
if (!parent.children) return false
for (const c of parent.children) {
if (c.id === childId) return true
if (isDescendant(c, childId)) return true
}
return false
}
// If moving a group, check if overNode is a descendant of activeNode
if (activeNode.type === 'group' && isDescendant(activeNode, overNode.id)) {
return
}
if (activeContainerId !== overNode.id) {
// ... implementation continues ...
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
// Remove active from old location
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
const idx = list.findIndex(i => i.id === activeId)
if (idx !== -1) return list.splice(idx, 1)[0]
for (const node of list) {
if (node.children) {
const res = removeRecursive(node.children)
if (res) return res
}
}
return null
}
const movedItem = removeRecursive(newItems)
if (!movedItem) return
// Insert into new Group (overNode)
// We need to find the overNode in the NEW structure (since we cloned it)
const findGroupAndInsert = (list: ExamNode[]) => {
for (const node of list) {
if (node.id === overId) {
if (!node.children) node.children = []
node.children.push(movedItem)
return true
}
if (node.children) {
if (findGroupAndInsert(node.children)) return true
}
}
return false
}
findGroupAndInsert(newItems)
onChange(newItems)
return
}
}
// Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B)
if (activeContainerId !== overContainerId) {
// Standard Sortable Move
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
const idx = list.findIndex(i => i.id === activeId)
if (idx !== -1) return list.splice(idx, 1)[0]
for (const node of list) {
if (node.children) {
const res = removeRecursive(node.children)
if (res) return res
}
}
return null
}
const movedItem = removeRecursive(newItems)
if (!movedItem) return
// Insert into destination list at specific index
// We need to find the destination list array and the index of `overId`
const insertRecursive = (list: ExamNode[]): boolean => {
const idx = list.findIndex(i => i.id === overId)
if (idx !== -1) {
// Insert before or after based on direction?
// Usually dnd-kit handles order if we are in same container, but cross-container we need to pick a spot.
// We'll insert at the index of `overId`.
// However, if we insert AT the index, dnd-kit might get confused if we are dragging DOWN vs UP.
// But since we are changing containers, just inserting at the target index is usually fine.
// The issue "swapping positions is not smooth" might be because we insert *at* index, displacing the target.
// Let's try to determine if we are "below" or "above" the target?
// For cross-container, simpler is better. Inserting at index is standard.
list.splice(idx, 0, movedItem)
return true
}
for (const node of list) {
if (node.children) {
if (insertRecursive(node.children)) return true
}
}
return false
}
insertRecursive(newItems)
onChange(newItems)
}
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
setActiveId(null)
if (!over) return
const activeId = active.id as string
const overId = over.id as string
if (activeId === overId) return
// Re-find positions in the potentially updated state
// Note: Since we mutate in DragOver, the item might already be in the new container.
// So activeContainerId might equal overContainerId now!
const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => {
if (list.some(i => i.id === id)) return parentId
for (const node of list) {
if (node.children) {
const res = findContainerId(id, node.children, node.id)
if (res) return res
}
}
return undefined
}
const activeContainerId = findContainerId(activeId, items)
const overContainerId = findContainerId(overId, items)
if (activeContainerId === overContainerId) {
// Same container reorder
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
const getMutableList = (groupId?: string): ExamNode[] => {
if (groupId === 'root') return newItems
// Need recursive find
const findGroup = (list: ExamNode[]): ExamNode | null => {
for (const node of list) {
if (node.id === groupId) return node
if (node.children) {
const res = findGroup(node.children)
if (res) return res
}
}
return null
}
return findGroup(newItems)?.children || []
}
const list = getMutableList(activeContainerId)
const oldIndex = list.findIndex(i => i.id === activeId)
const newIndex = list.findIndex(i => i.id === overId)
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const moved = arrayMove(list, oldIndex, newIndex)
// Update the list reference in parent
if (activeContainerId === 'root') {
onChange(moved)
} else {
// list is already a reference to children array if we did it right?
// getMutableList returned `group.children`. Modifying `list` directly via arrayMove returns NEW array.
// So we need to re-assign.
const group = findItem(activeContainerId!, newItems)
if (group) group.children = moved
onChange(newItems)
}
}
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={customCollisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
>
<div className="space-y-4">
<StructureRenderer
nodes={items}
onRemove={onRemove}
onScoreChange={onScoreChange}
onGroupTitleChange={onGroupTitleChange}
/>
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={onAddGroup} className="w-full border-dashed">
+ Add Section
</Button>
</div>
</div>
<DragOverlay dropAnimation={dropAnimation}>
{activeItem ? (
activeItem.type === 'group' ? (
<div className="rounded-lg border bg-background p-4 shadow-lg opacity-80 w-[300px]">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5" />
<span className="font-semibold">{activeItem.title || "Section"}</span>
</div>
</div>
) : (
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
<GripVertical className="h-4 w-4" />
<p className="text-sm line-clamp-1">{(activeItem.question?.content as any)?.text || "Question"}</p>
</div>
)
) : null}
</DragOverlay>
</DndContext>
)
}

View File

@@ -0,0 +1,170 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Exam } from "../types"
interface ExamActionsProps {
exam: Exam
}
export function ExamActions({ exam }: ExamActionsProps) {
const router = useRouter()
const [showViewDialog, setShowViewDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const copyId = () => {
navigator.clipboard.writeText(exam.id)
toast.success("Exam ID copied to clipboard")
}
const publishExam = async () => {
toast.success("Exam published")
}
const unpublishExam = async () => {
toast.success("Exam moved to draft")
}
const archiveExam = async () => {
toast.success("Exam archived")
}
const handleDelete = async () => {
try {
await new Promise((r) => setTimeout(r, 800))
toast.success("Exam deleted successfully")
setShowDeleteDialog(false)
} catch (e) {
toast.error("Failed to delete exam")
}
}
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>
<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>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={publishExam}>
<UploadCloud className="mr-2 h-4 w-4" /> Publish
</DropdownMenuItem>
<DropdownMenuItem onClick={unpublishExam}>
<Undo2 className="mr-2 h-4 w-4" /> Move to Draft
</DropdownMenuItem>
<DropdownMenuItem onClick={archiveExam}>
<Archive className="mr-2 h-4 w-4" /> Archive
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash className="mr-2 h-4 w-4" /> Delete
</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>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete exam?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the exam.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View 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>
)
}

View File

@@ -0,0 +1,137 @@
"use client"
import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Badge } from "@/shared/components/ui/badge"
import { cn, formatDate } from "@/shared/lib/utils"
import { Exam } from "../types"
import { ExamActions } from "./exam-actions"
export const examColumns: ColumnDef<Exam>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 36,
},
{
accessorKey: "title",
header: "Title",
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) => (
<Badge key={t} 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>
),
},
{
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
const variant = status === "published" ? "secondary" : status === "archived" ? "destructive" : "outline"
return (
<Badge variant={variant as any} className="capitalize">
{status}
</Badge>
)
},
},
{
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"}
</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: "actions",
cell: ({ row }) => <ExamActions exam={row.original} />,
},
]

View File

@@ -0,0 +1,110 @@
"use client"
import * as React from "react"
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
getFilteredRowModel,
RowSelectionState,
} from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
rowSelection,
},
})
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-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>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { Search, X } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
export function ExamFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
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" />
<Input
placeholder="Search exams..."
className="pl-7"
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>
<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>
{(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>
)}
</div>
)
}

View File

@@ -0,0 +1,99 @@
"use client"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle, CardFooter } 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 { createExamAction } from "../actions"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Exam"}
</Button>
)
}
export function ExamForm() {
const router = useRouter()
const [difficulty, setDifficulty] = useState<string>("3")
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`)
}
} else {
toast.error(result.message)
}
}
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>
<CardFooter className="justify-end">
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,177 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { gradeSubmissionAction } from "../actions"
type Answer = {
id: string
questionId: string
questionContent: any
questionType: string
maxScore: number
studentAnswer: any
score: number | null
feedback: string | null
order: number
}
type GradingViewProps = {
submissionId: string
studentName: string
examTitle: string
submittedAt: string | null
status: string
totalScore: number | null
answers: Answer[]
}
export function GradingView({
submissionId,
studentName,
examTitle,
submittedAt,
status,
totalScore,
answers: initialAnswers
}: GradingViewProps) {
const router = useRouter()
const [answers, setAnswers] = useState(initialAnswers)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleScoreChange = (id: string, val: string) => {
const score = val === "" ? 0 : parseInt(val)
setAnswers(prev => prev.map(a => a.id === id ? { ...a, score } : a))
}
const handleFeedbackChange = (id: string, val: string) => {
setAnswers(prev => prev.map(a => a.id === id ? { ...a, feedback: val } : a))
}
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
const handleSubmit = async () => {
setIsSubmitting(true)
const payload = answers.map(a => ({
id: a.id,
score: a.score || 0,
feedback: a.feedback
}))
const formData = new FormData()
formData.set("submissionId", submissionId)
formData.set("answersJson", JSON.stringify(payload))
const result = await gradeSubmissionAction(null, formData)
if (result.success) {
toast.success("Grading saved")
router.push("/teacher/exams/grading")
} else {
toast.error(result.message || "Failed to save")
}
setIsSubmitting(false)
}
return (
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left: Questions & Answers */}
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4">
<h3 className="font-semibold">Student Response</h3>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-8">
{answers.map((ans, index) => (
<div key={ans.id} className="space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span>
<div className="text-sm">{ans.questionContent?.text}</div>
{/* Render options if multiple choice, etc. - Simplified for now */}
</div>
<Badge variant="outline">Max: {ans.maxScore}</Badge>
</div>
<div className="rounded-md bg-muted/50 p-4">
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
<p className="text-sm font-medium">
{typeof ans.studentAnswer?.answer === 'string'
? ans.studentAnswer.answer
: JSON.stringify(ans.studentAnswer)}
</p>
</div>
<Separator />
</div>
))}
</div>
</ScrollArea>
</div>
{/* Right: Grading Panel */}
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4">
<h3 className="font-semibold">Grading</h3>
<div className="mt-2 flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total Score</span>
<span className="font-bold text-lg text-primary">{currentTotal}</span>
</div>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{answers.map((ans, index) => (
<Card key={ans.id} className="border-l-4 border-l-primary/20">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex justify-between">
Q{index + 1}
<span className="text-xs text-muted-foreground">Max: {ans.maxScore}</span>
</CardTitle>
</CardHeader>
<CardContent className="py-3 px-4 space-y-3">
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`fb-${ans.id}`}>Feedback</Label>
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
/>
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Submit Grades"}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
"use client"
import { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Eye, CheckSquare } from "lucide-react"
import { ExamSubmission } from "../types"
import Link from "next/link"
import { formatDate } from "@/shared/lib/utils"
export const submissionColumns: ColumnDef<ExamSubmission>[] = [
{
accessorKey: "studentName",
header: "Student",
},
{
accessorKey: "examTitle",
header: "Exam",
},
{
accessorKey: "submittedAt",
header: "Submitted",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{formatDate(row.original.submittedAt)}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.original.status
const variant = status === "graded" ? "secondary" : "outline"
return <Badge variant={variant as any} className="capitalize">{status}</Badge>
},
},
{
accessorKey: "score",
header: "Score",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">{row.original.score ?? "-"}</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/exams/grading/${row.original.id}`}>
<Eye className="h-4 w-4 mr-1" /> View
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/teacher/exams/grading/${row.original.id}`}>
<CheckSquare className="h-4 w-4 mr-1" /> Grade
</Link>
</Button>
</div>
),
},
]

View File

@@ -0,0 +1,94 @@
"use client"
import * as React from "react"
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
} from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function SubmissionDataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
sorting,
},
})
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="group">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No submissions.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<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>
</div>
</div>
)
}

View File

@@ -0,0 +1,182 @@
import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers, users } from "@/shared/db/schema"
import { eq, desc, like, and, or } from "drizzle-orm"
import { cache } from "react"
import type { ExamStatus } from "./types"
export type GetExamsParams = {
q?: string
status?: string
difficulty?: string
page?: number
pageSize?: number
}
export const getExams = cache(async (params: GetExamsParams) => {
const conditions = []
if (params.q) {
const search = `%${params.q}%`
conditions.push(or(like(exams.title, search), like(exams.description, search)))
}
if (params.status && params.status !== "all") {
conditions.push(eq(exams.status, params.status as any))
}
// Note: Difficulty is stored in JSON description field in current schema,
// so we might need to filter in memory or adjust schema.
// For now, let's fetch and filter in memory if difficulty is needed,
// or just ignore strict DB filtering for JSON fields to keep it simple.
const data = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(exams.createdAt)],
})
// Transform and Filter (especially for JSON fields)
let result = data.map((exam) => {
let meta: any = {}
try {
meta = JSON.parse(exam.description || "{}")
} catch { }
return {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
subject: meta.subject || "General",
grade: meta.grade || "General",
difficulty: meta.difficulty || 1,
totalScore: meta.totalScore || 100,
durationMin: meta.durationMin || 60,
questionCount: meta.questionCount || 0,
scheduledAt: exam.startTime?.toISOString(),
createdAt: exam.createdAt.toISOString(),
tags: meta.tags || [],
}
})
if (params.difficulty && params.difficulty !== "all") {
const d = parseInt(params.difficulty)
result = result.filter((e) => e.difficulty === d)
}
return result
})
export const getExamById = cache(async (id: string) => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, id),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: {
question: true
}
}
}
})
if (!exam) return null
let meta: any = {}
try {
meta = JSON.parse(exam.description || "{}")
} catch { }
return {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
subject: meta.subject || "General",
grade: meta.grade || "General",
difficulty: meta.difficulty || 1,
totalScore: meta.totalScore || 100,
durationMin: meta.durationMin || 60,
scheduledAt: exam.startTime?.toISOString(),
createdAt: exam.createdAt.toISOString(),
tags: meta.tags || [],
structure: exam.structure as any, // Return structure
questions: exam.questions.map(eq => ({
id: eq.questionId,
score: eq.score,
order: eq.order,
// ... include question details if needed
}))
}
})
export const getExamSubmissions = cache(async () => {
const data = await db.query.examSubmissions.findMany({
orderBy: [desc(examSubmissions.submittedAt)],
with: {
exam: true,
student: true
}
})
return data.map(sub => ({
id: sub.id,
examId: sub.examId,
examTitle: sub.exam.title,
studentName: sub.student.name || "Unknown",
submittedAt: sub.submittedAt ? sub.submittedAt.toISOString() : new Date().toISOString(),
score: sub.score || undefined,
status: sub.status as "pending" | "graded",
}))
})
export const getSubmissionDetails = cache(async (submissionId: string) => {
const submission = await db.query.examSubmissions.findFirst({
where: eq(examSubmissions.id, submissionId),
with: {
student: true,
exam: true,
}
})
if (!submission) return null
// Fetch answers
const answers = await db.query.submissionAnswers.findMany({
where: eq(submissionAnswers.submissionId, submissionId),
with: {
question: true
}
})
// Fetch exam questions structure (to know max score and order)
const examQ = await db.query.examQuestions.findMany({
where: eq(examQuestions.examId, submission.examId),
orderBy: [desc(examQuestions.order)],
})
// Map answers with question details
const answersWithDetails = answers.map(ans => {
const eqRel = examQ.find(q => q.questionId === ans.questionId)
return {
id: ans.id,
questionId: ans.questionId,
questionContent: ans.question.content,
questionType: ans.question.type,
maxScore: eqRel?.score || 0,
studentAnswer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
order: eqRel?.order || 0
}
}).sort((a, b) => a.order - b.order)
return {
id: submission.id,
studentName: submission.student.name || "Unknown",
examTitle: submission.exam.title,
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
status: submission.status,
totalScore: submission.score,
answers: answersWithDetails
}
})

View File

@@ -0,0 +1,102 @@
import { Exam, ExamSubmission } from "./types"
export let MOCK_EXAMS: Exam[] = [
{
id: "exam_001",
title: "Algebra Midterm",
subject: "Mathematics",
grade: "Grade 10",
status: "draft",
difficulty: 3,
totalScore: 100,
durationMin: 90,
questionCount: 25,
scheduledAt: undefined,
createdAt: new Date().toISOString(),
tags: ["Algebra", "Functions"],
},
{
id: "exam_002",
title: "Physics Mechanics Quiz",
subject: "Physics",
grade: "Grade 11",
status: "published",
difficulty: 4,
totalScore: 50,
durationMin: 45,
questionCount: 15,
scheduledAt: new Date(Date.now() + 86400000).toISOString(),
createdAt: new Date().toISOString(),
tags: ["Mechanics", "Kinematics"],
},
{
id: "exam_003",
title: "English Reading Comprehension",
subject: "English",
grade: "Grade 12",
status: "published",
difficulty: 2,
totalScore: 80,
durationMin: 60,
questionCount: 20,
scheduledAt: new Date(Date.now() + 2 * 86400000).toISOString(),
createdAt: new Date().toISOString(),
tags: ["Reading", "Vocabulary"],
},
{
id: "exam_004",
title: "Chemistry Final",
subject: "Chemistry",
grade: "Grade 12",
status: "archived",
difficulty: 5,
totalScore: 120,
durationMin: 120,
questionCount: 40,
scheduledAt: new Date(Date.now() - 30 * 86400000).toISOString(),
createdAt: new Date().toISOString(),
tags: ["Organic", "Inorganic"],
},
{
id: "exam_005",
title: "Geometry Chapter Test",
subject: "Mathematics",
grade: "Grade 9",
status: "published",
difficulty: 3,
totalScore: 60,
durationMin: 50,
questionCount: 18,
scheduledAt: new Date(Date.now() + 3 * 86400000).toISOString(),
createdAt: new Date().toISOString(),
tags: ["Geometry", "Triangles"],
},
]
export const MOCK_SUBMISSIONS: ExamSubmission[] = [
{
id: "sub_001",
examId: "exam_002",
examTitle: "Physics Mechanics Quiz",
studentName: "Alice Zhang",
submittedAt: new Date().toISOString(),
status: "pending",
},
{
id: "sub_002",
examId: "exam_003",
examTitle: "English Reading Comprehension",
studentName: "Bob Li",
submittedAt: new Date().toISOString(),
score: 72,
status: "graded",
},
]
export function addMockExam(exam: Exam) {
MOCK_EXAMS = [exam, ...MOCK_EXAMS]
}
export function updateMockExam(id: string, updates: Partial<Exam>) {
MOCK_EXAMS = MOCK_EXAMS.map((e) => (e.id === id ? { ...e, ...updates } : e))
}

View File

@@ -0,0 +1,32 @@
export type ExamStatus = "draft" | "published" | "archived"
export type ExamDifficulty = 1 | 2 | 3 | 4 | 5
export interface Exam {
id: string
title: string
subject: string
grade: string
status: ExamStatus
difficulty: ExamDifficulty
totalScore: number
durationMin: number
questionCount: number
scheduledAt?: string
createdAt: string
updatedAt?: string
tags?: string[]
}
export type SubmissionStatus = "pending" | "graded"
export interface ExamSubmission {
id: string
examId: string
examTitle: string
studentName: string
submittedAt: string
score?: number
status: SubmissionStatus
}