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