- Update attendance components and data-access for record management - Update audit log views, filters, and data-access - Update auth login and register forms - Update classes actions, components, and data-access (admin, schedule, stats) - Update course-plans actions, form, list, progress, and schema - Update exams actions, AI pipeline, preview components, and hooks - Update files components (icon, list, preview, upload) and data-access - Update homework assignment form, review view, auto-save hook, and stats-service - Update layout sidebar, header, and navigation config - Update proctoring actions, anti-cheat monitor, and data-access - Update questions actions, components (dialog, actions, columns, filters), and data-access - Update scheduling actions, auto-scheduler, components, and schema - Update textbooks constants and text-selection hook - Update users class-registration, import-dialog, data-access, and user-service
223 lines
9.7 KiB
TypeScript
223 lines
9.7 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo } from "react"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
|
import { Label } from "@/shared/components/ui/label"
|
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
import { FileText, ChevronLeft } from "lucide-react"
|
|
import Link from "next/link"
|
|
import { useTranslations } from "next-intl"
|
|
|
|
import type { StudentHomeworkTakeData } from "../types"
|
|
import { QuestionRenderer } from "./question-renderer"
|
|
import {
|
|
getCorrectnessState,
|
|
parseSavedAnswer,
|
|
} from "../lib/question-content-utils"
|
|
|
|
type HomeworkReviewViewProps = {
|
|
initialData: StudentHomeworkTakeData
|
|
}
|
|
|
|
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
|
const t = useTranslations("examHomework")
|
|
const submissionStatus = initialData.submission?.status ?? "not_started"
|
|
const isGraded = submissionStatus === "graded"
|
|
|
|
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 ? t("homework.review.gradedReport") : t("homework.review.submissionDetails")}
|
|
</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} {t("homework.review.questionsUnit")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link href="/student/learning/assignments">
|
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
{t("homework.review.backToList")}
|
|
</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 value = answersByQuestionId[q.questionId]?.answer
|
|
const correctness = isGraded
|
|
? getCorrectnessState({ score: q.score ?? null, maxScore: q.maxScore })
|
|
: "ungraded"
|
|
const borderClass =
|
|
correctness === "correct"
|
|
? "border-l-4 border-l-emerald-500"
|
|
: correctness === "incorrect"
|
|
? "border-l-4 border-l-red-500"
|
|
: correctness === "partial"
|
|
? "border-l-4 border-l-yellow-500"
|
|
: "border-l-4 border-l-primary"
|
|
|
|
return (
|
|
<Card key={q.questionId} className={`shadow-sm ${borderClass}`}>
|
|
<CardHeader className="pb-2">
|
|
<QuestionRenderer
|
|
questionId={q.questionId}
|
|
questionType={q.questionType}
|
|
questionContent={q.questionContent}
|
|
maxScore={q.maxScore}
|
|
index={idx}
|
|
mode="review"
|
|
value={value}
|
|
showCorrectAnswer={isGraded}
|
|
feedback={isGraded ? q.feedback : null}
|
|
headerExtra={
|
|
isGraded ? (
|
|
<Badge
|
|
variant="outline"
|
|
aria-label={t(`homework.grade.${correctness === "ungraded" ? "partial" : correctness}`)}
|
|
className={
|
|
correctness === "correct"
|
|
? "text-emerald-600 border-emerald-200 bg-emerald-50"
|
|
: "text-red-600 border-red-200 bg-red-50"
|
|
}
|
|
>
|
|
{q.score} / {q.maxScore}
|
|
</Badge>
|
|
) : null
|
|
}
|
|
/>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 pt-0">
|
|
{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>
|
|
)}
|
|
</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">{t("homework.take.assignmentInfo")}</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">{t("homework.take.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">{t("homework.review.description")}</Label>
|
|
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
|
{initialData.assignment.description || t("homework.review.noDescription")}
|
|
</p>
|
|
</div>
|
|
|
|
{isGraded && (
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.review.totalScore")}</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" aria-hidden="true" />
|
|
<span>{t("homework.grade.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" aria-hidden="true" />
|
|
<span>{t("homework.grade.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" aria-hidden="true" />
|
|
<span>{t("homework.grade.incorrect")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
|
|
{isGraded ? t("homework.review.questionBreakdown") : t("homework.review.responseSummary")}
|
|
</Label>
|
|
<div className="mt-2 grid grid-cols-5 gap-2">
|
|
{initialData.questions.map((q, i) => {
|
|
const answer = answersByQuestionId[q.questionId]?.answer
|
|
const hasAnswer = answer !== undefined &&
|
|
answer !== "" &&
|
|
(Array.isArray(answer) ? answer.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>
|
|
)
|
|
}
|