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:
219
src/modules/grades/components/batch-grade-entry.tsx
Normal file
219
src/modules/grades/components/batch-grade-entry.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
src/modules/grades/components/class-comparison-chart.tsx
Normal file
132
src/modules/grades/components/class-comparison-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/modules/grades/components/class-grade-report.tsx
Normal file
92
src/modules/grades/components/class-grade-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
src/modules/grades/components/export-button.tsx
Normal file
101
src/modules/grades/components/export-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
src/modules/grades/components/grade-distribution-chart.tsx
Normal file
138
src/modules/grades/components/grade-distribution-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
src/modules/grades/components/grade-query-filters.tsx
Normal file
104
src/modules/grades/components/grade-query-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
src/modules/grades/components/grade-record-form.tsx
Normal file
184
src/modules/grades/components/grade-record-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
src/modules/grades/components/grade-record-list.tsx
Normal file
136
src/modules/grades/components/grade-record-list.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
93
src/modules/grades/components/grade-stats-card.tsx
Normal file
93
src/modules/grades/components/grade-stats-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
src/modules/grades/components/grade-trend-chart.tsx
Normal file
137
src/modules/grades/components/grade-trend-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
src/modules/grades/components/student-grade-summary.tsx
Normal file
117
src/modules/grades/components/student-grade-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/modules/grades/components/subject-comparison-chart.tsx
Normal file
116
src/modules/grades/components/subject-comparison-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user