feat(homework,classes,course-plans): add scans, student data, take confirm, error boundaries, dialogs, hooks, calendar
homework: - Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross - Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components classes: - Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils - Add class-list-table, class-list-toolbar, class-skeleton - Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils - Add data-access-teacher and hooks directory course-plans: - Add course-plan-calendar, sortable-week-row, template-picker-dialog components - Add lib directory
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
@@ -24,6 +26,7 @@ export async function createAdminClassAction(
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_CREATE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = CreateAdminClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
@@ -36,7 +39,7 @@ export async function createAdminClassAction(
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name, grade and teacher are required" }
|
||||
return { success: false, message: t("actions.classNameGradeTeacherRequired") }
|
||||
}
|
||||
|
||||
const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||
@@ -56,13 +59,12 @@ export async function createAdminClassAction(
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
return { success: true, message: t("actions.classCreated"), data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +75,7 @@ export async function updateAdminClassAction(
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = UpdateAdminClassSchema.safeParse({
|
||||
classId,
|
||||
@@ -86,11 +89,11 @@ export async function updateAdminClassAction(
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||
const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null)
|
||||
const subjectTeachers = await parseSubjectTeachers(formData.get("subjectTeachers"))
|
||||
|
||||
try {
|
||||
await updateAdminClass(validatedClassId, {
|
||||
@@ -115,23 +118,23 @@ export async function updateAdminClassAction(
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
return { success: true, message: t("actions.classUpdated") }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAdminClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_DELETE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = DeleteAdminClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -140,12 +143,11 @@ export async function deleteAdminClassAction(classId: string): Promise<ActionSta
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
return { success: true, message: t("actions.classDeleted") }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
@@ -26,6 +28,7 @@ export async function createGradeClassAction(
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = CreateGradeClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
@@ -38,7 +41,7 @@ export async function createGradeClassAction(
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name, grade and teacher are required" }
|
||||
return { success: false, message: t("actions.classNameGradeTeacherRequired") }
|
||||
}
|
||||
|
||||
const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data
|
||||
@@ -46,7 +49,7 @@ export async function createGradeClassAction(
|
||||
// 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" }
|
||||
return { success: false, message: t("actions.notPermissionCreateGrade") }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -61,13 +64,12 @@ export async function createGradeClassAction(
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
return { success: true, message: t("actions.classCreated"), data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +80,7 @@ export async function updateGradeClassAction(
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = UpdateGradeClassSchema.safeParse({
|
||||
classId,
|
||||
@@ -91,28 +94,28 @@ export async function updateGradeClassAction(
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||
const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null)
|
||||
const subjectTeachers = await parseSubjectTeachers(formData.get("subjectTeachers"))
|
||||
|
||||
// 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" }
|
||||
return { success: false, message: t("actions.classNotFoundOrNotLinked") }
|
||||
}
|
||||
|
||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to update this class" }
|
||||
return { success: false, message: t("actions.notPermissionUpdateClass") }
|
||||
}
|
||||
|
||||
// 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" }
|
||||
return { success: false, message: t("actions.notPermissionMoveClass") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,23 +139,23 @@ export async function updateGradeClassAction(
|
||||
}
|
||||
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
return { success: true, message: t("actions.classUpdated") }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_DELETE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = DeleteGradeClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId } = parsed.data
|
||||
@@ -160,23 +163,22 @@ export async function deleteGradeClassAction(classId: string): Promise<ActionSta
|
||||
// Verify access
|
||||
const classGradeId = await getClassGradeId(validatedClassId)
|
||||
if (!classGradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
return { success: false, message: t("actions.classNotFoundOrNotLinked") }
|
||||
}
|
||||
|
||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to delete this class" }
|
||||
return { success: false, message: t("actions.notPermissionDeleteClass") }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminClass(validatedClassId)
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
return { success: true, message: t("actions.classDeleted") }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
@@ -12,11 +14,28 @@ import {
|
||||
ensureClassInvitationCode,
|
||||
regenerateClassInvitationCode,
|
||||
setStudentEnrollmentStatus,
|
||||
verifyTeacherOwnsClass,
|
||||
} from "./data-access"
|
||||
import {
|
||||
EnrollStudentByEmailSchema,
|
||||
} from "./schema"
|
||||
import { hasTeacherScope, hasStudentScope } from "./actions-shared"
|
||||
import { hasAdminScope, hasTeacherScope, hasStudentScope } from "./actions-shared"
|
||||
|
||||
/**
|
||||
* 校验当前用户对班级的归属权限(P0-2/P0-5 审计修复)。
|
||||
* - admin scope:跳过校验
|
||||
* - 其他用户:必须为该班级的班主任
|
||||
* 返回 null 表示通过,返回 string 表示错误消息。
|
||||
*/
|
||||
async function assertClassOwnership(
|
||||
ctx: Parameters<typeof hasAdminScope>[0],
|
||||
classId: string,
|
||||
t: (key: string) => string
|
||||
): Promise<string | null> {
|
||||
if (hasAdminScope(ctx)) return null
|
||||
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||
return owns ? null : t("actions.notPermissionManageClass")
|
||||
}
|
||||
|
||||
export async function enrollStudentByEmailAction(
|
||||
classId: string,
|
||||
@@ -24,27 +43,31 @@ export async function enrollStudentByEmailAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = EnrollStudentByEmailSchema.safeParse({
|
||||
classId,
|
||||
email: formData.get("email"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Please select a class and provide student email" }
|
||||
return { success: false, message: t("actions.classAndEmailRequired") }
|
||||
}
|
||||
|
||||
// P0-5: 越权校验——教师注册学生前校验班级归属
|
||||
const ownErr = await assertClassOwnership(ctx, parsed.data.classId, t)
|
||||
if (ownErr) return { success: false, message: ownErr }
|
||||
|
||||
try {
|
||||
await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
return { success: true, message: "Student added successfully" }
|
||||
return { success: true, message: t("actions.studentAdded") }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to add student" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,22 +77,23 @@ export async function joinClassByInvitationCodeAction(
|
||||
): Promise<ActionState<{ classId: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const code = formData.get("code")
|
||||
if (typeof code !== "string" || code.trim().length === 0) {
|
||||
return { success: false, message: "Invitation code is required" }
|
||||
return { success: false, message: t("actions.invitationCodeRequired") }
|
||||
}
|
||||
|
||||
// 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({
|
||||
const rlResult = await rateLimit({
|
||||
key: rlKey,
|
||||
limit: 10,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
})
|
||||
if (!rlResult.success) {
|
||||
return { success: false, message: "Too many attempts, please try again later" }
|
||||
return { success: false, message: t("actions.tooManyAttempts") }
|
||||
}
|
||||
|
||||
// P1-1: 使用 dataScope 替代 ctx.roles.includes("teacher") 硬编码
|
||||
@@ -78,7 +102,7 @@ export async function joinClassByInvitationCodeAction(
|
||||
const subject = isTeacher && typeof subjectValue === "string" ? subjectValue.trim() : null
|
||||
|
||||
if (isTeacher && (!subject || subject.length === 0)) {
|
||||
return { success: false, message: "Subject is required" }
|
||||
return { success: false, message: t("actions.subjectRequired") }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -88,7 +112,7 @@ export async function joinClassByInvitationCodeAction(
|
||||
|
||||
// 成功后重置 rate limit
|
||||
const { resetRateLimit } = await import("@/shared/lib/rate-limit")
|
||||
resetRateLimit(rlKey)
|
||||
await resetRateLimit(rlKey)
|
||||
|
||||
// 审计日志
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
@@ -113,7 +137,7 @@ export async function joinClassByInvitationCodeAction(
|
||||
revalidatePath("/teacher/classes/my")
|
||||
}
|
||||
revalidatePath("/profile")
|
||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
||||
return { success: true, message: t("actions.joinedClass"), data: { classId } }
|
||||
} catch (error) {
|
||||
// 审计日志:加入失败
|
||||
const { logAudit } = await import("@/shared/lib/audit-logger")
|
||||
@@ -128,55 +152,54 @@ export async function joinClassByInvitationCodeAction(
|
||||
},
|
||||
status: "failure",
|
||||
})
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
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 } }
|
||||
return { success: true, message: t("actions.invitationCodeReady"), data: { code } }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
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 } }
|
||||
return { success: true, message: t("actions.invitationCodeUpdated"), data: { code } }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,10 +216,11 @@ export async function createClassInvitationCodeAction(
|
||||
): Promise<ActionState<{ code: string; id: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const classId = String(formData.get("classId") ?? "").trim()
|
||||
if (!classId) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
const expiresInHoursRaw = formData.get("expiresInHours")
|
||||
@@ -213,10 +237,10 @@ export async function createClassInvitationCodeAction(
|
||||
: null
|
||||
|
||||
if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) {
|
||||
return { success: false, message: "Invalid expiresInHours" }
|
||||
return { success: false, message: t("actions.invalidExpiresInHours") }
|
||||
}
|
||||
if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) {
|
||||
return { success: false, message: "Invalid maxUses" }
|
||||
return { success: false, message: t("actions.invalidMaxUses") }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -248,18 +272,14 @@ export async function createClassInvitationCodeAction(
|
||||
revalidatePath(`/admin/school/classes`)
|
||||
return {
|
||||
success: true,
|
||||
message: "Invitation code generated",
|
||||
message: t("actions.invitationCodeGenerated"),
|
||||
data: { code: record.code, id: record.id },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to generate code",
|
||||
}
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,10 +292,11 @@ export async function revokeClassInvitationCodeAction(
|
||||
): Promise<ActionState<null>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const codeId = String(formData.get("codeId") ?? "").trim()
|
||||
if (!codeId) {
|
||||
return { success: false, message: "Missing code id" }
|
||||
return { success: false, message: t("actions.missingCodeId") }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -293,16 +314,12 @@ export async function revokeClassInvitationCodeAction(
|
||||
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath(`/admin/school/classes`)
|
||||
return { success: true, message: "Invitation code revoked" }
|
||||
return { success: true, message: t("actions.invitationCodeRevoked") }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to revoke code",
|
||||
}
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,12 +330,17 @@ export async function listClassInvitationCodesAction(
|
||||
classId: string
|
||||
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
// P0-2: 越权校验——防止教师枚举他班邀请码(admin scope 跳过)
|
||||
const ownErr = await assertClassOwnership(ctx, classId, t)
|
||||
if (ownErr) return { success: false, message: ownErr }
|
||||
|
||||
try {
|
||||
const { listClassInvitationCodes } = await import("./data-access-invitations")
|
||||
const codes = await listClassInvitationCodes(classId)
|
||||
@@ -339,14 +361,10 @@ export async function listClassInvitationCodesAction(
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to list codes",
|
||||
}
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,22 +375,22 @@ export async function setStudentEnrollmentStatusAction(
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
if (!classId?.trim() || !studentId?.trim()) {
|
||||
return { success: false, message: "Missing enrollment info" }
|
||||
return { success: false, message: t("actions.missingEnrollmentInfo") }
|
||||
}
|
||||
|
||||
try {
|
||||
await setStudentEnrollmentStatus(classId, studentId, status)
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
return { success: true, message: "Student updated successfully" }
|
||||
return { success: true, message: t("actions.studentUpdated") }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update student" }
|
||||
return handleActionError(error)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
throw e
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,11 +404,16 @@ export async function bulkEnrollStudentsAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ imported: number; failed: number; errors: string[] }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
// P0-5: 越权校验——批量注册学生前校验班级归属
|
||||
const ownErr = await assertClassOwnership(ctx, classId, t)
|
||||
if (ownErr) return { success: false, message: ownErr }
|
||||
|
||||
const csvText = String(formData.get("csv") ?? "").trim()
|
||||
if (!csvText) {
|
||||
return { success: false, message: "CSV data is required" }
|
||||
return { success: false, message: t("actions.csvRequired") }
|
||||
}
|
||||
|
||||
// 解析 CSV:每行一个邮箱,格式 name,email 或仅 email
|
||||
@@ -406,7 +429,7 @@ export async function bulkEnrollStudentsAction(
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return { success: false, message: "No valid entries found" }
|
||||
return { success: false, message: t("actions.noValidEntries") }
|
||||
}
|
||||
|
||||
// 逐个注册(复用 enrollStudentByEmail data-access 逻辑)
|
||||
@@ -429,13 +452,11 @@ export async function bulkEnrollStudentsAction(
|
||||
revalidatePath("/admin/school/classes")
|
||||
return {
|
||||
success: true,
|
||||
message: `Imported ${imported} students, ${failed} failed`,
|
||||
message: t("actions.bulkImportResult", { imported, failed }),
|
||||
data: { imported, failed, errors },
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Failed to bulk enroll students" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,10 +471,11 @@ export async function bulkAssignSubjectTeachersAction(
|
||||
): Promise<ActionState<{ updated: number; failed: number; errors: string[] }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const csvText = String(formData.get("csv") ?? "").trim()
|
||||
if (!csvText) {
|
||||
return { success: false, message: "CSV data is required" }
|
||||
return { success: false, message: t("actions.csvRequired") }
|
||||
}
|
||||
|
||||
// 解析 CSV:格式 className,subject,teacherEmail
|
||||
@@ -468,7 +490,7 @@ export async function bulkAssignSubjectTeachersAction(
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return { success: false, message: "No valid entries found" }
|
||||
return { success: false, message: t("actions.noValidEntries") }
|
||||
}
|
||||
|
||||
const updated = 0
|
||||
@@ -491,12 +513,10 @@ export async function bulkAssignSubjectTeachersAction(
|
||||
revalidatePath("/admin/school/classes")
|
||||
return {
|
||||
success: true,
|
||||
message: `Updated ${updated} assignments, ${failed} failed`,
|
||||
message: t("actions.bulkAssignResult", { updated, failed }),
|
||||
data: { updated, failed, errors },
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Failed to bulk assign teachers" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
@@ -16,14 +17,33 @@ import {
|
||||
UpdateClassScheduleItemSchema,
|
||||
DeleteClassScheduleItemSchema,
|
||||
} from "./schema"
|
||||
import { toWeekday } from "./actions-shared"
|
||||
import { hasAdminScope, toWeekday } from "./actions-shared"
|
||||
import { verifyTeacherOwnsClass } from "./data-access"
|
||||
import { getClassIdByScheduleId } from "./data-access-schedule"
|
||||
|
||||
/**
|
||||
* 校验当前用户对班级的归属权限(P0-3 审计修复)。
|
||||
* - admin scope:跳过校验(管理员可管理任意班级)
|
||||
* - 其他用户:必须为该班级的班主任
|
||||
* 返回 null 表示通过,返回 string 表示错误消息。
|
||||
*/
|
||||
async function assertClassOwnership(
|
||||
ctx: Parameters<typeof hasAdminScope>[0],
|
||||
classId: string,
|
||||
t: (key: string) => string
|
||||
): Promise<string | null> {
|
||||
if (hasAdminScope(ctx)) return null
|
||||
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||
return owns ? null : t("actions.notPermissionManageClassSchedule")
|
||||
}
|
||||
|
||||
export async function createClassScheduleItemAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
const ctx = await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = CreateClassScheduleItemSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
@@ -34,22 +54,26 @@ export async function createClassScheduleItemAction(
|
||||
location: formData.get("location"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid schedule item data" }
|
||||
return { success: false, message: t("actions.invalidScheduleData") }
|
||||
}
|
||||
|
||||
const { classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||
|
||||
// P0-3: 越权校验——防止教师为他班添加课表
|
||||
const ownErr = await assertClassOwnership(ctx, classId, t)
|
||||
if (ownErr) return { success: false, message: ownErr }
|
||||
|
||||
try {
|
||||
const id = await createClassScheduleItem({
|
||||
classId,
|
||||
weekday: toWeekday(weekday),
|
||||
weekday: await toWeekday(weekday),
|
||||
startTime,
|
||||
endTime,
|
||||
course,
|
||||
location: location ?? null,
|
||||
})
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item created successfully", data: id }
|
||||
return { success: true, message: t("actions.scheduleItemCreated"), data: id }
|
||||
} catch (error) {
|
||||
return handleActionError(error)
|
||||
}
|
||||
@@ -64,7 +88,8 @@ export async function updateClassScheduleItemAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
const ctx = await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = UpdateClassScheduleItemSchema.safeParse({
|
||||
scheduleId,
|
||||
@@ -76,22 +101,33 @@ export async function updateClassScheduleItemAction(
|
||||
location: formData.get("location"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing or invalid schedule id" }
|
||||
return { success: false, message: t("actions.missingScheduleId") }
|
||||
}
|
||||
|
||||
const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||
|
||||
// P0-3: 越权校验——更新时需校验目标班级归属
|
||||
// 优先用表单传入的 classId,否则从已有 schedule item 反查 classId
|
||||
const targetClassId = typeof classId === "string" && classId.trim()
|
||||
? classId
|
||||
: await getClassIdByScheduleId(validatedScheduleId)
|
||||
if (!targetClassId) {
|
||||
return { success: false, message: t("actions.scheduleItemNotFound") }
|
||||
}
|
||||
const ownErr = await assertClassOwnership(ctx, targetClassId, t)
|
||||
if (ownErr) return { success: false, message: ownErr }
|
||||
|
||||
try {
|
||||
await updateClassScheduleItem(validatedScheduleId, {
|
||||
classId: classId ?? undefined,
|
||||
weekday: typeof weekday === "number" ? toWeekday(weekday) : undefined,
|
||||
weekday: typeof weekday === "number" ? await 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" }
|
||||
return { success: true, message: t("actions.scheduleItemUpdated") }
|
||||
} catch (error) {
|
||||
return handleActionError(error)
|
||||
}
|
||||
@@ -102,17 +138,26 @@ export async function updateClassScheduleItemAction(
|
||||
|
||||
export async function deleteClassScheduleItemAction(scheduleId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
const ctx = await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing schedule id" }
|
||||
return { success: false, message: t("actions.missingScheduleId") }
|
||||
}
|
||||
|
||||
// P0-3: 越权校验——删除时需校验该 schedule item 所属班级归属
|
||||
const targetClassId = await getClassIdByScheduleId(parsed.data.scheduleId)
|
||||
if (!targetClassId) {
|
||||
return { success: false, message: t("actions.scheduleItemNotFound") }
|
||||
}
|
||||
const ownErr = await assertClassOwnership(ctx, targetClassId, t)
|
||||
if (ownErr) return { success: false, message: ownErr }
|
||||
|
||||
try {
|
||||
await deleteClassScheduleItem(parsed.data.scheduleId)
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item deleted successfully" }
|
||||
return { success: true, message: t("actions.scheduleItemDeleted") }
|
||||
} catch (error) {
|
||||
return handleActionError(error)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import type { AuthContext } from "@/shared/types/permissions"
|
||||
import type { ClassSubject } from "./types"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import { ValidationError } from "@/shared/lib/action-utils"
|
||||
|
||||
const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS
|
||||
|
||||
@@ -9,8 +12,11 @@ export const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_ST
|
||||
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")
|
||||
export const toWeekday = async (n: number): Promise<1 | 2 | 3 | 4 | 5 | 6 | 7> => {
|
||||
if (!isWeekday(n)) {
|
||||
const t = await getTranslations("classes")
|
||||
throw new ValidationError(t("actions.invalidWeekday"))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -35,16 +41,25 @@ export const hasStudentScope = (ctx: AuthContext): boolean => ctx.dataScope.type
|
||||
/**
|
||||
* 解析表单中的 subjectTeachers JSON 字符串为标准赋值数组。
|
||||
* 提取自原 actions.ts,供 admin/grade class 更新逻辑复用。
|
||||
*
|
||||
* 入参类型直接接受 FormDataEntryValue(string | File),避免在调用方
|
||||
* 使用 `as string | null` 强断言(P2-A 修复)。
|
||||
*/
|
||||
export const parseSubjectTeachers = (raw: string | null) => {
|
||||
export const parseSubjectTeachers = async (raw: FormDataEntryValue | 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")
|
||||
// JSON.parse 返回 any,此处显式拓宽到 unknown 以迫使后续使用类型守卫(合法的 any → unknown 转换)
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) {
|
||||
const t = await getTranslations("classes")
|
||||
throw new ValidationError(t("actions.invalidSubjectTeachers"))
|
||||
}
|
||||
|
||||
return parsed.flatMap((item) => {
|
||||
return parsed.flatMap((item): Array<{ subject: ClassSubject; teacherId: string | null }> => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const subject = (item as { subject?: unknown }).subject
|
||||
const teacherId = (item as { teacherId?: unknown }).teacherId
|
||||
|
||||
// 使用 `in` 操作符收窄类型,避免 `(item as { x?: unknown }).x` 反模式(P2-A 修复)
|
||||
const subject = "subject" in item ? item.subject : undefined
|
||||
const teacherId = "teacherId" in item ? item.teacherId : undefined
|
||||
|
||||
if (typeof subject !== "string" || !isClassSubject(subject)) return []
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
createTeacherClass,
|
||||
deleteTeacherClass,
|
||||
updateTeacherClass,
|
||||
verifyTeacherOwnsClass,
|
||||
} from "./data-access"
|
||||
import { findGradeIdByHeadAndName, isGradeHead } from "@/modules/school/data-access"
|
||||
import {
|
||||
@@ -25,6 +27,7 @@ export async function createTeacherClassAction(
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = CreateTeacherClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
@@ -36,7 +39,7 @@ export async function createTeacherClassAction(
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name and grade are required" }
|
||||
return { success: false, message: t("actions.classNameGradeRequired") }
|
||||
}
|
||||
|
||||
const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||
@@ -50,7 +53,7 @@ export async function createTeacherClassAction(
|
||||
? await isGradeHead(normalizedGradeId, userId)
|
||||
: Boolean(await findGradeIdByHeadAndName(userId, grade))
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "Only admins and grade heads can create classes" }
|
||||
return { success: false, message: t("actions.onlyAdminsAndGradeHeads") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +70,7 @@ export async function createTeacherClassAction(
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
return { success: true, message: t("actions.classCreated"), data: id }
|
||||
} catch (error) {
|
||||
return handleActionError(error)
|
||||
}
|
||||
@@ -82,7 +85,8 @@ export async function updateTeacherClassAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = UpdateTeacherClassSchema.safeParse({
|
||||
classId,
|
||||
@@ -95,11 +99,19 @@ export async function updateTeacherClassAction(
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
// P0-5: 越权校验——教师更新班级前校验归属(admin scope 跳过)
|
||||
if (!hasAdminScope(ctx)) {
|
||||
const owns = await verifyTeacherOwnsClass(validatedClassId, ctx.userId)
|
||||
if (!owns) {
|
||||
return { success: false, message: t("actions.notOwnClass") }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateTeacherClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
@@ -113,7 +125,7 @@ export async function updateTeacherClassAction(
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
return { success: true, message: t("actions.classUpdated") }
|
||||
} catch (error) {
|
||||
return handleActionError(error)
|
||||
}
|
||||
@@ -124,11 +136,20 @@ export async function updateTeacherClassAction(
|
||||
|
||||
export async function deleteTeacherClassAction(classId: string): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_DELETE)
|
||||
const ctx = await requirePermission(Permissions.CLASS_DELETE)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const parsed = DeleteTeacherClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
return { success: false, message: t("actions.missingClassId") }
|
||||
}
|
||||
|
||||
// P0-5: 越权校验——教师删除班级前校验归属(admin scope 跳过)
|
||||
if (!hasAdminScope(ctx)) {
|
||||
const owns = await verifyTeacherOwnsClass(parsed.data.classId, ctx.userId)
|
||||
if (!owns) {
|
||||
return { success: false, message: t("actions.notOwnClass") }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -136,7 +157,7 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
return { success: true, message: t("actions.classDeleted") }
|
||||
} catch (error) {
|
||||
return handleActionError(error)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
||||
import type { AdminClassListItem, TeacherOption } from "../types"
|
||||
import { createAdminClassAction, deleteAdminClassAction, updateAdminClassAction } from "../actions"
|
||||
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 { 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,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { useClassData } from "../hooks/use-class-data"
|
||||
import { useClassFilters } from "../hooks/use-class-filters"
|
||||
import type { ClassFormGrade } from "./class-form-utils"
|
||||
import { ClassDeleteDialog } from "./class-delete-dialog"
|
||||
import { ClassFormDialog } from "./class-form-dialog"
|
||||
import { ClassListTable } from "./class-list-table"
|
||||
import { ClassListToolbar } from "./class-list-toolbar"
|
||||
|
||||
export function AdminClassesClient({
|
||||
classes,
|
||||
@@ -47,483 +26,133 @@ export function AdminClassesClient({
|
||||
schools: { id: string; name: string }[]
|
||||
grades: { id: string; name: string; school: { id: string; name: string } }[]
|
||||
}) {
|
||||
const t = useTranslations("classes")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
|
||||
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
|
||||
|
||||
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
|
||||
const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools])
|
||||
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
|
||||
const [createSchoolId, setCreateSchoolId] = useState(defaultSchoolId)
|
||||
const [createGradeId, setCreateGradeId] = useState("")
|
||||
const [editTeacherId, setEditTeacherId] = useState("")
|
||||
const [editSchoolId, setEditSchoolId] = useState("")
|
||||
const [editGradeId, setEditGradeId] = useState("")
|
||||
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
|
||||
const data = useClassData({ defaultTeacherId, defaultSchoolId, defaultGradeId: "" })
|
||||
|
||||
const createGrades = useMemo(() => grades.filter((g) => g.school.id === createSchoolId), [grades, createSchoolId])
|
||||
const editGrades = useMemo(() => grades.filter((g) => g.school.id === editSchoolId), [grades, editSchoolId])
|
||||
const selectedCreateSchool = schools.find((s) => s.id === createSchoolId)
|
||||
const selectedCreateGrade = grades.find((g) => g.id === createGradeId)
|
||||
const selectedEditSchool = schools.find((s) => s.id === editSchoolId)
|
||||
const selectedEditGrade = grades.find((g) => g.id === editGradeId)
|
||||
const formGrades: ClassFormGrade[] = useMemo(
|
||||
() => grades.map((g) => ({ id: g.id, name: g.name, schoolId: g.school.id, schoolName: g.school.name })),
|
||||
[grades],
|
||||
)
|
||||
const { createGrades, editGrades } = useClassFilters(formGrades, data.createSchoolId, data.editSchoolId)
|
||||
|
||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||
if (createOpen !== prevCreateOpen) {
|
||||
setPrevCreateOpen(createOpen)
|
||||
if (createOpen) {
|
||||
setCreateTeacherId(defaultTeacherId)
|
||||
setCreateSchoolId(defaultSchoolId)
|
||||
setCreateGradeId("")
|
||||
}
|
||||
}
|
||||
|
||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||
if (editItem !== prevEditItem) {
|
||||
setPrevEditItem(editItem)
|
||||
if (editItem) {
|
||||
setEditTeacherId(editItem.teacher.id)
|
||||
setEditSchoolId(editItem.schoolId ?? "")
|
||||
setEditGradeId(editItem.gradeId ?? "")
|
||||
setEditSubjectTeachers(
|
||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||
subject: s,
|
||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
const handleCreate = async (formData: FormData): Promise<void> => {
|
||||
data.setIsWorking(true)
|
||||
try {
|
||||
const res = await createAdminClassAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setCreateOpen(false)
|
||||
data.setCreateOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create class")
|
||||
toast.error(res.message || t("list.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create class")
|
||||
toast.error(t("list.failedCreate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (formData: FormData) => {
|
||||
if (!editItem) return
|
||||
setIsWorking(true)
|
||||
const handleUpdate = async (formData: FormData): Promise<void> => {
|
||||
if (!data.editItem) return
|
||||
data.setIsWorking(true)
|
||||
try {
|
||||
const res = await updateAdminClassAction(editItem.id, undefined, formData)
|
||||
const res = await updateAdminClassAction(data.editItem.id, undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setEditItem(null)
|
||||
data.setEditItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update class")
|
||||
toast.error(res.message || t("list.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update class")
|
||||
toast.error(t("list.failedUpdate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteItem) return
|
||||
setIsWorking(true)
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
if (!data.deleteItem) return
|
||||
data.setIsWorking(true)
|
||||
try {
|
||||
const res = await deleteAdminClassAction(deleteItem.id)
|
||||
const res = await deleteAdminClassAction(data.deleteItem.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setDeleteItem(null)
|
||||
data.setDeleteItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete class")
|
||||
toast.error(res.message || t("list.failedDelete"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete class")
|
||||
toast.error(t("list.failedDelete"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
|
||||
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
|
||||
}
|
||||
|
||||
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
|
||||
const pairs = list
|
||||
.filter((x) => x.teacher)
|
||||
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
|
||||
.filter((x) => x.length > 0)
|
||||
return pairs.length > 0 ? pairs.join(",") : "-"
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New class
|
||||
</Button>
|
||||
</div>
|
||||
<ClassListToolbar count={classes.length} onNew={() => data.setCreateOpen(true)} isWorking={data.isWorking} />
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">All classes</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{classes.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No classes"
|
||||
description="Create classes to manage students and schedules."
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>School</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Grade</TableHead>
|
||||
<TableHead>Homeroom</TableHead>
|
||||
<TableHead>Room</TableHead>
|
||||
<TableHead>班主任</TableHead>
|
||||
<TableHead>任课老师</TableHead>
|
||||
<TableHead className="text-right">Students</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead className="w-[60px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{classes.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditItem(c)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteItem(c)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ClassListTable
|
||||
classes={classes}
|
||||
onEdit={data.setEditItem}
|
||||
onDelete={data.setDeleteItem}
|
||||
isWorking={data.isWorking}
|
||||
/>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New class</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">School</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={createSchoolId}
|
||||
onValueChange={(v) => {
|
||||
setCreateSchoolId(v)
|
||||
setCreateGradeId("")
|
||||
}}
|
||||
disabled={schools.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={schools.length === 0 ? "No schools" : "Select a school"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schools.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="schoolId" value={createSchoolId} />
|
||||
<input type="hidden" name="schoolName" value={selectedCreateSchool?.name ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<ClassFormDialog
|
||||
open={data.createOpen}
|
||||
onOpenChange={data.setCreateOpen}
|
||||
mode="create"
|
||||
teachers={teachers}
|
||||
schools={schools}
|
||||
grades={createGrades}
|
||||
onSubmit={handleCreate}
|
||||
isWorking={data.isWorking}
|
||||
teacherId={data.createTeacherId}
|
||||
schoolId={data.createSchoolId}
|
||||
gradeId={data.createGradeId}
|
||||
onTeacherIdChange={data.setCreateTeacherId}
|
||||
onSchoolIdChange={data.setCreateSchoolId}
|
||||
onGradeIdChange={data.setCreateGradeId}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Grade 10 · Class 3" autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Grade</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={createGradeId}
|
||||
onValueChange={setCreateGradeId}
|
||||
disabled={createGrades.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={createGrades.length === 0 ? "No grades" : "Select a grade"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{createGrades.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="gradeId" value={createGradeId} />
|
||||
<input type="hidden" name="grade" value={selectedCreateGrade?.name ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-homeroom" className="text-right">
|
||||
Homeroom
|
||||
</Label>
|
||||
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-room" className="text-right">
|
||||
Room
|
||||
</Label>
|
||||
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Teacher</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="teacherId" value={createTeacherId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId || !createGradeId}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (isWorking) return
|
||||
if (!open) setEditItem(null)
|
||||
<ClassFormDialog
|
||||
open={Boolean(data.editItem)}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) data.setEditItem(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit class</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editItem ? (
|
||||
<form action={handleUpdate} className="space-y-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">School</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={editSchoolId}
|
||||
onValueChange={(v) => {
|
||||
setEditSchoolId(v)
|
||||
setEditGradeId("")
|
||||
}}
|
||||
disabled={schools.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={schools.length === 0 ? "No schools" : "Select a school"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schools.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="schoolId" value={editSchoolId} />
|
||||
<input type="hidden" name="schoolName" value={selectedEditSchool?.name ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
mode="edit"
|
||||
editItem={data.editItem}
|
||||
teachers={teachers}
|
||||
schools={schools}
|
||||
grades={editGrades}
|
||||
onSubmit={handleUpdate}
|
||||
isWorking={data.isWorking}
|
||||
teacherId={data.editTeacherId}
|
||||
schoolId={data.editSchoolId}
|
||||
gradeId={data.editGradeId}
|
||||
subjectTeachers={data.editSubjectTeachers}
|
||||
onTeacherIdChange={data.setEditTeacherId}
|
||||
onSchoolIdChange={data.setEditSchoolId}
|
||||
onGradeIdChange={data.setEditGradeId}
|
||||
onSubjectTeacherChange={data.setSubjectTeacher}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Grade</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={editGradeId}
|
||||
onValueChange={setEditGradeId}
|
||||
disabled={editGrades.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={editGrades.length === 0 ? "No grades" : "Select a grade"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{editGrades.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="gradeId" value={editGradeId} />
|
||||
<input type="hidden" name="grade" value={selectedEditGrade?.name ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-homeroom" className="text-right">
|
||||
Homeroom
|
||||
</Label>
|
||||
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-room" className="text-right">
|
||||
Room
|
||||
</Label>
|
||||
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">班主任</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="teacherId" value={editTeacherId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-4">
|
||||
<div className="text-sm font-medium">任课老师</div>
|
||||
<div className="grid gap-3">
|
||||
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
|
||||
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
|
||||
return (
|
||||
<div key={subject} className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{subject}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={selected ?? ""}
|
||||
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
|
||||
disabled={teachers.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking || !editTeacherId}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteItem(null)
|
||||
<ClassDeleteDialog
|
||||
open={Boolean(data.deleteItem)}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) data.setDeleteItem(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete class</AlertDialogTitle>
|
||||
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
item={data.deleteItem}
|
||||
onConfirm={handleDelete}
|
||||
isWorking={data.isWorking}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
34
src/modules/classes/components/class-delete-dialog.tsx
Normal file
34
src/modules/classes/components/class-delete-dialog.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { AdminClassListItem } from "../types"
|
||||
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||
|
||||
export function ClassDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
onConfirm,
|
||||
isWorking,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
item: AdminClassListItem | null
|
||||
onConfirm: () => Promise<void>
|
||||
isWorking: boolean
|
||||
}) {
|
||||
const t = useTranslations("classes")
|
||||
return (
|
||||
<ConfirmDeleteDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("delete.title")}
|
||||
description={t("delete.description", { name: item?.name || t("delete.thisClass") })}
|
||||
onConfirm={onConfirm}
|
||||
isWorking={isWorking}
|
||||
confirmText={t("delete.confirm")}
|
||||
cancelText={t("delete.cancel")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { ChevronRight, FileText } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -26,20 +28,21 @@ interface ClassAssignmentsWidgetProps {
|
||||
}
|
||||
|
||||
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
|
||||
const t = useTranslations("classes")
|
||||
const activeAssignments = assignments.filter((a) => a.isActive)
|
||||
|
||||
return (
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold">Recent Homework</CardTitle>
|
||||
<CardTitle className="text-base font-semibold">{t("detail.widgets.recentHomework")}</CardTitle>
|
||||
<CardDescription>
|
||||
{activeAssignments.length} active assignments
|
||||
{t("detail.assignments.activeCount", { count: activeAssignments.length })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
|
||||
View All
|
||||
{t("detail.assignments.viewAll")}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -51,14 +54,14 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
|
||||
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No homework yet</p>
|
||||
<p className="text-sm font-medium">{t("detail.empty.noAssignments")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an assignment to get started.
|
||||
{t("detail.empty.noAssignmentsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
||||
Create Homework
|
||||
{t("detail.assignments.createHomework")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -78,11 +81,16 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
|
||||
Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}
|
||||
{assignment.dueAt
|
||||
? t("detail.assignments.due", { date: formatDate(assignment.dueAt) })
|
||||
: t("detail.assignments.noDueDate")}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{assignment.submittedCount}/{assignment.targetCount} Submitted
|
||||
{t("detail.assignments.submittedCount", {
|
||||
submitted: assignment.submittedCount,
|
||||
total: assignment.targetCount,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +103,7 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
|
||||
</Badge>
|
||||
{typeof assignment.avgScore === "number" && (
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
Avg: {assignment.avgScore.toFixed(0)}%
|
||||
{t("detail.assignments.avgLabel")}: {assignment.avgScore.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -34,6 +35,7 @@ export function ClassHeader({
|
||||
studentCount,
|
||||
}: ClassHeaderProps) {
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const t = useTranslations("classes")
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -56,45 +58,45 @@ export function ClassHeader({
|
||||
{homeroom && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">•</span>
|
||||
<span>Homeroom {homeroom}</span>
|
||||
<span>{t("detail.header.homeroom", { name: homeroom })}</span>
|
||||
</>
|
||||
)}
|
||||
{room && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">•</span>
|
||||
<span>Room {room}</span>
|
||||
<span>{t("detail.header.room", { room })}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/40">•</span>
|
||||
<span>{studentCount} Students</span>
|
||||
<span>{t("detail.header.students", { count: studentCount })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="hidden sm:flex">
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
Invite
|
||||
{t("detail.header.invite")}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More actions</span>
|
||||
<span className="sr-only">{t("detail.header.moreActions")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit details
|
||||
{t("detail.header.editDetails")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
Invite students
|
||||
{t("detail.header.inviteStudents")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Class settings
|
||||
{t("detail.header.classSettings")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
|
||||
@@ -16,30 +18,31 @@ export function ClassOverviewStats({
|
||||
papersToGrade,
|
||||
overdueCount,
|
||||
}: ClassOverviewStatsProps) {
|
||||
const t = useTranslations("classes")
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Class Average"
|
||||
title={t("detail.overview.averageScore")}
|
||||
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
|
||||
description="Overall performance"
|
||||
description={t("detail.overview.overallPerformance")}
|
||||
icon={BarChart3}
|
||||
/>
|
||||
<StatCard
|
||||
title="Submission Rate"
|
||||
title={t("detail.overview.submissionRate")}
|
||||
value={`${submissionRate.toFixed(0)}%`}
|
||||
description="Average turn-in rate"
|
||||
description={t("detail.overview.averageTurnInRate")}
|
||||
icon={CheckCircle2}
|
||||
/>
|
||||
<StatCard
|
||||
title="To Grade"
|
||||
title={t("detail.overview.papersToGrade")}
|
||||
value={papersToGrade.toString()}
|
||||
description="Pending reviews"
|
||||
description={t("detail.overview.pendingReviews")}
|
||||
icon={PenTool}
|
||||
/>
|
||||
<StatCard
|
||||
title="Missed Deadlines"
|
||||
title={t("detail.overview.overdueCount")}
|
||||
value={overdueCount.toString()}
|
||||
description="Active assignments past due"
|
||||
description={t("detail.overview.activeAssignmentsPastDue")}
|
||||
icon={AlertCircle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -10,31 +12,32 @@ interface ClassQuickActionsProps {
|
||||
}
|
||||
|
||||
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
|
||||
const t = useTranslations("classes")
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
|
||||
<CardTitle className="text-base font-semibold">{t("detail.widgets.quickActions")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<Button asChild className="w-full justify-start" size="sm">
|
||||
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
||||
<FilePlus className="mr-2 h-4 w-4" />
|
||||
Create Homework
|
||||
{t("detail.assignments.createHomework")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full justify-start" size="sm">
|
||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Manage Schedule
|
||||
{t("detail.quickActions.manageSchedule")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Message Class (Coming soon)
|
||||
{t("detail.quickActions.messageClassComingSoon")}
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Class Settings
|
||||
{t("detail.header.classSettings")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -12,17 +14,28 @@ interface ClassScheduleWidgetProps {
|
||||
schedule: ClassScheduleItem[]
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
|
||||
|
||||
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
|
||||
const t = useTranslations("classes")
|
||||
const weekdayLabels = [
|
||||
t("schedule.weekday.1"),
|
||||
t("schedule.weekday.2"),
|
||||
t("schedule.weekday.3"),
|
||||
t("schedule.weekday.4"),
|
||||
t("schedule.weekday.5"),
|
||||
]
|
||||
// Group by weekday
|
||||
const groupedSchedule = schedule.reduce((acc, item) => {
|
||||
const day = item.weekday
|
||||
if (!acc[day]) acc[day] = []
|
||||
acc[day].push(item)
|
||||
return acc
|
||||
}, {} as Record<number, ClassScheduleItem[]>)
|
||||
// P2-A: 使用 reduce<T> 泛型参数替代 `{} as Record<...>` 反模式
|
||||
const groupedSchedule = schedule.reduce<Record<number, ClassScheduleItem[]>>(
|
||||
(acc, item) => {
|
||||
const day = item.weekday
|
||||
if (!acc[day]) acc[day] = []
|
||||
acc[day].push(item)
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
// Sort items within each day by start time
|
||||
Object.keys(groupedSchedule).forEach(key => {
|
||||
@@ -35,15 +48,15 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Calendar className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">No sessions scheduled.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("detail.empty.noScheduleDescription")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
||||
{WEEKDAYS.slice(0, 5).map((day) => (
|
||||
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
||||
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
||||
{weekdayLabels.map((day, idx) => (
|
||||
<div key={WEEKDAY_INDICES[idx]} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
@@ -89,13 +102,14 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
|
||||
}
|
||||
|
||||
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
|
||||
const t = useTranslations("classes")
|
||||
return (
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base font-semibold">Weekly Schedule</CardTitle>
|
||||
<CardTitle className="text-base font-semibold">{t("detail.widgets.weeklySchedule")}</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
||||
Manage
|
||||
{t("detail.schedule.manage")}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -103,7 +117,7 @@ export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetPr
|
||||
<CardContent className="pt-4">
|
||||
<ClassScheduleGrid schedule={schedule} />
|
||||
<div className="mt-2 text-[10px] text-muted-foreground text-center">
|
||||
* Showing Mon-Fri schedule
|
||||
* {t("detail.schedule.showingWeekdays")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { ChevronRight, Users } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -22,20 +24,21 @@ interface ClassStudentsWidgetProps {
|
||||
}
|
||||
|
||||
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
|
||||
const t = useTranslations("classes")
|
||||
const activeCount = students.filter(s => s.status === "active").length
|
||||
|
||||
return (
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold">Students</CardTitle>
|
||||
<CardTitle className="text-base font-semibold">{t("detail.widgets.studentList")}</CardTitle>
|
||||
<CardDescription>
|
||||
{activeCount} active students
|
||||
{t("detail.students.activeCount", { count: activeCount })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
|
||||
View All
|
||||
{t("detail.students.viewAll")}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -46,7 +49,7 @@ export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetPr
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Users className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">No students enrolled yet.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("students.empty.description")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||
@@ -35,25 +36,6 @@ interface ClassTrendsWidgetProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
submitted: {
|
||||
label: "Submitted",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
target: {
|
||||
label: "Total Students",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
avg: {
|
||||
label: "Average Score",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
median: {
|
||||
label: "Median Score",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
|
||||
const data = [...assignments].reverse().map(a => ({
|
||||
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
|
||||
@@ -71,13 +53,20 @@ export function transformAssignmentsToChartData(assignments: AssignmentSummary[]
|
||||
return data
|
||||
}
|
||||
|
||||
export function ClassSubmissionTrendChart({
|
||||
data,
|
||||
className
|
||||
}: {
|
||||
export function ClassSubmissionTrendChart({
|
||||
data,
|
||||
className
|
||||
}: {
|
||||
data: Record<string, string | number>[]
|
||||
className?: string
|
||||
}) {
|
||||
const t = useTranslations("classes")
|
||||
const chartConfig = {
|
||||
submitted: { label: t("detail.trends.submitted"), color: "hsl(var(--primary))" },
|
||||
target: { label: t("detail.trends.totalStudents"), color: "hsl(var(--muted-foreground))" },
|
||||
avg: { label: t("detail.trends.averageScore"), color: "hsl(var(--chart-2))" },
|
||||
median: { label: t("detail.trends.medianScore"), color: "hsl(var(--chart-4))" },
|
||||
} satisfies ChartConfig
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className={className}>
|
||||
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
|
||||
@@ -120,11 +109,21 @@ export function ClassSubmissionTrendChart({
|
||||
}
|
||||
|
||||
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
|
||||
const t = useTranslations("classes")
|
||||
const chartConfig = {
|
||||
submitted: { label: t("detail.trends.submitted"), color: "hsl(var(--primary))" },
|
||||
target: { label: t("detail.trends.totalStudents"), color: "hsl(var(--muted-foreground))" },
|
||||
avg: { label: t("detail.trends.averageScore"), color: "hsl(var(--chart-2))" },
|
||||
median: { label: t("detail.trends.medianScore"), color: "hsl(var(--chart-4))" },
|
||||
} satisfies ChartConfig
|
||||
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
|
||||
const [selectedSubject, setSelectedSubject] = useState<string>("all")
|
||||
|
||||
// Extract unique subjects
|
||||
const subjects = Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]
|
||||
// P2-A: 使用类型守卫替代 `as string[]`(filter(Boolean) 不会收窄类型是 TS 已知陷阱)
|
||||
const subjects = Array.from(new Set(
|
||||
assignments.map((a) => a.subject).filter((s): s is string => typeof s === "string")
|
||||
))
|
||||
|
||||
const activeAssignments = assignments.filter((a) => {
|
||||
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
|
||||
@@ -140,7 +139,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
||||
const lastAssignment = chartData[chartData.length - 1]
|
||||
|
||||
let metricValue = "0%"
|
||||
const metricLabel = "Latest"
|
||||
const metricLabel = t("detail.trends.latest")
|
||||
|
||||
if (lastAssignment) {
|
||||
if (chartTab === "submission") {
|
||||
@@ -159,16 +158,16 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
|
||||
{chartTab === "submission" ? "Submission" : "Score"}
|
||||
{chartTab === "submission" ? t("detail.trends.submission") : t("detail.trends.score")}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
|
||||
Submission Trends
|
||||
{t("detail.widgets.submissionTrends")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
|
||||
Score Trends
|
||||
{t("detail.trends.scoreTrends")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -177,13 +176,13 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
|
||||
{selectedSubject === "all" ? "All Subjects" : selectedSubject}
|
||||
{selectedSubject === "all" ? t("detail.trends.allSubjects") : selectedSubject}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
|
||||
All Subjects
|
||||
{t("detail.trends.allSubjects")}
|
||||
</DropdownMenuItem>
|
||||
{subjects.map(s => (
|
||||
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
|
||||
@@ -275,28 +274,28 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
|
||||
{chartTab === "submission" ? t("detail.widgets.submissionTrends") : t("detail.trends.scoreTrends")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
|
||||
{chartTab === "submission" ? t("detail.trends.recentTurnInRates") : t("detail.trends.avgVsMedian")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
|
||||
<TabsList className="grid w-full grid-cols-2 h-8">
|
||||
<TabsTrigger value="submission" className="text-xs">Submission</TabsTrigger>
|
||||
<TabsTrigger value="score" className="text-xs">Score</TabsTrigger>
|
||||
<TabsTrigger value="submission" className="text-xs">{t("detail.trends.submission")}</TabsTrigger>
|
||||
<TabsTrigger value="score" className="text-xs">{t("detail.trends.score")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
{subjects.length > 0 && (
|
||||
<Tabs value={selectedSubject} onValueChange={setSelectedSubject} className="w-full">
|
||||
<TabsList className="h-8 w-auto flex-wrap justify-start bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
|
||||
>
|
||||
All Subjects
|
||||
{t("detail.trends.allSubjects")}
|
||||
</TabsTrigger>
|
||||
{subjects.map(s => (
|
||||
<TabsTrigger
|
||||
@@ -387,7 +386,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
|
||||
No data for this subject
|
||||
{t("detail.trends.noDataForSubject")}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
@@ -36,10 +37,11 @@ export function EditClassDialog({
|
||||
classId,
|
||||
initialData,
|
||||
}: EditClassDialogProps) {
|
||||
const t = useTranslations("classes")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const handleEdit = async (formData: FormData) => {
|
||||
const handleEdit = async (formData: FormData): Promise<void> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await updateTeacherClassAction(classId, null, formData)
|
||||
@@ -48,10 +50,10 @@ export function EditClassDialog({
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update class")
|
||||
toast.error(res.message || t("list.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update class")
|
||||
toast.error(t("list.failedUpdate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -67,26 +69,26 @@ export function EditClassDialog({
|
||||
>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit class</DialogTitle>
|
||||
<DialogDescription>Update basic class information.</DialogDescription>
|
||||
<DialogTitle>{t("detail.edit.title")}</DialogTitle>
|
||||
<DialogDescription>{t("detail.edit.title")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleEdit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="schoolName" className="text-right">
|
||||
School
|
||||
{t("list.column.school")}
|
||||
</Label>
|
||||
<Input
|
||||
id="schoolName"
|
||||
name="schoolName"
|
||||
className="col-span-3"
|
||||
defaultValue={initialData.schoolName ?? ""}
|
||||
placeholder="Optional"
|
||||
placeholder={t("form.optional")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
{t("detail.edit.name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
@@ -98,7 +100,7 @@ export function EditClassDialog({
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="grade" className="text-right">
|
||||
Grade
|
||||
{t("class.grade")}
|
||||
</Label>
|
||||
<Input
|
||||
id="grade"
|
||||
@@ -110,7 +112,7 @@ export function EditClassDialog({
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="homeroom" className="text-right">
|
||||
Homeroom
|
||||
{t("detail.edit.homeroom")}
|
||||
</Label>
|
||||
<Input
|
||||
id="homeroom"
|
||||
@@ -121,7 +123,7 @@ export function EditClassDialog({
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="room" className="text-right">
|
||||
Room
|
||||
{t("detail.edit.room")}
|
||||
</Label>
|
||||
<Input
|
||||
id="room"
|
||||
@@ -133,7 +135,7 @@ export function EditClassDialog({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : "Save Changes"}
|
||||
{isWorking ? t("form.saving") : t("form.saveChanges")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
57
src/modules/classes/components/class-error-boundary.tsx
Normal file
57
src/modules/classes/components/class-error-boundary.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 班级模块 Error Boundary。
|
||||
*
|
||||
* 薄包装:委托给共享 SectionErrorBoundary,通过自定义 fallback 实现
|
||||
* 重试时调用 router.refresh() 刷新服务端数据。
|
||||
* 保留同名导出以兼容现有 import。
|
||||
*/
|
||||
|
||||
import 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"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
|
||||
interface ClassErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
export function ClassErrorBoundary({
|
||||
children,
|
||||
fallback,
|
||||
}: ClassErrorBoundaryProps): ReactNode {
|
||||
const t = useTranslations("classes")
|
||||
const router = useRouter()
|
||||
|
||||
const customFallback = (_error: Error, reset: () => void): ReactNode => {
|
||||
const handleRetry = (): void => {
|
||||
reset()
|
||||
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" aria-hidden="true" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionErrorBoundary fallback={fallback ? () => fallback : customFallback}>
|
||||
{children}
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
232
src/modules/classes/components/class-form-dialog.tsx
Normal file
232
src/modules/classes/components/class-form-dialog.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { AdminClassListItem, TeacherOption } from "../types"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
||||
import type { ClassFormGrade, SubjectTeacherState } from "./class-form-utils"
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
|
||||
export function ClassFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
editItem,
|
||||
teachers,
|
||||
schools,
|
||||
grades,
|
||||
onSubmit,
|
||||
isWorking,
|
||||
teacherId,
|
||||
schoolId,
|
||||
gradeId,
|
||||
subjectTeachers,
|
||||
onTeacherIdChange,
|
||||
onSchoolIdChange,
|
||||
onGradeIdChange,
|
||||
onSubjectTeacherChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
mode: "create" | "edit"
|
||||
editItem?: AdminClassListItem | null
|
||||
teachers: TeacherOption[]
|
||||
schools?: { id: string; name: string }[]
|
||||
grades: ClassFormGrade[]
|
||||
onSubmit: (formData: FormData) => Promise<void>
|
||||
isWorking: boolean
|
||||
teacherId: string
|
||||
schoolId: string
|
||||
gradeId: string
|
||||
subjectTeachers?: SubjectTeacherState[]
|
||||
onTeacherIdChange: (id: string) => void
|
||||
onSchoolIdChange: (id: string) => void
|
||||
onGradeIdChange: (id: string) => void
|
||||
onSubjectTeacherChange?: (subject: string, teacherId: string | null) => void
|
||||
}) {
|
||||
const t = useTranslations("classes")
|
||||
const isEdit = mode === "edit"
|
||||
const selectedSchool = schools?.find((s) => s.id === schoolId)
|
||||
const selectedGrade = grades.find((g) => g.id === gradeId)
|
||||
// admin 模式:学校来自学校选择;grade 模式:学校来自所选年级
|
||||
const hiddenSchoolId = schools ? schoolId : (selectedGrade?.schoolId ?? "")
|
||||
const hiddenSchoolName = schools ? (selectedSchool?.name ?? "") : (selectedGrade?.schoolName ?? "")
|
||||
const showForm = mode === "create" || Boolean(editItem)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (isWorking) return
|
||||
onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? t("form.editTitle") : t("form.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{showForm ? (
|
||||
<form action={onSubmit} className="space-y-4">
|
||||
{schools ? (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{t("form.school")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={schoolId}
|
||||
onValueChange={(v) => {
|
||||
onSchoolIdChange(v)
|
||||
onGradeIdChange("")
|
||||
}}
|
||||
disabled={schools.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={schools.length === 0 ? t("form.noSchools") : t("form.selectSchool")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schools.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{t("form.grade")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={gradeId} onValueChange={onGradeIdChange} disabled={grades.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={grades.length === 0 ? t("form.noGrades") : t("form.selectGrade")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{grades.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{schools ? g.name : `${g.name} (${g.schoolName ?? ""})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="gradeId" value={gradeId} />
|
||||
<input type="hidden" name="grade" value={selectedGrade?.name ?? ""} />
|
||||
<input type="hidden" name="schoolId" value={hiddenSchoolId} />
|
||||
<input type="hidden" name="schoolName" value={hiddenSchoolName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="class-name" className="text-right">
|
||||
{t("form.name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="class-name"
|
||||
name="name"
|
||||
className="col-span-3"
|
||||
placeholder={t("form.namePlaceholder")}
|
||||
defaultValue={isEdit ? editItem?.name : undefined}
|
||||
autoFocus={!isEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="class-homeroom" className="text-right">
|
||||
{t("form.homeroomLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="class-homeroom"
|
||||
name="homeroom"
|
||||
className="col-span-3"
|
||||
placeholder={t("form.optional")}
|
||||
defaultValue={isEdit ? editItem?.homeroom ?? "" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="class-room" className="text-right">
|
||||
{t("form.room")}
|
||||
</Label>
|
||||
<Input
|
||||
id="class-room"
|
||||
name="room"
|
||||
className="col-span-3"
|
||||
placeholder={t("form.optional")}
|
||||
defaultValue={isEdit ? editItem?.room ?? "" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{t("form.homeroom")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={teacherId} onValueChange={onTeacherIdChange} disabled={teachers.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={teachers.length === 0 ? t("form.noTeachers") : t("form.selectTeacher")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((teacher) => (
|
||||
<SelectItem key={teacher.id} value={teacher.id}>
|
||||
{teacher.name} ({teacher.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="teacherId" value={teacherId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEdit && subjectTeachers && onSubjectTeacherChange ? (
|
||||
<div className="space-y-3 rounded-md border p-4">
|
||||
<div className="text-sm font-medium">{t("form.subjectTeachers")}</div>
|
||||
<div className="grid gap-3">
|
||||
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
|
||||
const selected = subjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
|
||||
return (
|
||||
<div key={subject} className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{subject}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={selected ?? ""}
|
||||
onValueChange={(v) => onSubjectTeacherChange(subject, v ? v : null)}
|
||||
disabled={teachers.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={teachers.length === 0 ? t("form.noTeachers") : t("form.selectTeacher")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((teacher) => (
|
||||
<SelectItem key={teacher.id} value={teacher.id}>
|
||||
{teacher.name} ({teacher.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<input type="hidden" name="subjectTeachers" value={JSON.stringify(subjectTeachers)} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking || teachers.length === 0 || !teacherId || !gradeId}>
|
||||
{isEdit ? t("form.save") : t("form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
35
src/modules/classes/components/class-form-utils.ts
Normal file
35
src/modules/classes/components/class-form-utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ClassSubjectTeacherAssignment } from "../types"
|
||||
|
||||
/**
|
||||
* 表单使用的年级选项(统一 admin / grade 两种来源的年级形状)。
|
||||
* - admin 模式:由 `{ id, name, school: { id, name } }` 转换而来
|
||||
* - grade 模式:managedGrades 已具备 `{ id, name, schoolId, schoolName }`
|
||||
*/
|
||||
export type ClassFormGrade = {
|
||||
id: string
|
||||
name: string
|
||||
schoolId?: string
|
||||
schoolName?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑弹窗中科目-教师分配的表单状态。
|
||||
*/
|
||||
export type SubjectTeacherState = {
|
||||
subject: string
|
||||
teacherId: string | null
|
||||
}
|
||||
|
||||
export type { ClassSubjectTeacherAssignment }
|
||||
|
||||
/**
|
||||
* 格式化班级任课教师列表为可读字符串。
|
||||
* 统一使用中文标点(冒号无空格、中文逗号分隔),与 K12 中文界面保持一致。
|
||||
*/
|
||||
export function formatSubjectTeachers(list: ClassSubjectTeacherAssignment[]): string {
|
||||
const pairs = list
|
||||
.filter((x) => x.teacher)
|
||||
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
|
||||
.filter((x) => x.length > 0)
|
||||
return pairs.length > 0 ? pairs.join(",") : "-"
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function ClassInvitationManager({
|
||||
const [revokeTarget, setRevokeTarget] = React.useState<InvitationCodeRecord | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
|
||||
const handleCopy = async (code: string) => {
|
||||
const handleCopy = async (code: string): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
toast.success(t("copied"))
|
||||
@@ -78,7 +78,7 @@ export function ClassInvitationManager({
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async () => {
|
||||
const handleRevoke = async (): Promise<void> => {
|
||||
if (!revokeTarget) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
@@ -231,7 +231,7 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP
|
||||
const [note, setNote] = React.useState("")
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
|
||||
106
src/modules/classes/components/class-list-table.tsx
Normal file
106
src/modules/classes/components/class-list-table.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { AdminClassListItem } from "../types"
|
||||
import { formatSubjectTeachers } from "./class-form-utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export function ClassListTable({
|
||||
classes,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isWorking,
|
||||
emptyDescription,
|
||||
}: {
|
||||
classes: AdminClassListItem[]
|
||||
onEdit: (item: AdminClassListItem) => void
|
||||
onDelete: (item: AdminClassListItem) => void
|
||||
isWorking: boolean
|
||||
emptyDescription?: string
|
||||
}) {
|
||||
const t = useTranslations("classes")
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("list.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("list.empty.title")}
|
||||
description={emptyDescription ?? t("list.empty.description")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("list.column.school")}</TableHead>
|
||||
<TableHead>{t("list.column.name")}</TableHead>
|
||||
<TableHead>{t("list.column.grade")}</TableHead>
|
||||
<TableHead>{t("list.column.homeroomLabel")}</TableHead>
|
||||
<TableHead>{t("list.column.room")}</TableHead>
|
||||
<TableHead>{t("list.column.homeroom")}</TableHead>
|
||||
<TableHead>{t("list.column.subjectTeachers")}</TableHead>
|
||||
<TableHead className="text-right">{t("list.column.studentCount")}</TableHead>
|
||||
<TableHead>{t("list.column.updated")}</TableHead>
|
||||
<TableHead className="w-[60px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{classes.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(c)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("list.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("list.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
32
src/modules/classes/components/class-list-toolbar.tsx
Normal file
32
src/modules/classes/components/class-list-toolbar.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"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"
|
||||
|
||||
export function ClassListToolbar({
|
||||
count,
|
||||
onNew,
|
||||
isWorking,
|
||||
disabled = false,
|
||||
}: {
|
||||
count: number
|
||||
onNew: () => void
|
||||
isWorking: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const t = useTranslations("classes")
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{count}
|
||||
</Badge>
|
||||
<Button onClick={onNew} disabled={isWorking || disabled}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("list.new")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
src/modules/classes/components/class-skeleton.tsx
Normal file
120
src/modules/classes/components/class-skeleton.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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 ClassListSkeletonProps {
|
||||
rows?: number
|
||||
}
|
||||
|
||||
export function ClassListSkeleton({ rows = 5 }: ClassListSkeletonProps): 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>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</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>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClassCardSkeleton(): 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>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClassGridSkeleton({ count = 6 }: { count?: number }): JSX.Element {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<ClassCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudentListSkeleton({ rows = 8 }: { rows?: number }): JSX.Element {
|
||||
return (
|
||||
<div className="rounded-md border bg-card">
|
||||
<div className="p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2 p-4 pt-0">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScheduleGridSkeleton({ count = 6 }: { count?: number }): JSX.Element {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: count }).map((_, idx) => (
|
||||
<div key={idx} className="rounded-lg border bg-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-3">
|
||||
<Skeleton className="h-4 w-[70%]" />
|
||||
<Skeleton className="h-4 w-[85%]" />
|
||||
<Skeleton className="h-4 w-[60%]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
||||
import type { AdminClassListItem, TeacherOption } from "../types"
|
||||
import { createGradeClassAction, deleteGradeClassAction, updateGradeClassAction } from "../actions"
|
||||
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 { 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,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { useClassData } from "../hooks/use-class-data"
|
||||
import { ClassDeleteDialog } from "./class-delete-dialog"
|
||||
import { ClassFormDialog } from "./class-form-dialog"
|
||||
import { ClassListTable } from "./class-list-table"
|
||||
import { ClassListToolbar } from "./class-list-toolbar"
|
||||
|
||||
export function GradeClassesClient({
|
||||
classes,
|
||||
@@ -36,402 +22,131 @@ export function GradeClassesClient({
|
||||
teachers: TeacherOption[]
|
||||
managedGrades: { id: string; name: string; schoolId: string; schoolName: string | null }[]
|
||||
}) {
|
||||
const t = useTranslations("classes")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
|
||||
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
|
||||
|
||||
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
|
||||
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
|
||||
const [createGradeId, setCreateGradeId] = useState(managedGrades[0]?.id ?? "")
|
||||
const defaultGradeId = useMemo(() => managedGrades[0]?.id ?? "", [managedGrades])
|
||||
const data = useClassData({ defaultTeacherId, defaultGradeId })
|
||||
|
||||
const [editTeacherId, setEditTeacherId] = useState("")
|
||||
const [editGradeId, setEditGradeId] = useState("")
|
||||
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
setCreateTeacherId(defaultTeacherId)
|
||||
setCreateGradeId(managedGrades[0]?.id ?? "")
|
||||
}, [createOpen, defaultTeacherId, managedGrades])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editItem) return
|
||||
setEditTeacherId(editItem.teacher.id)
|
||||
setEditGradeId(editItem.gradeId ?? managedGrades[0]?.id ?? "")
|
||||
setEditSubjectTeachers(
|
||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||
subject: s,
|
||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||
}))
|
||||
)
|
||||
}, [editItem, managedGrades])
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
const handleCreate = async (formData: FormData): Promise<void> => {
|
||||
data.setIsWorking(true)
|
||||
try {
|
||||
const res = await createGradeClassAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setCreateOpen(false)
|
||||
data.setCreateOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create class")
|
||||
toast.error(res.message || t("list.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create class")
|
||||
toast.error(t("list.failedCreate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (formData: FormData) => {
|
||||
if (!editItem) return
|
||||
setIsWorking(true)
|
||||
const handleUpdate = async (formData: FormData): Promise<void> => {
|
||||
if (!data.editItem) return
|
||||
data.setIsWorking(true)
|
||||
try {
|
||||
const res = await updateGradeClassAction(editItem.id, undefined, formData)
|
||||
const res = await updateGradeClassAction(data.editItem.id, undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setEditItem(null)
|
||||
data.setEditItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update class")
|
||||
toast.error(res.message || t("list.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update class")
|
||||
toast.error(t("list.failedUpdate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteItem) return
|
||||
setIsWorking(true)
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
if (!data.deleteItem) return
|
||||
data.setIsWorking(true)
|
||||
try {
|
||||
const res = await deleteGradeClassAction(deleteItem.id)
|
||||
const res = await deleteGradeClassAction(data.deleteItem.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setDeleteItem(null)
|
||||
data.setDeleteItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete class")
|
||||
toast.error(res.message || t("list.failedDelete"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete class")
|
||||
toast.error(t("list.failedDelete"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
data.setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
|
||||
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
|
||||
}
|
||||
|
||||
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
|
||||
const pairs = list
|
||||
.filter((x) => x.teacher)
|
||||
.map((x) => `${x.subject}: ${x.teacher?.name ?? ""}`)
|
||||
.filter((x) => x.length > 0)
|
||||
return pairs.length > 0 ? pairs.join(", ") : "-"
|
||||
}
|
||||
|
||||
const selectedCreateGrade = managedGrades.find(g => g.id === createGradeId)
|
||||
const selectedEditGrade = managedGrades.find(g => g.id === editGradeId)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setCreateOpen(true)} disabled={isWorking || managedGrades.length === 0}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New class
|
||||
</Button>
|
||||
</div>
|
||||
<ClassListToolbar
|
||||
count={classes.length}
|
||||
onNew={() => data.setCreateOpen(true)}
|
||||
isWorking={data.isWorking}
|
||||
disabled={managedGrades.length === 0}
|
||||
/>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">All classes</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{classes.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No classes"
|
||||
description={managedGrades.length === 0 ? "You are not managing any grades yet." : "Create classes to manage students and schedules."}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>School</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Grade</TableHead>
|
||||
<TableHead>Homeroom</TableHead>
|
||||
<TableHead>Room</TableHead>
|
||||
<TableHead>Homeroom Teacher</TableHead>
|
||||
<TableHead>Subject Teachers</TableHead>
|
||||
<TableHead className="text-right">Students</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead className="w-[60px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{classes.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditItem(c)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteItem(c)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ClassListTable
|
||||
classes={classes}
|
||||
onEdit={data.setEditItem}
|
||||
onDelete={data.setDeleteItem}
|
||||
isWorking={data.isWorking}
|
||||
emptyDescription={managedGrades.length === 0 ? t("grade.noManagedGradesDescription") : undefined}
|
||||
/>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New class</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Grade</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={createGradeId} onValueChange={setCreateGradeId} disabled={managedGrades.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={managedGrades.length === 0 ? "No managed grades" : "Select a grade"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{managedGrades.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name} ({g.schoolName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="gradeId" value={createGradeId} />
|
||||
<input type="hidden" name="grade" value={selectedCreateGrade?.name ?? ""} />
|
||||
<input type="hidden" name="schoolId" value={selectedCreateGrade?.schoolId ?? ""} />
|
||||
<input type="hidden" name="schoolName" value={selectedCreateGrade?.schoolName ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<ClassFormDialog
|
||||
open={data.createOpen}
|
||||
onOpenChange={data.setCreateOpen}
|
||||
mode="create"
|
||||
teachers={teachers}
|
||||
grades={managedGrades}
|
||||
onSubmit={handleCreate}
|
||||
isWorking={data.isWorking}
|
||||
teacherId={data.createTeacherId}
|
||||
schoolId={data.createSchoolId}
|
||||
gradeId={data.createGradeId}
|
||||
onTeacherIdChange={data.setCreateTeacherId}
|
||||
onSchoolIdChange={data.setCreateSchoolId}
|
||||
onGradeIdChange={data.setCreateGradeId}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 3" autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-homeroom" className="text-right">
|
||||
Homeroom
|
||||
</Label>
|
||||
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-room" className="text-right">
|
||||
Room
|
||||
</Label>
|
||||
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Homeroom Teacher</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="teacherId" value={createTeacherId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId || !createGradeId}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (isWorking) return
|
||||
if (!open) setEditItem(null)
|
||||
<ClassFormDialog
|
||||
open={Boolean(data.editItem)}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) data.setEditItem(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit class</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editItem ? (
|
||||
<form action={handleUpdate} className="space-y-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Grade</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editGradeId} onValueChange={setEditGradeId} disabled={managedGrades.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a grade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{managedGrades.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name} ({g.schoolName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="gradeId" value={editGradeId} />
|
||||
<input type="hidden" name="grade" value={selectedEditGrade?.name ?? ""} />
|
||||
<input type="hidden" name="schoolId" value={selectedEditGrade?.schoolId ?? ""} />
|
||||
<input type="hidden" name="schoolName" value={selectedEditGrade?.schoolName ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
mode="edit"
|
||||
editItem={data.editItem}
|
||||
teachers={teachers}
|
||||
grades={managedGrades}
|
||||
onSubmit={handleUpdate}
|
||||
isWorking={data.isWorking}
|
||||
teacherId={data.editTeacherId}
|
||||
schoolId={data.editSchoolId}
|
||||
gradeId={data.editGradeId}
|
||||
subjectTeachers={data.editSubjectTeachers}
|
||||
onTeacherIdChange={data.setEditTeacherId}
|
||||
onSchoolIdChange={data.setEditSchoolId}
|
||||
onGradeIdChange={data.setEditGradeId}
|
||||
onSubjectTeacherChange={data.setSubjectTeacher}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-homeroom" className="text-right">
|
||||
Homeroom
|
||||
</Label>
|
||||
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-room" className="text-right">
|
||||
Room
|
||||
</Label>
|
||||
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Homeroom Teacher</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="teacherId" value={editTeacherId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-4">
|
||||
<div className="text-sm font-medium">Subject Teachers</div>
|
||||
<div className="grid gap-3">
|
||||
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
|
||||
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
|
||||
return (
|
||||
<div key={subject} className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{subject}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={selected ?? ""}
|
||||
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
|
||||
disabled={teachers.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teachers.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking || !editTeacherId}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={Boolean(deleteItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteItem(null)
|
||||
<ClassDeleteDialog
|
||||
open={Boolean(data.deleteItem)}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) data.setDeleteItem(null)
|
||||
}}
|
||||
title="Delete class"
|
||||
description={`This will permanently delete ${deleteItem?.name || "this class"}.`}
|
||||
item={data.deleteItem}
|
||||
onConfirm={handleDelete}
|
||||
isWorking={isWorking}
|
||||
isWorking={data.isWorking}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -54,24 +55,25 @@ export function MyClassesGrid({
|
||||
subjectOptions: string[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("classes")
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
const [joinSubject, setJoinSubject] = useState("")
|
||||
|
||||
const handleJoin = async (formData: FormData) => {
|
||||
const handleJoin = async (formData: FormData): Promise<void> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await joinClassByInvitationCodeAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message || "Joined class successfully")
|
||||
toast.success(res.message || t("invitation.joinSuccess"))
|
||||
setJoinOpen(false)
|
||||
setJoinSubject("")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to join class")
|
||||
toast.error(res.message || t("invitation.joinFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to join class")
|
||||
toast.error(t("invitation.joinFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -98,7 +100,7 @@ export function MyClassesGrid({
|
||||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
|
||||
<Plus className="size-3.5" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="font-semibold tracking-tight">Join New Class</span>
|
||||
<span className="font-semibold tracking-tight">{t("myClasses.joinNew")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
@@ -111,10 +113,10 @@ export function MyClassesGrid({
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||
<Plus className="size-5" />
|
||||
</div>
|
||||
Join a Class
|
||||
{t("myClasses.joinTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground mt-1.5">
|
||||
Enter the 6-digit invitation code provided by your administrator.
|
||||
{t("myClasses.joinDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
@@ -123,14 +125,14 @@ export function MyClassesGrid({
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="join-code" className="text-sm font-medium">
|
||||
Invitation Code
|
||||
{t("myClasses.invitationCode")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="join-code"
|
||||
name="code"
|
||||
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
|
||||
placeholder="e.g. 123456"
|
||||
placeholder={t("myClasses.invitationCodePlaceholder")}
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="\d{6}"
|
||||
@@ -142,16 +144,16 @@ export function MyClassesGrid({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask your administrator for the code if you don't have one.
|
||||
{t("myClasses.invitationCodeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="join-subject" className="text-sm font-medium">
|
||||
教学科目
|
||||
{t("myClasses.subject")}
|
||||
</Label>
|
||||
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
|
||||
<SelectTrigger id="join-subject" className="h-12">
|
||||
<SelectValue placeholder={subjectOptions.length === 0 ? "暂无可选科目" : "选择教学科目"} />
|
||||
<SelectValue placeholder={subjectOptions.length === 0 ? t("myClasses.noSubjects") : t("myClasses.selectSubject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjectOptions.map((subject) => (
|
||||
@@ -166,10 +168,10 @@ export function MyClassesGrid({
|
||||
</div>
|
||||
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
||||
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
||||
Cancel
|
||||
{t("myClasses.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
|
||||
{isWorking ? "Joining..." : "Join Class"}
|
||||
{isWorking ? t("myClasses.joining") : t("myClasses.join")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -182,10 +184,10 @@ export function MyClassesGrid({
|
||||
<div className="flex flex-col gap-4">
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No classes yet"
|
||||
description="Join a class to start managing students and schedules."
|
||||
title={t("myClasses.empty.title")}
|
||||
description={t("myClasses.empty.description")}
|
||||
icon={Users}
|
||||
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
||||
action={{ label: t("myClasses.join"), onClick: () => setJoinOpen(true) }}
|
||||
className="h-[360px] bg-card border-dashed"
|
||||
/>
|
||||
) : (
|
||||
@@ -211,49 +213,50 @@ function ClassTicket({
|
||||
onWorkingChange: (v: boolean) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("classes")
|
||||
|
||||
const handleEnsureCode = async () => {
|
||||
const handleEnsureCode = async (): Promise<void> => {
|
||||
onWorkingChange(true)
|
||||
try {
|
||||
const res = await ensureClassInvitationCodeAction(c.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message || "Invitation code ready")
|
||||
toast.success(res.message || t("invitation.generateSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to generate invitation code")
|
||||
toast.error(res.message || t("invitation.generateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to generate invitation code")
|
||||
toast.error(t("invitation.generateFailed"))
|
||||
} finally {
|
||||
onWorkingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerateCode = async () => {
|
||||
const handleRegenerateCode = async (): Promise<void> => {
|
||||
onWorkingChange(true)
|
||||
try {
|
||||
const res = await regenerateClassInvitationCodeAction(c.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message || "Invitation code updated")
|
||||
toast.success(res.message || t("invitation.regenerateSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to regenerate invitation code")
|
||||
toast.error(res.message || t("list.failedRegenerate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to regenerate invitation code")
|
||||
toast.error(t("list.failedRegenerate"))
|
||||
} finally {
|
||||
onWorkingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
const handleCopyCode = async (): Promise<void> => {
|
||||
const code = c.invitationCode ?? ""
|
||||
if (!code) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
toast.success("Copied invitation code")
|
||||
toast.success(t("myClasses.codeCopied"))
|
||||
} catch {
|
||||
toast.error("Failed to copy")
|
||||
toast.error(t("list.failedCopy"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,11 +311,11 @@ function ClassTicket({
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="size-4 text-muted-foreground/70" />
|
||||
<span className="font-medium text-foreground/80">{c.studentCount}</span> Students
|
||||
<span className="font-medium text-foreground/80">{c.studentCount}</span> {t("myClasses.students")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 text-muted-foreground/70" />
|
||||
<span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
|
||||
<span className="font-medium text-foreground/80">{c.room || t("myClasses.noRoom")}</span>
|
||||
</div>
|
||||
{c.schoolName && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -331,7 +334,7 @@ function ClassTicket({
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
|
||||
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">{t("myClasses.entryPass")}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
|
||||
@@ -352,16 +355,16 @@ function ClassTicket({
|
||||
|
||||
{c.invitationCode ? (
|
||||
<div className="flex gap-1 z-10">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title="Copy">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title={t("myClasses.copyCode")}>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title={t("myClasses.regenerateCode")}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
|
||||
Generate
|
||||
{t("invitation.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -385,14 +388,14 @@ function ClassTicket({
|
||||
{/* Left: Submission Trends */}
|
||||
<div className="flex-1 flex flex-col gap-4 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-foreground/80">Submission Trends</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground/80">{t("myClasses.submissionTrends")}</h4>
|
||||
<span className={cn(
|
||||
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
|
||||
isPositive
|
||||
? "text-emerald-600 bg-emerald-50 border-emerald-100"
|
||||
: "text-red-600 bg-red-50 border-red-100"
|
||||
)}>
|
||||
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
|
||||
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>{t("myClasses.vsLastWeek")}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
164
src/modules/classes/components/schedule-create-dialog.tsx
Normal file
164
src/modules/classes/components/schedule-create-dialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import type { ClassScheduleItem, TeacherClass } from "../types"
|
||||
import { createClassScheduleItemAction } from "../actions"
|
||||
import { SCHEDULE_WEEKDAYS } from "./schedule-utils"
|
||||
|
||||
/**
|
||||
* 课表新增对话框(P1-13 从 schedule-view.tsx 拆分)。
|
||||
*
|
||||
* 自管理 isWorking / createClassId 状态,父组件仅需控制 open 与 weekday。
|
||||
*/
|
||||
export function ScheduleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
classes,
|
||||
defaultClassId,
|
||||
weekday,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
classes: TeacherClass[]
|
||||
defaultClassId: string
|
||||
weekday: ClassScheduleItem["weekday"]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("classes")
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createClassId, setCreateClassId] = useState<string>(defaultClassId)
|
||||
|
||||
// 打开时重置到默认班级,避免上一次选择残留
|
||||
const [prevOpen, setPrevOpen] = useState(open)
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) setCreateClassId(defaultClassId)
|
||||
}
|
||||
|
||||
const handleSubmit = async (formData: FormData): Promise<void> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("classId", createClassId || defaultClassId)
|
||||
formData.set("weekday", String(weekday))
|
||||
const res = await createClassScheduleItemAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedCreate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const weekdayLabel = SCHEDULE_WEEKDAYS.find((w) => w.key === weekday)?.label ?? "schedule.weekday.1"
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
onOpenChange(v)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("schedule.form.createTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("schedule.form.createDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{t("filters.class")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={createClassId} onValueChange={setCreateClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("filters.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="classId" value={createClassId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{t("schedule.column.weekday")}</Label>
|
||||
<Input value={t(weekdayLabel)} readOnly className="col-span-3" />
|
||||
<input type="hidden" name="weekday" value={String(weekday)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-startTime" className="text-right">
|
||||
{t("schedule.form.startLabel")}
|
||||
</Label>
|
||||
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-endTime" className="text-right">
|
||||
{t("schedule.form.endLabel")}
|
||||
</Label>
|
||||
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-course" className="text-right">
|
||||
{t("schedule.column.subject")}
|
||||
</Label>
|
||||
<Input
|
||||
id="create-course"
|
||||
name="course"
|
||||
className="col-span-3"
|
||||
placeholder={t("schedule.form.subjectPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-location" className="text-right">
|
||||
{t("schedule.column.location")}
|
||||
</Label>
|
||||
<Input
|
||||
id="create-location"
|
||||
name="location"
|
||||
className="col-span-3"
|
||||
placeholder={t("schedule.form.locationPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking || !createClassId}>
|
||||
{isWorking ? t("form.creating") : t("form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
85
src/modules/classes/components/schedule-delete-dialog.tsx
Normal file
85
src/modules/classes/components/schedule-delete-dialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import type { ClassScheduleItem } from "../types"
|
||||
import { deleteClassScheduleItemAction } from "../actions"
|
||||
|
||||
/**
|
||||
* 课表删除确认对话框(P1-13 从 schedule-view.tsx 拆分)。
|
||||
*
|
||||
* 自管理 isWorking 状态,父组件仅需传入 deleteItem 与 onClose 回调。
|
||||
*/
|
||||
export function ScheduleDeleteDialog({
|
||||
deleteItem,
|
||||
onClose,
|
||||
}: {
|
||||
deleteItem: ClassScheduleItem | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("classes")
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
if (!deleteItem) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await deleteClassScheduleItemAction(deleteItem.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
onClose()
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedDelete"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedDelete"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={!!deleteItem}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
if (!v) onClose()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("schedule.form.deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("schedule.form.deleteDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>{t("form.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
void handleConfirm()
|
||||
}}
|
||||
disabled={isWorking}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isWorking ? t("form.deleting") : t("list.actions.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
194
src/modules/classes/components/schedule-edit-dialog.tsx
Normal file
194
src/modules/classes/components/schedule-edit-dialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import type { ClassScheduleItem, TeacherClass } from "../types"
|
||||
import { updateClassScheduleItemAction } from "../actions"
|
||||
import { SCHEDULE_WEEKDAYS } from "./schedule-utils"
|
||||
|
||||
/**
|
||||
* 课表编辑对话框(P1-13 从 schedule-view.tsx 拆分)。
|
||||
*
|
||||
* 自管理 isWorking / editClassId / editWeekday 状态,父组件仅需传入 editItem。
|
||||
*/
|
||||
export function ScheduleEditDialog({
|
||||
editItem,
|
||||
classes,
|
||||
onClose,
|
||||
}: {
|
||||
editItem: ClassScheduleItem | null
|
||||
classes: TeacherClass[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("classes")
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [editClassId, setEditClassId] = useState<string>("")
|
||||
const [editWeekday, setEditWeekday] = useState<string>("1")
|
||||
|
||||
// editItem 变更时同步本地表单状态
|
||||
const [prevItem, setPrevItem] = useState(editItem)
|
||||
if (editItem !== prevItem) {
|
||||
setPrevItem(editItem)
|
||||
if (editItem) {
|
||||
setEditClassId(editItem.classId)
|
||||
setEditWeekday(String(editItem.weekday))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (formData: FormData): Promise<void> => {
|
||||
if (!editItem) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("classId", editClassId)
|
||||
formData.set("weekday", editWeekday)
|
||||
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
onClose()
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("list.failedUpdate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("list.failedUpdate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!editItem}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
if (!v) onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("schedule.form.editTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("schedule.form.editDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">{t("filters.class")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editClassId} onValueChange={setEditClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("filters.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="classId" value={editClassId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-weekday" className="text-right">
|
||||
{t("schedule.column.weekday")}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("filters.selectWeekday")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULE_WEEKDAYS.map((w) => (
|
||||
<SelectItem key={w.key} value={String(w.key)}>
|
||||
{t(w.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="weekday" value={editWeekday} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-startTime" className="text-right">
|
||||
{t("schedule.form.startLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-startTime"
|
||||
name="startTime"
|
||||
type="time"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem?.startTime}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-endTime" className="text-right">
|
||||
{t("schedule.form.endLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-endTime"
|
||||
name="endTime"
|
||||
type="time"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem?.endTime}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-course" className="text-right">
|
||||
{t("schedule.column.subject")}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-course"
|
||||
name="course"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem?.course}
|
||||
placeholder={t("schedule.form.subjectPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-location" className="text-right">
|
||||
{t("schedule.column.location")}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-location"
|
||||
name="location"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem?.location ?? ""}
|
||||
placeholder={t("schedule.form.locationPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking || !editClassId}>
|
||||
{isWorking ? t("form.saving") : t("form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Plus } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -32,6 +33,7 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||
|
||||
const router = useRouter()
|
||||
const t = useTranslations("classes")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
@@ -48,7 +50,7 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
const handleCreate = async (formData: FormData): Promise<void> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("classId", createClassId)
|
||||
@@ -58,27 +60,27 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create schedule item")
|
||||
toast.error(res.message || t("list.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create schedule item")
|
||||
toast.error(t("list.failedCreate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedClass = classes.find((c) => c.id === classId)
|
||||
const title = selectedClass ? selectedClass.name : "All Classes"
|
||||
const title = selectedClass ? selectedClass.name : t("filters.allClasses")
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
|
||||
<SelectValue placeholder="All Classes" />
|
||||
<SelectValue placeholder={t("filters.allClasses")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
|
||||
<SelectItem value="all" className="text-xs">{t("filters.allClasses")}</SelectItem>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||
{c.name}
|
||||
@@ -106,22 +108,22 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add Event
|
||||
{t("filters.addEvent")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add schedule item</DialogTitle>
|
||||
<DialogDescription>Create a class schedule entry.</DialogDescription>
|
||||
<DialogTitle>{t("schedule.form.createTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("filters.createScheduleEntryDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleCreate}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Class</Label>
|
||||
<Label className="text-right">{t("filters.class")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={createClassId} onValueChange={setCreateClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
<SelectValue placeholder={t("filters.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
@@ -136,21 +138,21 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="weekday" className="text-right">
|
||||
Weekday
|
||||
{t("schedule.column.weekday")}
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={weekday} onValueChange={setWeekday}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select weekday" />
|
||||
<SelectValue placeholder={t("filters.selectWeekday")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Mon</SelectItem>
|
||||
<SelectItem value="2">Tue</SelectItem>
|
||||
<SelectItem value="3">Wed</SelectItem>
|
||||
<SelectItem value="4">Thu</SelectItem>
|
||||
<SelectItem value="5">Fri</SelectItem>
|
||||
<SelectItem value="6">Sat</SelectItem>
|
||||
<SelectItem value="7">Sun</SelectItem>
|
||||
<SelectItem value="1">{t("schedule.weekday.1")}</SelectItem>
|
||||
<SelectItem value="2">{t("schedule.weekday.2")}</SelectItem>
|
||||
<SelectItem value="3">{t("schedule.weekday.3")}</SelectItem>
|
||||
<SelectItem value="4">{t("schedule.weekday.4")}</SelectItem>
|
||||
<SelectItem value="5">{t("schedule.weekday.5")}</SelectItem>
|
||||
<SelectItem value="6">{t("schedule.weekday.6")}</SelectItem>
|
||||
<SelectItem value="7">{t("schedule.weekday.7")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="weekday" value={weekday} />
|
||||
@@ -159,35 +161,35 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="startTime" className="text-right">
|
||||
Start
|
||||
{t("filters.startTime")}
|
||||
</Label>
|
||||
<Input id="startTime" name="startTime" type="time" className="col-span-3" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="endTime" className="text-right">
|
||||
End
|
||||
{t("filters.endTime")}
|
||||
</Label>
|
||||
<Input id="endTime" name="endTime" type="time" className="col-span-3" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="course" className="text-right">
|
||||
Course
|
||||
{t("schedule.column.subject")}
|
||||
</Label>
|
||||
<Input id="course" name="course" className="col-span-3" placeholder="e.g. Math" required />
|
||||
<Input id="course" name="course" className="col-span-3" placeholder={t("filters.subjectPlaceholderExample")} required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="location" className="text-right">
|
||||
Location
|
||||
{t("schedule.column.location")}
|
||||
</Label>
|
||||
<Input id="location" name="location" className="col-span-3" placeholder="Optional" />
|
||||
<Input id="location" name="location" className="col-span-3" placeholder={t("schedule.form.locationPlaceholder")} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking || !createClassId}>
|
||||
{isWorking ? "Creating..." : "Create"}
|
||||
{isWorking ? t("form.creating") : t("form.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
100
src/modules/classes/components/schedule-utils.ts
Normal file
100
src/modules/classes/components/schedule-utils.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 课表纯函数工具集(P0-4 审计修复 + P2-4 可测试性抽取)。
|
||||
*
|
||||
* 修复点:
|
||||
* - 原 `getSubjectColor` 使用英文关键词('math'/'english')匹配科目,
|
||||
* 但 `DEFAULT_CLASS_SUBJECTS` 为中文("数学"/"英语"),导致所有中文科目
|
||||
* 落到 default 分支,颜色视觉分组完全失效。
|
||||
* - 现支持中英文双语匹配,并导出为纯函数便于单测。
|
||||
*/
|
||||
|
||||
/** 科目到颜色类的映射表(中英文关键词均匹配)。 */
|
||||
const SUBJECT_COLOR_MAP: ReadonlyArray<{ keywords: readonly string[]; classes: string }> = [
|
||||
{
|
||||
keywords: ["math", "数学"],
|
||||
classes: "bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20",
|
||||
},
|
||||
{
|
||||
keywords: ["physics", "物理", "science", "科学"],
|
||||
classes: "bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20",
|
||||
},
|
||||
{
|
||||
keywords: ["english", "英语", "lit"],
|
||||
classes: "bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20",
|
||||
},
|
||||
{
|
||||
keywords: ["history", "历史", "geo", "地理", "社会"],
|
||||
classes: "bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20",
|
||||
},
|
||||
{
|
||||
keywords: ["art", "美术", "music", "音乐"],
|
||||
classes: "bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20",
|
||||
},
|
||||
{
|
||||
keywords: ["sport", "pe", "体育"],
|
||||
classes: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20",
|
||||
},
|
||||
{
|
||||
keywords: ["chinese", "语文", "language"],
|
||||
classes: "bg-rose-500/10 text-rose-700 border-rose-500/20 hover:bg-rose-500/20",
|
||||
},
|
||||
] as const
|
||||
|
||||
const DEFAULT_SUBJECT_COLOR = "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20"
|
||||
|
||||
/**
|
||||
* 根据科目名称返回对应的颜色类名(支持中英文)。
|
||||
* 匹配规则:科目名转为小写后,检查是否包含映射表中的任一关键词。
|
||||
*/
|
||||
export function getSubjectColor(subject: string): string {
|
||||
const s = subject.toLowerCase()
|
||||
for (const entry of SUBJECT_COLOR_MAP) {
|
||||
if (entry.keywords.some((kw) => s.includes(kw.toLowerCase()))) {
|
||||
return entry.classes
|
||||
}
|
||||
}
|
||||
return DEFAULT_SUBJECT_COLOR
|
||||
}
|
||||
|
||||
/** 课表时间范围:8:00 - 18:00。 */
|
||||
const MIN_TIME_MINUTES = 8 * 60
|
||||
const MAX_TIME_MINUTES = 18 * 60
|
||||
const TOTAL_DURATION_MINUTES = MAX_TIME_MINUTES - MIN_TIME_MINUTES
|
||||
|
||||
/**
|
||||
* 将 "HH:MM" 时间字符串转换为当天从 0 点起的分钟数。
|
||||
*/
|
||||
export function timeToMinutes(time: string): number {
|
||||
const parts = time.split(":").map(Number)
|
||||
const hours = parts[0] ?? 0
|
||||
const minutes = parts[1] ?? 0
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据课程的开始/结束时间计算课表块在时间轴上的定位样式(top% / height%)。
|
||||
* 时间轴范围:8:00 - 18:00。
|
||||
*/
|
||||
export function getPositionStyle(startTime: string, endTime: string): { top: string; height: string } {
|
||||
const startMinutes = timeToMinutes(startTime)
|
||||
const endMinutes = timeToMinutes(endTime)
|
||||
|
||||
const top = Math.max(0, ((startMinutes - MIN_TIME_MINUTES) / TOTAL_DURATION_MINUTES) * 100)
|
||||
const height = Math.min(100 - top, ((endMinutes - startMinutes) / TOTAL_DURATION_MINUTES) * 100)
|
||||
|
||||
return {
|
||||
top: `${top}%`,
|
||||
height: `${height}%`,
|
||||
}
|
||||
}
|
||||
|
||||
/** 课表周常量(周一~周日),label 为 i18n 翻译键。 */
|
||||
export const SCHEDULE_WEEKDAYS: ReadonlyArray<{ key: 1 | 2 | 3 | 4 | 5 | 6 | 7; label: string }> = [
|
||||
{ key: 1, label: "schedule.weekday.1" },
|
||||
{ key: 2, label: "schedule.weekday.2" },
|
||||
{ key: 3, label: "schedule.weekday.3" },
|
||||
{ key: 4, label: "schedule.weekday.4" },
|
||||
{ key: 5, label: "schedule.weekday.5" },
|
||||
{ key: 6, label: "schedule.weekday.6" },
|
||||
{ key: 7, label: "schedule.weekday.7" },
|
||||
]
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -14,44 +13,20 @@ 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import type { ClassScheduleItem, TeacherClass } from "../types"
|
||||
import {
|
||||
createClassScheduleItemAction,
|
||||
deleteClassScheduleItemAction,
|
||||
updateClassScheduleItemAction,
|
||||
} from "../actions"
|
||||
|
||||
const WEEKDAYS: Array<{ key: ClassScheduleItem["weekday"]; label: string }> = [
|
||||
{ key: 1, label: "Mon" },
|
||||
{ key: 2, label: "Tue" },
|
||||
{ key: 3, label: "Wed" },
|
||||
{ key: 4, label: "Thu" },
|
||||
{ key: 5, label: "Fri" },
|
||||
{ key: 6, label: "Sat" },
|
||||
{ key: 7, label: "Sun" },
|
||||
]
|
||||
import { getPositionStyle, getSubjectColor, SCHEDULE_WEEKDAYS } from "./schedule-utils"
|
||||
import { ScheduleCreateDialog } from "./schedule-create-dialog"
|
||||
import { ScheduleEditDialog } from "./schedule-edit-dialog"
|
||||
import { ScheduleDeleteDialog } from "./schedule-delete-dialog"
|
||||
|
||||
/**
|
||||
* 课表周视图(P1-13:已将创建/编辑/删除对话框拆分为独立组件)。
|
||||
*
|
||||
* 本组件仅负责:
|
||||
* - 渲染时间轴与周列布局
|
||||
* - 渲染课表块及其 hover 操作菜单
|
||||
* - 维护当前打开的对话框状态(createOpen / editItem / deleteItem)
|
||||
*/
|
||||
export function ScheduleView({
|
||||
schedule,
|
||||
classes,
|
||||
@@ -59,139 +34,21 @@ export function ScheduleView({
|
||||
schedule: ClassScheduleItem[]
|
||||
classes: TeacherClass[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const t = useTranslations("classes")
|
||||
const [editItem, setEditItem] = useState<ClassScheduleItem | null>(null)
|
||||
const [deleteItem, setDeleteItem] = useState<ClassScheduleItem | null>(null)
|
||||
|
||||
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createClassId, setCreateClassId] = useState<string>("")
|
||||
|
||||
const [editClassId, setEditClassId] = useState<string>("")
|
||||
const [editWeekday, setEditWeekday] = useState<string>("1")
|
||||
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
|
||||
|
||||
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
|
||||
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
|
||||
|
||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||
if (editItem !== prevEditItem) {
|
||||
setPrevEditItem(editItem)
|
||||
if (editItem) {
|
||||
setEditClassId(editItem.classId)
|
||||
setEditWeekday(String(editItem.weekday))
|
||||
}
|
||||
}
|
||||
|
||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||
if (createOpen !== prevCreateOpen) {
|
||||
setPrevCreateOpen(createOpen)
|
||||
if (createOpen) {
|
||||
setCreateClassId(defaultClassId)
|
||||
}
|
||||
}
|
||||
|
||||
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
|
||||
for (const d of WEEKDAYS) byDay.set(d.key, [])
|
||||
for (const d of SCHEDULE_WEEKDAYS) byDay.set(d.key, [])
|
||||
for (const item of schedule) byDay.get(item.weekday)?.push(item)
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("classId", createClassId || defaultClassId)
|
||||
formData.set("weekday", String(createWeekday))
|
||||
const res = await createClassScheduleItemAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setCreateOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create schedule item")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create schedule item")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (formData: FormData) => {
|
||||
if (!editItem) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
formData.set("classId", editClassId)
|
||||
formData.set("weekday", editWeekday)
|
||||
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setEditItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update schedule item")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update schedule item")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteItem) return
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await deleteClassScheduleItemAction(deleteItem.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setDeleteItem(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete schedule item")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete schedule item")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPositionStyle = (startTime: string, endTime: string) => {
|
||||
// Range 8:00 (480 min) -> 18:00 (1080 min)
|
||||
// Total duration: 600 min
|
||||
const startParts = startTime.split(':').map(Number)
|
||||
const endParts = endTime.split(':').map(Number)
|
||||
|
||||
const startMinutes = startParts[0] * 60 + startParts[1]
|
||||
const endMinutes = endParts[0] * 60 + endParts[1]
|
||||
|
||||
const minTime = 8 * 60
|
||||
const maxTime = 18 * 60
|
||||
const totalDuration = maxTime - minTime
|
||||
|
||||
// Calculate percentage positions
|
||||
const top = Math.max(0, ((startMinutes - minTime) / totalDuration) * 100)
|
||||
const height = Math.min(100 - top, ((endMinutes - startMinutes) / totalDuration) * 100)
|
||||
|
||||
return {
|
||||
top: `${top}%`,
|
||||
height: `${height}%`,
|
||||
}
|
||||
}
|
||||
|
||||
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
|
||||
|
||||
// Predefined colors for different subjects to add visual variety
|
||||
const getSubjectColor = (subject: string) => {
|
||||
const s = subject.toLowerCase()
|
||||
if (s.includes('math')) return 'bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20'
|
||||
if (s.includes('physics') || s.includes('science')) return 'bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20'
|
||||
if (s.includes('english') || s.includes('lit')) return 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20'
|
||||
if (s.includes('history') || s.includes('geo')) return 'bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20'
|
||||
if (s.includes('art') || s.includes('music')) return 'bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20'
|
||||
if (s.includes('sport') || s.includes('pe')) return 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20'
|
||||
return 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[600px] flex flex-col">
|
||||
<div className="flex h-full">
|
||||
@@ -200,8 +57,8 @@ export function ScheduleView({
|
||||
<div className="h-10" /> {/* Header spacer */}
|
||||
<div className="flex-1 relative">
|
||||
{HOURS.map((h, i) => (
|
||||
<div
|
||||
key={h}
|
||||
<div
|
||||
key={h}
|
||||
className="absolute w-full text-right pr-3 text-[11px] text-muted-foreground/60 font-medium -translate-y-1/2 font-mono"
|
||||
style={{ top: `${(i / 10) * 100}%` }}
|
||||
>
|
||||
@@ -213,19 +70,19 @@ export function ScheduleView({
|
||||
|
||||
{/* Days Columns */}
|
||||
<div className="flex-1 grid grid-cols-5">
|
||||
{WEEKDAYS.slice(0, 5).map((d) => (
|
||||
{SCHEDULE_WEEKDAYS.slice(0, 5).map((d) => (
|
||||
<div key={d.key} className="flex flex-col h-full min-w-0">
|
||||
<div className="flex items-center justify-center py-2 h-10 group">
|
||||
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{t(d.label)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative h-full mx-1">
|
||||
{/* Subtle vertical guideline */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-border/30" />
|
||||
|
||||
|
||||
{(byDay.get(d.key) ?? []).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
<div
|
||||
key={item.id}
|
||||
className="group absolute w-full px-1 z-10"
|
||||
style={getPositionStyle(item.startTime, item.endTime)}
|
||||
>
|
||||
@@ -243,18 +100,18 @@ export function ScheduleView({
|
||||
{classNameById.get(item.classId)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
|
||||
<Pencil className="mr-2 h-3 w-3" />
|
||||
Edit
|
||||
{t("list.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -262,7 +119,7 @@ export function ScheduleView({
|
||||
onClick={() => setDeleteItem(item)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
Delete
|
||||
{t("list.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -271,23 +128,23 @@ export function ScheduleView({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{/* Add Button Overlay - Only visible on hover of the column */}
|
||||
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
|
||||
disabled={classes.length === 0}
|
||||
onClick={() => {
|
||||
setCreateWeekday(d.key)
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
|
||||
disabled={classes.length === 0}
|
||||
onClick={() => {
|
||||
setCreateWeekday(d.key)
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -295,231 +152,24 @@ export function ScheduleView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
<ScheduleCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
setCreateOpen(v)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add schedule item</DialogTitle>
|
||||
<DialogDescription>Create a class schedule entry.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleCreate}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Class</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={createClassId} onValueChange={setCreateClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="classId" value={createClassId} />
|
||||
</div>
|
||||
</div>
|
||||
onOpenChange={setCreateOpen}
|
||||
classes={classes}
|
||||
defaultClassId={defaultClassId}
|
||||
weekday={createWeekday}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Weekday</Label>
|
||||
<Input value={WEEKDAYS.find((w) => w.key === createWeekday)?.label ?? ""} readOnly className="col-span-3" />
|
||||
<input type="hidden" name="weekday" value={String(createWeekday)} />
|
||||
</div>
|
||||
<ScheduleEditDialog
|
||||
editItem={editItem}
|
||||
classes={classes}
|
||||
onClose={() => setEditItem(null)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-startTime" className="text-right">
|
||||
Start
|
||||
</Label>
|
||||
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-endTime" className="text-right">
|
||||
End
|
||||
</Label>
|
||||
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-course" className="text-right">
|
||||
Course
|
||||
</Label>
|
||||
<Input id="create-course" name="course" className="col-span-3" placeholder="e.g. Math" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-location" className="text-right">
|
||||
Location
|
||||
</Label>
|
||||
<Input id="create-location" name="location" className="col-span-3" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking || !createClassId}>
|
||||
{isWorking ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!editItem}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
if (!v) setEditItem(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit schedule item</DialogTitle>
|
||||
<DialogDescription>Update class schedule entry.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleUpdate}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Class</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editClassId} onValueChange={setEditClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="classId" value={editClassId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-weekday" className="text-right">
|
||||
Weekday
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select weekday" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Mon</SelectItem>
|
||||
<SelectItem value="2">Tue</SelectItem>
|
||||
<SelectItem value="3">Wed</SelectItem>
|
||||
<SelectItem value="4">Thu</SelectItem>
|
||||
<SelectItem value="5">Fri</SelectItem>
|
||||
<SelectItem value="6">Sat</SelectItem>
|
||||
<SelectItem value="7">Sun</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="weekday" value={editWeekday} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-startTime" className="text-right">
|
||||
Start
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-startTime"
|
||||
name="startTime"
|
||||
type="time"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem?.startTime}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-endTime" className="text-right">
|
||||
End
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-endTime"
|
||||
name="endTime"
|
||||
type="time"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem?.endTime}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-course" className="text-right">
|
||||
Course
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-course"
|
||||
name="course"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem?.course}
|
||||
placeholder="e.g. Math"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-location" className="text-right">
|
||||
Location
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-location"
|
||||
name="location"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem?.location ?? ""}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking || !editClassId}>
|
||||
{isWorking ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteItem}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
if (!v) setDeleteItem(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this schedule item.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={isWorking}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isWorking ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ScheduleDeleteDialog
|
||||
deleteItem={deleteItem}
|
||||
onClose={() => setDeleteItem(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -42,6 +43,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||
|
||||
const router = useRouter()
|
||||
const t = useTranslations("classes")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
@@ -57,7 +59,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnroll = async (formData: FormData) => {
|
||||
const handleEnroll = async (formData: FormData): Promise<void> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await enrollStudentByEmailAction(enrollClassId, null, formData)
|
||||
@@ -66,19 +68,19 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to add student")
|
||||
toast.error(res.message || t("list.failedCreate"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to add student")
|
||||
toast.error(t("list.failedCreate"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedClass = classes.find(c => c.id === classId)
|
||||
const classLabel = classId === "all" ? "All Classes" : (selectedClass?.name || "Unknown Class")
|
||||
|
||||
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
|
||||
const classLabel = classId === "all" ? t("filters.allClasses") : (selectedClass?.name || t("filters.unknownClass"))
|
||||
|
||||
const statusLabel = status === "all" ? t("filters.allStatuses") : (status === "active" ? t("students.status.active") : t("students.status.inactive"))
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
@@ -87,7 +89,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
<div className="relative group">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
|
||||
<Input
|
||||
placeholder="Search students..."
|
||||
placeholder={t("students.searchPlaceholder")}
|
||||
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
@@ -110,7 +112,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
onClick={() => setClassId("all")}
|
||||
className="text-xs flex items-center justify-between"
|
||||
>
|
||||
All Classes
|
||||
{t("filters.allClasses")}
|
||||
{classId === "all" && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -136,17 +138,17 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">{t("filters.filterByStatus")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
|
||||
All Status
|
||||
{t("filters.allStatuses")}
|
||||
{status === "all" && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
|
||||
Active
|
||||
{t("students.status.active")}
|
||||
{status === "active" && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
|
||||
Inactive
|
||||
{t("students.status.inactive")}
|
||||
{status === "inactive" && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -163,22 +165,22 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
|
||||
<UserPlus className="size-3.5" />
|
||||
Add student
|
||||
{t("filters.addStudent")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add student</DialogTitle>
|
||||
<DialogDescription>Enroll a student by email to a class.</DialogDescription>
|
||||
<DialogTitle>{t("filters.addStudent")}</DialogTitle>
|
||||
<DialogDescription>{t("filters.enrollStudentDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleEnroll}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Class</Label>
|
||||
<Label className="text-right">{t("filters.class")}</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={enrollClassId} onValueChange={setEnrollClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
<SelectValue placeholder={t("filters.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
@@ -192,21 +194,21 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="student-email" className="text-right">
|
||||
Email
|
||||
{t("filters.email")}
|
||||
</Label>
|
||||
<Input
|
||||
id="student-email"
|
||||
name="email"
|
||||
type="email"
|
||||
className="col-span-3"
|
||||
placeholder="student@example.com"
|
||||
placeholder={t("filters.emailPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking || !enrollClassId}>
|
||||
{isWorking ? "Adding..." : "Add"}
|
||||
{isWorking ? t("form.adding") : t("form.add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -22,6 +23,7 @@ import { setStudentEnrollmentStatusAction } from "../actions"
|
||||
|
||||
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("classes")
|
||||
const [workingKey, setWorkingKey] = useState<string | null>(null)
|
||||
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
||||
|
||||
@@ -37,7 +39,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
toast.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update status")
|
||||
toast.error(t("list.failedStatus"))
|
||||
} finally {
|
||||
setWorkingKey(null)
|
||||
}
|
||||
@@ -124,12 +126,12 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
{s.status !== "active" ? (
|
||||
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
||||
<UserCheck className="mr-2 size-4" />
|
||||
Set active
|
||||
{t("students.actions.setActive")}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
||||
<UserX className="mr-2 size-4" />
|
||||
Set inactive
|
||||
{t("students.actions.setInactive")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -139,7 +141,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
disabled={s.status === "inactive" || workingKey !== null}
|
||||
>
|
||||
<UserX className="mr-2 size-4" />
|
||||
Remove from class
|
||||
{t("students.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -154,15 +156,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
if (workingKey !== null) return
|
||||
if (!open) setRemoveTarget(null)
|
||||
}}
|
||||
title="Remove student from class?"
|
||||
confirmText="Remove"
|
||||
title={t("students.removeTitle")}
|
||||
confirmText={t("students.actions.remove")}
|
||||
description={
|
||||
removeTarget ? (
|
||||
<>
|
||||
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
|
||||
<span className="font-medium text-foreground">{removeTarget.className}</span>.
|
||||
</>
|
||||
) : null
|
||||
removeTarget ? t("students.removeDescription", { name: removeTarget.name }) : null
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (!removeTarget) return
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import type {
|
||||
AdminClassListItem,
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
import {
|
||||
compareClassLike,
|
||||
generateUniqueInvitationCode,
|
||||
getClassSubjects,
|
||||
isDuplicateInvitationCodeError,
|
||||
} from "./data-access"
|
||||
|
||||
@@ -343,21 +345,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, teacherId), eq(roles.name, "teacher")))
|
||||
.where(and(eq(users.id, teacherId), eq(roles.name, ROLE_NAMES.TEACHER)))
|
||||
.limit(1)
|
||||
if (!teacher) throw new Error("Teacher not found")
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const invitationCode = await generateUniqueInvitationCode()
|
||||
try {
|
||||
const subjectNames = await getClassSubjects()
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map<ClassSubject, string>()
|
||||
.where(inArray(subjects.name, subjectNames))
|
||||
const idByName = new Map<string, string>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = toClassSubject(r.name)
|
||||
if (subject) idByName.set(subject, r.id)
|
||||
idByName.set(r.name, r.id)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -374,7 +376,7 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
||||
teacherId,
|
||||
})
|
||||
|
||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
||||
const values = subjectNames.flatMap((name) => {
|
||||
const subjectId = idByName.get(name)
|
||||
if (!subjectId) return []
|
||||
return [{ classId: id, subjectId, teacherId: null }]
|
||||
@@ -422,7 +424,7 @@ export async function updateAdminClass(
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, nextTeacherId), eq(roles.name, "teacher")))
|
||||
.where(and(eq(users.id, nextTeacherId), eq(roles.name, ROLE_NAMES.TEACHER)))
|
||||
.limit(1)
|
||||
if (!teacher) throw new Error("Teacher not found")
|
||||
|
||||
|
||||
@@ -102,6 +102,42 @@ export function isLegacyFormatCode(code: string): boolean {
|
||||
return /^\d{6}$/.test(code.trim())
|
||||
}
|
||||
|
||||
// ============ Legacy 6 位数字邀请码(classes.invitationCode) ============
|
||||
|
||||
/**
|
||||
* 判断错误是否为邀请码重复错误(旧 6 位数字码,classes.invitationCode 唯一约束)。
|
||||
*/
|
||||
export const isDuplicateInvitationCodeError = (err: unknown): boolean => {
|
||||
if (!err) return false
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const m = msg.toLowerCase()
|
||||
return m.includes("duplicate") && (m.includes("invitation") || m.includes("invitation_code"))
|
||||
}
|
||||
|
||||
const generateInvitationCode = (): string => {
|
||||
const n = Math.floor(Math.random() * 1_000_000)
|
||||
return String(n).padStart(6, "0")
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一邀请码(旧 6 位数字格式,classes.invitationCode)。
|
||||
* DB unique 约束 + 40 次重试。
|
||||
*/
|
||||
export const generateUniqueInvitationCode = async (): Promise<string> => {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const code = generateInvitationCode()
|
||||
const [existing] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.invitationCode, code))
|
||||
.limit(1)
|
||||
if (!existing) return code
|
||||
}
|
||||
throw new Error("Failed to generate invitation code")
|
||||
}
|
||||
|
||||
// ============ 新格式邀请码(class_invitation_codes 表) ============
|
||||
|
||||
/**
|
||||
* 生成唯一邀请码(带重试)。
|
||||
* DB unique 约束 + 40 次重试(沿用现有模式)。
|
||||
@@ -235,7 +271,9 @@ export async function validateInvitationCode(code: string): Promise<ValidationRe
|
||||
}
|
||||
// 已禁用
|
||||
if (record.status !== "active") {
|
||||
return { valid: false, reason: record.status as ValidationResult["reason"] }
|
||||
// P2-A: record.status 经 !== "active" 收窄后为 "disabled" | "expired" | "exhausted",
|
||||
// 均属于 ValidationResult["reason"] 联合类型,无需 as 断言
|
||||
return { valid: false, reason: record.status }
|
||||
}
|
||||
return { valid: true, classId: record.classId, codeId: record.id }
|
||||
}
|
||||
@@ -310,21 +348,22 @@ export async function purgeExpiredCodes(): Promise<number> {
|
||||
|
||||
// MySqlRawQueryResult 是 [rows, fields] 元组,rows 可能含 affectedRows
|
||||
const rows = Array.isArray(result) ? result[0] : result
|
||||
const affectedRows =
|
||||
typeof rows === "object" && rows !== null && "affectedRows" in rows
|
||||
? Number((rows as { affectedRows: unknown }).affectedRows)
|
||||
: 0
|
||||
return affectedRows
|
||||
// P2-A: 使用 in 操作符收窄 unknown,避免 `(rows as { affectedRows: unknown }).affectedRows` 断言
|
||||
if (typeof rows === "object" && rows !== null && "affectedRows" in rows) {
|
||||
return Number(rows.affectedRows)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============ helpers ============
|
||||
|
||||
function mapRecord(row: typeof classInvitationCodes.$inferSelect): InvitationCodeRecord {
|
||||
// P2-A: schema 中 status 列为 mysqlEnum,推断类型即 InvitationCodeStatus,无需 as 断言
|
||||
return {
|
||||
id: row.id,
|
||||
classId: row.classId,
|
||||
code: row.code,
|
||||
status: row.status as InvitationCodeStatus,
|
||||
status: row.status,
|
||||
maxUses: row.maxUses,
|
||||
usedCount: row.usedCount,
|
||||
expiresAt: row.expiresAt,
|
||||
|
||||
@@ -18,6 +18,19 @@ import {
|
||||
getSessionTeacherId,
|
||||
} from "./data-access"
|
||||
|
||||
/**
|
||||
* 根据课表项 ID 获取其所属班级 ID(P0-3 审计修复:供 actions-schedule 越权校验使用)。
|
||||
* classes 模块对 classSchedule 表有读权限(usedBy 含 classes)。
|
||||
*/
|
||||
export async function getClassIdByScheduleId(scheduleId: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ classId: classSchedule.classId })
|
||||
.from(classSchedule)
|
||||
.where(eq(classSchedule.id, scheduleId))
|
||||
.limit(1)
|
||||
return row?.classId ?? null
|
||||
}
|
||||
|
||||
const isWeekday = (n: unknown): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||
typeof n === "number" && n >= 1 && n <= 7 && Number.isInteger(n)
|
||||
|
||||
|
||||
@@ -294,3 +294,62 @@ export const getClassStudents = cache(
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataScope resolver helpers (P1-5/P1-6 audit fix)
|
||||
// These lightweight functions return only the IDs needed by the RBAC
|
||||
// data-scope resolver, so shared/lib/auth-guard no longer queries classes/
|
||||
// classEnrollments/classSubjectTeachers tables directly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a student's class IDs and grade IDs for DataScope resolution.
|
||||
* Joins classEnrollments with classes to resolve gradeId per enrollment.
|
||||
*/
|
||||
export async function getStudentScopeData(
|
||||
studentId: string,
|
||||
): Promise<{ classIds: string[]; gradeIds: string[] }> {
|
||||
const rows = await db
|
||||
.select({ classId: classEnrollments.classId, gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(eq(classEnrollments.studentId, studentId))
|
||||
|
||||
const classIds = rows.map((r) => r.classId)
|
||||
const gradeIdSet = new Set<string>()
|
||||
for (const row of rows) {
|
||||
if (row.gradeId !== null && row.gradeId.trim().length > 0) {
|
||||
gradeIdSet.add(row.gradeId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
classIds,
|
||||
gradeIds: Array.from(gradeIdSet),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grade IDs for a list of student IDs (used by parent DataScope).
|
||||
* Queries classEnrollments JOIN classes for all students in a single query.
|
||||
*/
|
||||
export async function getGradeIdsForStudentIds(
|
||||
studentIds: string[],
|
||||
): Promise<string[]> {
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
const rows = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classEnrollments.studentId, studentIds))
|
||||
|
||||
const gradeIdSet = new Set<string>()
|
||||
for (const row of rows) {
|
||||
if (row.gradeId !== null && row.gradeId.trim().length > 0) {
|
||||
gradeIdSet.add(row.gradeId)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(gradeIdSet)
|
||||
}
|
||||
|
||||
616
src/modules/classes/data-access-teacher.ts
Normal file
616
src/modules/classes/data-access-teacher.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
classSubjectTeachers,
|
||||
subjects,
|
||||
roles,
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import type {
|
||||
ClassSubject,
|
||||
CreateTeacherClassInput,
|
||||
TeacherOption,
|
||||
TeacherClass,
|
||||
UpdateTeacherClassInput,
|
||||
} from "./types"
|
||||
import {
|
||||
compareClassLike,
|
||||
getAccessibleClassIdsForTeacher,
|
||||
getClassSubjects,
|
||||
getSessionTeacherId,
|
||||
getTeacherIdForMutations,
|
||||
} from "./data-access"
|
||||
import { getClassHomeworkInsights } from "./data-access-stats"
|
||||
import { getClassSchedule } from "./data-access-schedule"
|
||||
import {
|
||||
generateUniqueInvitationCode,
|
||||
isDuplicateInvitationCodeError,
|
||||
} from "./data-access-invitations"
|
||||
|
||||
const isClassSubject = (v: unknown): v is ClassSubject =>
|
||||
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
|
||||
|
||||
const toClassSubject = (v: string): ClassSubject | null =>
|
||||
isClassSubject(v) ? v : null
|
||||
|
||||
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return []
|
||||
|
||||
const rows = await (async () => {
|
||||
try {
|
||||
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||
|
||||
if (allIds.length === 0) return []
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
schoolName: classes.schoolName,
|
||||
name: classes.name,
|
||||
grade: classes.grade,
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
invitationCode: classes.invitationCode,
|
||||
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(classes)
|
||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classes.id, allIds))
|
||||
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch (error) {
|
||||
console.error("getTeacherClasses query failed:", error)
|
||||
throw new Error("Failed to load teacher classes")
|
||||
}
|
||||
})()
|
||||
|
||||
const list = rows.map((r) => ({
|
||||
id: r.id,
|
||||
schoolName: r.schoolName,
|
||||
name: r.name,
|
||||
grade: r.grade,
|
||||
homeroom: r.homeroom,
|
||||
room: r.room,
|
||||
invitationCode: r.invitationCode ?? null,
|
||||
studentCount: Number(r.studentCount ?? 0),
|
||||
}))
|
||||
|
||||
list.sort(compareClassLike)
|
||||
|
||||
// Fetch recent assignments for trends and schedule
|
||||
const listWithTrends = await Promise.all(
|
||||
list.map(async (c) => {
|
||||
const [insights, schedule] = await Promise.all([
|
||||
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
|
||||
getClassSchedule({ classId: c.id, teacherId }),
|
||||
])
|
||||
|
||||
const recentAssignments = insights
|
||||
? insights.assignments.map((a) => ({
|
||||
id: a.assignmentId,
|
||||
title: a.title,
|
||||
status: a.status,
|
||||
subject: a.subject,
|
||||
isActive: a.isActive,
|
||||
isOverdue: a.isOverdue,
|
||||
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
||||
submittedCount: a.submittedCount,
|
||||
targetCount: a.targetCount,
|
||||
avgScore: a.scoreStats.avg,
|
||||
medianScore: a.scoreStats.median,
|
||||
}))
|
||||
: []
|
||||
return { ...c, recentAssignments, schedule }
|
||||
})
|
||||
)
|
||||
|
||||
return listWithTrends
|
||||
})
|
||||
|
||||
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
||||
const rows = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(roles.name, ROLE_NAMES.TEACHER))
|
||||
.orderBy(asc(users.createdAt))
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name ?? "Unnamed",
|
||||
email: r.email,
|
||||
}))
|
||||
})
|
||||
|
||||
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
|
||||
const teacherId = await getSessionTeacherId()
|
||||
if (!teacherId) return []
|
||||
|
||||
const rows = await db
|
||||
.select({ subject: subjects.name })
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
||||
.groupBy(subjects.name)
|
||||
.orderBy(asc(subjects.name))
|
||||
|
||||
return rows
|
||||
.map((r) => toClassSubject(r.subject))
|
||||
.filter((s): s is ClassSubject => s !== null)
|
||||
})
|
||||
|
||||
export async function createTeacherClass(data: CreateTeacherClassInput): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = createId()
|
||||
|
||||
const schoolName = data.schoolName?.trim() || null
|
||||
const schoolId = data.schoolId?.trim() || null
|
||||
const name = data.name.trim()
|
||||
const grade = data.grade.trim()
|
||||
const gradeId = data.gradeId?.trim() || null
|
||||
const homeroom = data.homeroom?.trim() || null
|
||||
const room = data.room?.trim() || null
|
||||
|
||||
if (!name) throw new Error("Name is required")
|
||||
if (!grade) throw new Error("Grade is required")
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const invitationCode = await generateUniqueInvitationCode()
|
||||
try {
|
||||
const subjectNames = await getClassSubjects()
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, subjectNames))
|
||||
const idByName = new Map<string, string>()
|
||||
for (const r of subjectRows) {
|
||||
idByName.set(r.name, r.id)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(classes).values({
|
||||
id,
|
||||
schoolName,
|
||||
schoolId,
|
||||
name,
|
||||
grade,
|
||||
gradeId,
|
||||
homeroom,
|
||||
room,
|
||||
invitationCode,
|
||||
teacherId,
|
||||
})
|
||||
|
||||
const values = subjectNames.flatMap((name) => {
|
||||
const subjectId = idByName.get(name)
|
||||
if (!subjectId) return []
|
||||
return [{ classId: id, subjectId, teacherId: null }]
|
||||
})
|
||||
await tx.insert(classSubjectTeachers).values(values)
|
||||
})
|
||||
return id
|
||||
} catch (err) {
|
||||
if (isDuplicateInvitationCodeError(err)) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to create class")
|
||||
}
|
||||
|
||||
export async function ensureClassInvitationCode(classId: string): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = classId.trim()
|
||||
if (!id) throw new Error("Missing class id")
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id, invitationCode: classes.invitationCode })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const existing = owned.invitationCode
|
||||
if (typeof existing === "string" && /^\d{6}$/.test(existing)) return existing
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const code = await generateUniqueInvitationCode()
|
||||
try {
|
||||
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
|
||||
return code
|
||||
} catch (err) {
|
||||
if (isDuplicateInvitationCodeError(err)) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to generate invitation code")
|
||||
}
|
||||
|
||||
export async function regenerateClassInvitationCode(classId: string): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = classId.trim()
|
||||
if (!id) throw new Error("Missing class id")
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const code = await generateUniqueInvitationCode()
|
||||
try {
|
||||
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
|
||||
return code
|
||||
} catch (err) {
|
||||
if (isDuplicateInvitationCodeError(err)) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to generate invitation code")
|
||||
}
|
||||
|
||||
export async function enrollStudentByInvitationCode(studentId: string, invitationCode: string): Promise<string> {
|
||||
const sid = studentId.trim()
|
||||
const code = invitationCode.trim()
|
||||
if (!sid) throw new Error("Missing student id")
|
||||
if (!code) throw new Error("Invalid invitation code")
|
||||
|
||||
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
||||
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
||||
const result = await validateInvitationCode(code)
|
||||
if (!result.valid || !result.classId) {
|
||||
throw new Error("Invalid invitation code")
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(classEnrollments)
|
||||
.values({ classId: result.classId, studentId: sid, status: "active" })
|
||||
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
||||
|
||||
// 消耗新表邀请码(旧表无计数,跳过)
|
||||
if (result.codeId) {
|
||||
await consumeInvitationCode(code)
|
||||
}
|
||||
|
||||
return result.classId
|
||||
}
|
||||
|
||||
export async function enrollTeacherByInvitationCode(
|
||||
teacherId: string,
|
||||
invitationCode: string,
|
||||
subject: string | null
|
||||
): Promise<string> {
|
||||
const tid = teacherId.trim()
|
||||
const code = invitationCode.trim()
|
||||
if (!tid) throw new Error("Missing teacher id")
|
||||
if (!code) throw new Error("Invalid invitation code")
|
||||
|
||||
const [teacher] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, tid), eq(roles.name, ROLE_NAMES.TEACHER)))
|
||||
.limit(1)
|
||||
|
||||
if (!teacher) throw new Error("Teacher not found")
|
||||
|
||||
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
||||
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
||||
const result = await validateInvitationCode(code)
|
||||
if (!result.valid || !result.classId) {
|
||||
throw new Error("Invalid invitation code")
|
||||
}
|
||||
|
||||
const [cls] = await db
|
||||
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, result.classId))
|
||||
.limit(1)
|
||||
|
||||
if (!cls) throw new Error("Invalid invitation code")
|
||||
if (cls.teacherId === tid) return cls.id
|
||||
|
||||
const subjectValue = typeof subject === "string" ? subject.trim() : ""
|
||||
const [existing] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (existing && !subjectValue) return cls.id
|
||||
if (subjectValue) {
|
||||
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
|
||||
if (!subRow) throw new Error("Subject not found")
|
||||
const sid = subRow.id
|
||||
|
||||
const [mapping] = await db
|
||||
.select({ teacherId: classSubjectTeachers.teacherId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
|
||||
.limit(1)
|
||||
|
||||
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
|
||||
if (mapping?.teacherId === tid) return cls.id
|
||||
if (!mapping) {
|
||||
await db
|
||||
.insert(classSubjectTeachers)
|
||||
.values({ classId: cls.id, subjectId: sid, teacherId: null })
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
|
||||
}
|
||||
|
||||
const [existingSubject] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (existingSubject) return cls.id
|
||||
|
||||
await db
|
||||
.update(classSubjectTeachers)
|
||||
.set({ teacherId: tid })
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
|
||||
|
||||
const [assigned] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (!assigned) throw new Error("Subject already assigned")
|
||||
} else {
|
||||
const subjectRows = await db
|
||||
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
|
||||
|
||||
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
|
||||
if (!preferred) throw new Error("Class already has assigned teachers")
|
||||
const subjectRow = subjectRows.find((r) => r.name === preferred)
|
||||
if (!subjectRow) throw new Error("Subject not found")
|
||||
const sid = subjectRow.id
|
||||
|
||||
await db
|
||||
.update(classSubjectTeachers)
|
||||
.set({ teacherId: tid })
|
||||
.where(
|
||||
and(
|
||||
eq(classSubjectTeachers.classId, cls.id),
|
||||
eq(classSubjectTeachers.subjectId, sid),
|
||||
isNull(classSubjectTeachers.teacherId)
|
||||
)
|
||||
)
|
||||
|
||||
const [assigned] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(
|
||||
and(
|
||||
eq(classSubjectTeachers.classId, cls.id),
|
||||
eq(classSubjectTeachers.subjectId, sid),
|
||||
eq(classSubjectTeachers.teacherId, tid)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!assigned) throw new Error("Class already has assigned teachers")
|
||||
}
|
||||
|
||||
// 消耗新表邀请码(旧表无计数,跳过)
|
||||
if (result.codeId) {
|
||||
await consumeInvitationCode(code)
|
||||
}
|
||||
|
||||
return cls.id
|
||||
}
|
||||
|
||||
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const update: Partial<typeof classes.$inferSelect> = {}
|
||||
if (data.schoolName !== undefined) update.schoolName = data.schoolName?.trim() || null
|
||||
if (data.schoolId !== undefined) update.schoolId = data.schoolId?.trim() || null
|
||||
if (typeof data.name === "string") update.name = data.name.trim()
|
||||
if (typeof data.grade === "string") update.grade = data.grade.trim()
|
||||
if (data.gradeId !== undefined) update.gradeId = data.gradeId?.trim() || null
|
||||
if (data.homeroom !== undefined) update.homeroom = data.homeroom?.trim() || null
|
||||
if (data.room !== undefined) update.room = data.room?.trim() || null
|
||||
|
||||
if (Object.keys(update).length === 0) return
|
||||
|
||||
await db
|
||||
.update(classes)
|
||||
.set(update)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
}
|
||||
|
||||
export async function setClassSubjectTeachers(params: {
|
||||
classId: string
|
||||
assignments: Array<{ subject: ClassSubject; teacherId: string | null }>
|
||||
}): Promise<void> {
|
||||
const classId = params.classId.trim()
|
||||
if (!classId) throw new Error("Missing class id")
|
||||
|
||||
const [existing] = await db.select({ id: classes.id }).from(classes).where(eq(classes.id, classId)).limit(1)
|
||||
if (!existing) throw new Error("Class not found")
|
||||
|
||||
const teacherIds = params.assignments
|
||||
.map((a) => a.teacherId)
|
||||
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
|
||||
|
||||
if (teacherIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(roles.name, ROLE_NAMES.TEACHER), inArray(users.id, teacherIds)))
|
||||
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
|
||||
}
|
||||
|
||||
const teacherBySubject = new Map<string, string | null>()
|
||||
const subjectNames = await getClassSubjects()
|
||||
for (const a of params.assignments) {
|
||||
if (!subjectNames.includes(a.subject)) continue
|
||||
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
|
||||
}
|
||||
|
||||
// Map subject names to ids
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, subjectNames))
|
||||
const idByName = new Map<string, string>()
|
||||
for (const r of subjectRows) {
|
||||
idByName.set(r.name, r.id)
|
||||
}
|
||||
|
||||
const values = subjectNames.flatMap((name) => {
|
||||
const subjectId = idByName.get(name)
|
||||
if (!subjectId) return []
|
||||
return [{ classId, subjectId, teacherId: teacherBySubject.get(name) ?? null }]
|
||||
})
|
||||
|
||||
await db
|
||||
.insert(classSubjectTeachers)
|
||||
.values(values)
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
|
||||
}
|
||||
|
||||
export async function deleteTeacherClass(classId: string): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
await db
|
||||
.delete(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
}
|
||||
|
||||
export async function enrollStudentByEmail(classId: string, email: string): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const normalized = email.trim().toLowerCase()
|
||||
if (!normalized) throw new Error("Student email is required")
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const [student] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, normalized))
|
||||
.limit(1)
|
||||
|
||||
if (!student) throw new Error("Student not found")
|
||||
const [studentRole] = await db
|
||||
.select({ id: usersToRoles.userId })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, ROLE_NAMES.STUDENT)))
|
||||
.limit(1)
|
||||
if (!studentRole) throw new Error("User is not a student")
|
||||
|
||||
await db
|
||||
.insert(classEnrollments)
|
||||
.values({ classId, studentId: student.id, status: "active" })
|
||||
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
||||
}
|
||||
|
||||
export async function setStudentEnrollmentStatus(classId: string, studentId: string, status: "active" | "inactive"): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const [existing] = await db
|
||||
.select({ classId: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Enrollment not found")
|
||||
|
||||
await db
|
||||
.update(classEnrollments)
|
||||
.set({ status })
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataScope resolver helpers (P1-5/P1-6 audit fix)
|
||||
// These lightweight functions return only the IDs needed by the RBAC
|
||||
// data-scope resolver, so shared/lib/auth-guard no longer queries classes/
|
||||
// classEnrollments/classSubjectTeachers tables directly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a teacher's class IDs and subject IDs for DataScope resolution.
|
||||
* Merges homeroom classes (classes.teacherId) and subject-teacher assignments
|
||||
* (classSubjectTeachers.teacherId). Returns deduplicated arrays.
|
||||
*/
|
||||
export async function getTeacherScopeData(
|
||||
teacherId: string,
|
||||
): Promise<{ classIds: string[]; subjectIds: string[] }> {
|
||||
const [homeroomRows, subjectRows] = await Promise.all([
|
||||
db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)),
|
||||
db
|
||||
.select({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(eq(classSubjectTeachers.teacherId, teacherId)),
|
||||
])
|
||||
|
||||
const classIdSet = new Set<string>(homeroomRows.map((r) => r.id))
|
||||
const subjectIdSet = new Set<string>()
|
||||
for (const row of subjectRows) {
|
||||
classIdSet.add(row.classId)
|
||||
if (row.subjectId !== null) subjectIdSet.add(row.subjectId)
|
||||
}
|
||||
|
||||
return {
|
||||
classIds: Array.from(classIdSet),
|
||||
subjectIds: Array.from(subjectIdSet),
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,18 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
classSubjectTeachers,
|
||||
subjects,
|
||||
roles,
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import type {
|
||||
ClassSubject,
|
||||
CreateTeacherClassInput,
|
||||
TeacherOption,
|
||||
TeacherClass,
|
||||
UpdateTeacherClassInput,
|
||||
} from "./types"
|
||||
import { getClassHomeworkInsights } from "./data-access-stats"
|
||||
import { getClassSchedule } from "./data-access-schedule"
|
||||
|
||||
const isClassSubject = (v: unknown): v is ClassSubject =>
|
||||
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
|
||||
|
||||
const toClassSubject = (v: string): ClassSubject | null =>
|
||||
isClassSubject(v) ? v : null
|
||||
|
||||
export const getSessionTeacherId = async (): Promise<string | null> => {
|
||||
const { auth } = await import("@/auth")
|
||||
@@ -42,38 +25,11 @@ export const getSessionTeacherId = async (): Promise<string | null> => {
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
|
||||
.where(and(eq(users.id, userId), eq(roles.name, ROLE_NAMES.TEACHER)))
|
||||
.limit(1)
|
||||
return teacher?.id ?? null
|
||||
}
|
||||
|
||||
// Strict subjectId-based mapping: no aliasing
|
||||
|
||||
export const isDuplicateInvitationCodeError = (err: unknown): boolean => {
|
||||
if (!err) return false
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const m = msg.toLowerCase()
|
||||
return m.includes("duplicate") && (m.includes("invitation") || m.includes("invitation_code"))
|
||||
}
|
||||
|
||||
const generateInvitationCode = (): string => {
|
||||
const n = Math.floor(Math.random() * 1_000_000)
|
||||
return String(n).padStart(6, "0")
|
||||
}
|
||||
|
||||
export const generateUniqueInvitationCode = async (): Promise<string> => {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const code = generateInvitationCode()
|
||||
const [existing] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.invitationCode, code))
|
||||
.limit(1)
|
||||
if (!existing) return code
|
||||
}
|
||||
throw new Error("Failed to generate invitation code")
|
||||
}
|
||||
|
||||
export const getTeacherIdForMutations = async (): Promise<string> => {
|
||||
const teacherId = await getSessionTeacherId()
|
||||
if (!teacherId) throw new Error("Teacher not found")
|
||||
@@ -86,7 +42,8 @@ export const getClassSubjects = async (): Promise<string[]> => {
|
||||
})
|
||||
|
||||
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
|
||||
return Array.from(new Set(names))
|
||||
// P1-5: subjects 表为空时回退到默认科目列表,避免班级创建流程中断
|
||||
return Array.from(new Set(names.length > 0 ? names : DEFAULT_CLASS_SUBJECTS))
|
||||
}
|
||||
|
||||
const normalizeSortText = (v: string | null | undefined): string =>
|
||||
@@ -423,544 +380,27 @@ export const getClassIdsByGradeIdsSubquery = (gradeIds: string[]) => {
|
||||
return db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, gradeIds))
|
||||
}
|
||||
|
||||
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return []
|
||||
|
||||
const rows = await (async () => {
|
||||
try {
|
||||
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||
|
||||
if (allIds.length === 0) return []
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
schoolName: classes.schoolName,
|
||||
name: classes.name,
|
||||
grade: classes.grade,
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
invitationCode: classes.invitationCode,
|
||||
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(classes)
|
||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classes.id, allIds))
|
||||
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch (error) {
|
||||
console.error("getTeacherClasses query failed:", error)
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
const list = rows.map((r) => ({
|
||||
id: r.id,
|
||||
schoolName: r.schoolName,
|
||||
name: r.name,
|
||||
grade: r.grade,
|
||||
homeroom: r.homeroom,
|
||||
room: r.room,
|
||||
invitationCode: r.invitationCode ?? null,
|
||||
studentCount: Number(r.studentCount ?? 0),
|
||||
}))
|
||||
|
||||
list.sort(compareClassLike)
|
||||
|
||||
// Fetch recent assignments for trends and schedule
|
||||
const listWithTrends = await Promise.all(
|
||||
list.map(async (c) => {
|
||||
const [insights, schedule] = await Promise.all([
|
||||
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
|
||||
getClassSchedule({ classId: c.id, teacherId }),
|
||||
])
|
||||
|
||||
const recentAssignments = insights
|
||||
? insights.assignments.map((a) => ({
|
||||
id: a.assignmentId,
|
||||
title: a.title,
|
||||
status: a.status,
|
||||
subject: a.subject,
|
||||
isActive: a.isActive,
|
||||
isOverdue: a.isOverdue,
|
||||
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
||||
submittedCount: a.submittedCount,
|
||||
targetCount: a.targetCount,
|
||||
avgScore: a.scoreStats.avg,
|
||||
medianScore: a.scoreStats.median,
|
||||
}))
|
||||
: []
|
||||
return { ...c, recentAssignments, schedule }
|
||||
})
|
||||
)
|
||||
|
||||
return listWithTrends
|
||||
})
|
||||
|
||||
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
||||
const rows = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(roles.name, "teacher"))
|
||||
.orderBy(asc(users.createdAt))
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name ?? "Unnamed",
|
||||
email: r.email,
|
||||
}))
|
||||
})
|
||||
|
||||
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
|
||||
const teacherId = await getSessionTeacherId()
|
||||
if (!teacherId) return []
|
||||
|
||||
const rows = await db
|
||||
.select({ subject: subjects.name })
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
||||
.groupBy(subjects.name)
|
||||
.orderBy(asc(subjects.name))
|
||||
|
||||
return rows
|
||||
.map((r) => toClassSubject(r.subject))
|
||||
.filter((s): s is ClassSubject => s !== null)
|
||||
})
|
||||
|
||||
export async function createTeacherClass(data: CreateTeacherClassInput): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = createId()
|
||||
|
||||
const schoolName = data.schoolName?.trim() || null
|
||||
const schoolId = data.schoolId?.trim() || null
|
||||
const name = data.name.trim()
|
||||
const grade = data.grade.trim()
|
||||
const gradeId = data.gradeId?.trim() || null
|
||||
const homeroom = data.homeroom?.trim() || null
|
||||
const room = data.room?.trim() || null
|
||||
|
||||
if (!name) throw new Error("Name is required")
|
||||
if (!grade) throw new Error("Grade is required")
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const invitationCode = await generateUniqueInvitationCode()
|
||||
try {
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map<ClassSubject, string>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = toClassSubject(r.name)
|
||||
if (subject) idByName.set(subject, r.id)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(classes).values({
|
||||
id,
|
||||
schoolName,
|
||||
schoolId,
|
||||
name,
|
||||
grade,
|
||||
gradeId,
|
||||
homeroom,
|
||||
room,
|
||||
invitationCode,
|
||||
teacherId,
|
||||
})
|
||||
|
||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
||||
const subjectId = idByName.get(name)
|
||||
if (!subjectId) return []
|
||||
return [{ classId: id, subjectId, teacherId: null }]
|
||||
})
|
||||
await tx.insert(classSubjectTeachers).values(values)
|
||||
})
|
||||
return id
|
||||
} catch (err) {
|
||||
if (isDuplicateInvitationCodeError(err)) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to create class")
|
||||
}
|
||||
|
||||
export async function ensureClassInvitationCode(classId: string): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = classId.trim()
|
||||
if (!id) throw new Error("Missing class id")
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id, invitationCode: classes.invitationCode })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const existing = owned.invitationCode
|
||||
if (typeof existing === "string" && /^\d{6}$/.test(existing)) return existing
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const code = await generateUniqueInvitationCode()
|
||||
try {
|
||||
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
|
||||
return code
|
||||
} catch (err) {
|
||||
if (isDuplicateInvitationCodeError(err)) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to generate invitation code")
|
||||
}
|
||||
|
||||
export async function regenerateClassInvitationCode(classId: string): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = classId.trim()
|
||||
if (!id) throw new Error("Missing class id")
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const code = await generateUniqueInvitationCode()
|
||||
try {
|
||||
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
|
||||
return code
|
||||
} catch (err) {
|
||||
if (isDuplicateInvitationCodeError(err)) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to generate invitation code")
|
||||
}
|
||||
|
||||
export async function enrollStudentByInvitationCode(studentId: string, invitationCode: string): Promise<string> {
|
||||
const sid = studentId.trim()
|
||||
const code = invitationCode.trim()
|
||||
if (!sid) throw new Error("Missing student id")
|
||||
if (!code) throw new Error("Invalid invitation code")
|
||||
|
||||
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
||||
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
||||
const result = await validateInvitationCode(code)
|
||||
if (!result.valid || !result.classId) {
|
||||
throw new Error("Invalid invitation code")
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(classEnrollments)
|
||||
.values({ classId: result.classId, studentId: sid, status: "active" })
|
||||
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
||||
|
||||
// 消耗新表邀请码(旧表无计数,跳过)
|
||||
if (result.codeId) {
|
||||
await consumeInvitationCode(code)
|
||||
}
|
||||
|
||||
return result.classId
|
||||
}
|
||||
|
||||
export async function enrollTeacherByInvitationCode(
|
||||
teacherId: string,
|
||||
invitationCode: string,
|
||||
subject: string | null
|
||||
): Promise<string> {
|
||||
const tid = teacherId.trim()
|
||||
const code = invitationCode.trim()
|
||||
if (!tid) throw new Error("Missing teacher id")
|
||||
if (!code) throw new Error("Invalid invitation code")
|
||||
|
||||
const [teacher] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, tid), eq(roles.name, "teacher")))
|
||||
.limit(1)
|
||||
|
||||
if (!teacher) throw new Error("Teacher not found")
|
||||
|
||||
// v3:优先走新邀请码体系(validateInvitationCode 内部含 fallback 到旧 classes.invitationCode)
|
||||
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
|
||||
const result = await validateInvitationCode(code)
|
||||
if (!result.valid || !result.classId) {
|
||||
throw new Error("Invalid invitation code")
|
||||
}
|
||||
|
||||
const [cls] = await db
|
||||
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, result.classId))
|
||||
.limit(1)
|
||||
|
||||
if (!cls) throw new Error("Invalid invitation code")
|
||||
if (cls.teacherId === tid) return cls.id
|
||||
|
||||
const subjectValue = typeof subject === "string" ? subject.trim() : ""
|
||||
const [existing] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (existing && !subjectValue) return cls.id
|
||||
if (subjectValue) {
|
||||
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
|
||||
if (!subRow) throw new Error("Subject not found")
|
||||
const sid = subRow.id
|
||||
|
||||
const [mapping] = await db
|
||||
.select({ teacherId: classSubjectTeachers.teacherId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
|
||||
.limit(1)
|
||||
|
||||
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
|
||||
if (mapping?.teacherId === tid) return cls.id
|
||||
if (!mapping) {
|
||||
await db
|
||||
.insert(classSubjectTeachers)
|
||||
.values({ classId: cls.id, subjectId: sid, teacherId: null })
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
|
||||
}
|
||||
|
||||
const [existingSubject] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (existingSubject) return cls.id
|
||||
|
||||
await db
|
||||
.update(classSubjectTeachers)
|
||||
.set({ teacherId: tid })
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
|
||||
|
||||
const [assigned] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (!assigned) throw new Error("Subject already assigned")
|
||||
} else {
|
||||
const subjectRows = await db
|
||||
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
|
||||
|
||||
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
|
||||
if (!preferred) throw new Error("Class already has assigned teachers")
|
||||
const subjectRow = subjectRows.find((r) => r.name === preferred)
|
||||
if (!subjectRow) throw new Error("Subject not found")
|
||||
const sid = subjectRow.id
|
||||
|
||||
await db
|
||||
.update(classSubjectTeachers)
|
||||
.set({ teacherId: tid })
|
||||
.where(
|
||||
and(
|
||||
eq(classSubjectTeachers.classId, cls.id),
|
||||
eq(classSubjectTeachers.subjectId, sid),
|
||||
isNull(classSubjectTeachers.teacherId)
|
||||
)
|
||||
)
|
||||
|
||||
const [assigned] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(
|
||||
and(
|
||||
eq(classSubjectTeachers.classId, cls.id),
|
||||
eq(classSubjectTeachers.subjectId, sid),
|
||||
eq(classSubjectTeachers.teacherId, tid)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!assigned) throw new Error("Class already has assigned teachers")
|
||||
}
|
||||
|
||||
// 消耗新表邀请码(旧表无计数,跳过)
|
||||
if (result.codeId) {
|
||||
await consumeInvitationCode(code)
|
||||
}
|
||||
|
||||
return cls.id
|
||||
}
|
||||
|
||||
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const update: Partial<typeof classes.$inferSelect> = {}
|
||||
if (data.schoolName !== undefined) update.schoolName = data.schoolName?.trim() || null
|
||||
if (data.schoolId !== undefined) update.schoolId = data.schoolId?.trim() || null
|
||||
if (typeof data.name === "string") update.name = data.name.trim()
|
||||
if (typeof data.grade === "string") update.grade = data.grade.trim()
|
||||
if (data.gradeId !== undefined) update.gradeId = data.gradeId?.trim() || null
|
||||
if (data.homeroom !== undefined) update.homeroom = data.homeroom?.trim() || null
|
||||
if (data.room !== undefined) update.room = data.room?.trim() || null
|
||||
|
||||
if (Object.keys(update).length === 0) return
|
||||
|
||||
await db
|
||||
.update(classes)
|
||||
.set(update)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
}
|
||||
|
||||
export async function setClassSubjectTeachers(params: {
|
||||
classId: string
|
||||
assignments: Array<{ subject: ClassSubject; teacherId: string | null }>
|
||||
}): Promise<void> {
|
||||
const classId = params.classId.trim()
|
||||
if (!classId) throw new Error("Missing class id")
|
||||
|
||||
const [existing] = await db.select({ id: classes.id }).from(classes).where(eq(classes.id, classId)).limit(1)
|
||||
if (!existing) throw new Error("Class not found")
|
||||
|
||||
const teacherIds = params.assignments
|
||||
.map((a) => a.teacherId)
|
||||
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
|
||||
|
||||
if (teacherIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
|
||||
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
|
||||
}
|
||||
|
||||
const teacherBySubject = new Map<ClassSubject, string | null>()
|
||||
for (const a of params.assignments) {
|
||||
if (!DEFAULT_CLASS_SUBJECTS.includes(a.subject)) continue
|
||||
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
|
||||
}
|
||||
|
||||
// Map subject names to ids
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map<ClassSubject, string>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = toClassSubject(r.name)
|
||||
if (subject) idByName.set(subject, r.id)
|
||||
}
|
||||
|
||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
||||
const subjectId = idByName.get(name)
|
||||
if (!subjectId) return []
|
||||
return [{ classId, subjectId, teacherId: teacherBySubject.get(name) ?? null }]
|
||||
})
|
||||
|
||||
await db
|
||||
.insert(classSubjectTeachers)
|
||||
.values(values)
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
|
||||
}
|
||||
|
||||
export async function deleteTeacherClass(classId: string): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
await db
|
||||
.delete(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
}
|
||||
|
||||
export async function enrollStudentByEmail(classId: string, email: string): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const normalized = email.trim().toLowerCase()
|
||||
if (!normalized) throw new Error("Student email is required")
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const [student] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, normalized))
|
||||
.limit(1)
|
||||
|
||||
if (!student) throw new Error("Student not found")
|
||||
const [studentRole] = await db
|
||||
.select({ id: usersToRoles.userId })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
|
||||
.limit(1)
|
||||
if (!studentRole) throw new Error("User is not a student")
|
||||
|
||||
await db
|
||||
.insert(classEnrollments)
|
||||
.values({ classId, studentId: student.id, status: "active" })
|
||||
.onDuplicateKeyUpdate({ set: { status: "active" } })
|
||||
}
|
||||
|
||||
export async function setStudentEnrollmentStatus(classId: string, studentId: string, status: "active" | "inactive"): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const [existing] = await db
|
||||
.select({ classId: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Enrollment not found")
|
||||
|
||||
await db
|
||||
.update(classEnrollments)
|
||||
.set({ status })
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
|
||||
}
|
||||
|
||||
// Re-export from split files for backward compatibility
|
||||
export * from "./data-access-stats"
|
||||
export * from "./data-access-schedule"
|
||||
export * from "./data-access-students"
|
||||
export * from "./data-access-admin"
|
||||
export * from "./data-access-invitations"
|
||||
export * from "./data-access-teacher"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataScope resolver helpers (P1-5/P1-6 audit fix)
|
||||
//
|
||||
// P0-1 audit fix (2026-06-25): 下列 `getTeacherScopeData` / `getStudentScopeData`
|
||||
// / `getGradeIdsForStudentIds` 三个函数原本在本文件与拆分文件中重复定义,
|
||||
// ES 模块语义下本地定义会覆盖 `export *` 的同名导出,导致:
|
||||
// - 通过 `@/modules/classes/data-access` 导入时得到本地版本
|
||||
// - 通过 `@/modules/classes/data-access-teacher` / `-students` 导入时得到另一份实现
|
||||
// 两份实现逻辑一致,但任何一方修改都不会同步,是高风险维护陷阱。
|
||||
//
|
||||
// 修复:删除本文件中的本地定义,统一从拆分文件通过 `export *` 暴露。
|
||||
// 实际定义见:
|
||||
// - getTeacherScopeData → data-access-teacher.ts
|
||||
// - getStudentScopeData → data-access-students.ts
|
||||
// - getGradeIdsForStudentIds → data-access-students.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
114
src/modules/classes/hooks/use-class-data.ts
Normal file
114
src/modules/classes/hooks/use-class-data.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import type { AdminClassListItem } from "../types"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
||||
import type { SubjectTeacherState } from "../components/class-form-utils"
|
||||
|
||||
export interface UseClassDataReturn {
|
||||
createOpen: boolean
|
||||
setCreateOpen: (open: boolean) => void
|
||||
editItem: AdminClassListItem | null
|
||||
setEditItem: (item: AdminClassListItem | null) => void
|
||||
deleteItem: AdminClassListItem | null
|
||||
setDeleteItem: (item: AdminClassListItem | null) => void
|
||||
isWorking: boolean
|
||||
setIsWorking: (v: boolean) => void
|
||||
createTeacherId: string
|
||||
setCreateTeacherId: (id: string) => void
|
||||
createSchoolId: string
|
||||
setCreateSchoolId: (id: string) => void
|
||||
createGradeId: string
|
||||
setCreateGradeId: (id: string) => void
|
||||
editTeacherId: string
|
||||
setEditTeacherId: (id: string) => void
|
||||
editSchoolId: string
|
||||
setEditSchoolId: (id: string) => void
|
||||
editGradeId: string
|
||||
setEditGradeId: (id: string) => void
|
||||
editSubjectTeachers: SubjectTeacherState[]
|
||||
setSubjectTeacher: (subject: string, teacherId: string | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级列表/弹窗共享状态:对话框开关、表单字段、提交中标记、重置逻辑。
|
||||
* - createOpen 打开时按默认值重置 create* 字段(使用 derived state 模式避免 useEffect)
|
||||
* - editItem 变化时按选中项重置 edit* 字段(使用 derived state 模式避免 useEffect)
|
||||
*/
|
||||
export function useClassData(config: {
|
||||
defaultTeacherId: string
|
||||
defaultSchoolId?: string
|
||||
defaultGradeId?: string
|
||||
}): UseClassDataReturn {
|
||||
const { defaultTeacherId, defaultSchoolId = "", defaultGradeId = "" } = config
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
|
||||
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
|
||||
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
|
||||
const [createSchoolId, setCreateSchoolId] = useState(defaultSchoolId)
|
||||
const [createGradeId, setCreateGradeId] = useState(defaultGradeId)
|
||||
const [editTeacherId, setEditTeacherId] = useState("")
|
||||
const [editSchoolId, setEditSchoolId] = useState("")
|
||||
const [editGradeId, setEditGradeId] = useState("")
|
||||
const [editSubjectTeachers, setEditSubjectTeachers] = useState<SubjectTeacherState[]>([])
|
||||
|
||||
// Derived state pattern: reset create form when dialog opens
|
||||
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
|
||||
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
|
||||
if (createOpen !== prevCreateOpen) {
|
||||
setPrevCreateOpen(createOpen)
|
||||
if (createOpen) {
|
||||
setCreateTeacherId(defaultTeacherId)
|
||||
setCreateSchoolId(defaultSchoolId)
|
||||
setCreateGradeId(defaultGradeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Derived state pattern: reset edit form when editItem changes
|
||||
const [prevEditItem, setPrevEditItem] = useState(editItem)
|
||||
if (editItem !== prevEditItem) {
|
||||
setPrevEditItem(editItem)
|
||||
if (editItem) {
|
||||
setEditTeacherId(editItem.teacher.id)
|
||||
setEditSchoolId(editItem.schoolId ?? defaultSchoolId)
|
||||
setEditGradeId(editItem.gradeId ?? defaultGradeId)
|
||||
setEditSubjectTeachers(
|
||||
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||
subject: s,
|
||||
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
|
||||
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
|
||||
}
|
||||
|
||||
return {
|
||||
createOpen,
|
||||
setCreateOpen,
|
||||
editItem,
|
||||
setEditItem,
|
||||
deleteItem,
|
||||
setDeleteItem,
|
||||
isWorking,
|
||||
setIsWorking,
|
||||
createTeacherId,
|
||||
setCreateTeacherId,
|
||||
createSchoolId,
|
||||
setCreateSchoolId,
|
||||
createGradeId,
|
||||
setCreateGradeId,
|
||||
editTeacherId,
|
||||
setEditTeacherId,
|
||||
editSchoolId,
|
||||
setEditSchoolId,
|
||||
editGradeId,
|
||||
setEditGradeId,
|
||||
editSubjectTeachers,
|
||||
setSubjectTeacher,
|
||||
}
|
||||
}
|
||||
31
src/modules/classes/hooks/use-class-filters.ts
Normal file
31
src/modules/classes/hooks/use-class-filters.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
import type { ClassFormGrade } from "../components/class-form-utils"
|
||||
|
||||
export interface UseClassFiltersReturn {
|
||||
createGrades: ClassFormGrade[]
|
||||
editGrades: ClassFormGrade[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 按学校筛选年级(admin 模式使用)。
|
||||
* createGrades 按 createSchoolId 筛选,editGrades 按 editSchoolId 筛选。
|
||||
* grade 模式不使用此 hook(managedGrades 直接展示,无需按学校过滤)。
|
||||
*/
|
||||
export function useClassFilters(
|
||||
grades: ClassFormGrade[],
|
||||
createSchoolId: string,
|
||||
editSchoolId: string,
|
||||
): UseClassFiltersReturn {
|
||||
const createGrades = useMemo(
|
||||
() => grades.filter((g) => g.schoolId === createSchoolId),
|
||||
[grades, createSchoolId],
|
||||
)
|
||||
const editGrades = useMemo(
|
||||
() => grades.filter((g) => g.schoolId === editSchoolId),
|
||||
[grades, editSchoolId],
|
||||
)
|
||||
return { createGrades, editGrades }
|
||||
}
|
||||
Reference in New Issue
Block a user