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

View File

@@ -0,0 +1,366 @@
"use server"
import { revalidatePath } from "next/cache"
import { headers } from "next/headers"
import { createId } from "@paralleldrive/cuid2"
import { and, count, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
exams,
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
users,
} from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
type CurrentUser = { id: string; role: "admin" | "teacher" | "student" }
async function getCurrentUser() {
const ref = (await headers()).get("referer") || ""
const roleHint: CurrentUser["role"] = ref.includes("/admin/")
? "admin"
: ref.includes("/student/")
? "student"
: ref.includes("/teacher/")
? "teacher"
: "teacher"
const byRole = await db.query.users.findFirst({
where: eq(users.role, roleHint),
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (byRole) return { id: byRole.id, role: roleHint }
const anyUser = await db.query.users.findFirst({
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (anyUser) return { id: anyUser.id, role: roleHint }
return { id: "user_teacher_123", role: roleHint }
}
async function ensureTeacher() {
const user = await getCurrentUser()
if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized")
return user
}
async function ensureStudent() {
const user = await getCurrentUser()
if (!user || user.role !== "student") throw new Error("Unauthorized")
return user
}
const parseStudentIds = (raw: string): string[] => {
return raw
.split(/[,\n\r\t ]+/g)
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
export async function createHomeworkAssignmentAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureTeacher()
const targetStudentIdsJson = formData.get("targetStudentIdsJson")
const targetStudentIdsText = formData.get("targetStudentIdsText")
const parsed = CreateHomeworkAssignmentSchema.safeParse({
sourceExamId: formData.get("sourceExamId"),
title: formData.get("title") || undefined,
description: formData.get("description") || undefined,
availableAt: formData.get("availableAt") || undefined,
dueAt: formData.get("dueAt") || undefined,
allowLate: formData.get("allowLate") || undefined,
lateDueAt: formData.get("lateDueAt") || undefined,
maxAttempts: formData.get("maxAttempts") || undefined,
publish: formData.get("publish") || undefined,
targetStudentIds:
typeof targetStudentIdsJson === "string" && targetStudentIdsJson.length > 0
? (JSON.parse(targetStudentIdsJson) as unknown)
: typeof targetStudentIdsText === "string" && targetStudentIdsText.trim().length > 0
? parseStudentIds(targetStudentIdsText)
: undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const input = parsed.data
const publish = input.publish ?? true
const exam = await db.query.exams.findFirst({
where: eq(exams.id, input.sourceExamId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!exam) return { success: false, message: "Exam not found" }
const assignmentId = createId()
const availableAt = input.availableAt ? new Date(input.availableAt) : null
const dueAt = input.dueAt ? new Date(input.dueAt) : null
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
const targetStudentIds =
input.targetStudentIds && input.targetStudentIds.length > 0
? input.targetStudentIds
: (
await db
.select({ id: users.id })
.from(users)
.where(eq(users.role, "student"))
).map((r) => r.id)
await db.transaction(async (tx) => {
await tx.insert(homeworkAssignments).values({
id: assignmentId,
sourceExamId: input.sourceExamId,
title: input.title?.trim().length ? input.title.trim() : exam.title,
description: input.description ?? null,
structure: publish ? (exam.structure as unknown) : null,
status: publish ? "published" : "draft",
creatorId: user.id,
availableAt,
dueAt,
allowLate: input.allowLate ?? false,
lateDueAt,
maxAttempts: input.maxAttempts ?? 1,
})
if (publish && exam.questions.length > 0) {
await tx.insert(homeworkAssignmentQuestions).values(
exam.questions.map((q) => ({
assignmentId,
questionId: q.questionId,
score: q.score ?? 0,
order: q.order ?? 0,
}))
)
}
if (publish && targetStudentIds.length > 0) {
await tx.insert(homeworkAssignmentTargets).values(
targetStudentIds.map((studentId) => ({
assignmentId,
studentId,
}))
)
}
})
revalidatePath("/teacher/homework/assignments")
revalidatePath("/teacher/homework/submissions")
return { success: true, message: "Assignment created", data: assignmentId }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Unexpected error" }
}
}
export async function startHomeworkSubmissionAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureStudent()
const assignmentId = formData.get("assignmentId")
if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" }
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
})
if (!assignment) return { success: false, message: "Assignment not found" }
if (assignment.status !== "published") return { success: false, message: "Assignment not available" }
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, user.id)),
})
if (!target) return { success: false, message: "Not assigned" }
if (assignment.availableAt && assignment.availableAt > new Date()) return { success: false, message: "Not available yet" }
const [attemptRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, user.id)))
const attemptNo = (attemptRow?.c ?? 0) + 1
if (attemptNo > assignment.maxAttempts) return { success: false, message: "No attempts left" }
const submissionId = createId()
await db.insert(homeworkSubmissions).values({
id: submissionId,
assignmentId,
studentId: user.id,
attemptNo,
status: "started",
startedAt: new Date(),
})
revalidatePath("/student/learning/assignments")
return { success: true, message: "Started", data: submissionId }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Unexpected error" }
}
}
export async function saveHomeworkAnswerAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureStudent()
const submissionId = formData.get("submissionId")
const questionId = formData.get("questionId")
const answerJson = formData.get("answerJson")
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
if (typeof questionId !== "string" || questionId.length === 0) return { success: false, message: "Missing questionId" }
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
with: { assignment: true },
})
if (!submission) return { success: false, message: "Submission not found" }
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
await db.transaction(async (tx) => {
const existing = await tx.query.homeworkAnswers.findFirst({
where: and(eq(homeworkAnswers.submissionId, submissionId), eq(homeworkAnswers.questionId, questionId)),
})
if (existing) {
await tx
.update(homeworkAnswers)
.set({ answerContent: payload, updatedAt: new Date() })
.where(eq(homeworkAnswers.id, existing.id))
} else {
await tx.insert(homeworkAnswers).values({
id: createId(),
submissionId,
questionId,
answerContent: payload,
})
}
})
return { success: true, message: "Saved", data: submissionId }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Unexpected error" }
}
}
export async function submitHomeworkAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const user = await ensureStudent()
const submissionId = formData.get("submissionId")
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
with: { assignment: true },
})
if (!submission) return { success: false, message: "Submission not found" }
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
if (submission.status !== "started") return { success: false, message: "Already submitted" }
const now = new Date()
const dueAt = submission.assignment.dueAt
const allowLate = submission.assignment.allowLate
const lateDueAt = submission.assignment.lateDueAt
if (dueAt && now > dueAt && !allowLate) return { success: false, message: "Past due" }
if (allowLate && lateDueAt && now > lateDueAt) return { success: false, message: "Past late due" }
const isLate = Boolean(dueAt && now > dueAt)
await db
.update(homeworkSubmissions)
.set({ status: "submitted", submittedAt: now, isLate, updatedAt: now })
.where(eq(homeworkSubmissions.id, submissionId))
revalidatePath("/teacher/homework/submissions")
revalidatePath("/student/learning/assignments")
return { success: true, message: "Submitted", data: submissionId }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Unexpected error" }
}
}
export async function gradeHomeworkSubmissionAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await ensureTeacher()
const rawAnswers = formData.get("answersJson") as string | null
const parsed = GradeHomeworkSchema.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
let totalScore = 0
for (const ans of answers) {
await db
.update(homeworkAnswers)
.set({ score: ans.score, feedback: ans.feedback ?? null, updatedAt: new Date() })
.where(eq(homeworkAnswers.id, ans.id))
totalScore += ans.score
}
await db
.update(homeworkSubmissions)
.set({ score: totalScore, status: "graded", updatedAt: new Date() })
.where(eq(homeworkSubmissions.id, submissionId))
revalidatePath("/teacher/homework/submissions")
return { success: true, message: "Grading saved" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Unexpected error" }
}
}

