Merge exams grading into homework
Some checks failed
CI / build-and-test (push) Failing after 3m34s
CI / deploy (push) Has been skipped

Redirect /teacher/exams/grading* to /teacher/homework/submissions; remove exam grading UI/actions/data-access; add homework student workflow and update design docs.
This commit is contained in:
SpecialX
2025-12-31 11:59:03 +08:00
parent f8e39f518d
commit 13e91e628d
36 changed files with 4491 additions and 452 deletions

View File

@@ -5,7 +5,7 @@ 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 { exams, examQuestions } from "@/shared/db/schema"
import { eq } from "drizzle-orm"
const ExamCreateSchema = z.object({
@@ -206,7 +206,6 @@ export async function deleteExamAction(
}
revalidatePath("/teacher/exams/all")
revalidatePath("/teacher/exams/grading")
return {
success: true,
@@ -310,76 +309,6 @@ export async function duplicateExamAction(
}
}
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

@@ -1,6 +1,5 @@
"use client"
import React from "react"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"

View File

@@ -6,14 +6,12 @@ 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 { Card, 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"

View File

@@ -1,181 +0,0 @@
"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"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type QuestionContent = { text?: string } & Record<string, unknown>
type Answer = {
id: string
questionId: string
questionContent: QuestionContent | null
questionType: string
maxScore: number
studentAnswer: unknown
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">
{isRecord(ans.studentAnswer) && 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

@@ -1,62 +0,0 @@
"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} 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

@@ -1,94 +0,0 @@
"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

@@ -1,5 +1,5 @@
import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
import { exams } from "@/shared/db/schema"
import { eq, desc, like, and, or } from "drizzle-orm"
import { cache } from "react"
@@ -137,82 +137,3 @@ export const getExamById = cache(async (id: string) => {
})),
}
})
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)],
})
type QuestionContent = { text?: string } & Record<string, unknown>
const toQuestionContent = (v: unknown): QuestionContent | null => {
if (!isRecord(v)) return null
return v as QuestionContent
}
// 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: toQuestionContent(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
}
})