feat(classes): optimize teacher dashboard ui and implement grade management
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "Start Assignment" 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
320
src/modules/homework/components/student-homework-review-view.tsx
Normal file
320
src/modules/homework/components/student-homework-review-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user