View File

@@ -0,0 +1,138 @@
"use client"
import { useMemo, useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardFooter, 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 { Textarea } from "@/shared/components/ui/textarea"
import { createHomeworkAssignmentAction } from "../actions"
type ExamOption = { id: string; title: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Assignment"}
</Button>
)
}
export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
const router = useRouter()
const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams])
const [examId, setExamId] = useState<string>(initialExamId)
const [allowLate, setAllowLate] = useState<boolean>(false)
const handleSubmit = async (formData: FormData) => {
if (!examId) {
toast.error("Please select an exam")
return
}
formData.set("sourceExamId", examId)
formData.set("allowLate", allowLate ? "true" : "false")
formData.set("publish", "true")
const result = await createHomeworkAssignmentAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/homework/assignments")
} else {
toast.error(result.message || "Failed to create")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Create Assignment</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 md:col-span-2">
<Label>Source Exam</Label>
<Select value={examId} onValueChange={setExamId}>
<SelectTrigger>
<SelectValue placeholder="Select an exam" />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="sourceExamId" value={examId} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Assignment Title (optional)</Label>
<Input id="title" name="title" placeholder="Defaults to exam title" />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea id="description" name="description" className="min-h-[80px]" />
</div>
<div className="grid gap-2">
<Label htmlFor="availableAt">Available At (optional)</Label>
<Input id="availableAt" name="availableAt" type="datetime-local" />
</div>
<div className="grid gap-2">
<Label htmlFor="dueAt">Due At (optional)</Label>
<Input id="dueAt" name="dueAt" type="datetime-local" />
</div>
<div className="flex items-center gap-2 md:col-span-2">
<input
id="allowLate"
type="checkbox"
checked={allowLate}
onChange={(e) => setAllowLate(e.target.checked)}
/>
<Label htmlFor="allowLate">Allow late submissions</Label>
<input type="hidden" name="allowLate" value={allowLate ? "true" : "false"} />
</div>
<div className="grid gap-2">
<Label htmlFor="lateDueAt">Late Due At (optional)</Label>
<Input id="lateDueAt" name="lateDueAt" type="datetime-local" />
</div>
<div className="grid gap-2">
<Label htmlFor="maxAttempts">Max Attempts</Label>
<Input id="maxAttempts" name="maxAttempts" type="number" min={1} max={20} defaultValue={1} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="targetStudentIdsText">Target student IDs (optional)</Label>
<Textarea
id="targetStudentIdsText"
name="targetStudentIdsText"
placeholder="Leave empty to assign to all students. You can paste IDs separated by comma or newline."
className="min-h-[90px]"
/>
</div>
</div>
<CardFooter className="justify-end">
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -11,7 +11,7 @@ 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"
import { gradeHomeworkSubmissionAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
@@ -29,57 +29,52 @@ type Answer = {
order: number
}
type GradingViewProps = {
type HomeworkGradingViewProps = {
submissionId: string
studentName: string
examTitle: string
assignmentTitle: string
submittedAt: string | null
status: string
totalScore: number | null
answers: Answer[]
}
export function GradingView({
export function HomeworkGradingView({
submissionId,
studentName,
examTitle,
submittedAt,
status,
totalScore,
answers: initialAnswers
}: GradingViewProps) {
answers: initialAnswers,
}: HomeworkGradingViewProps) {
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))
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))
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 => ({
const payload = answers.map((a) => ({
id: a.id,
score: a.score || 0,
feedback: a.feedback
feedback: a.feedback,
}))
const formData = new FormData()
formData.set("submissionId", submissionId)
formData.set("answersJson", JSON.stringify(payload))
const result = await gradeSubmissionAction(null, formData)
const result = await gradeHomeworkSubmissionAction(null, formData)
if (result.success) {
toast.success("Grading saved")
router.push("/teacher/exams/grading")
router.push("/teacher/homework/submissions")
} else {
toast.error(result.message || "Failed to save")
}
@@ -88,7 +83,6 @@ export function GradingView({
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>
@@ -101,7 +95,6 @@ export function GradingView({
<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>
@@ -114,7 +107,7 @@ export function GradingView({
: JSON.stringify(ans.studentAnswer)}
</p>
</div>
<Separator />
</div>
))}
@@ -122,7 +115,6 @@ export function GradingView({
</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>
@@ -131,7 +123,7 @@ export function GradingView({
<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) => (
@@ -145,10 +137,10 @@ export function GradingView({
<CardContent className="py-3 px-4 space-y-3">
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
<Input
id={`score-${ans.id}`}
type="number"
min={0}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
@@ -156,7 +148,7 @@ export function GradingView({
</div>
<div className="grid gap-2">
<Label htmlFor={`fb-${ans.id}`}>Feedback</Label>
<Textarea
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"

View File

@@ -0,0 +1,345 @@
"use client"
import { useMemo, 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 { Checkbox } from "@/shared/components/ui/checkbox"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type Option = { id: string; text: string }
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
const getOptions = (content: unknown): Option[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Option[] = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
const toAnswerShape = (questionType: string, v: unknown) => {
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
return { answer: v }
}
const parseSavedAnswer = (saved: unknown, questionType: string) => {
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
return toAnswerShape(questionType, saved)
}
type HomeworkTakeViewProps = {
assignmentId: string
initialData: StudentHomeworkTakeData
}
export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeViewProps) {
const router = useRouter()
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false)
const initialAnswersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()
for (const q of initialData.questions) {
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
}
return map
}, [initialData.questions])
const [answersByQuestionId, setAnswersByQuestionId] = useState(() => {
const obj: Record<string, { answer: unknown }> = {}
for (const [k, v] of initialAnswersByQuestionId.entries()) obj[k] = v
return obj
})
const isStarted = submissionStatus === "started"
const canEdit = isStarted && Boolean(submissionId)
const handleStart = async () => {
setIsBusy(true)
const fd = new FormData()
fd.set("assignmentId", assignmentId)
const res = await startHomeworkSubmissionAction(null, fd)
if (res.success && res.data) {
setSubmissionId(res.data)
setSubmissionStatus("started")
toast.success("Started")
router.refresh()
} else {
toast.error(res.message || "Failed to start")
}
setIsBusy(false)
}
const handleSaveQuestion = async (questionId: string) => {
if (!submissionId) return
setIsBusy(true)
const payload = answersByQuestionId[questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success("Saved")
else toast.error(res.message || "Failed to save")
setIsBusy(false)
}
const handleSubmit = async () => {
if (!submissionId) return
setIsBusy(true)
for (const q of initialData.questions) {
const payload = answersByQuestionId[q.questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", q.questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (!res.success) {
toast.error(res.message || "Failed to save")
setIsBusy(false)
return
}
}
const submitFd = new FormData()
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
toast.success("Submitted")
setSubmissionStatus("submitted")
router.push("/student/learning/assignments")
} else {
toast.error(submitRes.message || "Failed to submit")
}
setIsBusy(false)
}
return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Questions</h3>
<Badge variant="outline" className="capitalize">
{submissionStatus === "not_started" ? "not started" : submissionStatus}
</Badge>
</div>
{!canEdit ? (
<Button onClick={handleStart} disabled={isBusy}>
{isBusy ? "Starting..." : "Start"}
</Button>
) : (
<Button onClick={handleSubmit} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit"}
</Button>
)}
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{initialData.questions.map((q, idx) => {
const text = getQuestionText(q.questionContent)
const options = getOptions(q.questionContent)
const value = answersByQuestionId[q.questionId]?.answer
return (
<Card key={q.questionId} className="border-l-4 border-l-primary/20">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<span>
Q{idx + 1} <span className="text-muted-foreground font-normal">({q.questionType})</span>
</span>
<span className="text-xs text-muted-foreground">Max: {q.maxScore}</span>
</CardTitle>
</CardHeader>
<CardContent className="py-3 px-4 space-y-4">
<div className="text-sm">{text || "—"}</div>
{q.questionType === "text" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Textarea
value={typeof value === "string" ? value : ""}
onChange={(e) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: e.target.value },
}))
}
className="min-h-[100px]"
disabled={!canEdit}
/>
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Select
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v === "true" },
}))
}
disabled={!canEdit}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
</div>
) : q.questionType === "single_choice" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v },
}))
}
disabled={!canEdit}
>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : q.questionType === "multiple_choice" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<div className="space-y-2">
{options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false
return (
<label key={o.id} className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
checked={selected}
onCheckedChange={(checked) => {
const isChecked = checked === true
setAnswersByQuestionId((prev) => {
const current = Array.isArray(prev[q.questionId]?.answer)
? (prev[q.questionId]?.answer as string[])
: []
const next = isChecked
? Array.from(new Set([...current, o.id]))
: current.filter((x) => x !== o.id)
return { ...prev, [q.questionId]: { answer: next } }
})
}}
disabled={!canEdit}
/>
<span className="text-sm">{o.text}</span>
</label>
)
})}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Unsupported question type</div>
)}
{canEdit ? (
<>
<Separator />
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
>
Save
</Button>
</div>
</>
) : null}
</CardContent>
</Card>
)
})}
</div>
</ScrollArea>
</div>
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4">
<h3 className="font-semibold">Info</h3>
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Status</span>
<span className="capitalize text-foreground">{submissionStatus === "not_started" ? "not started" : submissionStatus}</span>
</div>
<div className="flex items-center justify-between">
<span>Questions</span>
<span className="text-foreground tabular-nums">{initialData.questions.length}</span>
</div>
</div>
</div>
<div className="flex-1 p-4">
<div className="space-y-3 text-sm">
<div className="text-muted-foreground">{initialData.assignment.description || "—"}</div>
</div>
</div>
<div className="border-t p-4 bg-muted/20">
{canEdit ? (
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit"}
</Button>
) : (
<Button className="w-full" onClick={handleStart} disabled={isBusy}>
{isBusy ? "Starting..." : "Start"}
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,334 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or } from "drizzle-orm"
import { db } from "@/shared/db"
import {
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
users,
} from "@/shared/db/schema"
import type {
HomeworkAssignmentListItem,
HomeworkQuestionContent,
HomeworkAssignmentStatus,
HomeworkSubmissionDetails,
HomeworkSubmissionListItem,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
StudentHomeworkTakeData,
} from "./types"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
if (!isRecord(v)) return null
return v as HomeworkQuestionContent
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[] }) => {
const conditions = []
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids))
const data = await db.query.homeworkAssignments.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(homeworkAssignments.createdAt)],
with: {
sourceExam: true,
},
})
return data.map((a) => {
const item: HomeworkAssignmentListItem = {
id: a.id,
sourceExamId: a.sourceExamId,
sourceExamTitle: a.sourceExam.title,
title: a.title,
status: (a.status as HomeworkAssignmentListItem["status"]) ?? "draft",
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
allowLate: a.allowLate,
lateDueAt: a.lateDueAt ? a.lateDueAt.toISOString() : null,
maxAttempts: a.maxAttempts,
createdAt: a.createdAt.toISOString(),
updatedAt: a.updatedAt.toISOString(),
}
return item
})
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string }) => {
const conditions = []
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
const data = await db.query.homeworkSubmissions.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(homeworkSubmissions.updatedAt)],
with: {
assignment: true,
student: true,
},
})
return data.map((s) => {
const item: HomeworkSubmissionListItem = {
id: s.id,
assignmentId: s.assignmentId,
assignmentTitle: s.assignment.title,
studentName: s.student.name || "Unknown",
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
score: s.score ?? null,
status: (s.status as HomeworkSubmissionListItem["status"]) ?? "started",
isLate: s.isLate,
}
return item
})
})
export const getHomeworkAssignmentById = cache(async (id: string) => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, id),
with: {
sourceExam: true,
},
})
if (!assignment) return null
const [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, id))
const [submissionsRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, id))
return {
id: assignment.id,
title: assignment.title,
description: assignment.description,
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
}
})
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
with: {
student: true,
assignment: true,
},
})
if (!submission) return null
const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, submissionId),
with: {
question: true,
},
})
const assignmentQ = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
orderBy: [desc(homeworkAssignmentQuestions.order)],
})
const answersWithDetails = answers
.map((ans) => {
const aqRel = assignmentQ.find((q) => q.questionId === ans.questionId)
return {
id: ans.id,
questionId: ans.questionId,
questionContent: toQuestionContent(ans.question.content),
questionType: ans.question.type,
maxScore: aqRel?.score || 0,
studentAnswer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
order: aqRel?.order || 0,
}
})
.sort((a, b) => a.order - b.order)
return {
id: submission.id,
assignmentId: submission.assignmentId,
assignmentTitle: submission.assignment.title,
studentName: submission.student.name || "Unknown",
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
status: submission.status as HomeworkSubmissionDetails["status"],
totalScore: submission.score,
answers: answersWithDetails,
}
})
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
const student = await db.query.users.findFirst({
where: eq(users.role, "student"),
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (student) return { id: student.id, name: student.name || "Student" }
const anyUser = await db.query.users.findFirst({
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (!anyUser) return null
return { id: anyUser.id, name: anyUser.name || "User" }
})
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
if (v === "started") return "in_progress"
if (v === "submitted") return "submitted"
if (v === "graded") return "graded"
return "not_started"
}
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
const now = new Date()
const targetAssignmentIds = db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, studentId))
const assignments = await db.query.homeworkAssignments.findMany({
where: and(
eq(homeworkAssignments.status, "published"),
inArray(homeworkAssignments.id, targetAssignmentIds),
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
),
orderBy: [desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt)],
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
orderBy: [desc(homeworkSubmissions.createdAt)],
})
const attemptsByAssignmentId = new Map<string, number>()
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
}
return assignments.map((a) => {
const latest = latestByAssignmentId.get(a.id) ?? null
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
const item: StudentHomeworkAssignmentListItem = {
id: a.id,
title: a.title,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
maxAttempts: a.maxAttempts,
attemptsUsed,
progressStatus: toStudentProgressStatus(latest?.status),
latestSubmissionId: latest?.id ?? null,
latestSubmittedAt: latest?.submittedAt ? latest.submittedAt.toISOString() : null,
latestScore: latest?.score ?? null,
}
return item
})
})
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
})
if (!target) return null
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
})
if (!assignment) return null
if (assignment.status !== "published") return null
const now = new Date()
if (assignment.availableAt && assignment.availableAt > now) return null
const startedSubmission = await db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "started")
),
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
const latestSubmission =
startedSubmission ??
(await db.query.homeworkSubmissions.findFirst({
where: and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, studentId)),
orderBy: (s, { desc }) => [desc(s.createdAt)],
}))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true },
orderBy: (q, { asc }) => [asc(q.order)],
})
const savedByQuestionId = new Map<string, unknown>()
if (latestSubmission) {
const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
})
for (const ans of answers) savedByQuestionId.set(ans.questionId, ans.answerContent)
}
return {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
},
submission: latestSubmission
? {
id: latestSubmission.id,
status: (latestSubmission.status as NonNullable<StudentHomeworkTakeData["submission"]>["status"]) ?? "started",
attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? null,
}
: null,
questions: assignmentQuestions.map((aq) => ({
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null,
})),
}
})

