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:
133
src/modules/grades/actions-analytics.ts
Normal file
133
src/modules/grades/actions-analytics.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
"use server"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import {
|
||||
getClassComparison,
|
||||
getGradeDistribution,
|
||||
getGradeTrend,
|
||||
getSubjectComparison,
|
||||
type ClassComparisonParams,
|
||||
type GradeDistributionParams,
|
||||
type GradeTrendParams,
|
||||
type SubjectComparisonParams,
|
||||
} from "./data-access-analytics"
|
||||
import { getRankingTrend } from "./data-access-ranking"
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionResult,
|
||||
GradeTrendResult,
|
||||
RankingTrendResult,
|
||||
SubjectComparisonItem,
|
||||
} from "./types"
|
||||
|
||||
export async function getGradeTrendAction(
|
||||
params: Omit<GradeTrendParams, "scope" | "currentUserId">
|
||||
): Promise<ActionState<GradeTrendResult | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const result = await getGradeTrend({
|
||||
...params,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClassComparisonAction(
|
||||
params: Omit<ClassComparisonParams, "scope">
|
||||
): Promise<ActionState<ClassComparisonItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const result = await getClassComparison({
|
||||
...params,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubjectComparisonAction(
|
||||
params: Omit<SubjectComparisonParams, "scope">
|
||||
): Promise<ActionState<SubjectComparisonItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const result = await getSubjectComparison({
|
||||
...params,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGradeDistributionAction(
|
||||
params: Omit<GradeDistributionParams, "scope" | "currentUserId">
|
||||
): Promise<ActionState<GradeDistributionResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const result = await getGradeDistribution({
|
||||
...params,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRankingTrendAction(
|
||||
studentId: string,
|
||||
subjectId?: string,
|
||||
semester?: "1" | "2"
|
||||
): Promise<ActionState<RankingTrendResult | null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
// Students can only view their own ranking trend
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||
return { success: false, message: "Can only view your own ranking trend" }
|
||||
}
|
||||
// Parents can only view their children's ranking trend
|
||||
if (
|
||||
ctx.dataScope.type === "children" &&
|
||||
!ctx.dataScope.childrenIds.includes(studentId)
|
||||
) {
|
||||
return { success: false, message: "Can only view your children's ranking trend" }
|
||||
}
|
||||
|
||||
const result = await getRankingTrend(studentId, subjectId, semester)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
312
src/modules/grades/actions.ts
Normal file
312
src/modules/grades/actions.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import {
|
||||
CreateGradeRecordSchema,
|
||||
BatchCreateGradeRecordSchema,
|
||||
UpdateGradeRecordSchema,
|
||||
} from "./schema"
|
||||
import {
|
||||
createGradeRecord,
|
||||
batchCreateGradeRecords,
|
||||
updateGradeRecord,
|
||||
deleteGradeRecord,
|
||||
getGradeRecords,
|
||||
getGradeRecordById,
|
||||
getClassGradeStatsWithMeta,
|
||||
getStudentGradeSummary,
|
||||
getClassRanking,
|
||||
} from "./data-access"
|
||||
import {
|
||||
exportGradeRecordsToExcel,
|
||||
exportClassGradeReportToExcel,
|
||||
formatDateForFile,
|
||||
} from "./export"
|
||||
import type { GradeQueryParams, GradeRecordListItem, GradeStats } from "./types"
|
||||
|
||||
export async function createGradeRecordAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
const parsed = CreateGradeRecordSchema.safeParse({
|
||||
studentId: formData.get("studentId"),
|
||||
classId: formData.get("classId"),
|
||||
subjectId: formData.get("subjectId"),
|
||||
examId: formData.get("examId") || undefined,
|
||||
academicYearId: formData.get("academicYearId") || undefined,
|
||||
title: formData.get("title"),
|
||||
score: formData.get("score"),
|
||||
fullScore: formData.get("fullScore") || undefined,
|
||||
type: formData.get("type") || undefined,
|
||||
semester: formData.get("semester") || undefined,
|
||||
remark: formData.get("remark") || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const id = await createGradeRecord(parsed.data, ctx.userId)
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record created", data: id }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function batchCreateGradeRecordsAction(
|
||||
prevState: ActionState<number> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<number>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
const recordsJson = formData.get("recordsJson")
|
||||
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
|
||||
return { success: false, message: "Missing records data" }
|
||||
}
|
||||
|
||||
const parsed = BatchCreateGradeRecordSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
subjectId: formData.get("subjectId"),
|
||||
examId: formData.get("examId") || undefined,
|
||||
academicYearId: formData.get("academicYearId") || undefined,
|
||||
title: formData.get("title"),
|
||||
fullScore: formData.get("fullScore") || undefined,
|
||||
type: formData.get("type") || undefined,
|
||||
semester: formData.get("semester") || undefined,
|
||||
records: JSON.parse(recordsJson),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const count = await batchCreateGradeRecords(parsed.data, ctx.userId)
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: `Created ${count} grade records`, data: count }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGradeRecordAction(
|
||||
id: string,
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
|
||||
const parsed = UpdateGradeRecordSchema.safeParse({
|
||||
title: formData.get("title") || undefined,
|
||||
score: formData.get("score") || undefined,
|
||||
fullScore: formData.get("fullScore") || undefined,
|
||||
type: formData.get("type") || undefined,
|
||||
semester: formData.get("semester") || undefined,
|
||||
remark: formData.get("remark") || undefined,
|
||||
examId: formData.get("examId") || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await updateGradeRecord(id, parsed.data)
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record updated" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGradeRecordAction(
|
||||
id: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
await deleteGradeRecord(id)
|
||||
revalidatePath("/teacher/grades")
|
||||
return { success: true, message: "Grade record deleted" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGradeRecordsAction(
|
||||
params: GradeQueryParams
|
||||
): Promise<ActionState<GradeRecordListItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const records = await getGradeRecords({
|
||||
...params,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
})
|
||||
return { success: true, data: records }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClassGradeStatsAction(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ActionState<GradeStats | null>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const result = await getClassGradeStatsWithMeta(classId, subjectId, examId)
|
||||
return { success: true, data: result?.stats ?? null }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudentGradeSummaryAction(
|
||||
studentId: string
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getStudentGradeSummary>>>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||
return { success: false, message: "Can only view your own grades" }
|
||||
}
|
||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
return { success: false, message: "Can only view your children's grades" }
|
||||
}
|
||||
|
||||
const summary = await getStudentGradeSummary(studentId)
|
||||
return { success: true, data: summary }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClassRankingAction(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getClassRanking>>>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const ranking = await getClassRanking(classId, subjectId, examId)
|
||||
return { success: true, data: ranking }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGradeRecordByIdAction(
|
||||
id: string
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getGradeRecordById>>>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const record = await getGradeRecordById(id)
|
||||
return { success: true, data: record }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出成绩单(返回 base64 编码的 Excel)
|
||||
*/
|
||||
export async function exportGradesAction(params: {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
examId?: string
|
||||
reportType?: "detail" | "class"
|
||||
}): Promise<ActionState<{ buffer: string; filename: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
let buffer: Buffer
|
||||
let filename: string
|
||||
|
||||
if (params.reportType === "class") {
|
||||
buffer = await exportClassGradeReportToExcel({
|
||||
classId: params.classId,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
|
||||
} else {
|
||||
buffer = await exportGradeRecordsToExcel({
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
examId: params.examId,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
filename = `成绩单_${formatDateForFile()}.xlsx`
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
buffer: buffer.toString("base64"),
|
||||
filename,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "导出失败" }
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
293
src/modules/grades/data-access-analytics.ts
Normal file
293
src/modules/grades/data-access-analytics.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
gradeRecords,
|
||||
subjects,
|
||||
} from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
ClassComparisonItem,
|
||||
GradeDistributionBucket,
|
||||
GradeDistributionResult,
|
||||
GradeTrendPoint,
|
||||
GradeTrendResult,
|
||||
SubjectComparisonItem,
|
||||
} from "./types"
|
||||
|
||||
const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const normalize = (score: number, fullScore: number): number => {
|
||||
if (fullScore <= 0) return 0
|
||||
return Math.round((score / fullScore) * 10000) / 100
|
||||
}
|
||||
|
||||
const buildScopeClassFilter = (scope: DataScope) => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
||||
}
|
||||
if (scope.type === "grade_managed") return sql`1=0`
|
||||
if (scope.type === "class_members") return null
|
||||
if (scope.type === "children") {
|
||||
return scope.childrenIds.length > 0
|
||||
? inArray(gradeRecords.studentId, scope.childrenIds)
|
||||
: sql`1=0`
|
||||
}
|
||||
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
|
||||
return sql`1=0`
|
||||
}
|
||||
|
||||
export interface GradeTrendParams {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
studentId?: string
|
||||
semester?: "1" | "2"
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}
|
||||
|
||||
export async function getGradeTrend(
|
||||
params: GradeTrendParams
|
||||
): Promise<GradeTrendResult | null> {
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const points: GradeTrendPoint[] = rows.map((r) => {
|
||||
const score = toNumber(r.record.score)
|
||||
const fullScore = toNumber(r.record.fullScore)
|
||||
return {
|
||||
date: r.record.createdAt.toISOString(),
|
||||
title: r.record.title,
|
||||
score,
|
||||
fullScore,
|
||||
normalizedScore: normalize(score, fullScore),
|
||||
type: r.record.type,
|
||||
}
|
||||
})
|
||||
|
||||
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
|
||||
const className = rows[0].className ?? "Class"
|
||||
const subjectName = rows[0].subjectName ?? "All Subjects"
|
||||
const studentLabel = params.studentId
|
||||
? `Student ${params.studentId.slice(-4)}`
|
||||
: "Class Average"
|
||||
|
||||
return {
|
||||
label: params.subjectId
|
||||
? `${className} · ${subjectName} · ${studentLabel}`
|
||||
: `${className} · ${studentLabel}`,
|
||||
points,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClassComparisonParams {
|
||||
gradeId: string
|
||||
subjectId: string
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
}
|
||||
|
||||
export async function getClassComparison(
|
||||
params: ClassComparisonParams
|
||||
): Promise<ClassComparisonItem[]> {
|
||||
const classRows = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.gradeId, params.gradeId))
|
||||
|
||||
if (classRows.length === 0) return []
|
||||
|
||||
const scope = params.scope
|
||||
const allowedClassIds =
|
||||
scope.type === "class_taught"
|
||||
? classRows.filter((c) => scope.classIds.includes(c.id)).map((c) => c.id)
|
||||
: classRows.map((c) => c.id)
|
||||
|
||||
if (allowedClassIds.length === 0) return []
|
||||
|
||||
const result: ClassComparisonItem[] = []
|
||||
|
||||
for (const cls of classRows) {
|
||||
if (!allowedClassIds.includes(cls.id)) continue
|
||||
|
||||
const conditions = [
|
||||
eq(gradeRecords.classId, cls.id),
|
||||
eq(gradeRecords.subjectId, params.subjectId),
|
||||
]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
studentId: gradeRecords.studentId,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
if (rows.length === 0) {
|
||||
result.push({
|
||||
classId: cls.id, className: cls.name, averageScore: 0, medianScore: 0,
|
||||
passRate: 0, excellentRate: 0, count: 0, studentCount: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = rows.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
|
||||
const sorted = [...normalized].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
|
||||
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
|
||||
|
||||
result.push({
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((normalized.filter((s) => s >= 60).length / normalized.length) * 10000) / 100,
|
||||
excellentRate: Math.round((normalized.filter((s) => s >= 85).length / normalized.length) * 10000) / 100,
|
||||
count: normalized.length,
|
||||
studentCount: uniqueStudents,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface SubjectComparisonParams {
|
||||
classId: string
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
}
|
||||
|
||||
export async function getSubjectComparison(
|
||||
params: SubjectComparisonParams
|
||||
): Promise<SubjectComparisonItem[]> {
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: gradeRecords.subjectId,
|
||||
subjectName: subjects.name,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(and(...conditions))
|
||||
|
||||
const bySubject = new Map<string, { name: string; scores: number[] }>()
|
||||
|
||||
for (const r of rows) {
|
||||
const sid = r.subjectId
|
||||
if (!sid) continue
|
||||
const entry = bySubject.get(sid) ?? { name: r.subjectName ?? "Unknown", scores: [] }
|
||||
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
|
||||
bySubject.set(sid, entry)
|
||||
}
|
||||
|
||||
const result: SubjectComparisonItem[] = []
|
||||
for (const [subjectId, entry] of bySubject.entries()) {
|
||||
if (entry.scores.length === 0) continue
|
||||
const sorted = [...entry.scores].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
|
||||
|
||||
result.push({
|
||||
subjectId,
|
||||
subjectName: entry.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((entry.scores.filter((s) => s >= 60).length / entry.scores.length) * 10000) / 100,
|
||||
excellentRate: Math.round((entry.scores.filter((s) => s >= 85).length / entry.scores.length) * 10000) / 100,
|
||||
count: entry.scores.length,
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.averageScore - a.averageScore)
|
||||
}
|
||||
|
||||
export interface GradeDistributionParams {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
currentUserId?: string
|
||||
}
|
||||
|
||||
export async function getGradeDistribution(
|
||||
params: GradeDistributionParams
|
||||
): Promise<GradeDistributionResult> {
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
const buckets: GradeDistributionBucket[] = [
|
||||
{ label: "90-100", min: 90, max: 100, count: 0 },
|
||||
{ label: "80-89", min: 80, max: 89, count: 0 },
|
||||
{ label: "70-79", min: 70, max: 79, count: 0 },
|
||||
{ label: "60-69", min: 60, max: 69, count: 0 },
|
||||
{ label: "<60", min: 0, max: 59, count: 0 },
|
||||
]
|
||||
|
||||
for (const r of rows) {
|
||||
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
|
||||
const rounded = Math.round(normalized)
|
||||
if (rounded >= 90) buckets[0].count++
|
||||
else if (rounded >= 80) buckets[1].count++
|
||||
else if (rounded >= 70) buckets[2].count++
|
||||
else if (rounded >= 60) buckets[3].count++
|
||||
else buckets[4].count++
|
||||
}
|
||||
|
||||
return { buckets, totalCount: rows.length }
|
||||
}
|
||||
121
src/modules/grades/data-access-ranking.ts
Normal file
121
src/modules/grades/data-access-ranking.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classEnrollments,
|
||||
gradeRecords,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import type {
|
||||
RankingTrendPoint,
|
||||
RankingTrendResult,
|
||||
} from "./types"
|
||||
|
||||
const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const normalize = (score: number, fullScore: number): number => {
|
||||
if (fullScore <= 0) return 0
|
||||
return Math.round((score / fullScore) * 10000) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a student's ranking trend across assessments within their class.
|
||||
* Each point represents one assessment (grouped by title), with the
|
||||
* student's normalized score, rank, and total participants.
|
||||
*/
|
||||
export async function getRankingTrend(
|
||||
studentId: string,
|
||||
subjectId?: string,
|
||||
semester?: "1" | "2"
|
||||
): Promise<RankingTrendResult | null> {
|
||||
const [student] = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, studentId))
|
||||
.limit(1)
|
||||
if (!student) return null
|
||||
|
||||
const [enrollment] = await db
|
||||
.select({ classId: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
.where(
|
||||
and(
|
||||
eq(classEnrollments.studentId, studentId),
|
||||
eq(classEnrollments.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!enrollment) {
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
points: [],
|
||||
}
|
||||
}
|
||||
|
||||
const conditions = [eq(gradeRecords.classId, enrollment.classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (semester) conditions.push(eq(gradeRecords.semester, semester))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
title: gradeRecords.title,
|
||||
createdAt: gradeRecords.createdAt,
|
||||
studentId: gradeRecords.studentId,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
const byTitle = new Map<
|
||||
string,
|
||||
{
|
||||
date: Date
|
||||
entries: Array<{ studentId: string; normalized: number }>
|
||||
}
|
||||
>()
|
||||
|
||||
for (const r of rows) {
|
||||
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
|
||||
entry.entries.push({
|
||||
studentId: r.studentId,
|
||||
normalized: normalize(toNumber(r.score), toNumber(r.fullScore)),
|
||||
})
|
||||
byTitle.set(r.title, entry)
|
||||
}
|
||||
|
||||
const points: RankingTrendPoint[] = []
|
||||
for (const [title, entry] of byTitle.entries()) {
|
||||
if (entry.entries.length === 0) continue
|
||||
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
|
||||
const rank = sorted.findIndex((e) => e.studentId === studentId) + 1
|
||||
if (rank <= 0) continue
|
||||
const studentEntry = sorted.find((e) => e.studentId === studentId)
|
||||
if (!studentEntry) continue
|
||||
|
||||
points.push({
|
||||
title,
|
||||
date: entry.date.toISOString(),
|
||||
score: studentEntry.normalized,
|
||||
rank,
|
||||
totalStudents: sorted.length,
|
||||
})
|
||||
}
|
||||
|
||||
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
points,
|
||||
}
|
||||
}
|
||||
419
src/modules/grades/data-access.ts
Normal file
419
src/modules/grades/data-access.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
gradeRecords,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
ClassGradeStats,
|
||||
ClassRankingItem,
|
||||
GradeQueryParams,
|
||||
GradeRecord,
|
||||
GradeRecordListItem,
|
||||
GradeStats,
|
||||
StudentGradeSummary,
|
||||
} from "./types"
|
||||
import type {
|
||||
BatchCreateGradeRecordInput,
|
||||
CreateGradeRecordInput,
|
||||
UpdateGradeRecordInput,
|
||||
} from "./schema"
|
||||
|
||||
const toNumber = (v: unknown): number => {
|
||||
const n = typeof v === "number" ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
||||
id: r.id,
|
||||
studentId: r.studentId,
|
||||
classId: r.classId,
|
||||
subjectId: r.subjectId,
|
||||
examId: r.examId ?? null,
|
||||
academicYearId: r.academicYearId ?? null,
|
||||
title: r.title,
|
||||
score: String(r.score),
|
||||
fullScore: String(r.fullScore),
|
||||
type: r.type,
|
||||
semester: r.semester,
|
||||
recordedBy: r.recordedBy,
|
||||
remark: r.remark ?? null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})
|
||||
|
||||
const buildScopeClassFilter = (scope: DataScope) => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
||||
}
|
||||
if (scope.type === "grade_managed") {
|
||||
return sql`1=0`
|
||||
}
|
||||
if (scope.type === "class_members") {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "children") {
|
||||
return scope.childrenIds.length > 0 ? inArray(gradeRecords.studentId, scope.childrenIds) : sql`1=0`
|
||||
}
|
||||
if (scope.type === "owned") {
|
||||
return eq(gradeRecords.studentId, scope.userId)
|
||||
}
|
||||
return sql`1=0`
|
||||
}
|
||||
|
||||
export async function getGradeRecords(
|
||||
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
|
||||
): Promise<GradeRecordListItem[]> {
|
||||
const conditions = []
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
if (params.classId) conditions.push(eq(gradeRecords.classId, params.classId))
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
if (params.type) conditions.push(eq(gradeRecords.type, params.type))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
studentName: users.name,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(users, eq(users.id, gradeRecords.studentId))
|
||||
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(gradeRecords.createdAt))
|
||||
|
||||
const recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
|
||||
const recorderMap = new Map<string, string>()
|
||||
if (recorderIds.length > 0) {
|
||||
const recorders = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, recorderIds))
|
||||
for (const r of recorders) {
|
||||
recorderMap.set(r.id, r.name ?? "Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName: r.studentName ?? "Unknown",
|
||||
classId: r.record.classId,
|
||||
className: r.className ?? "Unknown",
|
||||
subjectId: r.record.subjectId,
|
||||
subjectName: r.subjectName ?? "Unknown",
|
||||
examId: r.record.examId ?? null,
|
||||
title: r.record.title,
|
||||
score: toNumber(r.record.score),
|
||||
fullScore: toNumber(r.record.fullScore),
|
||||
type: r.record.type,
|
||||
semester: r.record.semester,
|
||||
recordedBy: r.record.recordedBy,
|
||||
recorderName: recorderMap.get(r.record.recordedBy) ?? "Unknown",
|
||||
remark: r.record.remark ?? null,
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getGradeRecordById(id: string): Promise<GradeRecord | null> {
|
||||
const [row] = await db.select().from(gradeRecords).where(eq(gradeRecords.id, id)).limit(1)
|
||||
return row ? serializeRecord(row) : null
|
||||
}
|
||||
|
||||
export async function createGradeRecord(
|
||||
data: CreateGradeRecordInput,
|
||||
recordedBy: string
|
||||
): Promise<string> {
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const id = createId()
|
||||
await db.insert(gradeRecords).values({
|
||||
id,
|
||||
studentId: data.studentId,
|
||||
classId: data.classId,
|
||||
subjectId: data.subjectId,
|
||||
examId: data.examId ?? null,
|
||||
academicYearId: data.academicYearId ?? null,
|
||||
title: data.title,
|
||||
score: String(data.score),
|
||||
fullScore: String(data.fullScore ?? 100),
|
||||
type: data.type ?? "exam",
|
||||
semester: data.semester ?? "1",
|
||||
recordedBy,
|
||||
remark: data.remark ?? null,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function batchCreateGradeRecords(
|
||||
data: BatchCreateGradeRecordInput,
|
||||
recordedBy: string
|
||||
): Promise<number> {
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const rows = data.records.map((r) => ({
|
||||
id: createId(),
|
||||
studentId: r.studentId,
|
||||
classId: data.classId,
|
||||
subjectId: data.subjectId,
|
||||
examId: data.examId ?? null,
|
||||
academicYearId: data.academicYearId ?? null,
|
||||
title: data.title,
|
||||
score: String(r.score),
|
||||
fullScore: String(data.fullScore ?? 100),
|
||||
type: data.type ?? "exam",
|
||||
semester: data.semester ?? "1",
|
||||
recordedBy,
|
||||
remark: r.remark ?? null,
|
||||
}))
|
||||
|
||||
if (rows.length === 0) return 0
|
||||
await db.insert(gradeRecords).values(rows)
|
||||
return rows.length
|
||||
}
|
||||
|
||||
export async function updateGradeRecord(
|
||||
id: string,
|
||||
data: UpdateGradeRecordInput
|
||||
): Promise<void> {
|
||||
const update: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (data.title !== undefined) update.title = data.title
|
||||
if (data.score !== undefined) update.score = String(data.score)
|
||||
if (data.fullScore !== undefined) update.fullScore = String(data.fullScore)
|
||||
if (data.type !== undefined) update.type = data.type
|
||||
if (data.semester !== undefined) update.semester = data.semester
|
||||
if (data.remark !== undefined) update.remark = data.remark
|
||||
if (data.examId !== undefined) update.examId = data.examId
|
||||
|
||||
await db.update(gradeRecords).set(update).where(eq(gradeRecords.id, id))
|
||||
}
|
||||
|
||||
export async function deleteGradeRecord(id: string): Promise<void> {
|
||||
await db.delete(gradeRecords).where(eq(gradeRecords.id, id))
|
||||
}
|
||||
|
||||
export async function getClassGradeStats(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<GradeStats | null> {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (examId) conditions.push(eq(gradeRecords.examId, examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const scores = rows.map((r) => toNumber(r.score))
|
||||
const fullScores = rows.map((r) => toNumber(r.fullScore))
|
||||
const countN = scores.length
|
||||
const sum = scores.reduce((a, b) => a + b, 0)
|
||||
const average = sum / countN
|
||||
const sorted = [...scores].sort((a, b) => a - b)
|
||||
const mid = Math.floor(countN / 2)
|
||||
const median = countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const max = sorted[countN - 1]
|
||||
const min = sorted[0]
|
||||
const variance = scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
|
||||
const stdDev = Math.sqrt(variance)
|
||||
|
||||
let passCount = 0
|
||||
let excellentCount = 0
|
||||
for (let i = 0; i < countN; i++) {
|
||||
const ratio = scores[i] / fullScores[i]
|
||||
if (ratio >= 0.6) passCount++
|
||||
if (ratio >= 0.85) excellentCount++
|
||||
}
|
||||
|
||||
return {
|
||||
average: Math.round(average * 100) / 100,
|
||||
median: Math.round(median * 100) / 100,
|
||||
max,
|
||||
min,
|
||||
stdDev: Math.round(stdDev * 100) / 100,
|
||||
passRate: Math.round((passCount / countN) * 10000) / 100,
|
||||
excellentRate: Math.round((excellentCount / countN) * 10000) / 100,
|
||||
count: countN,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudentGradeSummary(
|
||||
studentId: string
|
||||
): Promise<StudentGradeSummary | null> {
|
||||
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
|
||||
if (!student) return null
|
||||
|
||||
const records = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(eq(gradeRecords.studentId, studentId))
|
||||
.orderBy(desc(gradeRecords.createdAt))
|
||||
|
||||
if (records.length === 0) {
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
records: [],
|
||||
averageScore: 0,
|
||||
rank: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const listItems: GradeRecordListItem[] = records.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
classId: r.record.classId,
|
||||
className: r.className ?? "Unknown",
|
||||
subjectId: r.record.subjectId,
|
||||
subjectName: r.subjectName ?? "Unknown",
|
||||
examId: r.record.examId ?? null,
|
||||
title: r.record.title,
|
||||
score: toNumber(r.record.score),
|
||||
fullScore: toNumber(r.record.fullScore),
|
||||
type: r.record.type,
|
||||
semester: r.record.semester,
|
||||
recordedBy: r.record.recordedBy,
|
||||
recorderName: "Unknown",
|
||||
remark: r.record.remark ?? null,
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
const avg = listItems.reduce((a, b) => a + b.score, 0) / listItems.length
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
records: listItems,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
rank: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClassRanking(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ClassRankingItem[]> {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (examId) conditions.push(eq(gradeRecords.examId, examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: gradeRecords.studentId,
|
||||
studentName: users.name,
|
||||
avgScore: sql<number>`AVG(${gradeRecords.score})`,
|
||||
recordCount: count(gradeRecords.id),
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(users, eq(users.id, gradeRecords.studentId))
|
||||
.where(and(...conditions))
|
||||
.groupBy(gradeRecords.studentId, users.name)
|
||||
.orderBy(desc(sql`AVG(${gradeRecords.score})`))
|
||||
|
||||
return rows.map((r, idx) => ({
|
||||
studentId: r.studentId,
|
||||
studentName: r.studentName ?? "Unknown",
|
||||
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
|
||||
rank: idx + 1,
|
||||
recordCount: toNumber(r.recordCount),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getClassStudentsForEntry(classId: string): Promise<
|
||||
Array<{ id: string; name: string; email: string }>
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(users.name))
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name ?? "Unknown",
|
||||
email: r.email,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getClassGradeStatsWithMeta(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ClassGradeStats | null> {
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
if (!classRow) return null
|
||||
|
||||
const stats = await getClassGradeStats(classId, subjectId, examId)
|
||||
if (!stats) {
|
||||
return {
|
||||
classId,
|
||||
className: classRow.name,
|
||||
stats: {
|
||||
average: 0,
|
||||
median: 0,
|
||||
max: 0,
|
||||
min: 0,
|
||||
stdDev: 0,
|
||||
passRate: 0,
|
||||
excellentRate: 0,
|
||||
count: 0,
|
||||
},
|
||||
studentCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const [studentCountRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
|
||||
return {
|
||||
classId,
|
||||
className: classRow.name,
|
||||
stats,
|
||||
studentCount: toNumber(studentCountRow?.c ?? 0),
|
||||
}
|
||||
}
|
||||
214
src/modules/grades/export.ts
Normal file
214
src/modules/grades/export.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
gradeRecords,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { exportToExcel } from "@/shared/lib/excel"
|
||||
|
||||
import { getClassGradeStats, getGradeRecords } from "./data-access"
|
||||
import type { GradeRecordType } from "./types"
|
||||
|
||||
const TYPE_LABELS: Record<GradeRecordType, string> = {
|
||||
exam: "考试",
|
||||
quiz: "测验",
|
||||
homework: "作业",
|
||||
other: "其他",
|
||||
}
|
||||
|
||||
const formatDateForFile = (d = new Date()) => {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出成绩单
|
||||
* Sheet 1: 成绩明细
|
||||
* Sheet 2: 统计汇总
|
||||
*/
|
||||
export async function exportGradeRecordsToExcel(params: {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
examId?: string
|
||||
scope: DataScope
|
||||
}): Promise<Buffer> {
|
||||
const records = await getGradeRecords({
|
||||
scope: params.scope,
|
||||
classId: params.classId,
|
||||
subjectId: params.subjectId,
|
||||
examId: params.examId,
|
||||
})
|
||||
|
||||
const detailRows = records.map((r) => ({
|
||||
studentName: r.studentName,
|
||||
className: r.className,
|
||||
subjectName: r.subjectName,
|
||||
title: r.title,
|
||||
score: r.score,
|
||||
fullScore: r.fullScore,
|
||||
type: TYPE_LABELS[r.type] ?? r.type,
|
||||
semester: r.semester === "1" ? "第一学期" : "第二学期",
|
||||
recorderName: r.recorderName,
|
||||
remark: r.remark ?? "",
|
||||
createdAt: r.createdAt.split("T")[0],
|
||||
}))
|
||||
|
||||
const stats = await getClassGradeStats(params.classId, params.subjectId, params.examId)
|
||||
const statsRows = stats
|
||||
? [
|
||||
{ metric: "均分", value: stats.average },
|
||||
{ metric: "中位数", value: stats.median },
|
||||
{ metric: "最高分", value: stats.max },
|
||||
{ metric: "最低分", value: stats.min },
|
||||
{ metric: "标准差", value: stats.stdDev },
|
||||
{ metric: "及格率(%)", value: stats.passRate },
|
||||
{ metric: "优秀率(%)", value: stats.excellentRate },
|
||||
{ metric: "参考人数", value: stats.count },
|
||||
]
|
||||
: [{ metric: "无数据", value: "" }]
|
||||
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: "成绩明细",
|
||||
columns: [
|
||||
{ header: "学生姓名", key: "studentName", width: 16 },
|
||||
{ header: "班级", key: "className", width: 20 },
|
||||
{ header: "科目", key: "subjectName", width: 14 },
|
||||
{ header: "标题", key: "title", width: 24 },
|
||||
{ header: "分数", key: "score", width: 10 },
|
||||
{ header: "满分", key: "fullScore", width: 10 },
|
||||
{ header: "类型", key: "type", width: 10 },
|
||||
{ header: "学期", key: "semester", width: 12 },
|
||||
{ header: "录入人", key: "recorderName", width: 14 },
|
||||
{ header: "备注", key: "remark", width: 24 },
|
||||
{ header: "录入日期", key: "createdAt", width: 14 },
|
||||
],
|
||||
rows: detailRows,
|
||||
},
|
||||
{
|
||||
name: "统计汇总",
|
||||
columns: [
|
||||
{ header: "指标", key: "metric", width: 20 },
|
||||
{ header: "数值", key: "value", width: 16 },
|
||||
],
|
||||
rows: statsRows,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出班级成绩总表(多科目横向对比)
|
||||
*/
|
||||
export async function exportClassGradeReportToExcel(params: {
|
||||
classId: string
|
||||
scope: DataScope
|
||||
}): Promise<Buffer> {
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, params.classId))
|
||||
.limit(1)
|
||||
const className = classRow?.name ?? "Unknown"
|
||||
|
||||
// Get all subjects that have grade records for this class
|
||||
const subjectRows = await db
|
||||
.select({
|
||||
id: subjects.id,
|
||||
name: subjects.name,
|
||||
})
|
||||
.from(subjects)
|
||||
.innerJoin(gradeRecords, eq(gradeRecords.subjectId, subjects.id))
|
||||
.where(eq(gradeRecords.classId, params.classId))
|
||||
.groupBy(subjects.id, subjects.name)
|
||||
|
||||
// Get all students with grades in this class
|
||||
const studentRows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
})
|
||||
.from(users)
|
||||
.innerJoin(gradeRecords, eq(gradeRecords.studentId, users.id))
|
||||
.where(eq(gradeRecords.classId, params.classId))
|
||||
.groupBy(users.id, users.name)
|
||||
.orderBy(users.name)
|
||||
|
||||
// Build a map: studentId -> subjectId -> average score
|
||||
const allRecords = await getGradeRecords({
|
||||
scope: params.scope,
|
||||
classId: params.classId,
|
||||
})
|
||||
const scoreMap = new Map<string, Map<string, number[]>>()
|
||||
for (const r of allRecords) {
|
||||
if (!scoreMap.has(r.studentId)) scoreMap.set(r.studentId, new Map())
|
||||
const subjMap = scoreMap.get(r.studentId)!
|
||||
const arr = subjMap.get(r.subjectId) ?? []
|
||||
arr.push(r.score)
|
||||
subjMap.set(r.subjectId, arr)
|
||||
}
|
||||
|
||||
const avg = (arr: number[]) =>
|
||||
arr.length > 0 ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : 0
|
||||
|
||||
const columns = [
|
||||
{ header: "学生姓名", key: "studentName", width: 16 },
|
||||
...subjectRows.map((s) => ({
|
||||
header: s.name,
|
||||
key: s.id,
|
||||
width: 14,
|
||||
})),
|
||||
{ header: "总分", key: "_total", width: 12 },
|
||||
{ header: "平均分", key: "_average", width: 12 },
|
||||
{ header: "排名", key: "_rank", width: 10 },
|
||||
]
|
||||
|
||||
const rowsData = studentRows.map((student) => {
|
||||
const subjMap = scoreMap.get(student.id) ?? new Map<string, number[]>()
|
||||
const row: Record<string, unknown> = {
|
||||
studentName: student.name ?? "Unknown",
|
||||
}
|
||||
let total = 0
|
||||
let count = 0
|
||||
for (const subj of subjectRows) {
|
||||
const scores = subjMap.get(subj.id) ?? []
|
||||
const score = avg(scores)
|
||||
row[subj.id] = scores.length > 0 ? score : "-"
|
||||
if (scores.length > 0) {
|
||||
total += score
|
||||
count++
|
||||
}
|
||||
}
|
||||
row["_total"] = total
|
||||
row["_average"] = count > 0 ? Math.round((total / count) * 100) / 100 : 0
|
||||
return { row, total }
|
||||
})
|
||||
|
||||
// Rank by total descending
|
||||
rowsData.sort((a, b) => b.total - a.total)
|
||||
const rows = rowsData.map((d, idx) => ({
|
||||
...d.row,
|
||||
_rank: idx + 1,
|
||||
}))
|
||||
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: `${className}_成绩总表`,
|
||||
columns,
|
||||
rows,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export { formatDateForFile }
|
||||
52
src/modules/grades/schema.ts
Normal file
52
src/modules/grades/schema.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const GradeRecordTypeEnum = z.enum(["exam", "quiz", "homework", "other"])
|
||||
export const GradeRecordSemesterEnum = z.enum(["1", "2"])
|
||||
|
||||
export const CreateGradeRecordSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
examId: z.string().optional(),
|
||||
academicYearId: z.string().optional(),
|
||||
title: z.string().min(1).max(255),
|
||||
score: z.coerce.number().min(0),
|
||||
fullScore: z.coerce.number().min(1).optional(),
|
||||
type: GradeRecordTypeEnum.optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
export type CreateGradeRecordInput = z.infer<typeof CreateGradeRecordSchema>
|
||||
|
||||
export const BatchGradeRecordItemSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
score: z.coerce.number().min(0),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchCreateGradeRecordSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
examId: z.string().optional(),
|
||||
academicYearId: z.string().optional(),
|
||||
title: z.string().min(1).max(255),
|
||||
fullScore: z.coerce.number().min(1).optional(),
|
||||
type: GradeRecordTypeEnum.optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
records: z.array(BatchGradeRecordItemSchema),
|
||||
})
|
||||
|
||||
export type BatchCreateGradeRecordInput = z.infer<typeof BatchCreateGradeRecordSchema>
|
||||
|
||||
export const UpdateGradeRecordSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
score: z.coerce.number().min(0).optional(),
|
||||
fullScore: z.coerce.number().min(1).optional(),
|
||||
type: GradeRecordTypeEnum.optional(),
|
||||
semester: GradeRecordSemesterEnum.optional(),
|
||||
remark: z.string().optional(),
|
||||
examId: z.string().optional(),
|
||||
})
|
||||
|
||||
export type UpdateGradeRecordInput = z.infer<typeof UpdateGradeRecordSchema>
|
||||
176
src/modules/grades/types.ts
Normal file
176
src/modules/grades/types.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
export type GradeRecordType = "exam" | "quiz" | "homework" | "other"
|
||||
export type GradeRecordSemester = "1" | "2"
|
||||
|
||||
export interface GradeRecord {
|
||||
id: string
|
||||
studentId: string
|
||||
classId: string
|
||||
subjectId: string
|
||||
examId: string | null
|
||||
academicYearId: string | null
|
||||
title: string
|
||||
score: string
|
||||
fullScore: string
|
||||
type: GradeRecordType
|
||||
semester: GradeRecordSemester
|
||||
recordedBy: string
|
||||
remark: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GradeRecordListItem {
|
||||
id: string
|
||||
studentId: string
|
||||
studentName: string
|
||||
classId: string
|
||||
className: string
|
||||
subjectId: string
|
||||
subjectName: string
|
||||
examId: string | null
|
||||
title: string
|
||||
score: number
|
||||
fullScore: number
|
||||
type: GradeRecordType
|
||||
semester: GradeRecordSemester
|
||||
recordedBy: string
|
||||
recorderName: string
|
||||
remark: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface GradeStats {
|
||||
average: number
|
||||
median: number
|
||||
max: number
|
||||
min: number
|
||||
stdDev: number
|
||||
passRate: number
|
||||
excellentRate: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ClassGradeStats {
|
||||
classId: string
|
||||
className: string
|
||||
stats: GradeStats
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
export interface StudentGradeSummary {
|
||||
studentId: string
|
||||
studentName: string
|
||||
records: GradeRecordListItem[]
|
||||
averageScore: number
|
||||
rank: number
|
||||
}
|
||||
|
||||
export interface ClassRankingItem {
|
||||
studentId: string
|
||||
studentName: string
|
||||
averageScore: number
|
||||
rank: number
|
||||
recordCount: number
|
||||
}
|
||||
|
||||
export interface GradeQueryParams {
|
||||
classId?: string
|
||||
subjectId?: string
|
||||
studentId?: string
|
||||
type?: GradeRecordType
|
||||
semester?: GradeRecordSemester
|
||||
examId?: string
|
||||
}
|
||||
|
||||
// --- Analytics Types ---
|
||||
|
||||
export interface GradeTrendPoint {
|
||||
/** ISO date string of the grade record creation */
|
||||
date: string
|
||||
/** Title of the exam/assessment */
|
||||
title: string
|
||||
/** Raw score */
|
||||
score: number
|
||||
/** Full score for this record */
|
||||
fullScore: number
|
||||
/** Score normalized to 0-100 scale for cross-record comparison */
|
||||
normalizedScore: number
|
||||
/** Type of grade record */
|
||||
type: GradeRecordType
|
||||
}
|
||||
|
||||
export interface GradeTrendResult {
|
||||
/** Label for the trend series (e.g. class name + subject) */
|
||||
label: string
|
||||
/** Sorted ascending by date */
|
||||
points: GradeTrendPoint[]
|
||||
/** Average of normalized scores */
|
||||
averageScore: number
|
||||
}
|
||||
|
||||
export interface ClassComparisonItem {
|
||||
classId: string
|
||||
className: string
|
||||
/** Average score (normalized to 0-100) */
|
||||
averageScore: number
|
||||
/** Median score */
|
||||
medianScore: number
|
||||
/** Pass rate (score/fullScore >= 0.6) */
|
||||
passRate: number
|
||||
/** Excellent rate (score/fullScore >= 0.85) */
|
||||
excellentRate: number
|
||||
/** Number of grade records */
|
||||
count: number
|
||||
/** Number of unique students */
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
export interface SubjectComparisonItem {
|
||||
subjectId: string
|
||||
subjectName: string
|
||||
/** Average normalized score (0-100) */
|
||||
averageScore: number
|
||||
/** Median normalized score */
|
||||
medianScore: number
|
||||
/** Pass rate */
|
||||
passRate: number
|
||||
/** Excellent rate */
|
||||
excellentRate: number
|
||||
/** Number of records */
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface GradeDistributionBucket {
|
||||
/** Bucket label e.g. "90-100" */
|
||||
label: string
|
||||
/** Lower bound (inclusive) */
|
||||
min: number
|
||||
/** Upper bound (inclusive) */
|
||||
max: number
|
||||
/** Number of students in this bucket */
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface GradeDistributionResult {
|
||||
buckets: GradeDistributionBucket[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export interface RankingTrendPoint {
|
||||
/** Title of the exam/assessment */
|
||||
title: string
|
||||
/** ISO date string */
|
||||
date: string
|
||||
/** Student's average score (normalized) */
|
||||
score: number
|
||||
/** Rank in class for this assessment (1-based) */
|
||||
rank: number
|
||||
/** Total students participating */
|
||||
totalStudents: number
|
||||
}
|
||||
|
||||
export interface RankingTrendResult {
|
||||
studentId: string
|
||||
studentName: string
|
||||
points: RankingTrendPoint[]
|
||||
}
|
||||
Reference in New Issue
Block a user