feat(classes): optimize teacher dashboard ui and implement grade management

This commit is contained in:
SpecialX
2026-01-14 13:59:11 +08:00
parent ade8d4346c
commit 9bfc621d3f
104 changed files with 12793 additions and 2309 deletions

View File

@@ -46,7 +46,7 @@ async function getCurrentUser() {
if (anyUser) return { id: anyUser.id, role: roleHint }
return { id: "user_teacher_123", role: roleHint }
return { id: "user_teacher_math", role: roleHint }
}
async function ensureTeacher() {

View File

@@ -17,7 +17,7 @@ export function HomeworkAssignmentExamContentCard({
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="p-0">
<HomeworkAssignmentExamErrorExplorerLazy
structure={structure}
questions={questions}

View File

@@ -7,44 +7,47 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
function ExamErrorExplorerFallback() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]">
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3 text-sm font-medium"></div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-10 w-[40%]" />
<Skeleton className="h-10 w-[60%]" />
<Skeleton className="h-10 w-[75%]" />
<Skeleton className="h-10 w-[55%]" />
<Skeleton className="h-10 w-[68%]" />
<div className="grid grid-cols-1 gap-0 md:grid-cols-3 h-[600px] divide-y md:divide-y-0 md:divide-x">
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
<span className="text-sm font-medium">Question Preview</span>
</div>
<div className="flex-1 p-6 space-y-6">
<Skeleton className="h-8 w-[60%]" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-4 w-[80%]" />
</div>
<div className="space-y-3 pt-4">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
</div>
</div>
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3">
<div className="text-sm font-medium"></div>
<div className="mt-2 flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="min-w-0 flex-1 grid gap-1">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
<div className="border-b px-6 py-4">
<div className="text-sm font-medium">Error Analysis</div>
</div>
<div className="flex-1 p-6 space-y-6">
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
<Skeleton className="size-16 rounded-full shrink-0" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers</div>
<div className="space-y-3">
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />
</div>
</div>
</div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-4 w-[45%]" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</div>

View File

@@ -26,7 +26,7 @@ export function HomeworkAssignmentExamErrorExplorer({
}, [questions, selectedQuestionId])
return (
<div className={`grid grid-cols-1 gap-4 md:grid-cols-3 ${heightClassName}`}>
<div className={`grid grid-cols-1 gap-0 md:grid-cols-3 ${heightClassName} divide-y md:divide-y-0 md:divide-x border rounded-md bg-background overflow-hidden`}>
<HomeworkAssignmentExamPreviewPane
structure={structure}
questions={questions.map((q) => ({

View File

@@ -20,15 +20,19 @@ export function HomeworkAssignmentExamPreviewPane({
onQuestionSelect: (questionId: string) => void
}) {
return (
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3 text-sm font-medium"></div>
<ScrollArea className="flex-1 p-4">
<ExamViewer
structure={structure}
questions={questions}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={onQuestionSelect}
/>
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
<span className="text-sm font-medium">Question Preview</span>
</div>
<ScrollArea className="flex-1 bg-background">
<div className="p-6">
<ExamViewer
structure={structure}
questions={questions}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={onQuestionSelect}
/>
</div>
</ScrollArea>
</div>
)

View File

@@ -92,59 +92,63 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
const errorRate = selected?.errorRate ?? 0
return (
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3">
<div className="text-sm font-medium"></div>
{selected ? (
<div className="mt-2 flex items-center gap-3">
<div className="shrink-0">
<ErrorRatePieChart errorRate={errorRate} />
</div>
<div className="min-w-0 flex-1 grid gap-1 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{errorCount}</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{(errorRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{gradedSampleCount}</span>
</div>
</div>
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground"></div>
)}
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
<div className="border-b px-6 py-4 bg-muted/5">
<div className="text-sm font-medium">Error Analysis</div>
</div>
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full p-4">
{!selected ? (
<div className="text-sm text-muted-foreground"></div>
) : wrongAnswers.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground"></div>
<div className="space-y-2">
{wrongAnswers.map((item, idx) => (
<div key={`${selected.questionId}-${idx}`} className="rounded-md border bg-muted/20 px-3 py-2">
<div className="flex items-start gap-3">
<div className="w-24 shrink-0 text-xs text-muted-foreground truncate">{item.studentName}</div>
<div className="min-w-0 flex-1 text-sm wrap-break-word whitespace-pre-wrap">
{formatAnswer(item.answerContent, selected)}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{selected ? (
<>
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
<div className="shrink-0">
<ErrorRatePieChart errorRate={errorRate} />
</div>
<div className="min-w-0 flex-1 grid gap-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Question</span>
<span className="font-medium">Q{selected.questionId.slice(-4)}</span>
</div>
))}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Errors</span>
<span className="font-medium text-destructive">
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
</span>
</div>
</div>
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers ({wrongAnswers.length})</div>
{wrongAnswers.length === 0 ? (
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
No wrong answers recorded.
</div>
) : (
<div className="space-y-3">
{wrongAnswers.map((wa, i) => (
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>
</div>
<div className="font-medium text-destructive break-words">
{formatAnswer(wa.answerContent, selected)}
</div>
</div>
))}
</div>
)}
</div>
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
<p>Select a question from the left</p>
<p className="text-xs mt-1">to view error analysis</p>
</div>
)}
</ScrollArea>
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -1,49 +0,0 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
export function HomeworkAssignmentQuestionErrorDetailsCard({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Details</CardTitle>
</CardHeader>
<CardContent className="p-0">
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No data available.</div>
) : (
<ScrollArea className="h-72">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[70px]">Question</TableHead>
<TableHead className="text-right">Error Count</TableHead>
<TableHead className="text-right">Error Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questions.map((q, index) => (
<TableRow key={q.questionId}>
<TableCell className="text-sm">
<div className="font-medium">Q{index + 1}</div>
</TableCell>
<TableCell className="text-right text-sm tabular-nums">{q.errorCount}</TableCell>
<TableCell className="text-right text-sm tabular-nums">{(q.errorRate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,103 +1,8 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
function ErrorRateChart({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
const w = 100
const h = 60
const padL = 10
const padR = 3
const padT = 4
const padB = 10
const plotW = w - padL - padR
const plotH = h - padT - padB
const n = questions.length
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
const xFor = (i: number) => padL + (n <= 1 ? 0 : (i / (n - 1)) * plotW)
const yFor = (rate: number) => padT + (1 - clamp01(rate)) * plotH
const points = questions.map((q, i) => `${xFor(i)},${yFor(q.errorRate)}`).join(" ")
const areaD =
n === 0
? ""
: `M ${padL} ${padT + plotH} L ${points.split(" ").join(" L ")} L ${padL + plotW} ${padT + plotH} Z`
const gridYs = [
{ v: 1, label: "100%" },
{ v: 0.5, label: "50%" },
{ v: 0, label: "0%" },
]
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
{gridYs.map((g) => {
const y = yFor(g.v)
return (
<g key={g.label}>
<line x1={padL} y1={y} x2={padL + plotW} y2={y} className="stroke-border" strokeWidth={0.5} />
<text x={2} y={y + 1.2} className="fill-muted-foreground text-[3px]">
{g.label}
</text>
</g>
)
})}
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} className="stroke-border" strokeWidth={0.7} />
<line
x1={padL}
y1={padT + plotH}
x2={padL + plotW}
y2={padT + plotH}
className="stroke-border"
strokeWidth={0.7}
/>
{n >= 2 ? <path d={areaD} className="fill-primary/10" /> : null}
<polyline
points={points}
fill="none"
className="stroke-primary"
strokeWidth={1.2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{questions.map((q, i) => {
const cx = xFor(i)
const cy = yFor(q.errorRate)
const label = `Q${i + 1}`
return (
<g key={q.questionId}>
<circle cx={cx} cy={cy} r={1.2} className="fill-primary" />
<title>{`${label}: ${(q.errorRate * 100).toFixed(1)}% (${q.errorCount} / ${gradedSampleCount})`}</title>
</g>
)
})}
{questions.map((q, i) => {
if (n > 12 && i % 2 === 1) return null
const x = xFor(i)
return (
<text
key={`x-${q.questionId}`}
x={x}
y={h - 2}
textAnchor="middle"
className="fill-muted-foreground text-[3px]"
>
{i + 1}
</text>
)
})}
</svg>
)
}
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
export function HomeworkAssignmentQuestionErrorOverviewCard({
questions,
@@ -106,26 +11,78 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
const data = questions.map((q, index) => ({
name: `Q${index + 1}`,
errorRate: q.errorRate * 100,
errorCount: q.errorCount,
total: gradedSampleCount,
}))
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Overview</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">Error Rate Overview</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="h-72">
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="text-sm text-muted-foreground">
No graded submissions yet. Error analytics will appear here after grading.
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No graded submissions yet.
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Graded students</span>
<span className="font-medium text-foreground">{gradedSampleCount}</span>
</div>
<div className="h-56 rounded-md border bg-muted/40 px-3 py-2">
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
</div>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
/>
<Tooltip
cursor={{ fill: "hsl(var(--muted)/0.2)" }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const d = payload[0].payload
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Question</span>
<span className="font-bold text-muted-foreground">{d.name}</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Error Rate</span>
<span className="font-bold">{d.errorRate.toFixed(1)}%</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Errors</span>
<span className="font-bold">
{d.errorCount} / {d.total}
</span>
</div>
</div>
</div>
)
}
return null
}}
/>
<Bar
dataKey="errorRate"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={40}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>

View File

@@ -3,10 +3,20 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Check, MessageSquarePlus, X } from "lucide-react"
import {
Check,
MessageSquarePlus,
X,
ChevronLeft,
ChevronRight,
Save,
User,
AlertCircle,
Clock
} from "lucide-react"
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 { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } 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"
@@ -39,20 +49,48 @@ type HomeworkGradingViewProps = {
status: string
totalScore: number | null
answers: Answer[]
prevSubmissionId?: string | null
nextSubmissionId?: string | null
}
export function HomeworkGradingView({
submissionId,
answers: initialAnswers,
prevSubmissionId,
nextSubmissionId,
studentName,
assignmentTitle,
submittedAt,
}: HomeworkGradingViewProps) {
const router = useRouter()
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
const [isSubmitting, setIsSubmitting] = useState(false)
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>({})
// Initialize feedback visibility for answers that already have feedback
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>(() => {
const initialVisibility: Record<string, boolean> = {}
if (initialAnswers) {
initialAnswers.forEach(a => {
if (a.feedback && a.feedback.trim().length > 0) {
initialVisibility[a.id] = true
}
})
}
return initialVisibility
})
const handleManualScoreChange = (id: string, val: string) => {
const parsed = val === "" ? 0 : Number(val)
const nextScore = Number.isFinite(parsed) ? parsed : 0
// Clamp score between 0 and maxScore? Or allow extra credit?
// Usually maxScore is the limit, but let's just ensure it's a number.
// Ideally we should clamp it to [0, maxScore] to avoid errors, but sometimes teachers want to give 0 for invalid input.
const targetAnswer = answers.find(a => a.id === id)
const max = targetAnswer?.maxScore ?? 100
let nextScore = Number.isFinite(parsed) ? parsed : 0
if (nextScore > max) nextScore = max
if (nextScore < 0) nextScore = 0
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
}
@@ -69,10 +107,12 @@ export function HomeworkGradingView({
}
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
const binaryAnswers = answers.filter(shouldUseBinaryGrading)
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount
const maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 0
const correctCount = answers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
const incorrectCount = answers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
const partialCount = answers.reduce((sum, a) => sum + (a.score !== null && a.score > 0 && a.score < a.maxScore ? 1 : 0), 0)
const handleSubmit = async () => {
setIsSubmitting(true)
@@ -89,177 +129,357 @@ export function HomeworkGradingView({
const result = await gradeHomeworkSubmissionAction(null, formData)
if (result.success) {
toast.success("Grading saved")
router.push("/teacher/homework/submissions")
toast.success("Grading saved successfully")
// Optionally redirect or stay
router.refresh()
} else {
toast.error(result.message || "Failed to save")
toast.error(result.message || "Failed to save grading")
}
setIsSubmitting(false)
}
const handleScrollToQuestion = (id: string) => {
const el = document.getElementById(`question-card-${id}`)
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" })
}
}
return (
<div className="grid h-[calc(100vh-8rem)] 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">
<h3 className="font-semibold">Student Response</h3>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-8">
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-12">
{/* Main Content: Questions List */}
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
<ScrollArea className="flex-1 p-4 lg:p-8">
<div className="mx-auto max-w-4xl space-y-8 pb-20">
{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>
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${
ans.score === ans.maxScore ? "border-l-4 border-l-emerald-500" :
ans.score === 0 && ans.maxScore > 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted"
}`}>
<CardHeader className="bg-card pb-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-6 w-6 shrink-0 justify-center rounded-full p-0">
{index + 1}
</Badge>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{ans.questionType.replace("_", " ")}
</span>
{isAutoGradable(ans) && (
<Badge variant="secondary" className="text-[10px] h-5">Auto-graded</Badge>
)}
</div>
<CardTitle className="text-base font-medium leading-relaxed pt-2">
{ans.questionContent?.text || "No question text"}
</CardTitle>
</div>
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className="whitespace-nowrap">
{ans.score ?? 0} / {ans.maxScore} pts
</Badge>
</div>
</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">
{formatStudentAnswer(ans.studentAnswer)}
</p>
</div>
</CardHeader>
<Separator />
</div>
<CardContent className="bg-card/50 p-6 space-y-6">
{/* Student Answer Display */}
<div className="space-y-3">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<User className="h-3 w-3" /> Student Answer
</Label>
<div className="rounded-md border bg-background p-4 shadow-sm">
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
Array.isArray(ans.questionContent?.options) ? (
<div className="space-y-2">
{(ans.questionContent.options as ChoiceOption[]).map((opt: ChoiceOption) => {
const isSelected = Array.isArray(extractAnswerValue(ans.studentAnswer))
? (extractAnswerValue(ans.studentAnswer) as string[]).includes(opt.id as string)
: extractAnswerValue(ans.studentAnswer) === opt.id
const isCorrect = opt.isCorrect === true
// Visual logic:
// If selected and correct -> Green + Check
// If selected and wrong -> Red + X
// If not selected but correct -> Green outline (show missed correct answer)
let containerClass = "border-transparent hover:bg-muted/50"
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
if (isSelected) {
if (isCorrect) {
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
} else {
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
indicatorClass = "border-red-500 bg-red-500 text-white"
}
} else if (isCorrect) {
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
}
return (
<div
key={opt.id as string}
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
>
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
{opt.id as string}
</div>
<span className="flex-1">{opt.text}</span>
{isCorrect && <Check className="h-4 w-4 text-emerald-600" />}
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" />}
</div>
)
})}
</div>
) : (
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{formatStudentAnswer(ans.studentAnswer)}
</p>
)}
</div>
</div>
{/* Reference Answer (for text/non-choice questions) */}
{ans.questionType === "text" && (
<div className="space-y-2">
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
<Check className="h-3 w-3" /> Reference Answer
</Label>
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
{getTextCorrectAnswers(ans.questionContent).join(" / ") || "No reference answer provided."}
</div>
</div>
)}
</CardContent>
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between w-full gap-4">
{/* Grading Controls */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant={getCorrectnessState(ans) === "correct" ? "default" : "outline"}
size="sm"
className={getCorrectnessState(ans) === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
onClick={() => handleMarkCorrect(ans.id)}
>
<Check className="mr-1 h-4 w-4" /> Correct
</Button>
<Button
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
size="sm"
className={getCorrectnessState(ans) === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
onClick={() => handleMarkIncorrect(ans.id)}
>
<X className="mr-1 h-4 w-4" /> Incorrect
</Button>
</div>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<div className="flex items-center gap-2">
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">Score:</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
className="w-20 h-8"
value={ans.score ?? ""}
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
/>
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
</div>
</div>
{/* Feedback Toggle */}
<Button
variant="ghost"
size="sm"
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
>
<MessageSquarePlus className="mr-2 h-4 w-4" />
{showFeedbackByAnswerId[ans.id] ? "Hide Feedback" : "Add Feedback"}
</Button>
</div>
{/* Feedback Textarea */}
{showFeedbackByAnswerId[ans.id] && (
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
<Textarea
placeholder={`Provide feedback for ${studentName}...`}
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
className="min-h-[80px] bg-background"
/>
</div>
)}
</CardFooter>
</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">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>
{binaryAnswers.length > 0 ? (
<div className="mt-3 flex items-center gap-2">
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700">
Correct {correctCount}
</Badge>
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700">
Incorrect {incorrectCount}
</Badge>
{ungradedCount > 0 ? (
<Badge variant="outline" className="text-muted-foreground">
Ungraded {ungradedCount}
</Badge>
) : null}
{/* Sidebar: Summary & Actions */}
<div className="lg:col-span-3 h-full flex flex-col gap-6">
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
<CardHeader className="pb-2">
<CardTitle className="text-lg">Grading Summary</CardTitle>
<CardDescription>{assignmentTitle}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total Score</span>
<span className="font-bold">{currentTotal} / {maxTotal}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary transition-all duration-500 ease-in-out"
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
/>
</div>
</div>
) : null}
</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">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<span>Q{index + 1}</span>
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span>
{shouldUseBinaryGrading(ans) ? (
<Badge
variant="outline"
className={getCorrectnessBadgeClassName(ans)}
>
{getCorrectnessLabel(ans)}
</Badge>
) : null}
</CardTitle>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" /> Student
</span>
<span className="font-medium">{studentName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-4 w-4" /> Submitted
</span>
<span className="font-medium">
{submittedAt ? new Date(submittedAt).toLocaleDateString() : "N/A"}
</span>
</div>
</div>
<div className="flex items-center gap-1">
{shouldUseBinaryGrading(ans) ? (
<>
<Button
{answers.length > 0 && (
<div className="space-y-4 pt-2">
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
<span className="text-xs text-muted-foreground">Correct</span>
</div>
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
<span className="text-xs text-muted-foreground">Incorrect</span>
</div>
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
<span className="text-xs text-muted-foreground">Partial</span>
</div>
</div>
<Separator />
<div>
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
Question Status
</Label>
<div className="grid grid-cols-5 gap-2">
{answers.map((ans, i) => {
const state = getCorrectnessState(ans)
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
else if (state === "incorrect") badgeClass = "border-red-200 bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:border-red-800 dark:text-red-400"
else if (state === "partial") badgeClass = "border-amber-200 bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-400"
return (
<button
key={ans.id}
type="button"
variant="outline"
size="icon"
aria-label="mark correct"
className={getMarkCorrectButtonClassName(ans)}
onClick={() => handleMarkCorrect(ans.id)}
onClick={() => handleScrollToQuestion(ans.id)}
className={`flex h-8 items-center justify-center rounded border text-xs font-medium transition-colors cursor-pointer hover:ring-2 hover:ring-ring hover:ring-offset-2 ${badgeClass}`}
title={`Q${i + 1}: ${state}`}
>
<Check />
</Button>
<Button
type="button"
variant="outline"
size="icon"
aria-label="mark incorrect"
className={getMarkIncorrectButtonClassName(ans)}
onClick={() => handleMarkIncorrect(ans.id)}
>
<X />
</Button>
</>
) : null}
{i + 1}
</button>
)
})}
</div>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-3 pt-2">
<Button
className="w-full"
size="lg"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>Saving...</>
) : (
<>
<Save className="mr-2 h-4 w-4" /> Submit Grades
</>
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="add feedback"
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)}
onClick={() =>
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) }))
}
>
<MessageSquarePlus />
</Button>
</TooltipTrigger>
<TooltipContent>add feedback</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="py-3 px-4 space-y-3">
<div className="grid gap-2">
{!shouldUseBinaryGrading(ans) ? (
<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) => handleManualScoreChange(ans.id, e.target.value)}
/>
</div>
) : null}
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
<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)}
/>
) : null}
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
<div className="flex w-full items-center justify-between gap-2 pt-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={!prevSubmissionId}
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
>
<ChevronLeft className="mr-1 h-4 w-4" /> Prev
</Button>
</TooltipTrigger>
<TooltipContent>Previous Student</TooltipContent>
</Tooltip>
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Submit Grades"}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={!nextSubmissionId}
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
>
Next <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Next Student</TooltipContent>
</Tooltip>
</div>
</CardFooter>
</Card>
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-950/30 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p>
Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.
</p>
</div>
</div>
</div>
</div>
)
}
type ChoiceOption = { id?: unknown; isCorrect?: unknown }
type ChoiceOption = { id?: unknown; isCorrect?: unknown; text?: string }
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
@@ -295,14 +515,6 @@ const getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | nu
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
}
const shouldUseBinaryGrading = (ans: Answer): boolean => {
if (ans.questionType === "single_choice") return true
if (ans.questionType === "multiple_choice") return true
if (ans.questionType === "judgment") return true
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
return false
}
const isAutoGradable = (ans: Answer): boolean => {
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
@@ -370,39 +582,6 @@ const getCorrectnessState = (ans: Answer): CorrectnessState => {
return "partial"
}
const getCorrectnessLabel = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "Correct"
if (s === "incorrect") return "Incorrect"
if (s === "partial") return "Partial"
return "Ungraded"
}
const getCorrectnessBadgeClassName = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "border-emerald-200 bg-emerald-50 text-emerald-700"
if (s === "incorrect") return "border-red-200 bg-red-50 text-red-700"
if (s === "partial") return "border-amber-200 bg-amber-50 text-amber-800"
return "text-muted-foreground"
}
const getMarkCorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "correct"
return active ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "text-muted-foreground"
}
const getMarkIncorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "incorrect"
return active ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-100" : "text-muted-foreground"
}
const getFeedbackIconButtonClassName = (ans: Answer, isOpen: boolean): string => {
const hasFeedback = typeof ans.feedback === "string" && ans.feedback.trim().length > 0
if (isOpen) return "text-primary"
if (hasFeedback) return "text-primary/80"
return "text-muted-foreground"
}
const formatStudentAnswer = (studentAnswer: unknown): string => {
const v = extractAnswerValue(studentAnswer)
if (typeof v === "string") return v

View File

@@ -3,22 +3,17 @@
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Clock, CheckCircle2, Save, FileText } from "lucide-react"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
@@ -87,6 +82,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const isStarted = submissionStatus === "started"
const canEdit = isStarted && Boolean(submissionId)
const showQuestions = submissionStatus !== "not_started"
const handleStart = async () => {
setIsBusy(true)
@@ -106,7 +102,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const handleSaveQuestion = async (questionId: string) => {
if (!submissionId) return
setIsBusy(true)
// setIsBusy(true) // Don't block UI for individual saves
const payload = answersByQuestionId[questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
@@ -115,12 +111,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success("Saved")
else toast.error(res.message || "Failed to save")
setIsBusy(false)
// setIsBusy(false)
}
const handleSubmit = async () => {
if (!submissionId) return
setIsBusy(true)
// Save all first
for (const q of initialData.questions) {
const payload = answersByQuestionId[q.questionId]?.answer ?? null
const fd = new FormData()
@@ -149,50 +146,86 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
}
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 className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<FileText className="h-4 w-4 text-primary" />
</div>
<div>
<h3 className="font-semibold leading-none">Questions</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
{submissionStatus === "not_started" ? "Not Started" : submissionStatus}
</Badge>
<span></span>
<span>{initialData.questions.length} Questions</span>
</div>
</div>
</div>
{!canEdit ? (
<Button onClick={handleStart} disabled={isBusy}>
{isBusy ? "Starting..." : "Start"}
<Button onClick={handleStart} disabled={isBusy} size="sm">
{isBusy ? "Starting..." : "Start Assignment"}
</Button>
) : (
<Button onClick={handleSubmit} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit"}
</Button>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground hidden sm:inline-block">
Auto-saving enabled
</span>
<Button onClick={handleSubmit} disabled={isBusy} size="sm">
<CheckCircle2 className="mr-2 h-4 w-4" />
{isBusy ? "Submitting..." : "Submit Assignment"}
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{initialData.questions.map((q, idx) => {
<ScrollArea className="flex-1 bg-muted/10">
<div className="space-y-6 p-6 max-w-4xl mx-auto">
{!isStarted && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium">Ready to start?</h3>
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
Click the &quot;Start Assignment&quot; button above to begin. The timer will start once you confirm.
</p>
<Button onClick={handleStart} disabled={isBusy}>
Start Now
</Button>
</div>
)}
{showQuestions && 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>
<Card key={q.questionId} className="border-l-4 border-l-primary shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-base font-medium">
Question {idx + 1}
</CardTitle>
<CardDescription className="text-xs">
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} {q.maxScore} points
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="py-3 px-4 space-y-4">
<div className="text-sm">{text || "—"}</div>
<CardContent className="space-y-4">
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
{q.questionType === "text" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Label className="sr-only">Your answer</Label>
<Textarea
placeholder="Type your answer here..."
value={typeof value === "string" ? value : ""}
onChange={(e) =>
setAnswersByQuestionId((prev) => ({
@@ -200,14 +233,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
[q.questionId]: { answer: e.target.value },
}))
}
className="min-h-[100px]"
className="min-h-[120px] resize-y"
disabled={!canEdit}
/>
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Select
<RadioGroup
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
@@ -216,20 +248,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
}))
}
disabled={!canEdit}
className="flex flex-col gap-2"
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">True</Label>
</div>
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">False</Label>
</div>
</RadioGroup>
</div>
) : q.questionType === "single_choice" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<Select
<RadioGroup
value={typeof value === "string" ? value : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
@@ -238,28 +271,27 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
}))
}
disabled={!canEdit}
className="flex flex-col gap-2"
>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{options.map((o) => (
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
{o.text}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
</div>
))}
</RadioGroup>
</div>
) : q.questionType === "multiple_choice" ? (
<div className="grid gap-2">
<Label>Your answer</Label>
<div className="space-y-2">
<div className="flex flex-col gap-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">
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<Checkbox
id={`${q.questionId}-${o.id}`}
checked={selected}
onCheckedChange={(checked) => {
const isChecked = checked === true
@@ -275,30 +307,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
}}
disabled={!canEdit}
/>
<span className="text-sm">{o.text}</span>
</label>
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
{o.text}
</Label>
</div>
)
})}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Unsupported question type</div>
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
)}
{submissionStatus === "graded" && (
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
{q.feedback ? (
<div className="text-sm space-y-1">
<div className="font-medium text-foreground">Teacher Feedback</div>
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
{q.feedback}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
)}
</div>
)}
{canEdit ? (
<>
<Separator />
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
>
Save
</Button>
</div>
</>
<div className="flex justify-end pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
className="text-muted-foreground hover:text-foreground"
>
<Save className="mr-2 h-3 w-3" />
Save Answer
</Button>
</div>
) : null}
</CardContent>
</Card>
@@ -308,38 +356,66 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</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 className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 bg-muted/30">
<h3 className="font-semibold">Assignment Info</h3>
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
<div className="mt-1 flex items-center gap-2">
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
{submissionStatus === "not_started" ? "not started" : submissionStatus}
</Badge>
</div>
</div>
<div className="flex items-center justify-between">
<span>Questions</span>
<span className="text-foreground tabular-nums">{initialData.questions.length}</span>
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{initialData.assignment.description || "No description provided."}
</p>
</div>
{showQuestions && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Progress</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{initialData.questions.map((q, i) => {
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
answersByQuestionId[q.questionId]?.answer !== "" &&
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
return (
<div
key={q.questionId}
className={`
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
${hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"}
`}
>
{i + 1}
</div>
)
})}
</div>
</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 ? (
{canEdit && (
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit"}
{isBusy ? "Submitting..." : "Submit All"}
</Button>
) : (
<Button className="w-full" onClick={handleStart} disabled={isBusy}>
{isBusy ? "Starting..." : "Start"}
</Button>
)}
</div>
<p className="mt-2 text-xs text-center text-muted-foreground">
Make sure you have answered all questions.
</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,320 @@
"use client"
import { useMemo } from "react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { CheckCircle2, FileText, ChevronLeft } from "lucide-react"
import Link from "next/link"
import type { StudentHomeworkTakeData } from "../types"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
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 HomeworkReviewViewProps = {
initialData: StudentHomeworkTakeData
}
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
const submissionStatus = initialData.submission?.status ?? "not_started"
const isGraded = submissionStatus === "graded"
const isSubmitted = submissionStatus === "submitted"
const answersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()
for (const q of initialData.questions) {
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
}
const obj: Record<string, { answer: unknown }> = {}
for (const [k, v] of map.entries()) obj[k] = v
return obj
}, [initialData.questions])
return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<FileText className="h-4 w-4 text-primary" />
</div>
<div>
<h3 className="font-semibold leading-none">
{isGraded ? "Graded Report" : "Submission Details"}
</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] capitalize">
{submissionStatus}
</Badge>
<span></span>
<span>{initialData.questions.length} Questions</span>
</div>
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/student/learning/assignments">
<ChevronLeft className="mr-2 h-4 w-4" />
Back to List
</Link>
</Button>
</div>
<ScrollArea className="flex-1 bg-muted/10">
<div className="space-y-6 p-6 max-w-4xl mx-auto">
{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={`shadow-sm ${isGraded ? 'border-l-4' : 'border-l-4 border-l-primary'}`}
style={isGraded ? { borderLeftColor: q.score === q.maxScore && q.maxScore > 0 ? '#10b981' : q.score && q.score > 0 ? '#eab308' : '#ef4444' } : undefined}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-base font-medium flex items-center gap-2">
Question {idx + 1}
{isGraded && (
<Badge variant="outline" className={`ml-2 ${q.score === q.maxScore ? "text-emerald-600 border-emerald-200 bg-emerald-50" : "text-red-600 border-red-200 bg-red-50"}`}>
{q.score} / {q.maxScore}
</Badge>
)}
</CardTitle>
<CardDescription className="text-xs flex flex-col gap-1.5">
<span>{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} {q.maxScore} points</span>
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
<div className="flex flex-wrap gap-1">
{q.knowledgePoints.map((kp) => (
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
{kp.name}
</Badge>
))}
</div>
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
{q.questionType === "text" ? (
<div className="grid gap-2">
<Label className="sr-only">Your answer</Label>
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
{typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>}
</div>
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
disabled
className="flex flex-col gap-2"
>
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
<Label htmlFor={`${q.questionId}-true`} className="flex-1 font-normal">True</Label>
</div>
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
<Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label>
</div>
</RadioGroup>
</div>
) : q.questionType === "single_choice" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "string" ? value : ""}
disabled
className="flex flex-col gap-2"
>
{options.map((o) => (
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
{o.text}
</Label>
</div>
))}
</RadioGroup>
</div>
) : q.questionType === "multiple_choice" ? (
<div className="grid gap-2">
<div className="flex flex-col gap-2">
{options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false
return (
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 bg-muted/20">
<Checkbox
id={`${q.questionId}-${o.id}`}
checked={selected}
disabled
/>
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal">
{o.text}
</Label>
</div>
)
})}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
)}
{isGraded && (
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
{q.feedback ? (
<div className="text-sm space-y-1">
<div className="font-medium text-foreground">Teacher Feedback</div>
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
{q.feedback}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
)}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
</ScrollArea>
</div>
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 bg-muted/30">
<h3 className="font-semibold">Assignment Info</h3>
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary" className="capitalize">
{submissionStatus}
</Badge>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{initialData.assignment.description || "No description provided."}
</p>
</div>
{isGraded && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Total Score</Label>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-primary">
{initialData.submission?.score ?? 0}
</span>
<span className="text-sm text-muted-foreground">
/ {initialData.questions.reduce((acc, q) => acc + q.maxScore, 0)}
</span>
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-emerald-600"></div>
<span>Correct</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
<span>Partial</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<span>Incorrect</span>
</div>
</div>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
{isGraded ? "Question Breakdown" : "Response Summary"}
</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{initialData.questions.map((q, i) => {
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
answersByQuestionId[q.questionId]?.answer !== "" &&
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
const score = q.score ?? 0
const max = q.maxScore
let statusClass = "bg-background text-muted-foreground border-input"
if (isGraded) {
if (score === max && max > 0) statusClass = "bg-emerald-600 text-white border-emerald-600"
else if (score > 0) statusClass = "bg-yellow-500 text-white border-yellow-500"
else statusClass = "bg-red-500 text-white border-red-500"
} else if (hasAnswer) {
statusClass = "bg-primary text-primary-foreground border-primary"
}
return (
<div
key={q.questionId}
className={`
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
${statusClass}
`}
>
{i + 1}
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -524,6 +524,17 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
})
.sort((a, b) => a.order - b.order)
// Fetch adjacent submissions for navigation
const allSubmissions = await db.query.homeworkSubmissions.findMany({
where: eq(homeworkSubmissions.assignmentId, submission.assignmentId),
orderBy: [desc(homeworkSubmissions.updatedAt)],
columns: { id: true },
})
const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId)
const prevSubmissionId = currentIndex > 0 ? allSubmissions[currentIndex - 1].id : null
const nextSubmissionId = currentIndex >= 0 && currentIndex < allSubmissions.length - 1 ? allSubmissions[currentIndex + 1].id : null
return {
id: submission.id,
assignmentId: submission.assignmentId,
@@ -533,6 +544,8 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
status: submission.status as HomeworkSubmissionDetails["status"],
totalScore: submission.score,
answers: answersWithDetails,
prevSubmissionId,
nextSubmissionId,
}
})
@@ -643,16 +656,32 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true },
with: {
question: {
with: {
knowledgePoints: {
with: {
knowledgePoint: true
}
}
}
}
},
orderBy: (q, { asc }) => [asc(q.order)],
})
const savedByQuestionId = new Map<string, unknown>()
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
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)
for (const ans of answers) {
answersByQuestionId.set(ans.questionId, {
answer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
})
}
}
return {
@@ -675,14 +704,25 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
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,
})),
questions: assignmentQuestions.map((aq) => {
const saved = answersByQuestionId.get(aq.questionId)
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
const kps = aq.question.knowledgePoints ?? []
return {
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
savedAnswer: saved?.answer ?? null,
score: saved?.score ?? null,
feedback: saved?.feedback ?? null,
knowledgePoints: kps.map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name,
})),
}
}),
}
})

View File

@@ -73,6 +73,8 @@ export type HomeworkSubmissionDetails = {
status: HomeworkSubmissionStatus
totalScore: number | null
answers: HomeworkSubmissionAnswerDetails[]
prevSubmissionId?: string | null
nextSubmissionId?: string | null
}
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
@@ -114,6 +116,9 @@ export type StudentHomeworkTakeQuestion = {
maxScore: number
order: number
savedAnswer: unknown
score?: number | null
feedback?: string | null
knowledgePoints?: Array<{ id: string; name: string }>
}
export type StudentHomeworkTakeData = {
@@ -145,7 +150,7 @@ export type HomeworkAssignmentQuestionAnalytics = {
order: number
errorCount: number
errorRate: number
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }>
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown; count?: number }>
}
export type HomeworkAssignmentAnalytics = {