View File

@@ -0,0 +1,28 @@
import { z } from "zod"
export const CreateHomeworkAssignmentSchema = z.object({
sourceExamId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
availableAt: z.string().optional(),
dueAt: z.string().optional(),
allowLate: z.coerce.boolean().optional(),
lateDueAt: z.string().optional(),
maxAttempts: z.coerce.number().int().min(1).max(20).optional(),
targetStudentIds: z.array(z.string().min(1)).optional(),
publish: z.coerce.boolean().optional(),
})
export type CreateHomeworkAssignmentInput = z.infer<typeof CreateHomeworkAssignmentSchema>
export const GradeHomeworkSchema = z.object({
submissionId: z.string().min(1),
answers: z.array(
z.object({
id: z.string().min(1),
score: z.coerce.number().min(0),
feedback: z.string().optional(),
})
),
})

View File

@@ -0,0 +1,99 @@
export type HomeworkAssignmentStatus = "draft" | "published" | "archived"
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
export interface HomeworkAssignmentListItem {
id: string
sourceExamId: string
sourceExamTitle: string
title: string
status: HomeworkAssignmentStatus
availableAt: string | null
dueAt: string | null
allowLate: boolean
lateDueAt: string | null
maxAttempts: number
createdAt: string
updatedAt: string
}
export interface HomeworkSubmissionListItem {
id: string
assignmentId: string
assignmentTitle: string
studentName: string
submittedAt: string | null
score: number | null
status: HomeworkSubmissionStatus
isLate: boolean
}
export type HomeworkQuestionContent = { text?: string } & Record<string, unknown>
export type HomeworkSubmissionAnswerDetails = {
id: string
questionId: string
questionContent: HomeworkQuestionContent | null
questionType: string
maxScore: number
studentAnswer: unknown
score: number | null
feedback: string | null
order: number
}
export type HomeworkSubmissionDetails = {
id: string
assignmentId: string
assignmentTitle: string
studentName: string
submittedAt: string | null
status: HomeworkSubmissionStatus
totalScore: number | null
answers: HomeworkSubmissionAnswerDetails[]
}
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
export interface StudentHomeworkAssignmentListItem {
id: string
title: string
dueAt: string | null
availableAt: string | null
maxAttempts: number
attemptsUsed: number
progressStatus: StudentHomeworkProgressStatus
latestSubmissionId: string | null
latestSubmittedAt: string | null
latestScore: number | null
}
export type StudentHomeworkTakeQuestion = {
questionId: string
questionType: string
questionContent: HomeworkQuestionContent | null
maxScore: number
order: number
savedAnswer: unknown
}
export type StudentHomeworkTakeData = {
assignment: {
id: string
title: string
description: string | null
availableAt: string | null
dueAt: string | null
allowLate: boolean
lateDueAt: string | null
maxAttempts: number
}
submission: {
id: string
status: HomeworkSubmissionStatus
attemptNo: number
submittedAt: string | null
score: number | null
} | null
questions: StudentHomeworkTakeQuestion[]
}

View File

@@ -96,7 +96,6 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
items: [
{ title: "All Exams", href: "/teacher/exams/all" },
{ title: "Create Exam", href: "/teacher/exams/create" },
{ title: "Grading", href: "/teacher/exams/grading" },
]
},
{