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:
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user