Bug fixes (from bugs/ directory): - Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions - Fix shared/lib <-> auth circular dependency via new session.ts module - Fix divide-by-zero guard in grades data-access - Fix audit export data truncation (paginated fetch for full datasets) - Fix missing transactions in homework grading and elective lottery - Fix missing revalidatePath in course-plans actions - Fix frontend permission checks using requirePermission instead of requireAuth - Fix dashboard role routing using session.user.roles - Fix student auth pattern (migrate getDemoStudentUser to users module) - Fix ActionState return type handling in components Code quality fixes: - Remove 60+ as type assertions (replace with type guards) - Remove non-null assertions (use optional chaining or explicit checks) - Convert dynamic imports to static imports (grades, diagnostic) - Add React.cache() wrapping for read functions - Parallelize independent queries with Promise.all - Add explicit return types to 30+ arrow functions - Replace any with unknown + type guards - Fix import type for type-only imports - Add Zod validation schemas for classes and diagnostic modules - Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction) - Add console.error to silent catch blocks - Fix permission naming consistency (exam:proctor_read -> exam:proctor:read) Architecture doc sync: - Update 004_architecture_impact_map.md and 005_architecture_data.json - Update management-modules-audit.md for P0-7 cross-module fix Moved deleted proctoring event route to deletes/ folder.
169 lines
7.7 KiB
TypeScript
169 lines
7.7 KiB
TypeScript
import Link from "next/link"
|
|
|
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
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 { formatDate } from "@/shared/lib/utils"
|
|
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
|
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
|
import { Inbox } from "lucide-react"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
|
if (status === "graded") return "default"
|
|
if (status === "submitted") return "secondary"
|
|
if (status === "in_progress") return "secondary"
|
|
return "outline"
|
|
}
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
if (status === "graded") return "Graded"
|
|
if (status === "submitted") return "Submitted"
|
|
if (status === "in_progress") return "In progress"
|
|
return "Not started"
|
|
}
|
|
|
|
const getActionLabel = (status: string) => {
|
|
if (status === "graded") return "Review"
|
|
if (status === "submitted") return "View"
|
|
if (status === "in_progress") return "Continue"
|
|
return "Start"
|
|
}
|
|
|
|
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
|
if (status === "graded" || status === "submitted") return "outline"
|
|
return "default"
|
|
}
|
|
|
|
const isAnswered = (status: string) => status === "submitted" || status === "graded"
|
|
|
|
export default async function StudentAssignmentsPage() {
|
|
const student = await getCurrentStudentUser()
|
|
|
|
if (!student) {
|
|
return (
|
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
|
<EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const assignments = await getStudentHomeworkAssignments(student.id)
|
|
const hasAssignments = assignments.length > 0
|
|
const assignmentsBySubject = assignments.reduce((acc, assignment) => {
|
|
const subject = assignment.subjectName?.trim() || "Other"
|
|
const existing = acc.get(subject)
|
|
if (existing) {
|
|
existing.push(assignment)
|
|
} else {
|
|
acc.set(subject, [assignment])
|
|
}
|
|
return acc
|
|
}, new Map<string, typeof assignments>())
|
|
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
|
|
|
return (
|
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
|
{!hasAssignments ? (
|
|
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
|
|
) : (
|
|
<div className="space-y-6">
|
|
{subjectEntries.map(([subject, items]) => {
|
|
const answeredItems = items.filter((a) => isAnswered(a.progressStatus))
|
|
const unansweredItems = items.filter((a) => !isAnswered(a.progressStatus))
|
|
|
|
return (
|
|
<div key={subject} className="space-y-3">
|
|
<div className="text-sm font-semibold text-muted-foreground">{subject}</div>
|
|
{unansweredItems.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">未答题</div>
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{unansweredItems.map((a) => (
|
|
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
|
|
<CardHeader className="gap-2 pb-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<CardTitle className="text-base">
|
|
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
|
|
{a.title}
|
|
</Link>
|
|
</CardTitle>
|
|
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
|
{getStatusLabel(a.progressStatus)}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
|
|
<span className="px-2">•</span>
|
|
<span>
|
|
Attempts {a.attemptsUsed}/{a.maxAttempts}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="mt-auto flex items-center justify-between">
|
|
<div className="text-sm">
|
|
<div className="text-muted-foreground">Score</div>
|
|
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
|
|
</div>
|
|
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
|
<Link href={`/student/learning/assignments/${a.id}`}>
|
|
{getActionLabel(a.progressStatus)}
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{answeredItems.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">已答题</div>
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{answeredItems.map((a) => (
|
|
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
|
|
<CardHeader className="gap-2 pb-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<CardTitle className="text-base">
|
|
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
|
|
{a.title}
|
|
</Link>
|
|
</CardTitle>
|
|
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
|
{getStatusLabel(a.progressStatus)}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
|
|
<span className="px-2">•</span>
|
|
<span>
|
|
Attempts {a.attemptsUsed}/{a.maxAttempts}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="mt-auto flex items-center justify-between">
|
|
<div className="text-sm">
|
|
<div className="text-muted-foreground">Score</div>
|
|
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
|
|
</div>
|
|
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
|
<Link href={`/student/learning/assignments/${a.id}`}>
|
|
{getActionLabel(a.progressStatus)}
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|