refactor(school,classes): 完成 school/grade/class 审计全量改进项
P0-1/P0-2: 删除 grade-management 死模块,年级 CRUD 统一由 school 模块负责 P0-3: classes/actions.ts 从 974 行拆分为 6 个职责文件 + barrel re-export P0-5: 13 个页面 i18n 全量接入(grades/departments/academic-year/classes/insights) P1-1: 角色硬编码改为 hasAdminScope/hasTeacherScope/hasStudentScope 基于 dataScope.type P1-3: 新增 SchoolErrorBoundary + SchoolListSkeleton/SchoolCardSkeleton,4 个页面包裹 Error Boundary P1-4: classes/types.ts 跨领域类型添加归属决策注释 P1-5: schools-view.tsx 拆分为组合模式(SchoolFormDialog + SchoolDeleteDialog + SchoolListToolbar) P1-6: 新增 getSchoolsForUser/getGradesForUser 权限感知查询函数 P2-1: 抽取 useSchoolData hook,对话框状态管理与 UI 分离 同步更新架构图文档 004/005
This commit is contained in:
151
src/modules/classes/actions-admin.ts
Normal file
151
src/modules/classes/actions-admin.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
"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 {
|
||||
createAdminClass,
|
||||
deleteAdminClass,
|
||||
setClassSubjectTeachers,
|
||||
updateAdminClass,
|
||||
} from "./data-access"
|
||||
import {
|
||||
CreateAdminClassSchema,
|
||||
UpdateAdminClassSchema,
|
||||
DeleteAdminClassSchema,
|
||||
} from "./schema"
|
||||
import { parseSubjectTeachers } from "./actions-shared"
|
||||
|
||||
export async function createAdminClassAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const parsed = CreateAdminClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name, grade and teacher are required" }
|
||||
}
|
||||
|
||||
const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
try {
|
||||
const id = await createAdminClass({
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade,
|
||||
gradeId: gradeId ?? null,
|
||||
teacherId,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/admin/school/classes")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminClassAction(
|
||||
classId: string,
|
||||
prevState: ActionState | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const parsed = UpdateAdminClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||
const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null)
|
||||
|
||||
try {
|
||||
await updateAdminClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
teacherId: teacherId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
|
||||
if (subjectTeachers) {
|
||||
await setClassSubjectTeachers({
|
||||
classId: validatedClassId,
|
||||
assignments: subjectTeachers,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/admin/school/classes")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAdminClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
const parsed = DeleteAdminClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminClass(parsed.data.classId)
|
||||
revalidatePath("/admin/school/classes")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
182
src/modules/classes/actions-grade.ts
Normal file
182
src/modules/classes/actions-grade.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
"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 {
|
||||
createAdminClass,
|
||||
deleteAdminClass,
|
||||
getClassGradeId,
|
||||
setClassSubjectTeachers,
|
||||
updateAdminClass,
|
||||
} from "./data-access"
|
||||
import { isGradeManager } from "@/modules/school/data-access"
|
||||
import {
|
||||
CreateGradeClassSchema,
|
||||
UpdateGradeClassSchema,
|
||||
DeleteGradeClassSchema,
|
||||
} from "./schema"
|
||||
import { parseSubjectTeachers } from "./actions-shared"
|
||||
|
||||
export async function createGradeClassAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const parsed = CreateGradeClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
grade: formData.get("grade"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name, grade and teacher are required" }
|
||||
}
|
||||
|
||||
const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data
|
||||
|
||||
// Verify access
|
||||
const isManager = await isGradeManager(gradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to create classes for this grade" }
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createAdminClass({
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade: grade ?? "", // Should be passed from UI based on selected grade
|
||||
gradeId,
|
||||
teacherId,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGradeClassAction(
|
||||
classId: string,
|
||||
prevState: ActionState | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const parsed = UpdateGradeClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||
const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null)
|
||||
|
||||
// Verify access: Check if the class belongs to a managed grade
|
||||
const classGradeId = await getClassGradeId(validatedClassId)
|
||||
if (!classGradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
}
|
||||
|
||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to update this class" }
|
||||
}
|
||||
|
||||
// If changing gradeId, verify target grade too
|
||||
if (typeof gradeId === "string" && gradeId !== classGradeId) {
|
||||
const isTargetManager = await isGradeManager(gradeId, ctx.userId)
|
||||
if (!isTargetManager) {
|
||||
return { success: false, message: "You do not have permission to move class to this grade" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAdminClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
teacherId: teacherId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
|
||||
if (subjectTeachers) {
|
||||
await setClassSubjectTeachers({
|
||||
classId: validatedClassId,
|
||||
assignments: subjectTeachers,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
const parsed = DeleteGradeClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId } = parsed.data
|
||||
|
||||
// Verify access
|
||||
const classGradeId = await getClassGradeId(validatedClassId)
|
||||
if (!classGradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
}
|
||||
|
||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to delete this class" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminClass(validatedClassId)
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
377
src/modules/classes/actions-invitations.ts
Normal file
377
src/modules/classes/actions-invitations.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
"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 {
|
||||
enrollStudentByEmail,
|
||||
enrollStudentByInvitationCode,
|
||||
enrollTeacherByInvitationCode,
|
||||
ensureClassInvitationCode,
|
||||
regenerateClassInvitationCode,
|
||||
setStudentEnrollmentStatus,
|
||||
} from "./data-access"
|
||||
import {
|
||||
EnrollStudentByEmailSchema,
|
||||
} from "./schema"
|
||||
import { hasTeacherScope, hasStudentScope } from "./actions-shared"
|
||||
|
||||
export async function enrollStudentByEmailAction(
|
||||
classId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const parsed = EnrollStudentByEmailSchema.safeParse({
|
||||
classId,
|
||||
email: formData.get("email"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Please select a class and provide student email" }
|
||||
}
|
||||
|
||||
try {
|
||||
await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
return { success: true, message: "Student added successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to add student" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinClassByInvitationCodeAction(
|
||||
prevState: ActionState<{ classId: string }> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ classId: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const code = formData.get("code")
|
||||
if (typeof code !== "string" || code.trim().length === 0) {
|
||||
return { success: false, message: "Invitation code is required" }
|
||||
}
|
||||
|
||||
// v3:rate limit 防爆破(10 次/5 分钟,按 userId)
|
||||
const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit")
|
||||
const rlKey = rateLimitKey("class-join", ctx.userId)
|
||||
const rlResult = rateLimit({
|
||||
key: rlKey,
|
||||
limit: 10,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
})
|
||||
if (!rlResult.success) {
|
||||
return { success: false, message: "Too many attempts, please try again later" }
|
||||
}
|
||||
|
||||
// P1-1: 使用 dataScope 替代 ctx.roles.includes("teacher") 硬编码
|
||||
const isTeacher = hasTeacherScope(ctx)
|
||||
const subjectValue = formData.get("subject")
|
||||
const subject = isTeacher && typeof subjectValue === "string" ? subjectValue.trim() : null
|
||||
|
||||
if (isTeacher && (!subject || subject.length === 0)) {
|
||||
return { success: false, message: "Subject is required" }
|
||||
}
|
||||
|
||||
try {
|
||||
const classId = isTeacher
|
||||
? await enrollTeacherByInvitationCode(ctx.userId, code, subject)
|
||||
: await enrollStudentByInvitationCode(ctx.userId, code)
|
||||
|
||||
// 成功后重置 rate limit
|
||||
const { resetRateLimit } = await import("@/shared/lib/rate-limit")
|
||||
resetRateLimit(rlKey)
|
||||
|
||||
// 审计日志
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.consume",
|
||||
module: "classes",
|
||||
targetId: classId,
|
||||
targetType: "class",
|
||||
detail: {
|
||||
code: String(code).trim().toUpperCase(),
|
||||
userId: ctx.userId,
|
||||
// P1-1: 使用 dataScope 推断角色名,避免硬编码
|
||||
role: hasStudentScope(ctx) ? "student" : "teacher",
|
||||
subject,
|
||||
},
|
||||
})
|
||||
|
||||
if (hasStudentScope(ctx)) {
|
||||
revalidatePath("/student/learning/courses")
|
||||
revalidatePath("/student/schedule")
|
||||
} else {
|
||||
revalidatePath("/teacher/classes/my")
|
||||
}
|
||||
revalidatePath("/profile")
|
||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
||||
} catch (error) {
|
||||
// 审计日志:加入失败
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.consume_failed",
|
||||
module: "classes",
|
||||
targetId: String(code).trim().toUpperCase(),
|
||||
targetType: "invitation_code",
|
||||
detail: {
|
||||
userId: ctx.userId,
|
||||
reason: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
status: "failure",
|
||||
})
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const code = await ensureClassInvitationCode(classId)
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||
return { success: true, message: "Invitation code ready", data: { code } }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const code = await regenerateClassInvitationCode(classId)
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||
return { success: true, message: "Invitation code updated", data: { code } }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:生成自定义邀请码(支持有效期/次数/备注)。
|
||||
* 对标 Google Classroom / 钉钉教育:管理员/教师可为班级生成带有效期与次数限制的邀请码。
|
||||
*
|
||||
* 权限:CLASS_ENROLL(沿用现有权限点,避免过度拆分)
|
||||
* 审计:调用 logAudit 记录生成操作
|
||||
*/
|
||||
export async function createClassInvitationCodeAction(
|
||||
prevState: ActionState<{ code: string; id: string }> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ code: string; id: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const classId = String(formData.get("classId") ?? "").trim()
|
||||
if (!classId) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const expiresInHoursRaw = formData.get("expiresInHours")
|
||||
const maxUsesRaw = formData.get("maxUses")
|
||||
const note = String(formData.get("note") ?? "").trim() || null
|
||||
|
||||
const expiresInHours =
|
||||
expiresInHoursRaw && String(expiresInHoursRaw).trim() !== ""
|
||||
? Number(expiresInHoursRaw)
|
||||
: null
|
||||
const maxUses =
|
||||
maxUsesRaw && String(maxUsesRaw).trim() !== ""
|
||||
? Number(maxUsesRaw)
|
||||
: null
|
||||
|
||||
if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) {
|
||||
return { success: false, message: "Invalid expiresInHours" }
|
||||
}
|
||||
if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) {
|
||||
return { success: false, message: "Invalid maxUses" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { createInvitationCode } = await import("./data-access-invitations")
|
||||
const record = await createInvitationCode(classId, ctx.userId, {
|
||||
expiresInHours,
|
||||
maxUses,
|
||||
note,
|
||||
})
|
||||
|
||||
// 审计日志
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.create",
|
||||
module: "classes",
|
||||
targetId: classId,
|
||||
targetType: "class",
|
||||
detail: {
|
||||
codeId: record.id,
|
||||
code: record.code,
|
||||
expiresInHours,
|
||||
maxUses,
|
||||
note,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
|
||||
revalidatePath(`/admin/school/classes`)
|
||||
return {
|
||||
success: true,
|
||||
message: "Invitation code generated",
|
||||
data: { code: record.code, id: record.id },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to generate code",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:撤销邀请码(软删除)。
|
||||
*/
|
||||
export async function revokeClassInvitationCodeAction(
|
||||
prevState: ActionState<null> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const codeId = String(formData.get("codeId") ?? "").trim()
|
||||
if (!codeId) {
|
||||
return { success: false, message: "Missing code id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { revokeInvitationCode } = await import("./data-access-invitations")
|
||||
await revokeInvitationCode(codeId, ctx.userId)
|
||||
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
await logAudit({
|
||||
action: "class.invitation.revoke",
|
||||
module: "classes",
|
||||
targetId: codeId,
|
||||
targetType: "invitation_code",
|
||||
detail: { revokedBy: ctx.userId },
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/admin/school/classes`)
|
||||
return { success: true, message: "Invitation code revoked" }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to revoke code",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 新增:列出班级所有邀请码(管理端列表用)。
|
||||
*/
|
||||
export async function listClassInvitationCodesAction(
|
||||
classId: string
|
||||
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { listClassInvitationCodes } = await import("./data-access-invitations")
|
||||
const codes = await listClassInvitationCodes(classId)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
codes: codes.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
status: c.status,
|
||||
maxUses: c.maxUses,
|
||||
usedCount: c.usedCount,
|
||||
expiresAt: c.expiresAt?.toISOString() ?? null,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
revokedAt: c.revokedAt?.toISOString() ?? null,
|
||||
note: c.note,
|
||||
})),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to list codes",
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStudentEnrollmentStatusAction(
|
||||
classId: string,
|
||||
studentId: string,
|
||||
status: "active" | "inactive"
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
if (!classId?.trim() || !studentId?.trim()) {
|
||||
return { success: false, message: "Missing enrollment info" }
|
||||
}
|
||||
|
||||
try {
|
||||
await setStudentEnrollmentStatus(classId, studentId, status)
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
return { success: true, message: "Student updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update student" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
124
src/modules/classes/actions-schedule.ts
Normal file
124
src/modules/classes/actions-schedule.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
"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 {
|
||||
createClassScheduleItem,
|
||||
updateClassScheduleItem,
|
||||
deleteClassScheduleItem,
|
||||
} from "@/modules/scheduling/data-access-class-schedule"
|
||||
import {
|
||||
CreateClassScheduleItemSchema,
|
||||
UpdateClassScheduleItemSchema,
|
||||
DeleteClassScheduleItemSchema,
|
||||
} from "./schema"
|
||||
import { toWeekday } from "./actions-shared"
|
||||
|
||||
export async function createClassScheduleItemAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
const parsed = CreateClassScheduleItemSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
weekday: formData.get("weekday"),
|
||||
course: formData.get("course"),
|
||||
startTime: formData.get("startTime"),
|
||||
endTime: formData.get("endTime"),
|
||||
location: formData.get("location"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid schedule item data" }
|
||||
}
|
||||
|
||||
const { classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||
|
||||
try {
|
||||
const id = await createClassScheduleItem({
|
||||
classId,
|
||||
weekday: toWeekday(weekday),
|
||||
startTime,
|
||||
endTime,
|
||||
course,
|
||||
location: location ?? null,
|
||||
})
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateClassScheduleItemAction(
|
||||
scheduleId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
const parsed = UpdateClassScheduleItemSchema.safeParse({
|
||||
scheduleId,
|
||||
classId: formData.get("classId"),
|
||||
weekday: formData.get("weekday") || undefined,
|
||||
course: formData.get("course"),
|
||||
startTime: formData.get("startTime"),
|
||||
endTime: formData.get("endTime"),
|
||||
location: formData.get("location"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing or invalid schedule id" }
|
||||
}
|
||||
|
||||
const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||
|
||||
try {
|
||||
await updateClassScheduleItem(validatedScheduleId, {
|
||||
classId: classId ?? undefined,
|
||||
weekday: typeof weekday === "number" ? toWeekday(weekday) : undefined,
|
||||
startTime: startTime ?? undefined,
|
||||
endTime: endTime ?? undefined,
|
||||
course: course ?? undefined,
|
||||
location: location ?? undefined,
|
||||
})
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteClassScheduleItemAction(scheduleId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing schedule id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteClassScheduleItem(parsed.data.scheduleId)
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
59
src/modules/classes/actions-shared.ts
Normal file
59
src/modules/classes/actions-shared.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { AuthContext } from "@/shared/types/permissions"
|
||||
import type { ClassSubject } from "./types"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
|
||||
const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS
|
||||
|
||||
export const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_STRINGS.includes(v)
|
||||
|
||||
export const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||
n >= 1 && n <= 7 && Number.isInteger(n)
|
||||
|
||||
export const toWeekday = (n: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
if (!isWeekday(n)) throw new Error("Invalid weekday")
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* P1-1: 替代 `ctx.roles.includes("admin")` 硬编码。
|
||||
* 通过 dataScope.type === "all" 判断是否拥有 admin 级别的数据访问范围。
|
||||
*/
|
||||
export const hasAdminScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "all"
|
||||
|
||||
/**
|
||||
* P1-1: 替代 `ctx.roles.includes("teacher")` 硬编码。
|
||||
* 通过 dataScope.type === "class_taught" 判断是否为教师(有授课班级)。
|
||||
*/
|
||||
export const hasTeacherScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "class_taught"
|
||||
|
||||
/**
|
||||
* P1-1: 替代 `ctx.roles.includes("student")` 硬编码。
|
||||
* 通过 dataScope.type === "class_members" 判断是否为学生。
|
||||
*/
|
||||
export const hasStudentScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "class_members"
|
||||
|
||||
/**
|
||||
* 解析表单中的 subjectTeachers JSON 字符串为标准赋值数组。
|
||||
* 提取自原 actions.ts,供 admin/grade class 更新逻辑复用。
|
||||
*/
|
||||
export const parseSubjectTeachers = (raw: string | null) => {
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) return null
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
|
||||
|
||||
return parsed.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const subject = (item as { subject?: unknown }).subject
|
||||
const teacherId = (item as { teacherId?: unknown }).teacherId
|
||||
|
||||
if (typeof subject !== "string" || !isClassSubject(subject)) return []
|
||||
|
||||
if (teacherId === null || typeof teacherId === "undefined") {
|
||||
return [{ subject, teacherId: null }]
|
||||
}
|
||||
|
||||
if (typeof teacherId !== "string") return []
|
||||
const trimmed = teacherId.trim()
|
||||
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
|
||||
})
|
||||
}
|
||||
148
src/modules/classes/actions-teacher.ts
Normal file
148
src/modules/classes/actions-teacher.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
"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 {
|
||||
createTeacherClass,
|
||||
deleteTeacherClass,
|
||||
updateTeacherClass,
|
||||
} from "./data-access"
|
||||
import { findGradeIdByHeadAndName, isGradeHead } from "@/modules/school/data-access"
|
||||
import {
|
||||
CreateTeacherClassSchema,
|
||||
UpdateTeacherClassSchema,
|
||||
DeleteTeacherClassSchema,
|
||||
} from "./schema"
|
||||
import { hasAdminScope } from "./actions-shared"
|
||||
|
||||
export async function createTeacherClassAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const parsed = CreateTeacherClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name and grade are required" }
|
||||
}
|
||||
|
||||
const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
// P1-1: 使用 dataScope 替代 ctx.roles.includes("admin") 硬编码
|
||||
if (!hasAdminScope(ctx)) {
|
||||
const userId = ctx.userId
|
||||
|
||||
const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : ""
|
||||
const isOwner = normalizedGradeId
|
||||
? await isGradeHead(normalizedGradeId, userId)
|
||||
: Boolean(await findGradeIdByHeadAndName(userId, grade))
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "Only admins and grade heads can create classes" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createTeacherClass({
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade,
|
||||
gradeId: gradeId ?? null,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTeacherClassAction(
|
||||
classId: string,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const parsed = UpdateTeacherClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
try {
|
||||
await updateTeacherClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTeacherClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
const parsed = DeleteTeacherClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTeacherClass(parsed.data.classId)
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* 班级模块类型定义。
|
||||
*
|
||||
* 说明(P1-4 审计决策):
|
||||
* 下列 `ClassHomeworkInsights` / `GradeHomeworkInsights` / `ClassHomeworkAssignmentStats`
|
||||
* / `ScoreStats` / `AssignmentSummary` 等类型虽涉及 homework 概念,但它们是
|
||||
* **classes 模块对 homework 数据的视图**(按班级/年级聚合的作业统计),由
|
||||
* `data-access-stats.ts` 产出并被 classes 组件消费。homework 模块自身的
|
||||
* `types.ts` 定义的是作业实体类型(HomeworkAssignmentStatus 等),不包含这些聚合视图类型。
|
||||
* 因此将这些类型保留在 classes 模块,避免让 homework 模块承担 classes 视角的类型定义职责。
|
||||
*/
|
||||
|
||||
export type TeacherClass = {
|
||||
id: string
|
||||
schoolName?: string | null
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { AcademicYearListItem } from "../types"
|
||||
import { createAcademicYearAction, deleteAcademicYearAction, updateAcademicYearAction } from "../actions"
|
||||
@@ -38,6 +39,7 @@ import { formatDate } from "@/shared/lib/utils"
|
||||
const toDateInput = (iso: string) => iso.slice(0, 10)
|
||||
|
||||
export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) {
|
||||
const t = useTranslations("school")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
@@ -58,10 +60,10 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
setCreateOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create academic year")
|
||||
toast.error(res.message || t("academicYear.delete.title"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create academic year")
|
||||
toast.error(t("academicYear.delete.title"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -78,10 +80,10 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
setEditItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update academic year")
|
||||
toast.error(res.message || t("academicYear.delete.title"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update academic year")
|
||||
toast.error(t("academicYear.delete.title"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -97,10 +99,10 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
setDeleteItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete academic year")
|
||||
toast.error(res.message || t("academicYear.delete.title"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete academic year")
|
||||
toast.error(t("academicYear.delete.title"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -117,14 +119,14 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
disabled={isWorking}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New academic year
|
||||
{t("academicYear.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Active year</CardTitle>
|
||||
<CardTitle className="text-base">{t("academicYear.active")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeYear ? (
|
||||
@@ -133,12 +135,12 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(activeYear.startDate)} – {formatDate(activeYear.endDate)}
|
||||
</div>
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
<Badge variant="secondary">{t("academicYear.active")}</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No active year"
|
||||
description="Set one academic year as active."
|
||||
title={t("academicYear.empty.title")}
|
||||
description={t("academicYear.empty.description")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
@@ -147,7 +149,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
|
||||
<Card className="lg:col-span-2 shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">All years</CardTitle>
|
||||
<CardTitle className="text-base">{t("academicYear.all")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{years.length}
|
||||
</Badge>
|
||||
@@ -155,17 +157,17 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
<CardContent>
|
||||
{years.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No academic years"
|
||||
description="Create an academic year to define school calendar."
|
||||
title={t("academicYear.empty.title")}
|
||||
description={t("academicYear.empty.description")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Range</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>{t("academicYear.column.name")}</TableHead>
|
||||
<TableHead>{t("academicYear.column.startDate")}</TableHead>
|
||||
<TableHead>{t("academicYear.column.status")}</TableHead>
|
||||
<TableHead className="w-[60px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -176,7 +178,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(y.startDate)} – {formatDate(y.endDate)}
|
||||
</TableCell>
|
||||
<TableCell>{y.isActive ? <Badge variant="secondary">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
|
||||
<TableCell>{y.isActive ? <Badge variant="secondary">{t("academicYear.active")}</Badge> : <Badge variant="outline">-</Badge>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -192,7 +194,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
{t("academicYear.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -200,7 +202,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
onClick={() => setDeleteItem(y)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{t("academicYear.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -217,35 +219,35 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New academic year</DialogTitle>
|
||||
<DialogTitle>{t("academicYear.form.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleCreate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" name="name" placeholder="e.g. 2025-2026" autoFocus />
|
||||
<Label htmlFor="name">{t("academicYear.form.name")}</Label>
|
||||
<Input id="name" name="name" placeholder={t("academicYear.form.namePlaceholder")} autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">Start date</Label>
|
||||
<Label htmlFor="startDate">{t("academicYear.form.startDate")}</Label>
|
||||
<Input id="startDate" name="startDate" type="date" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endDate">End date</Label>
|
||||
<Label htmlFor="endDate">{t("academicYear.form.endDate")}</Label>
|
||||
<Input id="endDate" name="endDate" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={createActive} onCheckedChange={(v) => setCreateActive(Boolean(v))} />
|
||||
<Label className="cursor-pointer" onClick={() => setCreateActive((v) => !v)}>
|
||||
Set as active
|
||||
{t("academicYear.form.isActive")}
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
||||
Cancel
|
||||
{t("academicYear.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
Create
|
||||
{t("academicYear.form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -257,36 +259,36 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit academic year</DialogTitle>
|
||||
<DialogTitle>{t("academicYear.form.editTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editItem ? (
|
||||
<form action={handleUpdate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Name</Label>
|
||||
<Label htmlFor="edit-name">{t("academicYear.form.name")}</Label>
|
||||
<Input id="edit-name" name="name" defaultValue={editItem.name} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-startDate">Start date</Label>
|
||||
<Label htmlFor="edit-startDate">{t("academicYear.form.startDate")}</Label>
|
||||
<Input id="edit-startDate" name="startDate" type="date" defaultValue={toDateInput(editItem.startDate)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-endDate">End date</Label>
|
||||
<Label htmlFor="edit-endDate">{t("academicYear.form.endDate")}</Label>
|
||||
<Input id="edit-endDate" name="endDate" type="date" defaultValue={toDateInput(editItem.endDate)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={editActive} onCheckedChange={(v) => setEditActive(Boolean(v))} />
|
||||
<Label className="cursor-pointer" onClick={() => setEditActive((v) => !v)}>
|
||||
Set as active
|
||||
{t("academicYear.form.isActive")}
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
||||
Cancel
|
||||
{t("academicYear.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
Save
|
||||
{t("academicYear.form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -299,13 +301,13 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
|
||||
}}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete academic year</AlertDialogTitle>
|
||||
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this academic year"}.</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t("academicYear.delete.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("academicYear.delete.description", { name: deleteItem?.name || "" })}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isWorking}>{t("academicYear.delete.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
Delete
|
||||
{t("academicYear.delete.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { DepartmentListItem } from "../types"
|
||||
import { createDepartmentAction, deleteDepartmentAction, updateDepartmentAction } from "../actions"
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export function DepartmentsClient({ departments }: { departments: DepartmentListItem[] }) {
|
||||
const t = useTranslations("school")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
@@ -51,10 +53,10 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
setCreateOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create department")
|
||||
toast.error(res.message || t("departments.delete.title"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create department")
|
||||
toast.error(t("departments.delete.title"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -70,10 +72,10 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
setEditItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update department")
|
||||
toast.error(res.message || t("departments.delete.title"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update department")
|
||||
toast.error(t("departments.delete.title"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -89,10 +91,10 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
setDeleteItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete department")
|
||||
toast.error(res.message || t("departments.delete.title"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete department")
|
||||
toast.error(t("departments.delete.title"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -103,13 +105,13 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New department
|
||||
{t("departments.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">All departments</CardTitle>
|
||||
<CardTitle className="text-base">{t("departments.all")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{departments.length}
|
||||
</Badge>
|
||||
@@ -117,17 +119,17 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
<CardContent>
|
||||
{departments.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No departments"
|
||||
description="Create your first department to get started."
|
||||
title={t("departments.empty.title")}
|
||||
description={t("departments.empty.description")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead>{t("departments.column.name")}</TableHead>
|
||||
<TableHead>{t("departments.column.description")}</TableHead>
|
||||
<TableHead>{t("departments.column.updated")}</TableHead>
|
||||
<TableHead className="w-[60px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -147,7 +149,7 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditItem(d)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
{t("departments.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -155,7 +157,7 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
onClick={() => setDeleteItem(d)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{t("departments.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -171,23 +173,23 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New department</DialogTitle>
|
||||
<DialogTitle>{t("departments.form.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleCreate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" name="name" placeholder="e.g. Mathematics" autoFocus />
|
||||
<Label htmlFor="name">{t("departments.form.name")}</Label>
|
||||
<Input id="name" name="name" placeholder={t("departments.form.namePlaceholder")} autoFocus />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" name="description" placeholder="Optional" />
|
||||
<Label htmlFor="description">{t("departments.form.description")}</Label>
|
||||
<Textarea id="description" name="description" placeholder={t("departments.form.descriptionPlaceholder")} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
||||
Cancel
|
||||
{t("departments.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
Create
|
||||
{t("departments.form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -199,24 +201,24 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit department</DialogTitle>
|
||||
<DialogTitle>{t("departments.form.editTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editItem ? (
|
||||
<form action={handleUpdate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Name</Label>
|
||||
<Label htmlFor="edit-name">{t("departments.form.name")}</Label>
|
||||
<Input id="edit-name" name="name" defaultValue={editItem.name} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Label htmlFor="edit-description">{t("departments.form.description")}</Label>
|
||||
<Textarea id="edit-description" name="description" defaultValue={editItem.description || ""} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
||||
Cancel
|
||||
{t("departments.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
Save
|
||||
{t("departments.form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -229,15 +231,15 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
|
||||
}}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete department</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("departments.delete.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete {deleteItem?.name || "this department"}.
|
||||
{t("departments.delete.description", { name: deleteItem?.name || "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isWorking}>{t("departments.delete.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
Delete
|
||||
{t("departments.delete.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { parseAsString, useQueryState } from "nuqs"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
|
||||
import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions"
|
||||
@@ -66,43 +67,6 @@ const parseOrder = (raw: string) => {
|
||||
return n
|
||||
}
|
||||
|
||||
const validateForm = (
|
||||
state: FormState,
|
||||
params: { grades: GradeListItem[]; excludeGradeId?: string }
|
||||
): { ok: boolean; errors: FormErrors } => {
|
||||
const errors: FormErrors = {}
|
||||
|
||||
const schoolId = state.schoolId.trim()
|
||||
if (!schoolId) errors.schoolId = "请选择学校"
|
||||
|
||||
const name = normalizeName(state.name)
|
||||
if (!name) errors.name = "请输入年级名称"
|
||||
if (name.length > 100) errors.name = "年级名称最多 100 个字符"
|
||||
|
||||
const order = parseOrder(state.order)
|
||||
if (order === null) errors.order = "Order 必须是非负整数"
|
||||
|
||||
if (schoolId && name) {
|
||||
const dup = params.grades.find((g) => {
|
||||
if (params.excludeGradeId && g.id === params.excludeGradeId) return false
|
||||
return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase()
|
||||
})
|
||||
if (dup) errors.name = "该学校下已存在同名年级"
|
||||
}
|
||||
|
||||
return { ok: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
const formatStaffDetail = (u: StaffOption | null) => {
|
||||
if (!u) return <Badge variant="outline">未设置</Badge>
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{u.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GradesClient({
|
||||
grades,
|
||||
schools,
|
||||
@@ -112,6 +76,7 @@ export function GradesClient({
|
||||
schools: SchoolListItem[]
|
||||
staff: StaffOption[]
|
||||
}) {
|
||||
const t = useTranslations("school")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
@@ -149,6 +114,46 @@ export function GradesClient({
|
||||
})
|
||||
}, [staff])
|
||||
|
||||
const validateForm = useCallback(
|
||||
(state: FormState, params: { grades: GradeListItem[]; excludeGradeId?: string }): {
|
||||
ok: boolean
|
||||
errors: FormErrors
|
||||
} => {
|
||||
const errors: FormErrors = {}
|
||||
|
||||
const schoolId = state.schoolId.trim()
|
||||
if (!schoolId) errors.schoolId = t("grades.validation.selectSchool")
|
||||
|
||||
const name = normalizeName(state.name)
|
||||
if (!name) errors.name = t("grades.validation.enterName")
|
||||
if (name.length > 100) errors.name = t("grades.validation.nameTooLong")
|
||||
|
||||
const order = parseOrder(state.order)
|
||||
if (order === null) errors.order = t("grades.validation.orderInvalid")
|
||||
|
||||
if (schoolId && name) {
|
||||
const dup = params.grades.find((g) => {
|
||||
if (params.excludeGradeId && g.id === params.excludeGradeId) return false
|
||||
return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase()
|
||||
})
|
||||
if (dup) errors.name = t("grades.validation.duplicateName")
|
||||
}
|
||||
|
||||
return { ok: Object.keys(errors).length === 0, errors }
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const formatStaffDetail = (u: StaffOption | null) => {
|
||||
if (!u) return <Badge variant="outline">{t("grades.notSet")}</Badge>
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{u.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredGrades = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const bySchool = school === "all" ? "" : school
|
||||
@@ -202,10 +207,13 @@ export function GradesClient({
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
const createValidation = useMemo(() => validateForm(createState, { grades }), [createState, grades])
|
||||
const createValidation = useMemo(
|
||||
() => validateForm(createState, { grades }),
|
||||
[createState, grades, validateForm]
|
||||
)
|
||||
const editValidation = useMemo(
|
||||
() => validateForm(editState, { grades, excludeGradeId: editItem?.id }),
|
||||
[editItem?.id, editState, grades]
|
||||
[editItem?.id, editState, grades, validateForm]
|
||||
)
|
||||
|
||||
const isEditDirty = useMemo(() => {
|
||||
@@ -236,7 +244,7 @@ export function GradesClient({
|
||||
const handleCreate = async () => {
|
||||
const validation = validateForm(createState, { grades })
|
||||
if (!validation.ok) {
|
||||
toast.error(Object.values(validation.errors)[0] || "请完善表单信息")
|
||||
toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,10 +263,10 @@ export function GradesClient({
|
||||
setCreateOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create grade")
|
||||
toast.error(res.message || t("grades.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create grade")
|
||||
toast.error(t("grades.failedCreate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -268,11 +276,11 @@ export function GradesClient({
|
||||
if (!editItem) return
|
||||
const validation = validateForm(editState, { grades, excludeGradeId: editItem.id })
|
||||
if (!validation.ok) {
|
||||
toast.error(Object.values(validation.errors)[0] || "请完善表单信息")
|
||||
toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm"))
|
||||
return
|
||||
}
|
||||
if (!isEditDirty) {
|
||||
toast.message("没有可保存的变更")
|
||||
toast.message(t("grades.validation.noChanges"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,10 +299,10 @@ export function GradesClient({
|
||||
setEditItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update grade")
|
||||
toast.error(res.message || t("grades.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update grade")
|
||||
toast.error(t("grades.failedUpdate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -310,10 +318,10 @@ export function GradesClient({
|
||||
setDeleteItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete grade")
|
||||
toast.error(res.message || t("grades.failedDelete"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete grade")
|
||||
toast.error(t("grades.failedDelete"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -324,15 +332,15 @@ export function GradesClient({
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-2 md:flex-row md:items-center">
|
||||
<div className="flex-1 md:max-w-sm">
|
||||
<Input placeholder="搜索年级/学校/组长..." value={q} onChange={(e) => setQ(e.target.value || null)} />
|
||||
<Input placeholder={t("grades.filters.search")} value={q} onChange={(e) => setQ(e.target.value || null)} />
|
||||
</div>
|
||||
|
||||
<Select value={school} onValueChange={(v) => setSchool(v === "all" ? null : v)}>
|
||||
<SelectTrigger className="w-full md:w-[220px]">
|
||||
<SelectValue placeholder="学校" />
|
||||
<SelectValue placeholder={t("grades.filters.school")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部学校</SelectItem>
|
||||
<SelectItem value="all">{t("grades.filters.allSchools")}</SelectItem>
|
||||
{schools.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
@@ -343,28 +351,28 @@ export function GradesClient({
|
||||
|
||||
<Select value={head} onValueChange={(v) => setHead(v === "all" ? null : v)}>
|
||||
<SelectTrigger className="w-full md:w-[220px]">
|
||||
<SelectValue placeholder="负责人" />
|
||||
<SelectValue placeholder={t("grades.filters.head")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="missing">两者都未设置</SelectItem>
|
||||
<SelectItem value="missing_grade_head">未设置年级组长</SelectItem>
|
||||
<SelectItem value="missing_teaching_head">未设置教研组长</SelectItem>
|
||||
<SelectItem value="all">{t("grades.filters.allHeads")}</SelectItem>
|
||||
<SelectItem value="missing">{t("grades.filters.missing")}</SelectItem>
|
||||
<SelectItem value="missing_grade_head">{t("grades.filters.missingGradeHead")}</SelectItem>
|
||||
<SelectItem value="missing_teaching_head">{t("grades.filters.missingTeachingHead")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sort} onValueChange={(v) => setSort(v === "default" ? null : v)}>
|
||||
<SelectTrigger className="w-full md:w-[220px]">
|
||||
<SelectValue placeholder="排序" />
|
||||
<SelectValue placeholder={t("grades.filters.sort")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">默认排序</SelectItem>
|
||||
<SelectItem value="updated_desc">更新时间(新→旧)</SelectItem>
|
||||
<SelectItem value="updated_asc">更新时间(旧→新)</SelectItem>
|
||||
<SelectItem value="name_asc">年级名称(A→Z)</SelectItem>
|
||||
<SelectItem value="name_desc">年级名称(Z→A)</SelectItem>
|
||||
<SelectItem value="order_asc">Order(小→大)</SelectItem>
|
||||
<SelectItem value="order_desc">Order(大→小)</SelectItem>
|
||||
<SelectItem value="default">{t("grades.filters.defaultSort")}</SelectItem>
|
||||
<SelectItem value="updated_desc">{t("grades.filters.updatedDesc")}</SelectItem>
|
||||
<SelectItem value="updated_asc">{t("grades.filters.updatedAsc")}</SelectItem>
|
||||
<SelectItem value="name_asc">{t("grades.filters.nameAsc")}</SelectItem>
|
||||
<SelectItem value="name_desc">{t("grades.filters.nameDesc")}</SelectItem>
|
||||
<SelectItem value="order_asc">{t("grades.filters.orderAsc")}</SelectItem>
|
||||
<SelectItem value="order_desc">{t("grades.filters.orderDesc")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -378,20 +386,20 @@ export function GradesClient({
|
||||
setSort(null)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
{t("grades.filters.reset")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button onClick={openCreate} disabled={isWorking || schools.length === 0}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建年级
|
||||
{t("grades.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">年级列表</CardTitle>
|
||||
<CardTitle className="text-base">{t("grades.list.title")}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{filteredGrades.length}
|
||||
@@ -404,26 +412,30 @@ export function GradesClient({
|
||||
<CardContent>
|
||||
{schools.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无学校"
|
||||
description="请先创建学校,再在学校下创建年级。"
|
||||
title={t("grades.list.noSchools")}
|
||||
description={t("grades.list.noSchoolsDescription")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : filteredGrades.length === 0 ? (
|
||||
<EmptyState
|
||||
title={grades.length === 0 ? "暂无年级" : "没有匹配结果"}
|
||||
description={grades.length === 0 ? "创建年级以便管理负责人和班级。" : "尝试修改筛选条件或清空搜索。"}
|
||||
title={grades.length === 0 ? t("grades.list.noGrades") : t("grades.list.noMatch")}
|
||||
description={
|
||||
grades.length === 0
|
||||
? t("grades.list.noGradesDescription")
|
||||
: t("grades.list.noMatchDescription")
|
||||
}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>学校</TableHead>
|
||||
<TableHead>年级</TableHead>
|
||||
<TableHead>Order</TableHead>
|
||||
<TableHead>年级组长</TableHead>
|
||||
<TableHead>教研组长</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead>{t("grades.column.school")}</TableHead>
|
||||
<TableHead>{t("grades.column.grade")}</TableHead>
|
||||
<TableHead>{t("grades.column.order")}</TableHead>
|
||||
<TableHead>{t("grades.column.gradeHead")}</TableHead>
|
||||
<TableHead>{t("grades.column.teachingHead")}</TableHead>
|
||||
<TableHead>{t("grades.column.updated")}</TableHead>
|
||||
<TableHead className="w-[60px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -449,19 +461,19 @@ export function GradesClient({
|
||||
router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`)
|
||||
}
|
||||
>
|
||||
学情
|
||||
{t("grades.actions.insights")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openEdit(g)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
{t("grades.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteItem(g)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
{t("grades.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -477,7 +489,7 @@ export function GradesClient({
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建年级</DialogTitle>
|
||||
<DialogTitle>{t("grades.form.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
@@ -487,14 +499,14 @@ export function GradesClient({
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">School</Label>
|
||||
<Label className="text-right">{t("grades.form.school")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={createState.schoolId}
|
||||
onValueChange={(v) => setCreateState((p) => ({ ...p, schoolId: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a school" />
|
||||
<SelectValue placeholder={t("grades.form.school")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schools.map((s) => (
|
||||
@@ -514,14 +526,14 @@ export function GradesClient({
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-grade-name" className="text-right">
|
||||
Grade
|
||||
{t("grades.form.name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="create-grade-name"
|
||||
className="col-span-3"
|
||||
value={createState.name}
|
||||
onChange={(e) => setCreateState((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="e.g. Grade 10"
|
||||
placeholder={t("grades.form.name")}
|
||||
autoFocus
|
||||
/>
|
||||
{createValidation.errors.name ? (
|
||||
@@ -533,7 +545,7 @@ export function GradesClient({
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-grade-order" className="text-right">
|
||||
Order
|
||||
{t("grades.form.order")}
|
||||
</Label>
|
||||
<Input
|
||||
id="create-grade-order"
|
||||
@@ -553,7 +565,7 @@ export function GradesClient({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">年级组长</Label>
|
||||
<Label className="text-right">{t("grades.form.gradeHead")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={createState.gradeHeadId}
|
||||
@@ -562,7 +574,7 @@ export function GradesClient({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Optional" />
|
||||
<SelectValue placeholder={t("grades.optional")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
|
||||
@@ -577,7 +589,7 @@ export function GradesClient({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">教研组长</Label>
|
||||
<Label className="text-right">{t("grades.form.teachingHead")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={createState.teachingHeadId}
|
||||
@@ -586,7 +598,7 @@ export function GradesClient({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Optional" />
|
||||
<SelectValue placeholder={t("grades.optional")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
|
||||
@@ -602,10 +614,10 @@ export function GradesClient({
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
||||
Cancel
|
||||
{t("grades.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
创建
|
||||
{t("grades.form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -620,7 +632,7 @@ export function GradesClient({
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑年级</DialogTitle>
|
||||
<DialogTitle>{t("grades.form.editTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editItem ? (
|
||||
<form
|
||||
@@ -631,14 +643,14 @@ export function GradesClient({
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">School</Label>
|
||||
<Label className="text-right">{t("grades.form.school")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={editState.schoolId}
|
||||
onValueChange={(v) => setEditState((p) => ({ ...p, schoolId: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a school" />
|
||||
<SelectValue placeholder={t("grades.form.school")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schools.map((s) => (
|
||||
@@ -658,7 +670,7 @@ export function GradesClient({
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-grade-name" className="text-right">
|
||||
Grade
|
||||
{t("grades.form.name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-grade-name"
|
||||
@@ -675,7 +687,7 @@ export function GradesClient({
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-grade-order" className="text-right">
|
||||
Order
|
||||
{t("grades.form.order")}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-grade-order"
|
||||
@@ -695,7 +707,7 @@ export function GradesClient({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">年级组长</Label>
|
||||
<Label className="text-right">{t("grades.form.gradeHead")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={editState.gradeHeadId}
|
||||
@@ -704,7 +716,7 @@ export function GradesClient({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Optional" />
|
||||
<SelectValue placeholder={t("grades.optional")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
|
||||
@@ -719,7 +731,7 @@ export function GradesClient({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">教研组长</Label>
|
||||
<Label className="text-right">{t("grades.form.teachingHead")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={editState.teachingHeadId}
|
||||
@@ -728,7 +740,7 @@ export function GradesClient({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Optional" />
|
||||
<SelectValue placeholder={t("grades.optional")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
|
||||
@@ -744,10 +756,10 @@ export function GradesClient({
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
||||
Cancel
|
||||
{t("grades.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
保存
|
||||
{t("grades.form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -763,13 +775,15 @@ export function GradesClient({
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除年级</AlertDialogTitle>
|
||||
<AlertDialogDescription>将永久删除 {deleteItem?.name || "该年级"}。</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t("grades.delete.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("grades.delete.description", { name: deleteItem?.name || "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isWorking}>{t("grades.delete.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
删除
|
||||
{t("grades.delete.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
71
src/modules/school/components/school-delete-dialog.tsx
Normal file
71
src/modules/school/components/school-delete-dialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { SchoolListItem } from "../types"
|
||||
import { deleteSchoolAction } from "../actions"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
|
||||
|
||||
type SchoolDeleteDialogProps = {
|
||||
deleteItem: SchoolListItem | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校删除确认对话框。
|
||||
*
|
||||
* 内部管理 deleteMutation,对话框的 open 状态由 `deleteItem` 是否为空推导。
|
||||
* 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。
|
||||
*/
|
||||
export function SchoolDeleteDialog({
|
||||
deleteItem,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: SchoolDeleteDialogProps) {
|
||||
const t = useTranslations("school")
|
||||
|
||||
const deleteMutation = useActionMutation({
|
||||
errorMessage: "Failed to delete school",
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
|
||||
const isWorking = deleteMutation.isWorking
|
||||
|
||||
const handleDelete = (): void => {
|
||||
if (!deleteItem) return
|
||||
void deleteMutation.mutate(() => deleteSchoolAction(deleteItem.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={Boolean(deleteItem)} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("schools.delete.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("schools.delete.description", { name: deleteItem?.name || "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>{t("schools.delete.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
{t("schools.delete.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
72
src/modules/school/components/school-error-boundary.tsx
Normal file
72
src/modules/school/components/school-error-boundary.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { Component, type ErrorInfo, type JSX, type ReactNode } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
interface SchoolErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface SchoolErrorBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
function SchoolErrorFallback({ onReset }: { onReset: () => void }): JSX.Element {
|
||||
const t = useTranslations("school")
|
||||
const router = useRouter()
|
||||
|
||||
const handleRetry = (): void => {
|
||||
onReset()
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center"
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t("errors.boundary.title")}</h3>
|
||||
<p className="mb-4 mt-2 max-w-md text-sm text-muted-foreground">
|
||||
{t("errors.boundary.description")}
|
||||
</p>
|
||||
<Button onClick={handleRetry}>{t("errors.boundary.retry")}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export class SchoolErrorBoundary extends Component<
|
||||
SchoolErrorBoundaryProps,
|
||||
SchoolErrorBoundaryState
|
||||
> {
|
||||
constructor(props: SchoolErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): SchoolErrorBoundaryState {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error("SchoolErrorBoundary caught an error:", error, errorInfo)
|
||||
}
|
||||
|
||||
private handleReset = (): void => {
|
||||
this.setState({ hasError: false })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? <SchoolErrorFallback onReset={this.handleReset} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
112
src/modules/school/components/school-form-dialog.tsx
Normal file
112
src/modules/school/components/school-form-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { SchoolListItem } from "../types"
|
||||
import { createSchoolAction, updateSchoolAction } from "../actions"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
|
||||
|
||||
type SchoolFormDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editItem?: SchoolListItem | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校创建/编辑表单对话框。
|
||||
*
|
||||
* 内部管理 createMutation 与 updateMutation,根据 `editItem` 是否存在自动切换模式。
|
||||
* 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。
|
||||
*/
|
||||
export function SchoolFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editItem,
|
||||
onSuccess,
|
||||
}: SchoolFormDialogProps) {
|
||||
const t = useTranslations("school")
|
||||
const isEdit = Boolean(editItem)
|
||||
|
||||
const createMutation = useActionMutation({
|
||||
errorMessage: "Failed to create school",
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useActionMutation({
|
||||
errorMessage: "Failed to update school",
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
|
||||
const isWorking = createMutation.isWorking || updateMutation.isWorking
|
||||
|
||||
const handleCreate = (formData: FormData): void => {
|
||||
void createMutation.mutate(() => createSchoolAction(undefined, formData))
|
||||
}
|
||||
|
||||
const handleUpdate = (formData: FormData): void => {
|
||||
if (!editItem) return
|
||||
void updateMutation.mutate(() => updateSchoolAction(editItem.id, undefined, formData))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? t("schools.form.editTitle") : t("schools.form.createTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isEdit ? (
|
||||
<form action={handleUpdate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">{t("schools.form.name")}</Label>
|
||||
<Input id="edit-name" name="name" defaultValue={editItem?.name} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-code">{t("schools.form.code")}</Label>
|
||||
<Input id="edit-code" name="code" defaultValue={editItem?.code || ""} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
|
||||
{t("schools.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{t("schools.form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : (
|
||||
<form action={handleCreate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t("schools.form.name")}</Label>
|
||||
<Input id="name" name="name" placeholder={t("schools.form.namePlaceholder")} autoFocus />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{t("schools.form.code")}</Label>
|
||||
<Input id="code" name="code" placeholder={t("schools.form.codePlaceholder")} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
|
||||
{t("schools.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{t("schools.form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
41
src/modules/school/components/school-list-toolbar.tsx
Normal file
41
src/modules/school/components/school-list-toolbar.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { Plus } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
type SchoolListToolbarProps = {
|
||||
count: number
|
||||
onCreate: () => void
|
||||
isWorking: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校列表工具栏。
|
||||
*
|
||||
* 展示学校数量 Badge 与「新建学校」按钮,按钮在任意对话框打开期间禁用。
|
||||
*/
|
||||
export function SchoolListToolbar({
|
||||
count,
|
||||
onCreate,
|
||||
isWorking,
|
||||
}: SchoolListToolbarProps) {
|
||||
const t = useTranslations("school")
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-medium">{t("schools.all")}</span>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button onClick={onCreate} disabled={isWorking}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("schools.new")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
src/modules/school/components/school-skeleton.tsx
Normal file
69
src/modules/school/components/school-skeleton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
|
||||
interface SchoolListSkeletonProps {
|
||||
rows?: number
|
||||
}
|
||||
|
||||
export function SchoolListSkeleton({ rows = 5 }: SchoolListSkeletonProps): JSX.Element {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-10 rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchoolCardSkeleton(): JSX.Element {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { SchoolListItem } from "../types"
|
||||
import { createSchoolAction, deleteSchoolAction, updateSchoolAction } from "../actions"
|
||||
import { useSchoolData } from "../hooks/use-school-data"
|
||||
import { SchoolDeleteDialog } from "./school-delete-dialog"
|
||||
import { SchoolFormDialog } from "./school-form-dialog"
|
||||
import { SchoolListToolbar } from "./school-list-toolbar"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -22,82 +20,47 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
|
||||
|
||||
export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
||||
const t = useTranslations("school")
|
||||
const router = useRouter()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editItem, setEditItem] = useState<SchoolListItem | null>(null)
|
||||
const [deleteItem, setDeleteItem] = useState<SchoolListItem | null>(null)
|
||||
const {
|
||||
createOpen,
|
||||
editItem,
|
||||
deleteItem,
|
||||
setCreateOpen,
|
||||
setEditItem,
|
||||
setDeleteItem,
|
||||
isWorking,
|
||||
} = useSchoolData()
|
||||
|
||||
const createMutation = useActionMutation({
|
||||
errorMessage: "Failed to create school",
|
||||
onSuccess: () => {
|
||||
const handleSuccess = (): void => {
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const handleFormOpenChange = (open: boolean): void => {
|
||||
if (!open) {
|
||||
setCreateOpen(false)
|
||||
router.refresh()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useActionMutation({
|
||||
errorMessage: "Failed to update school",
|
||||
onSuccess: () => {
|
||||
setEditItem(null)
|
||||
router.refresh()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMutation = useActionMutation({
|
||||
errorMessage: "Failed to delete school",
|
||||
onSuccess: () => {
|
||||
const handleDeleteOpenChange = (open: boolean): void => {
|
||||
if (!open) {
|
||||
setDeleteItem(null)
|
||||
router.refresh()
|
||||
},
|
||||
})
|
||||
|
||||
const isWorking = createMutation.isWorking || updateMutation.isWorking || deleteMutation.isWorking
|
||||
|
||||
const handleCreate = (formData: FormData) => {
|
||||
void createMutation.mutate(() => createSchoolAction(undefined, formData))
|
||||
}
|
||||
|
||||
const handleUpdate = (formData: FormData) => {
|
||||
if (!editItem) return
|
||||
void updateMutation.mutate(() => updateSchoolAction(editItem.id, undefined, formData))
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteItem) return
|
||||
void deleteMutation.mutate(() => deleteSchoolAction(deleteItem.id))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("schools.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<SchoolListToolbar
|
||||
count={schools.length}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("schools.all")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{schools.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{schools.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -152,87 +115,18 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("schools.form.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleCreate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t("schools.form.name")}</Label>
|
||||
<Input id="name" name="name" placeholder={t("schools.form.namePlaceholder")} autoFocus />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{t("schools.form.code")}</Label>
|
||||
<Input id="code" name="code" placeholder={t("schools.form.codePlaceholder")} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
||||
{t("schools.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{t("schools.form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SchoolFormDialog
|
||||
open={createOpen || Boolean(editItem)}
|
||||
onOpenChange={handleFormOpenChange}
|
||||
editItem={editItem}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditItem(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("schools.form.editTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editItem ? (
|
||||
<form action={handleUpdate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">{t("schools.form.name")}</Label>
|
||||
<Input id="edit-name" name="name" defaultValue={editItem.name} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-code">{t("schools.form.code")}</Label>
|
||||
<Input id="edit-code" name="code" defaultValue={editItem.code || ""} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
||||
{t("schools.form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{t("schools.form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteItem(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("schools.delete.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("schools.delete.description", { name: deleteItem?.name || "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>{t("schools.delete.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
{t("schools.delete.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<SchoolDeleteDialog
|
||||
deleteItem={deleteItem}
|
||||
onOpenChange={handleDeleteOpenChange}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,172 @@ export const getGradesForStaff = cache(async (staffId: string): Promise<GradeLis
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据用户角色返回可见的学校列表(权限感知)。
|
||||
* - admin: 返回全量学校
|
||||
* - grade_head / teaching_head: 返回其负责年级所在学校
|
||||
* - teacher: 返回其任课班级所在学校
|
||||
* - 其他角色: 返回空数组
|
||||
*/
|
||||
export const getSchoolsForUser = cache(async (userId: string): Promise<SchoolListItem[]> => {
|
||||
const id = userId.trim()
|
||||
if (!id) return []
|
||||
|
||||
try {
|
||||
const roleRows = await db
|
||||
.select({ name: roles.name })
|
||||
.from(roles)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, id))
|
||||
|
||||
const roleNames = new Set(roleRows.map((r) => r.name))
|
||||
|
||||
if (roleNames.has("admin")) {
|
||||
return await getSchools()
|
||||
}
|
||||
|
||||
let schoolIds: string[] = []
|
||||
|
||||
if (roleNames.has("grade_head") || roleNames.has("teaching_head")) {
|
||||
const gradeRows = await db
|
||||
.select({ schoolId: grades.schoolId })
|
||||
.from(grades)
|
||||
.where(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id)))
|
||||
schoolIds = gradeRows.map((r) => r.schoolId)
|
||||
} else if (roleNames.has("teacher")) {
|
||||
const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access")
|
||||
const classIds = await getAccessibleClassIdsForTeacher(id)
|
||||
if (classIds.length === 0) return []
|
||||
|
||||
const gradeIds = await getGradeIdsByClassIds(classIds)
|
||||
if (gradeIds.length === 0) return []
|
||||
|
||||
const gradeRows = await db
|
||||
.select({ schoolId: grades.schoolId })
|
||||
.from(grades)
|
||||
.where(inArray(grades.id, gradeIds))
|
||||
schoolIds = gradeRows.map((r) => r.schoolId)
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
|
||||
const uniqueSchoolIds = Array.from(
|
||||
new Set(schoolIds.filter((v): v is string => typeof v === "string" && v.length > 0))
|
||||
)
|
||||
if (uniqueSchoolIds.length === 0) return []
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schools)
|
||||
.where(inArray(schools.id, uniqueSchoolIds))
|
||||
.orderBy(asc(schools.name))
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
code: r.code ?? null,
|
||||
createdAt: toIso(r.createdAt),
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getSchoolsForUser failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据用户角色返回可见的年级列表(权限感知)。
|
||||
* - admin: 返回全量年级
|
||||
* - grade_head / teaching_head: 返回其负责的年级
|
||||
* - teacher: 返回其任课班级所在年级
|
||||
* - 其他角色: 返回空数组
|
||||
*/
|
||||
export const getGradesForUser = cache(async (userId: string): Promise<GradeListItem[]> => {
|
||||
const id = userId.trim()
|
||||
if (!id) return []
|
||||
|
||||
try {
|
||||
const roleRows = await db
|
||||
.select({ name: roles.name })
|
||||
.from(roles)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, id))
|
||||
|
||||
const roleNames = new Set(roleRows.map((r) => r.name))
|
||||
|
||||
if (roleNames.has("admin")) {
|
||||
return await getGrades()
|
||||
}
|
||||
|
||||
if (roleNames.has("grade_head") || roleNames.has("teaching_head")) {
|
||||
return await getGradesForStaff(id)
|
||||
}
|
||||
|
||||
if (roleNames.has("teacher")) {
|
||||
const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access")
|
||||
const classIds = await getAccessibleClassIdsForTeacher(id)
|
||||
if (classIds.length === 0) return []
|
||||
|
||||
const gradeIds = await getGradeIdsByClassIds(classIds)
|
||||
if (gradeIds.length === 0) return []
|
||||
|
||||
const uniqueGradeIds = Array.from(new Set(gradeIds))
|
||||
if (uniqueGradeIds.length === 0) return []
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: grades.id,
|
||||
name: grades.name,
|
||||
order: grades.order,
|
||||
schoolId: schools.id,
|
||||
schoolName: schools.name,
|
||||
gradeHeadId: grades.gradeHeadId,
|
||||
teachingHeadId: grades.teachingHeadId,
|
||||
createdAt: grades.createdAt,
|
||||
updatedAt: grades.updatedAt,
|
||||
})
|
||||
.from(grades)
|
||||
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||||
.where(inArray(grades.id, uniqueGradeIds))
|
||||
.orderBy(asc(schools.name), asc(grades.order), asc(grades.name))
|
||||
|
||||
const headIds = Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.flatMap((r) => [r.gradeHeadId, r.teachingHeadId])
|
||||
.filter((v): v is string => typeof v === "string" && v.length > 0)
|
||||
)
|
||||
)
|
||||
|
||||
const heads = headIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.where(inArray(users.id, headIds))
|
||||
: []
|
||||
|
||||
const headById = new Map<string, StaffOption>()
|
||||
for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email })
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
school: { id: r.schoolId, name: r.schoolName },
|
||||
name: r.name,
|
||||
order: Number(r.order ?? 0),
|
||||
gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null,
|
||||
teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null,
|
||||
createdAt: toIso(r.createdAt),
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error("getGradesForUser failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutations — DB write operations (called only from actions.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
42
src/modules/school/hooks/use-school-data.ts
Normal file
42
src/modules/school/hooks/use-school-data.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import type { SchoolListItem } from "../types"
|
||||
|
||||
export type UseSchoolDataReturn = {
|
||||
createOpen: boolean
|
||||
editItem: SchoolListItem | null
|
||||
deleteItem: SchoolListItem | null
|
||||
setCreateOpen: (open: boolean) => void
|
||||
setEditItem: (item: SchoolListItem | null) => void
|
||||
setDeleteItem: (item: SchoolListItem | null) => void
|
||||
isWorking: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校管理客户端的数据/状态 Hook。
|
||||
*
|
||||
* 集中管理创建/编辑/删除对话框的开关状态以及当前操作的学校项,
|
||||
* 供 SchoolsClient 组合容器及其子组件共享。
|
||||
*
|
||||
* `isWorking` 表示任意对话框处于打开状态,用于禁用工具栏按钮与行内操作菜单,
|
||||
* 避免并发打开多个对话框;各对话框内部的 mutation loading 由对应组件自行管理。
|
||||
*/
|
||||
export function useSchoolData(): UseSchoolDataReturn {
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editItem, setEditItem] = useState<SchoolListItem | null>(null)
|
||||
const [deleteItem, setDeleteItem] = useState<SchoolListItem | null>(null)
|
||||
|
||||
const isWorking = createOpen || Boolean(editItem) || Boolean(deleteItem)
|
||||
|
||||
return {
|
||||
createOpen,
|
||||
editItem,
|
||||
deleteItem,
|
||||
setCreateOpen,
|
||||
setEditItem,
|
||||
setDeleteItem,
|
||||
isWorking,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user