Files
CICD/src/modules/homework/components/homework-take-view.tsx
SpecialX eb08c0ab68
All checks were successful
CI / build-deploy (push) Successful in 4m39s
sync-docs-and-fixes
2026-03-03 17:32:26 +08:00

421 lines
19 KiB
TypeScript

"use client"
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, 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 { Clock, CheckCircle2, Save, FileText } from "lucide-react"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type Option = { id: string; text: string }
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
const getOptions = (content: unknown): Option[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Option[] = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
const toAnswerShape = (questionType: string, v: unknown) => {
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
return { answer: v }
}
const parseSavedAnswer = (saved: unknown, questionType: string) => {
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
return toAnswerShape(questionType, saved)
}
type HomeworkTakeViewProps = {
assignmentId: string
initialData: StudentHomeworkTakeData
}
export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeViewProps) {
const router = useRouter()
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false)
const initialAnswersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()
for (const q of initialData.questions) {
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
}
return map
}, [initialData.questions])
const [answersByQuestionId, setAnswersByQuestionId] = useState(() => {
const obj: Record<string, { answer: unknown }> = {}
for (const [k, v] of initialAnswersByQuestionId.entries()) obj[k] = v
return obj
})
const isStarted = submissionStatus === "started"
const canEdit = isStarted && Boolean(submissionId)
const showQuestions = submissionStatus !== "not_started"
const handleStart = async () => {
setIsBusy(true)
const fd = new FormData()
fd.set("assignmentId", assignmentId)
const res = await startHomeworkSubmissionAction(null, fd)
if (res.success && res.data) {
setSubmissionId(res.data)
setSubmissionStatus("started")
toast.success("Started")
router.refresh()
} else {
toast.error(res.message || "Failed to start")
}
setIsBusy(false)
}
const handleSaveQuestion = async (questionId: string) => {
if (!submissionId) return
// setIsBusy(true) // Don't block UI for individual saves
const payload = answersByQuestionId[questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success("Saved")
else toast.error(res.message || "Failed to save")
// setIsBusy(false)
}
const handleSubmit = async () => {
if (!submissionId) return
setIsBusy(true)
// Save all first
for (const q of initialData.questions) {
const payload = answersByQuestionId[q.questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", q.questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (!res.success) {
toast.error(res.message || "Failed to save")
setIsBusy(false)
return
}
}
const submitFd = new FormData()
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
toast.success("Submitted")
setSubmissionStatus("submitted")
router.push("/student/learning/assignments")
} else {
toast.error(submitRes.message || "Failed to submit")
}
setIsBusy(false)
}
return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-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} size="sm">
{isBusy ? "Starting..." : "Start Assignment"}
</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 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 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="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>
<Textarea
placeholder="Type your answer here..."
value={typeof value === "string" ? value : ""}
onChange={(e) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: e.target.value },
}))
}
className="min-h-[120px] resize-y"
disabled={!canEdit}
/>
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v === "true" },
}))
}
disabled={!canEdit}
className="flex flex-col gap-2"
>
<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">
<RadioGroup
value={typeof value === "string" ? value : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v },
}))
}
disabled={!canEdit}
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 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}
</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 hover:bg-muted/50 transition-colors">
<Checkbox
id={`${q.questionId}-${o.id}`}
checked={selected}
onCheckedChange={(checked) => {
const isChecked = checked === true
setAnswersByQuestionId((prev) => {
const current = Array.isArray(prev[q.questionId]?.answer)
? (prev[q.questionId]?.answer as string[])
: []
const next = isChecked
? Array.from(new Set([...current, o.id]))
: current.filter((x) => x !== o.id)
return { ...prev, [q.questionId]: { answer: next } }
})
}}
disabled={!canEdit}
/>
<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 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 ? (
<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>
)
})}
</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={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
{submissionStatus === "not_started" ? "not started" : 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>
{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>
{canEdit && (
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit All"}
</Button>
<p className="mt-2 text-xs text-center text-muted-foreground">
Make sure you have answered all questions.
</p>
</div>
)}
</div>
</div>
)
}