feat(attendance,elective): 考勤与选修课模块审计重构 — P0 修复 + i18n + Error Boundary
审计报告:docs/architecture/audit/attendance-elective-audit-report.md P0 修复: - attendance: getAttendanceStats 统计失真(仅基于前 20 条记录)改为 SQL 聚合查询 - attendance: getClassStudentsForAttendance 跨模块直查 classEnrollments 改为调用 classes data-access - attendance: update/delete Action 新增资源归属校验(assertRecordOwnership) - elective: update/delete/openSelection/closeSelection/runLottery Action 新增资源归属校验(assertCourseOwnership) i18n 接入: - 新增 attendance/elective 命名空间(zh-CN + en) - attendance-stats-cards 接入 useTranslations - elective-course-list/form 接入 useTranslations 类型安全(P1): - elective-course-form: 移除 as 断言,改用类型守卫 isSelectionMode - elective-course-list: 移除 null as never 类型逃逸,改用泛型 Error Boundary: - 新增 admin/teacher attendance error.tsx - 新增 admin/student elective error.tsx 架构图同步: - 004: 修正 attendance/elective/parent 章节的导出函数、文件清单、已知问题 - 005: 修正 actions 的 usedBy(标记无调用方的死代码)、新增 issues 字段、更新依赖矩阵
This commit is contained in:
24
src/app/(dashboard)/admin/attendance/error.tsx
Normal file
24
src/app/(dashboard)/admin/attendance/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AdminAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/admin/elective/error.tsx
Normal file
24
src/app/(dashboard)/admin/elective/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AdminElectiveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("elective")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/student/elective/error.tsx
Normal file
24
src/app/(dashboard)/student/elective/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentElectiveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("elective")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/teacher/attendance/error.tsx
Normal file
24
src/app/(dashboard)/teacher/attendance/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 { verifyTeacherOwnsClass } from "@/modules/classes/data-access"
|
||||
|
||||
import {
|
||||
RecordAttendanceSchema,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
batchCreateAttendanceRecords,
|
||||
updateAttendanceRecord,
|
||||
deleteAttendanceRecord,
|
||||
getAttendanceRecordClassId,
|
||||
getAttendanceRecords,
|
||||
getClassAttendanceForDate,
|
||||
getAttendanceRules,
|
||||
@@ -27,6 +29,27 @@ import {
|
||||
} from "./data-access-stats"
|
||||
import type { AttendanceQueryParams, AttendanceListItem } from "./types"
|
||||
|
||||
/**
|
||||
* 校验当前用户对考勤记录的归属权限。
|
||||
* - admin(scope=all):直接放行
|
||||
* - teacher(scope=class_taught):必须为记录所属班级的任课教师
|
||||
* - 其他 scope:拒绝(学生/家长不应调用写操作)
|
||||
*/
|
||||
async function assertRecordOwnership(
|
||||
recordId: string,
|
||||
ctx: Awaited<ReturnType<typeof requirePermission>>
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
if (ctx.dataScope.type === "all") return { ok: true }
|
||||
if (ctx.dataScope.type === "class_taught") {
|
||||
const classId = await getAttendanceRecordClassId(recordId)
|
||||
if (!classId) return { ok: false, message: "Attendance record not found" }
|
||||
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||
if (!owns) return { ok: false, message: "You do not own this attendance record" }
|
||||
return { ok: true }
|
||||
}
|
||||
return { ok: false, message: "Insufficient permissions" }
|
||||
}
|
||||
|
||||
export async function recordAttendanceAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -101,7 +124,12 @@ export async function updateAttendanceAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
|
||||
const ownership = await assertRecordOwnership(id, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
const parsed = UpdateAttendanceSchema.safeParse({
|
||||
status: formData.get("status") || undefined,
|
||||
@@ -131,7 +159,13 @@ export async function deleteAttendanceAction(
|
||||
id: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
|
||||
const ownership = await assertRecordOwnership(id, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
await deleteAttendanceRecord(id)
|
||||
revalidatePath("/teacher/attendance")
|
||||
return { success: true, message: "Attendance record deleted" }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
@@ -15,44 +16,45 @@ interface AttendanceStatsCardsProps {
|
||||
}
|
||||
|
||||
export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
|
||||
const t = useTranslations("attendance")
|
||||
const cards = [
|
||||
{
|
||||
title: "总记录数",
|
||||
title: t("stats.totalRecords"),
|
||||
value: stats.totalRecords,
|
||||
icon: FileText,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: "出勤",
|
||||
title: t("stats.present"),
|
||||
value: stats.presentCount,
|
||||
icon: CheckCircle2,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
title: "缺勤",
|
||||
title: t("stats.absent"),
|
||||
value: stats.absentCount,
|
||||
icon: XCircle,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
{
|
||||
title: "迟到",
|
||||
title: t("stats.late"),
|
||||
value: stats.lateCount,
|
||||
icon: Clock,
|
||||
color: "text-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
},
|
||||
{
|
||||
title: "早退",
|
||||
title: t("stats.earlyLeave"),
|
||||
value: stats.earlyLeaveCount,
|
||||
icon: LogOut,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-500/10",
|
||||
},
|
||||
{
|
||||
title: "出勤率",
|
||||
title: t("stats.attendanceRate"),
|
||||
value: `${stats.attendanceRate}%`,
|
||||
icon: Users,
|
||||
color: "text-primary",
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
attendanceRecords,
|
||||
attendanceRules,
|
||||
classes,
|
||||
classEnrollments,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
@@ -205,17 +205,24 @@ export async function deleteAttendanceRecord(id: string): Promise<void> {
|
||||
await db.delete(attendanceRecords).where(eq(attendanceRecords.id, id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考勤记录的 classId(用于资源归属校验)。
|
||||
* 返回 null 表示记录不存在。
|
||||
*/
|
||||
export async function getAttendanceRecordClassId(id: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ classId: attendanceRecords.classId })
|
||||
.from(attendanceRecords)
|
||||
.where(eq(attendanceRecords.id, id))
|
||||
.limit(1)
|
||||
return row?.classId ?? null
|
||||
}
|
||||
|
||||
export async function getClassStudentsForAttendance(
|
||||
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 }))
|
||||
// 通过 classes data-access 获取班级学生,避免跨模块直查 classEnrollments 表
|
||||
return getClassActiveStudentsWithInfo(classId)
|
||||
}
|
||||
|
||||
export async function getAttendanceRules(classId?: string): Promise<AttendanceRule[]> {
|
||||
@@ -288,15 +295,38 @@ export async function getAttendanceStats(params: {
|
||||
classId?: string
|
||||
date?: string
|
||||
}): Promise<AttendanceOverviewStats> {
|
||||
// 简化实现:基于已有查询统计
|
||||
const records = await getAttendanceRecords(params)
|
||||
const items = records.items
|
||||
const total = items.length
|
||||
const present = items.filter((r) => r.status === "present").length
|
||||
const absent = items.filter((r) => r.status === "absent").length
|
||||
const late = items.filter((r) => r.status === "late").length
|
||||
const earlyLeave = items.filter((r) => r.status === "early_leave").length
|
||||
const excused = items.filter((r) => r.status === "excused").length
|
||||
// 直接使用 SQL 聚合查询,避免分页截断导致统计失真(P0 修复)
|
||||
const conditions: SQL[] = []
|
||||
|
||||
const scopeFilter = buildScopeFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
|
||||
}
|
||||
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
||||
if (params.date) conditions.push(eq(attendanceRecords.date, new Date(params.date)))
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
totalRecords: count(),
|
||||
presentCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
|
||||
absentCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
|
||||
lateCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
earlyLeaveCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
|
||||
excusedCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
|
||||
const total = Number(row?.totalRecords ?? 0)
|
||||
const present = Number(row?.presentCount ?? 0)
|
||||
const absent = Number(row?.absentCount ?? 0)
|
||||
const late = Number(row?.lateCount ?? 0)
|
||||
const earlyLeave = Number(row?.earlyLeaveCount ?? 0)
|
||||
const excused = Number(row?.excusedCount ?? 0)
|
||||
|
||||
return {
|
||||
totalRecords: total,
|
||||
presentCount: present,
|
||||
|
||||
@@ -221,6 +221,22 @@ export const getActiveStudentIdsByClassId = async (classId: string): Promise<str
|
||||
return rows.map((r) => r.studentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级所有活跃学生基本信息(id/name/email),按姓名升序。
|
||||
* 供跨模块调用使用(如考勤点名),避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getClassActiveStudentsWithInfo = async (
|
||||
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 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教师在一个班级所教的科目 ID 列表。
|
||||
* 参数顺序为 (classId, teacherId),供跨模块调用使用。
|
||||
|
||||
@@ -54,6 +54,24 @@ const requireCourseId = (formData: FormData): string => {
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户对课程的管理权限(资源归属校验)。
|
||||
* - admin(scope=all):直接放行
|
||||
* - teacher(其他 scope):必须为课程的授课教师
|
||||
*/
|
||||
async function assertCourseOwnership(
|
||||
courseId: string,
|
||||
ctx: Awaited<ReturnType<typeof requirePermission>>
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
if (ctx.dataScope.type === "all") return { ok: true }
|
||||
const course = await getElectiveCourseById(courseId)
|
||||
if (!course) return { ok: false, message: "Course not found" }
|
||||
if (course.teacherId !== ctx.userId) {
|
||||
return { ok: false, message: "You do not own this course" }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function createElectiveCourseAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -97,9 +115,12 @@ export async function updateElectiveCourseAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const existing = await getElectiveCourseById(id)
|
||||
if (!existing) return { success: false, message: "Course not found" }
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
|
||||
const ownership = await assertCourseOwnership(id, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
const parsed = UpdateElectiveCourseSchema.safeParse({
|
||||
name: formData.get("name") || undefined,
|
||||
@@ -138,11 +159,13 @@ export async function deleteElectiveCourseAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const id = requireCourseId(formData)
|
||||
|
||||
const existing = await getElectiveCourseById(id)
|
||||
if (!existing) return { success: false, message: "Course not found" }
|
||||
const ownership = await assertCourseOwnership(id, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
await deleteElectiveCourse(id)
|
||||
revalidateElectivePaths()
|
||||
@@ -157,8 +180,14 @@ export async function openSelectionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const courseId = requireCourseId(formData)
|
||||
|
||||
const ownership = await assertCourseOwnership(courseId, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
await openSelection(courseId)
|
||||
revalidateElectivePaths(courseId)
|
||||
return { success: true, message: "Selection opened" }
|
||||
@@ -172,8 +201,14 @@ export async function closeSelectionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const courseId = requireCourseId(formData)
|
||||
|
||||
const ownership = await assertCourseOwnership(courseId, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
await closeSelection(courseId)
|
||||
revalidateElectivePaths(courseId)
|
||||
return { success: true, message: "Selection closed" }
|
||||
@@ -187,7 +222,7 @@ export async function runLotteryAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ enrolled: number; waitlist: number }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const parsed = RunLotterySchema.safeParse({
|
||||
courseId: formData.get("courseId"),
|
||||
})
|
||||
@@ -198,6 +233,12 @@ export async function runLotteryAction(
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const ownership = await assertCourseOwnership(parsed.data.courseId, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
const result = await runLottery(parsed.data.courseId)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
|
||||
import type { ElectiveCourseWithDetails } from "../types"
|
||||
import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types"
|
||||
|
||||
type Mode = "create" | "edit"
|
||||
|
||||
@@ -27,6 +28,9 @@ interface Option {
|
||||
name: string
|
||||
}
|
||||
|
||||
const isSelectionMode = (v: string): v is ElectiveSelectionMode =>
|
||||
v === "fcfs" || v === "lottery"
|
||||
|
||||
export function ElectiveCourseForm({
|
||||
mode,
|
||||
course,
|
||||
@@ -43,12 +47,13 @@ export function ElectiveCourseForm({
|
||||
backHref?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const [subjectId, setSubjectId] = useState(course?.subjectId ?? "")
|
||||
const [gradeId, setGradeId] = useState(course?.gradeId ?? "")
|
||||
const [teacherId, setTeacherId] = useState(course?.teacherId ?? "")
|
||||
const [selectionMode, setSelectionMode] = useState(course?.selectionMode ?? "fcfs")
|
||||
const [selectionMode, setSelectionMode] = useState<ElectiveSelectionMode>(course?.selectionMode ?? "fcfs")
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
@@ -200,14 +205,19 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Selection Mode</Label>
|
||||
<Select value={selectionMode} onValueChange={(v) => setSelectionMode(v as "fcfs" | "lottery")}>
|
||||
<Label>{t("fields.selectionMode")}</Label>
|
||||
<Select
|
||||
value={selectionMode}
|
||||
onValueChange={(v) => {
|
||||
if (isSelectionMode(v)) setSelectionMode(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fcfs">First Come First Served</SelectItem>
|
||||
<SelectItem value="lottery">Lottery</SelectItem>
|
||||
<SelectItem value="fcfs">{t("selectionMode.fcfs")}</SelectItem>
|
||||
<SelectItem value="lottery">{t("selectionMode.lottery")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="selectionMode" value={selectionMode} />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -10,6 +11,7 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import {
|
||||
@@ -37,13 +39,14 @@ export function ElectiveCourseList({
|
||||
canManage?: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const { hasPermission } = usePermission()
|
||||
const manageResolved = canManage ?? hasPermission(Permissions.ELECTIVE_MANAGE)
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const runAction = async (
|
||||
action: (prevState: never, formData: FormData) => Promise<{ success: boolean; message?: string }>,
|
||||
const runAction = async <T,>(
|
||||
action: (prevState: ActionState<T> | null, formData: FormData) => Promise<ActionState<T>>,
|
||||
courseId: string,
|
||||
successMsg: string
|
||||
) => {
|
||||
@@ -51,7 +54,7 @@ export function ElectiveCourseList({
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("courseId", courseId)
|
||||
const res = await action(null as never, formData)
|
||||
const res = await action(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? successMsg)
|
||||
router.refresh()
|
||||
@@ -82,13 +85,13 @@ export function ElectiveCourseList({
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{courses.length} course{courses.length === 1 ? "" : "s"}
|
||||
{courses.length} {t("title.adminList")}
|
||||
</p>
|
||||
{manageResolved && createHref ? (
|
||||
<Button asChild>
|
||||
<a href={createHref}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Course
|
||||
{t("actions.create")}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -96,8 +99,8 @@ export function ElectiveCourseList({
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No elective courses"
|
||||
description="There are no elective courses available."
|
||||
title={t("list.empty")}
|
||||
description={t("list.emptyDescription")}
|
||||
icon={Plus}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
|
||||
95
src/shared/i18n/messages/en/attendance.json
Normal file
95
src/shared/i18n/messages/en/attendance.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"title": {
|
||||
"adminOverview": "Attendance Overview",
|
||||
"teacherRecords": "Attendance Records",
|
||||
"teacherStats": "Attendance Statistics",
|
||||
"sheet": "Record Attendance",
|
||||
"student": "My Attendance",
|
||||
"parent": "Children Attendance",
|
||||
"rules": "Attendance Rules"
|
||||
},
|
||||
"description": {
|
||||
"adminOverview": "View attendance records for all classes school-wide.",
|
||||
"teacherRecords": "Manage student attendance records.",
|
||||
"teacherStats": "View class attendance statistics and analysis.",
|
||||
"student": "View your attendance summary and records.",
|
||||
"parent": "View your children's attendance summary and warnings."
|
||||
},
|
||||
"status": {
|
||||
"present": "Present",
|
||||
"absent": "Absent",
|
||||
"late": "Late",
|
||||
"early_leave": "Early Leave",
|
||||
"excused": "Excused"
|
||||
},
|
||||
"stats": {
|
||||
"totalRecords": "Total Records",
|
||||
"present": "Present",
|
||||
"absent": "Absent",
|
||||
"late": "Late",
|
||||
"earlyLeave": "Early Leave",
|
||||
"excused": "Excused",
|
||||
"attendanceRate": "Attendance Rate",
|
||||
"lateRate": "Late Rate",
|
||||
"recentRecords": "Recent Records"
|
||||
},
|
||||
"filters": {
|
||||
"class": "Class",
|
||||
"status": "Status",
|
||||
"date": "Date",
|
||||
"allClasses": "All Classes",
|
||||
"allStatuses": "All Statuses"
|
||||
},
|
||||
"actions": {
|
||||
"record": "Record Attendance",
|
||||
"stats": "Statistics",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"markAllPresent": "Mark All Present"
|
||||
},
|
||||
"list": {
|
||||
"empty": "No attendance records",
|
||||
"emptyDescription": "No attendance records have been generated yet.",
|
||||
"emptyTeacherDescription": "Start recording attendance for your class.",
|
||||
"columns": {
|
||||
"student": "Student",
|
||||
"class": "Class",
|
||||
"date": "Date",
|
||||
"status": "Status",
|
||||
"remark": "Remark",
|
||||
"recorder": "Recorder",
|
||||
"createdAt": "Created At"
|
||||
}
|
||||
},
|
||||
"sheet": {
|
||||
"selectClass": "Select Class",
|
||||
"selectDate": "Select Date",
|
||||
"noStudents": "No students in this class",
|
||||
"confirmDelete": "Are you sure you want to delete this attendance record?",
|
||||
"saved": "Attendance saved",
|
||||
"updated": "Attendance updated",
|
||||
"deleted": "Attendance record deleted"
|
||||
},
|
||||
"rules": {
|
||||
"lateThreshold": "Late Threshold (minutes)",
|
||||
"earlyLeaveThreshold": "Early Leave Threshold (minutes)",
|
||||
"enableAutoMark": "Enable Auto Mark",
|
||||
"saved": "Attendance rules saved"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Attendance record not found",
|
||||
"noOwnership": "You do not own this attendance record",
|
||||
"invalidForm": "Invalid form data",
|
||||
"unexpected": "Unexpected error"
|
||||
},
|
||||
"parent": {
|
||||
"warningTitle": "Attendance Warnings",
|
||||
"rateCardTitle": "Attendance Rate Summary",
|
||||
"calendarTitle": "Attendance Calendar",
|
||||
"noWarnings": "No attendance warnings",
|
||||
"absentWarning": "{count} absence(s)",
|
||||
"lateWarning": "{count} late arrival(s)",
|
||||
"lowRateWarning": "Attendance rate {rate}% below threshold"
|
||||
}
|
||||
}
|
||||
96
src/shared/i18n/messages/en/elective.json
Normal file
96
src/shared/i18n/messages/en/elective.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"title": {
|
||||
"adminList": "Elective Courses",
|
||||
"create": "Create Course",
|
||||
"edit": "Edit Course",
|
||||
"teacher": "My Elective Courses",
|
||||
"student": "Course Selection"
|
||||
},
|
||||
"description": {
|
||||
"adminList": "Manage elective courses, open/close selection and lottery.",
|
||||
"teacher": "View and manage the elective courses you teach.",
|
||||
"student": "Browse available courses and make selections."
|
||||
},
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"selectionMode": {
|
||||
"fcfs": "First Come First Served",
|
||||
"lottery": "Lottery"
|
||||
},
|
||||
"selectionStatus": {
|
||||
"selected": "Selected",
|
||||
"enrolled": "Enrolled",
|
||||
"waitlist": "Waitlist",
|
||||
"dropped": "Dropped",
|
||||
"rejected": "Rejected"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Course Name",
|
||||
"subject": "Subject",
|
||||
"teacher": "Teacher",
|
||||
"grade": "Grade",
|
||||
"description": "Description",
|
||||
"capacity": "Capacity",
|
||||
"enrolled": "Enrolled",
|
||||
"classroom": "Classroom",
|
||||
"schedule": "Schedule",
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"selectionStart": "Selection Start",
|
||||
"selectionEnd": "Selection End",
|
||||
"selectionMode": "Selection Mode",
|
||||
"credit": "Credit"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Create Course",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"openSelection": "Open Selection",
|
||||
"closeSelection": "Close Selection",
|
||||
"runLottery": "Run Lottery",
|
||||
"select": "Select",
|
||||
"drop": "Drop",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
},
|
||||
"list": {
|
||||
"empty": "No elective courses",
|
||||
"emptyStudent": "No available courses",
|
||||
"emptyDescription": "No elective courses have been created yet."
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "Create Elective Course",
|
||||
"editTitle": "Edit Elective Course",
|
||||
"namePlaceholder": "Enter course name",
|
||||
"descriptionPlaceholder": "Enter course description"
|
||||
},
|
||||
"student": {
|
||||
"mySelections": "My Selections",
|
||||
"availableCourses": "Available Courses",
|
||||
"selected": "Selected",
|
||||
"enrolled": "Enrolled",
|
||||
"waitlist": "Waitlist",
|
||||
"capacityFull": "Capacity full",
|
||||
"selectSuccess": "Course selected successfully",
|
||||
"dropSuccess": "Course dropped successfully",
|
||||
"confirmDrop": "Are you sure you want to drop this course?"
|
||||
},
|
||||
"lottery": {
|
||||
"result": "Lottery result: {enrolled} enrolled, {waitlist} waitlisted",
|
||||
"running": "Running lottery..."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Course not found",
|
||||
"noOwnership": "You do not own this course",
|
||||
"capacityFull": "Course capacity is full",
|
||||
"alreadySelected": "You have already selected this course",
|
||||
"selectionClosed": "Selection is closed",
|
||||
"gradeMismatch": "Your grade does not match the course requirement",
|
||||
"invalidForm": "Invalid form data",
|
||||
"unexpected": "Unexpected error"
|
||||
}
|
||||
}
|
||||
95
src/shared/i18n/messages/zh-CN/attendance.json
Normal file
95
src/shared/i18n/messages/zh-CN/attendance.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"title": {
|
||||
"adminOverview": "考勤总览",
|
||||
"teacherRecords": "考勤记录",
|
||||
"teacherStats": "考勤统计",
|
||||
"sheet": "录入考勤",
|
||||
"student": "我的考勤",
|
||||
"parent": "子女考勤",
|
||||
"rules": "考勤规则"
|
||||
},
|
||||
"description": {
|
||||
"adminOverview": "查看全校所有班级的考勤记录。",
|
||||
"teacherRecords": "管理学生考勤记录。",
|
||||
"teacherStats": "查看班级考勤统计分析。",
|
||||
"student": "查看个人考勤汇总与记录。",
|
||||
"parent": "查看子女考勤汇总与异常预警。"
|
||||
},
|
||||
"status": {
|
||||
"present": "出勤",
|
||||
"absent": "缺勤",
|
||||
"late": "迟到",
|
||||
"early_leave": "早退",
|
||||
"excused": "请假"
|
||||
},
|
||||
"stats": {
|
||||
"totalRecords": "总记录数",
|
||||
"present": "出勤",
|
||||
"absent": "缺勤",
|
||||
"late": "迟到",
|
||||
"earlyLeave": "早退",
|
||||
"excused": "请假",
|
||||
"attendanceRate": "出勤率",
|
||||
"lateRate": "迟到率",
|
||||
"recentRecords": "最近记录"
|
||||
},
|
||||
"filters": {
|
||||
"class": "班级",
|
||||
"status": "状态",
|
||||
"date": "日期",
|
||||
"allClasses": "全部班级",
|
||||
"allStatuses": "全部状态"
|
||||
},
|
||||
"actions": {
|
||||
"record": "录入考勤",
|
||||
"stats": "统计分析",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"markAllPresent": "全部标记到场"
|
||||
},
|
||||
"list": {
|
||||
"empty": "暂无考勤记录",
|
||||
"emptyDescription": "系统中尚未产生任何考勤记录。",
|
||||
"emptyTeacherDescription": "开始为您的班级录入考勤。",
|
||||
"columns": {
|
||||
"student": "学生",
|
||||
"class": "班级",
|
||||
"date": "日期",
|
||||
"status": "状态",
|
||||
"remark": "备注",
|
||||
"recorder": "记录人",
|
||||
"createdAt": "创建时间"
|
||||
}
|
||||
},
|
||||
"sheet": {
|
||||
"selectClass": "选择班级",
|
||||
"selectDate": "选择日期",
|
||||
"noStudents": "该班级暂无学生",
|
||||
"confirmDelete": "确定删除此条考勤记录吗?",
|
||||
"saved": "考勤已保存",
|
||||
"updated": "考勤已更新",
|
||||
"deleted": "考勤记录已删除"
|
||||
},
|
||||
"rules": {
|
||||
"lateThreshold": "迟到阈值(分钟)",
|
||||
"earlyLeaveThreshold": "早退阈值(分钟)",
|
||||
"enableAutoMark": "启用自动标记",
|
||||
"saved": "考勤规则已保存"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "考勤记录不存在",
|
||||
"noOwnership": "您无权操作此考勤记录",
|
||||
"invalidForm": "表单数据无效",
|
||||
"unexpected": "发生未知错误"
|
||||
},
|
||||
"parent": {
|
||||
"warningTitle": "考勤异常预警",
|
||||
"rateCardTitle": "出勤率汇总",
|
||||
"calendarTitle": "考勤月历",
|
||||
"noWarnings": "暂无考勤异常",
|
||||
"absentWarning": "{count} 次缺勤",
|
||||
"lateWarning": "{count} 次迟到",
|
||||
"lowRateWarning": "出勤率 {rate}% 低于阈值"
|
||||
}
|
||||
}
|
||||
96
src/shared/i18n/messages/zh-CN/elective.json
Normal file
96
src/shared/i18n/messages/zh-CN/elective.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"title": {
|
||||
"adminList": "选修课程",
|
||||
"create": "创建课程",
|
||||
"edit": "编辑课程",
|
||||
"teacher": "我的选修课",
|
||||
"student": "选课中心"
|
||||
},
|
||||
"description": {
|
||||
"adminList": "管理选修课程、开放/关闭选课与抽签。",
|
||||
"teacher": "查看和管理您教授的选修课程。",
|
||||
"student": "浏览可选课程并进行选课。"
|
||||
},
|
||||
"status": {
|
||||
"draft": "草稿",
|
||||
"open": "开放选课",
|
||||
"closed": "已关闭",
|
||||
"cancelled": "已取消"
|
||||
},
|
||||
"selectionMode": {
|
||||
"fcfs": "先到先得",
|
||||
"lottery": "抽签"
|
||||
},
|
||||
"selectionStatus": {
|
||||
"selected": "已选",
|
||||
"enrolled": "已录取",
|
||||
"waitlist": "候补",
|
||||
"dropped": "已退选",
|
||||
"rejected": "已拒绝"
|
||||
},
|
||||
"fields": {
|
||||
"name": "课程名称",
|
||||
"subject": "科目",
|
||||
"teacher": "授课教师",
|
||||
"grade": "年级",
|
||||
"description": "课程描述",
|
||||
"capacity": "容量",
|
||||
"enrolled": "已录取",
|
||||
"classroom": "教室",
|
||||
"schedule": "上课时间",
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"selectionStart": "选课开始",
|
||||
"selectionEnd": "选课结束",
|
||||
"selectionMode": "选课模式",
|
||||
"credit": "学分"
|
||||
},
|
||||
"actions": {
|
||||
"create": "创建课程",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"openSelection": "开放选课",
|
||||
"closeSelection": "关闭选课",
|
||||
"runLottery": "执行抽签",
|
||||
"select": "选课",
|
||||
"drop": "退课",
|
||||
"cancel": "取消",
|
||||
"save": "保存"
|
||||
},
|
||||
"list": {
|
||||
"empty": "暂无选修课程",
|
||||
"emptyStudent": "暂无可选课程",
|
||||
"emptyDescription": "系统中尚未创建任何选修课程。"
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "创建选修课程",
|
||||
"editTitle": "编辑选修课程",
|
||||
"namePlaceholder": "请输入课程名称",
|
||||
"descriptionPlaceholder": "请输入课程描述"
|
||||
},
|
||||
"student": {
|
||||
"mySelections": "我的选课",
|
||||
"availableCourses": "可选课程",
|
||||
"selected": "已选",
|
||||
"enrolled": "已录取",
|
||||
"waitlist": "候补",
|
||||
"capacityFull": "名额已满",
|
||||
"selectSuccess": "选课成功",
|
||||
"dropSuccess": "退课成功",
|
||||
"confirmDrop": "确定退选此课程吗?"
|
||||
},
|
||||
"lottery": {
|
||||
"result": "抽签结果:录取 {enrolled} 人,候补 {waitlist} 人",
|
||||
"running": "抽签进行中..."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "课程不存在",
|
||||
"noOwnership": "您无权操作此课程",
|
||||
"capacityFull": "课程名额已满",
|
||||
"alreadySelected": "您已选过此课程",
|
||||
"selectionClosed": "选课已关闭",
|
||||
"gradeMismatch": "您的年级不符合课程要求",
|
||||
"invalidForm": "表单数据无效",
|
||||
"unexpected": "发生未知错误"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user