feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013

## P1 功能(20 项)
- 站内消息系统、家长仪表盘、学生考勤管理
- Excel 导入导出、用户批量导入、成绩导出
- 排课规则+自动排课+课表调整
- 成绩趋势+对比分析、密码安全策略、速率限制
- 数据变更日志、文件预览+存储策略、全文检索
- 依赖审计集成 CI、数据库定时备份、E2E 测试完善
- 通知偏好管理

## 基础设施修复
- src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求)
- .env: MySQL 端口从 13002 切换至 14013
- scripts/create-db.ts: 新增数据库初始化脚本

## 架构文档同步
- 004_architecture_impact_map.md 和 005_architecture_data.json
  完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View File

@@ -0,0 +1,219 @@
"use client"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { batchCreateGradeRecordsAction } from "../actions"
type Option = { id: string; name: string }
type Student = { id: string; name: string; email: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save All Grades"}
</Button>
)
}
export function BatchGradeEntry({
classes,
subjects,
students,
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
students: Student[]
defaultClassId?: string
defaultSubjectId?: string
}) {
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
const [semester, setSemester] = useState<"1" | "2">("1")
const [scores, setScores] = useState<Record<string, string>>({})
const handleScoreChange = (studentId: string, value: string) => {
setScores((prev) => ({ ...prev, [studentId]: value }))
}
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId) {
toast.error("Please select class and subject")
return
}
const records = students
.map((s) => ({
studentId: s.id,
score: Number(scores[s.id] ?? 0),
remark: undefined as string | undefined,
}))
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
if (records.length === 0) {
toast.error("Please enter at least one score")
return
}
formData.set("classId", classId)
formData.set("subjectId", subjectId)
formData.set("type", type)
formData.set("semester", semester)
formData.set("recordsJson", JSON.stringify(records))
const result = await batchCreateGradeRecordsAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to save")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Batch Grade Entry</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Class</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Exam / Quiz Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Semester</Label>
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">No students in this class.</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-32">Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Input
type="number"
step="0.01"
min="0"
placeholder="0"
value={scores[s.id] ?? ""}
onChange={(e) => handleScoreChange(s.id, e.target.value)}
className="h-8"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,132 @@
"use client"
import { BarChart3 } from "lucide-react"
import {
Bar,
BarChart,
CartesianGrid,
Legend,
XAxis,
YAxis,
} from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { ClassComparisonItem } from "@/modules/grades/types"
const chartConfig = {
averageScore: { label: "Average (%)", color: "hsl(var(--primary))" },
passRate: { label: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
excellentRate: { label: "Excellent (%)", color: "hsl(var(--chart-3))" },
}
interface ClassComparisonChartProps {
data: ClassComparisonItem[]
}
export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Class Comparison
</CardTitle>
<CardDescription>
Compare average, pass rate, and excellent rate across classes.
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={BarChart3}
title="No comparison data"
description="Select a grade and subject to compare classes."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.map((d) => ({
name: d.className,
averageScore: d.averageScore,
passRate: d.passRate,
excellentRate: d.excellentRate,
count: d.count,
studentCount: d.studentCount,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Class Comparison
</CardTitle>
<CardDescription>
Average score, pass rate (60%), and excellent rate (85%) per class.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<BarChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value}%`}
width={36}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[240px]" />} />
<Legend />
<Bar
dataKey="averageScore"
fill="var(--color-averageScore)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="passRate"
fill="var(--color-passRate)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="excellentRate"
fill="var(--color-excellentRate)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,92 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Trophy } from "lucide-react"
import { GradeStatsCard } from "./grade-stats-card"
import type { ClassGradeStats, ClassRankingItem } from "../types"
interface ClassGradeReportProps {
stats: ClassGradeStats | null
ranking: ClassRankingItem[]
}
export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
return (
<div className="space-y-6">
{stats ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{stats.className}</h3>
<p className="text-sm text-muted-foreground">
{stats.studentCount} students · {stats.stats.count} grade records
</p>
</div>
</div>
<GradeStatsCard stats={stats.stats} />
</div>
) : (
<EmptyState
title="No data"
description="No grade records found for this class."
icon={Trophy}
className="border-none shadow-none"
/>
)}
{ranking.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Class Ranking</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Rank</TableHead>
<TableHead>Student</TableHead>
<TableHead className="text-right">Average Score</TableHead>
<TableHead className="text-right">Records</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ranking.map((r) => (
<TableRow key={r.studentId}>
<TableCell>
<Badge
variant={
r.rank === 1 ? "default" : r.rank <= 3 ? "secondary" : "outline"
}
className="font-mono"
>
#{r.rank}
</Badge>
</TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell className="text-right font-mono">
{r.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{r.recordCount}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { Download, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { exportGradesAction } from "../actions"
function downloadBase64File(base64: string, filename: string) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
type ExportButtonProps = {
classId: string
subjectId?: string
examId?: string
variant?: "default" | "outline" | "secondary" | "ghost"
size?: "default" | "sm" | "lg" | "icon"
label?: string
}
export function ExportButton({
classId,
subjectId,
examId,
variant = "outline",
size = "default",
label = "导出",
}: ExportButtonProps) {
const [isExporting, setIsExporting] = useState(false)
const handleExport = async (reportType: "detail" | "class") => {
if (!classId) {
toast.error("请先选择班级")
return
}
setIsExporting(true)
const result = await exportGradesAction({
classId,
subjectId,
examId,
reportType,
})
setIsExporting(false)
if (result.success && result.data) {
downloadBase64File(result.data.buffer, result.data.filename)
toast.success("导出成功")
} else {
toast.error(result.message ?? "导出失败")
}
}
if (isExporting) {
return (
<Button variant={variant} size={size} disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size}>
<Download className="mr-2 h-4 w-4" />
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("detail")}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("class")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,138 @@
"use client"
import { PieChart as PieChartIcon } from "lucide-react"
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { GradeDistributionResult } from "@/modules/grades/types"
const BUCKET_COLORS: Record<string, string> = {
"90-100": "hsl(142, 71%, 45%)",
"80-89": "hsl(217, 91%, 60%)",
"70-79": "hsl(43, 96%, 56%)",
"60-69": "hsl(25, 95%, 53%)",
"<60": "hsl(0, 84%, 60%)",
}
const chartConfig = {
count: { label: "Students", color: "hsl(var(--primary))" },
}
interface GradeDistributionChartProps {
data: GradeDistributionResult | null
}
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
if (!data || data.totalCount === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChartIcon className="h-4 w-4" />
Score Distribution
</CardTitle>
<CardDescription>
Number of students in each score range (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={PieChartIcon}
title="No distribution data"
description="Select a class and subject to view score distribution."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.buckets.map((b) => ({
label: b.label,
count: b.count,
percentage:
data.totalCount > 0
? Math.round((b.count / data.totalCount) * 1000) / 10
: 0,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChartIcon className="h-4 w-4" />
Score Distribution
</CardTitle>
<CardDescription>
{data.totalCount} grade record{data.totalCount === 1 ? "" : "s"} across
score ranges.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[200px]"
formatter={(payload: unknown) => {
const item = (payload as { payload?: (typeof chartData)[number] })?.payload
if (!item) return null
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-sm font-medium">
{item.label}: {item.count} student
{item.count === 1 ? "" : "s"}
</span>
<span className="text-xs text-muted-foreground">
{item.percentage}% of total
</span>
</div>
)
}}
/>
}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{chartData.map((entry) => (
<Cell key={entry.label} fill={BUCKET_COLORS[entry.label]} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,104 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
type Option = { id: string; name: string }
interface GradeQueryFiltersProps {
classes: Option[]
subjects: Option[]
}
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) {
const router = useRouter()
const searchParams = useSearchParams()
const updateParam = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value && value !== "all") {
params.set(key, value)
} else {
params.delete(key)
}
router.push(`?${params.toString()}`)
},
[router, searchParams]
)
const classId = searchParams.get("classId") ?? "all"
const subjectId = searchParams.get("subjectId") ?? "all"
const type = searchParams.get("type") ?? "all"
const semester = searchParams.get("semester") ?? "all"
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-4">
<div className="grid gap-2">
<Label className="text-xs">Class</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All classes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Subject</Label>
<Select value={subjectId} onValueChange={(v) => updateParam("subjectId", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All subjects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All subjects</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Type</Label>
<Select value={type} onValueChange={(v) => updateParam("type", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Semester</Label>
<Select value={semester} onValueChange={(v) => updateParam("semester", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All semesters" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All semesters</SelectItem>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
"use client"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { createGradeRecordAction } from "../actions"
type Option = { id: string; name: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Record"}
</Button>
)
}
export function GradeRecordForm({
classes,
subjects,
students,
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
students: Option[]
defaultClassId?: string
defaultSubjectId?: string
}) {
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
const [studentId, setStudentId] = useState(students[0]?.id ?? "")
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
const [semester, setSemester] = useState<"1" | "2">("1")
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId || !studentId) {
toast.error("Please select class, subject and student")
return
}
formData.set("classId", classId)
formData.set("subjectId", subjectId)
formData.set("studentId", studentId)
formData.set("type", type)
formData.set("semester", semester)
const result = await createGradeRecordAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to create")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Record Grade</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Class</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Student</Label>
<Select value={studentId} onValueChange={setStudentId}>
<SelectTrigger>
<SelectValue placeholder="Select a student" />
</SelectTrigger>
<SelectContent>
{students.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
</div>
<div className="grid gap-2">
<Label htmlFor="score">Score</Label>
<Input id="score" name="score" type="number" step="0.01" min="0" required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Semester</Label>
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="remark">Remark (optional)</Label>
<Textarea id="remark" name="remark" placeholder="Notes about this grade..." />
</div>
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,136 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { formatDate } from "@/shared/lib/utils"
import { Trash2 } from "lucide-react"
import { deleteGradeRecordAction } from "../actions"
import type { GradeRecordListItem } from "../types"
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
exam: "default",
quiz: "secondary",
homework: "outline",
other: "outline",
}
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
const router = useRouter()
const [deleteId, setDeleteId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
if (!deleteId) return
setIsDeleting(true)
const result = await deleteGradeRecordAction(deleteId)
setIsDeleting(false)
if (result.success) {
toast.success(result.message)
setDeleteId(null)
router.refresh()
} else {
toast.error(result.message || "Failed to delete")
}
}
if (records.length === 0) {
return (
<div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
No grade records found.
</div>
)
}
return (
<>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Class</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Title</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Type</TableHead>
<TableHead>Semester</TableHead>
<TableHead>Recorded By</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.subjectName}</TableCell>
<TableCell>{r.title}</TableCell>
<TableCell className="text-right font-mono">
{r.score} / {r.fullScore}
</TableCell>
<TableCell>
<Badge variant={typeColors[r.type]} className="capitalize">
{r.type}
</Badge>
</TableCell>
<TableCell>S{r.semester}</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Grade Record</DialogTitle>
<DialogDescription>
Are you sure you want to delete this grade record? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,93 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
import type { GradeStats } from "../types"
interface StatItemProps {
label: string
value: string | number
icon: React.ReactNode
hint?: string
}
function StatItem({ label, value, icon, hint }: StatItemProps) {
return (
<div className="flex flex-col gap-1 rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className="text-muted-foreground">{icon}</span>
</div>
<span className="text-2xl font-bold">{value}</span>
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
</div>
)
}
export function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
if (!stats || stats.count === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No data available for statistics.</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label="Average"
value={stats.average.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label="Median"
value={stats.median.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label="Max"
value={stats.max.toFixed(2)}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatItem
label="Min"
value={stats.min.toFixed(2)}
icon={<TrendingDown className="h-4 w-4" />}
/>
<StatItem
label="Std Dev"
value={stats.stdDev.toFixed(2)}
icon={<Target className="h-4 w-4" />}
hint="Standard deviation"
/>
<StatItem
label="Pass Rate"
value={`${stats.passRate.toFixed(1)}%`}
icon={<CheckCircle2 className="h-4 w-4" />}
hint="Score >= 60% of full"
/>
<StatItem
label="Excellent Rate"
value={`${stats.excellentRate.toFixed(1)}%`}
icon={<Award className="h-4 w-4" />}
hint="Score >= 85% of full"
/>
<StatItem
label="Count"
value={stats.count}
icon={<BarChart3 className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,137 @@
"use client"
import { BarChart3 } from "lucide-react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import type { GradeTrendResult } from "@/modules/grades/types"
const chartConfig = {
normalizedScore: {
label: "Score (%)",
color: "hsl(var(--primary))",
},
}
interface GradeTrendChartProps {
data: GradeTrendResult | null
}
export function GradeTrendChart({ data }: GradeTrendChartProps) {
if (!data || data.points.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Grade Trend
</CardTitle>
<CardDescription>
Score progression over time (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={BarChart3}
title="No trend data"
description="Select a class and subject to view the grade trend."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.points.map((p) => ({
title: p.title,
normalizedScore: p.normalizedScore,
fullTitle: p.title,
date: formatDate(p.date),
rawScore: p.score,
fullScore: p.fullScore,
type: p.type,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Grade Trend
</CardTitle>
<CardDescription>
{data.label} · avg {data.averageScore.toFixed(1)}%
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<LineChart
data={chartData}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
>
<CartesianGrid
vertical={false}
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<XAxis
dataKey="title"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 10 ? `${value.slice(0, 10)}...` : value
}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value}%`}
width={36}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={
<ChartTooltipContent
indicator="line"
labelKey="fullTitle"
className="w-[220px]"
/>
}
/>
<Line
dataKey="normalizedScore"
type="monotone"
stroke="var(--color-normalizedScore)"
strokeWidth={2}
dot={{
fill: "var(--color-normalizedScore)",
r: 3,
strokeWidth: 2,
}}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,117 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { GraduationCap } from "lucide-react"
import type { StudentGradeSummary } from "../types"
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
exam: "default",
quiz: "secondary",
homework: "outline",
other: "outline",
}
export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
if (!summary) {
return (
<EmptyState
title="No data"
description="Student grade summary is not available."
icon={GraduationCap}
className="border-none shadow-none"
/>
)
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Average Score</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.averageScore.toFixed(2)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Records</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.records.length}</p>
</CardContent>
</Card>
</div>
{summary.records.length === 0 ? (
<EmptyState
title="No grades yet"
description="There are no grade records for this student."
icon={GraduationCap}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Grade History</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Class</TableHead>
<TableHead>Subject</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Type</TableHead>
<TableHead>Semester</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.title}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.subjectName}</TableCell>
<TableCell className="text-right font-mono">
{r.score} / {r.fullScore}
</TableCell>
<TableCell>
<Badge variant={typeColors[r.type]} className="capitalize">
{r.type}
</Badge>
</TableCell>
<TableCell>S{r.semester}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
"use client"
import { Radar } from "lucide-react"
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar as RechartsRadar,
RadarChart,
} from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { SubjectComparisonItem } from "@/modules/grades/types"
const chartConfig = {
averageScore: { label: "Average (%)", color: "hsl(var(--primary))" },
passRate: { label: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
}
interface SubjectComparisonChartProps {
data: SubjectComparisonItem[]
}
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radar className="h-4 w-4" />
Subject Comparison
</CardTitle>
<CardDescription>
Compare performance across subjects for the selected class.
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={Radar}
title="No comparison data"
description="Select a class to compare subject performance."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
const chartData = data.map((d) => ({
subject: d.subjectName,
averageScore: d.averageScore,
passRate: d.passRate,
excellentRate: d.excellentRate,
count: d.count,
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radar className="h-4 w-4" />
Subject Comparison
</CardTitle>
<CardDescription>
Average score and pass rate per subject (normalized to 0-100).
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<RadarChart data={chartData} outerRadius="75%">
<PolarGrid strokeOpacity={0.4} />
<PolarAngleAxis
dataKey="subject"
tick={{ fontSize: 12 }}
tickFormatter={(value: string) =>
value.length > 6 ? `${value.slice(0, 6)}...` : value
}
/>
<PolarRadiusAxis
domain={[0, 100]}
tickFormatter={(value: number) => `${value}%`}
tick={{ fontSize: 10 }}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
<RechartsRadar
name="Average"
dataKey="averageScore"
stroke="var(--color-averageScore)"
fill="var(--color-averageScore)"
fillOpacity={0.4}
/>
<RechartsRadar
name="Pass Rate"
dataKey="passRate"
stroke="var(--color-passRate)"
fill="var(--color-passRate)"
fillOpacity={0.2}
/>
</RadarChart>
</ChartContainer>
</CardContent>
</Card>
)
}