feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013
## P1 功能(20 项) - 站内消息系统、家长仪表盘、学生考勤管理 - Excel 导入导出、用户批量导入、成绩导出 - 排课规则+自动排课+课表调整 - 成绩趋势+对比分析、密码安全策略、速率限制 - 数据变更日志、文件预览+存储策略、全文检索 - 依赖审计集成 CI、数据库定时备份、E2E 测试完善 - 通知偏好管理 ## 基础设施修复 - src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求) - .env: MySQL 端口从 13002 切换至 14013 - scripts/create-db.ts: 新增数据库初始化脚本 ## 架构文档同步 - 004_architecture_impact_map.md 和 005_architecture_data.json 完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
@@ -7,6 +7,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
schedule:
|
||||||
|
- cron: "0 2 * * *" # 每天凌晨 2 点触发定时备份
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -128,3 +130,43 @@ jobs:
|
|||||||
nextjs-app
|
nextjs-app
|
||||||
|
|
||||||
echo "Deploy complete!"
|
echo "Deploy complete!"
|
||||||
|
|
||||||
|
security-audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- run: npm ci
|
||||||
|
- name: Run npm audit
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
continue-on-error: true
|
||||||
|
- name: Check for critical vulnerabilities
|
||||||
|
run: npm audit --audit-level=critical
|
||||||
|
- name: Upload audit report
|
||||||
|
if: always()
|
||||||
|
run: npm audit --json > audit-report.json
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: security-audit-report
|
||||||
|
path: audit-report.json
|
||||||
|
|
||||||
|
scheduled-backup:
|
||||||
|
if: github.event_name == 'schedule'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Run database backup
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
BACKUP_DIR: ./backups
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/backup-db.sh
|
||||||
|
./scripts/backup-db.sh
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: db-backup
|
||||||
|
path: backups/
|
||||||
|
retention-days: 30
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -39,3 +39,13 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# database backups
|
||||||
|
/backups/
|
||||||
|
|
||||||
|
# security audit reports
|
||||||
|
/audit-report.json
|
||||||
|
|
||||||
|
# playwright
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
# Next_Edu 差距审计报告
|
# Next_Edu 差距审计报告(v2 — 基于完整架构图)
|
||||||
|
|
||||||
> 对照《企业级 K12 教务管理系统标准功能模块清单》(006),基于架构影响地图(004/005)与源码扫描
|
> 对照《企业级 K12 教务管理系统标准功能模块清单》(006),基于完整架构影响地图(004/005)与源码全量扫描
|
||||||
> 审计日期:2026-06-16
|
> 审计日期:2026-06-16(v2 更新)
|
||||||
|
> v2 变更:架构图已全量补全(12 模块 + 46 路由 + 32 表 + 200+ 导出),安全漏洞已修复
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,21 +10,30 @@
|
|||||||
|
|
||||||
| 维度 | P0 子功能总数 | 已完成 | 部分完成 | 未实现 | 完成率 |
|
| 维度 | P0 子功能总数 | 已完成 | 部分完成 | 未实现 | 完成率 |
|
||||||
|------|-------------|--------|---------|--------|--------|
|
|------|-------------|--------|---------|--------|--------|
|
||||||
| 核心业务 | 31 | 22 | 5 | 4 | **71%** |
|
| 核心业务 | 31 | 23 | 5 | 3 | **74%** |
|
||||||
| 平台基础 | 8 | 3 | 2 | 3 | **38%** |
|
| 平台基础 | 8 | 3 | 2 | 3 | **38%** |
|
||||||
| 非功能性 | 8 | 5 | 2 | 1 | **63%** |
|
| 非功能性 | 8 | 5 | 2 | 1 | **63%** |
|
||||||
| 合规安全 | 8 | 6 | 1 | 1 | **75%** |
|
| 合规安全 | 8 | 7 | 1 | 0 | **88%** |
|
||||||
| **合计** | **55** | **36** | **10** | **9** | **65%** |
|
| **合计** | **55** | **38** | **10** | **7** | **69%** |
|
||||||
|
|
||||||
> P1 完成率约 **25%**,P2 完成率约 **5%**。
|
> P1 完成率约 **25%**,P2 完成率约 **7%**。
|
||||||
|
|
||||||
### 关键风险项
|
### v2 修复项
|
||||||
|
|
||||||
|
| 修复项 | v1 状态 | v2 状态 | 说明 |
|
||||||
|
|--------|---------|---------|------|
|
||||||
|
| school/actions.ts 权限校验 | ❌ 12 个 Action 无权限 | ✅ 全部接入 requirePermission | SCHOOL_MANAGE / GRADE_MANAGE |
|
||||||
|
| settings/actions.ts 权限校验 | ❌ 使用 ensureUser() | ✅ 接入 requirePermission(AI_CONFIGURE) | 移除 auth() 直接调用 |
|
||||||
|
| users/actions.ts 权限校验 | ❌ 使用 auth() | ✅ 接入 requireAuth() | 自助操作用 requireAuth |
|
||||||
|
| 架构图完整性 | ⚠️ 大量缺失 | ✅ 全量补全 | 12 模块 + 46 路由 + 200+ 导出 |
|
||||||
|
|
||||||
|
### 关键风险项(v2 更新后)
|
||||||
|
|
||||||
1. **通知公告系统完全缺失** — P0 级功能,家校沟通核心载体,无任何代码实现
|
1. **通知公告系统完全缺失** — P0 级功能,家校沟通核心载体,无任何代码实现
|
||||||
2. **操作/登录日志完全缺失** — P0 级功能,合规审计基础,无 DB 表、无代码
|
2. **操作/登录日志完全缺失** — P0 级功能,合规审计基础,无 DB 表、无代码
|
||||||
3. **成绩分析严重不足** — 仅有作业维度的分数趋势,缺少独立的成绩录入/统计报表/查询模块
|
3. **成绩分析严重不足** — 仅有作业维度的分数趋势,缺少独立的成绩录入/统计报表/查询模块
|
||||||
4. **文件上传/权限控制缺失** — 当前无文件上传能力,题目/教材无法关联附件
|
4. **文件上传/权限控制缺失** — 当前无文件上传能力,题目/教材无法关联附件
|
||||||
5. **排课仅手动录入** — 无排课规则引擎,无自动排课,无冲突检测
|
5. **13 个幽灵导航路由** — navigation.ts 引用了 13 个不存在的页面(/admin/users/*, /courses/*, /reports, /finance, /parent/children, /parent/tuition, /messages),用户点击会 404
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,77 +44,77 @@
|
|||||||
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
||||||
|----------|------------|------|-------------|----------|
|
|----------|------------|------|-------------|----------|
|
||||||
| **用户与权限** | 用户注册/登录 | ✅ | NextAuth v5,邮箱+OAuth 登录,JWT 策略 | — |
|
| **用户与权限** | 用户注册/登录 | ✅ | NextAuth v5,邮箱+OAuth 登录,JWT 策略 | — |
|
||||||
| | 多角色体系 | ✅ | 6 角色(admin/teacher/student/parent/grade_head/teaching_head),usersToRoles 多对多 | — |
|
| | 多角色体系 | ✅ | 6 角色,usersToRoles 多对多 | — |
|
||||||
| | RBAC 权限模型 | ✅ | 30 个 `resource:action` 权限点,ROLE_PERMISSIONS 映射 | — |
|
| | RBAC 权限模型 | ✅ | 30 个权限点,ROLE_PERMISSIONS 映射 | — |
|
||||||
| | 数据范围控制 | ✅ | DataScope 6 种类型(all/owned/class_taught/grade_managed/class_members/children) | — |
|
| | 数据范围控制 | ✅ | DataScope 6 种类型 | — |
|
||||||
| | 角色切换 | ❌ | JWT 存 roles[],但无主动切换 UI | 新增角色切换下拉组件,切换后重写 JWT |
|
| | 角色切换 | ❌ | JWT 存 roles[],但无主动切换 UI | 新增角色切换下拉组件 |
|
||||||
| | 用户档案管理 | ⚠️ | profile 页可编辑姓名/邮箱,但无头像上传、地址编辑 | 增加 avatar 字段 + 图片上传 |
|
| | 用户档案管理 | ✅ | users 模块 updateUserProfile + getUserProfile,profile 页可编辑 | — |
|
||||||
| | 新手引导 | ✅ | OnboardingGate 组件,角色选择→学校/班级配置 | — |
|
| | 新手引导 | ✅ | OnboardingGate 组件 | — |
|
||||||
| | 组织架构管理 | ⚠️ | 部门/年级 CRUD 已有(school 模块),但无教研组管理 | 新增 teachingGroups 表 + CRUD |
|
| | 组织架构管理 | ⚠️ | 部门/年级 CRUD 已有,但无教研组管理 | 新增 teachingGroups 表 |
|
||||||
| | 用户批量导入 | ❌ | 无导入功能 | 新增 Excel 解析 + 批量 insert 事务 |
|
| | 用户批量导入 | ❌ | 无导入功能 | Excel 解析 + 批量 insert |
|
||||||
| | 密码安全策略 | ⚠️ | NextAuth 默认 bcrypt,但无强度校验、无锁定策略 | 前端强度校验 + 后端失败计数锁定 |
|
| | 密码安全策略 | ⚠️ | NextAuth 默认 bcrypt,但无强度校验、无锁定策略 | 前端强度校验 + 后端锁定 |
|
||||||
| **学校管理** | 学校信息配置 | ✅ | schools 表 + createSchoolAction/updateSchoolAction | — |
|
| **学校管理** | 学校信息配置 | ✅ | schools 表 + CRUD,requirePermission(SCHOOL_MANAGE) | — |
|
||||||
| | 学年学期管理 | ✅ | academicYears 表 + CRUD actions,isActive 标记 | — |
|
| | 学年学期管理 | ✅ | academicYears 表 + CRUD | — |
|
||||||
| | 年级管理 | ✅ | grades 表 + CRUD,gradeHeadId/teachingHeadId 指派 | — |
|
| | 年级管理 | ✅ | grades 表 + CRUD,requirePermission(GRADE_MANAGE) | — |
|
||||||
| | 班级管理 | ✅ | classes 表 + 17 个 actions,含邀请码、学生注册 | — |
|
| | 班级管理 | ✅ | classes 表 + 17 个 actions,含邀请码 | — |
|
||||||
| | 学科管理 | ⚠️ | subjects 表存在,但仅有 name/order,无代码/学段归属字段 | 扩展 subjects 表字段 |
|
| | 学科管理 | ⚠️ | subjects 表存在,但仅有 name/order/code | 扩展学段归属字段 |
|
||||||
| | 部门管理 | ✅ | departments 表 + CRUD actions | — |
|
| | 部门管理 | ✅ | departments 表 + CRUD | — |
|
||||||
| | 校区管理 | ❌ | 无校区概念 | 新增 campuses 表,schools 关联 campusId |
|
| | 校区管理 | ❌ | 无校区概念 | 新增 campuses 表 |
|
||||||
| | 学校参数配置 | ❌ | 无参数配置功能 | 新增 schoolSettings KV 表 |
|
| | 学校参数配置 | ❌ | 无参数配置功能 | 新增 schoolSettings KV 表 |
|
||||||
| **教务排课** | 课程计划管理 | ❌ | classSchedule 表仅存单条课表项,无课程计划概念 | 新增 coursePlans 表 + 管理界面 |
|
| **教务排课** | 课程计划管理 | ❌ | classSchedule 表仅存单条课表项 | 新增 coursePlans 表 |
|
||||||
| | 排课规则配置 | ❌ | 无规则引擎 | 新增 schedulingRules 表 + 约束求解器 |
|
| | 排课规则配置 | ❌ | 无规则引擎 | 新增 schedulingRules 表 |
|
||||||
| | 自动排课引擎 | ❌ | 无 | 集成开源排课算法或自研 CSP 求解器 |
|
| | 自动排课引擎 | ❌ | 无 | CSP 求解器 |
|
||||||
| | 课表查看 | ⚠️ | 教师班级课表 + 学生课表已有,但无教室维度 | 增加 classroom 维度查询 |
|
| | 课表查看 | ✅ | 教师课表 + 学生课表 + 班级课表,含 ScheduleView/ScheduleFilters 组件 | — |
|
||||||
| | 课表调整/代课 | ❌ | 仅 CRUD 课表项,无调课/代课流程 | 新增 scheduleChanges 表 + 审批流 |
|
| | 课表调整/代课 | ❌ | 仅 CRUD 课表项 | 新增 scheduleChanges 表 + 审批流 |
|
||||||
| | 教室资源管理 | ⚠️ | classrooms 表存在(字段: location, capacity),但无管理 UI | 增加 CRUD 页面 + 设备标签 |
|
| | 教室资源管理 | ⚠️ | classrooms 表(name/building/floor/capacity),但无管理 UI | 增加 CRUD 页面 |
|
||||||
| | 选课管理 | ❌ | 无 | 新增 electiveCourses + studentSelections 表 |
|
| | 选课管理 | ❌ | 无 | 新增 electiveCourses 表 |
|
||||||
| **教材资源** | 教材库管理 | ✅ | textbooks 表 + createTextbookAction,含 subject/grade/publisher | — |
|
| **教材资源** | 教材库管理 | ✅ | textbooks 表 + createTextbookAction | — |
|
||||||
| | 章节结构管理 | ✅ | chapters 树形结构 + reorderChaptersAction 拖拽排序 | — |
|
| | 章节结构管理 | ✅ | chapters 树形结构 + reorderChaptersAction 拖拽排序 | — |
|
||||||
| | 知识点图谱 | ⚠️ | knowledgePoints 有 CRUD + 章节关联,但无前置/后继关系 | 增加 prerequisiteEdges 表 |
|
| | 知识点图谱 | ⚠️ | knowledgePoints 有 CRUD + 章节关联,但无前置/后继关系 | 增加 prerequisiteEdges 表 |
|
||||||
| | 教材内容阅读 | ✅ | textbook-content-panel.tsx,Markdown 渲染 + rehype-sanitize | — |
|
| | 教材内容阅读 | ✅ | TextbookContentPanel,Markdown + rehype-sanitize | — |
|
||||||
| | 教材版本管理 | ❌ | 无版本概念 | 增加 textbookVersions 表或 version 字段 |
|
| | 教材版本管理 | ❌ | 无版本概念 | 增加 version 字段 |
|
||||||
| | 资源附件管理 | ❌ | 无文件上传能力 | 新增 attachments 表 + 文件上传服务 |
|
| | 资源附件管理 | ❌ | 无文件上传能力 | 新增 attachments 表 |
|
||||||
| | 教材审核流程 | ❌ | 无审核机制 | 新增 reviewWorkflow 表 + 状态机 |
|
| | 教材审核流程 | ❌ | 无审核机制 | 新增 reviewWorkflow 表 |
|
||||||
| **题库与试卷** | 题目创建/编辑 | ✅ | 5 种题型(single_choice/multiple_choice/text/judgment/composite),支持子题目 | — |
|
| **题库与试卷** | 题目创建/编辑 | ✅ | 5 种题型,支持子题目,CreateQuestionDialog | — |
|
||||||
| | 题目分类标签 | ✅ | 知识点关联 + difficulty + type 多维标签 | — |
|
| | 题目分类标签 | ✅ | 知识点关联 + difficulty + type 多维标签 | — |
|
||||||
| | 题目批量导入 | ❌ | 无 | Excel 模板 + 批量解析 |
|
| | 题目批量导入 | ❌ | 无 | Excel 模板 + 批量解析 |
|
||||||
| | 题目版本管理 | ❌ | 无 | 增加 questionVersions 表 |
|
| | 题目版本管理 | ❌ | 无 | 增加 questionVersions 表 |
|
||||||
| | 试卷手动组卷 | ✅ | exams 表 structure 字段,examQuestions 关联 | — |
|
| | 试卷手动组卷 | ✅ | ExamAssembly 组件 + StructureEditor + QuestionBankList | — |
|
||||||
| | 试卷智能组卷 | ❌ | 无自动抽题 | 按知识点/难度分布约束随机抽题算法 |
|
| | 试卷智能组卷 | ❌ | 无自动抽题 | 按知识点/难度分布随机抽题 |
|
||||||
| | AI 辅助出题 | ✅ | ai-pipeline.ts:generateAiPreviewData/generateAiCreateDraftFromSource/regenerateAiQuestionByInstruction | — |
|
| | AI 辅助出题 | ✅ | ai-pipeline.ts:3 个 AI 生成函数 + generateAiExamDraft | — |
|
||||||
| | 试卷模板管理 | ❌ | 无 | 新增 examTemplates 表 |
|
| | 试卷模板管理 | ❌ | 无 | 新增 examTemplates 表 |
|
||||||
| | 试卷预览/打印 | ⚠️ | exam-preview-dialog.tsx 可预览,但无打印适配 | 增加打印 CSS @media print |
|
| | 试卷预览/打印 | ⚠️ | ExamPreviewDialog 可预览,但无打印适配 | 增加 @media print |
|
||||||
| **作业与考试** | 作业布置 | ✅ | createHomeworkAssignmentAction,关联 sourceExamId + classId | — |
|
| **作业与考试** | 作业布置 | ✅ | createHomeworkAssignmentAction + HomeworkAssignmentForm | — |
|
||||||
| | 作业提交 | ✅ | startHomeworkSubmissionAction + saveHomeworkAnswerAction + submitHomeworkAction | — |
|
| | 作业提交 | ✅ | startHomeworkSubmissionAction + saveHomeworkAnswerAction + submitHomeworkAction | — |
|
||||||
| | 作业批改评分 | ✅ | gradeHomeworkSubmissionAction,逐题评分 + feedback | — |
|
| | 作业批改评分 | ✅ | gradeHomeworkSubmissionAction + HomeworkGradingView | — |
|
||||||
| | 迟交/补交策略 | ⚠️ | homeworkAssignments 表有 allowLate/lateDueAt 字段,但前端未暴露配置 | 作业创建表单增加迟交开关 |
|
| | 迟交/补交策略 | ⚠️ | allowLate/lateDueAt 字段存在,前端未暴露配置 | 作业创建表单增加开关 |
|
||||||
| | 多次提交/重做 | ⚠️ | maxAttempts 字段存在,startHomeworkSubmissionAction 有次数检查 | 前端暴露配置 + 重做入口 |
|
| | 多次提交/重做 | ⚠️ | maxAttempts 字段存在,有次数检查 | 前端暴露配置 + 重做入口 |
|
||||||
| | 作业统计分析 | ⚠️ | getHomeworkAssignmentAnalytics 存在,但仅限单次作业维度 | 增加班级/时间维度汇总 |
|
| | 作业统计分析 | ✅ | getHomeworkAssignmentAnalytics + HomeworkAssignmentQuestionErrorOverviewCard | — |
|
||||||
| | 作业归档 | ❌ | 无归档机制 | 增加 archivedAt 字段 + 归档 API |
|
| | 作业归档 | ❌ | 无归档机制 | 增加 archivedAt 字段 |
|
||||||
| | 在线考试模式 | ❌ | 无限时/防切屏/乱序/自动交卷 | 新增 examMode 字段 + 前端计时器 + 乱序逻辑 |
|
| | 在线考试模式 | ❌ | 无限时/防切屏/乱序/自动交卷 | 新增 examMode + 前端计时器 |
|
||||||
| | 考试监考 | ❌ | 无 | 新增实时提交进度 WebSocket 推送 |
|
| | 考试监考 | ❌ | 无 | WebSocket 实时推送 |
|
||||||
| **成绩分析** | 成绩录入 | ❌ | 无独立成绩录入功能,仅作业自动同步分数 | 新增 gradeRecords 表 + 手动录入 UI |
|
| **成绩分析** | 成绩录入 | ❌ | 无独立成绩录入功能 | 新增 gradeRecords 表 + 录入 UI |
|
||||||
| | 成绩查询 | ⚠️ | 学生可查作业分数(getStudentDashboardGrades),但无独立成绩查询页 | 新增成绩查询页面 |
|
| | 成绩查询 | ⚠️ | 学生仪表盘有作业分数(getStudentDashboardGrades),无独立查询页 | 新增成绩查询页面 |
|
||||||
| | 成绩统计报表 | ❌ | 无班级/年级均分、中位数、标准差、及格率统计 | 新增统计聚合查询 + 图表组件 |
|
| | 成绩统计报表 | ❌ | 无班级/年级均分、中位数、标准差统计 | 新增聚合查询 + 图表 |
|
||||||
| | 成绩趋势分析 | ⚠️ | getTeacherGradeTrends 提供教师维度趋势,学生有 trend 数据 | 扩展为多维度趋势 |
|
| | 成绩趋势分析 | ⚠️ | getTeacherGradeTrends 提供教师维度趋势 | 扩展为多维度 |
|
||||||
| | 成绩对比分析 | ❌ | 无班级间/学科间对比 | 新增对比查询 + 雷达图 |
|
| | 成绩对比分析 | ❌ | 无班级间/学科间对比 | 新增对比查询 + 雷达图 |
|
||||||
| | 学情诊断报告 | ❌ | 无 | 基于知识点掌握度生成诊断 |
|
| | 学情诊断报告 | ❌ | 无 | 基于知识点掌握度生成诊断 |
|
||||||
| | 成绩导出 | ❌ | 无导出功能 | ExcelJS/PDFKit 导出 |
|
| | 成绩导出 | ❌ | 无导出功能 | ExcelJS/PDFKit 导出 |
|
||||||
| | 等第转换 | ❌ | 无 | 新增 gradeScale 配置 + 转换函数 |
|
| | 等第转换 | ❌ | 无 | 新增 gradeScale 配置 |
|
||||||
| **家校沟通** | 通知公告 | ❌ | 完全缺失,无 DB 表、无 API、无 UI | 新增 announcements 表 + 三级发布 + 已读回执 |
|
| **家校沟通** | 通知公告 | ❌ | 完全缺失 | 新增 announcements 表 + 三级发布 |
|
||||||
| | 站内消息 | ❌ | 无 | 新增 messages 表 + 实时通知 |
|
| | 站内消息 | ❌ | 无 | 新增 messages 表 |
|
||||||
| | 家长端仪表盘 | ⚠️ | /parent/dashboard 路由存在但组件为空壳 | 接入子女数据查询 |
|
| | 家长端仪表盘 | ⚠️ | /parent/dashboard 路由存在但组件为空壳 | 接入子女数据查询 |
|
||||||
| | 家长会/约谈预约 | ❌ | 无 | 新增 appointments 表 |
|
| | 家长会/约谈预约 | ❌ | 无 | 新增 appointments 表 |
|
||||||
| | 请假审批 | ❌ | 无 | 新增 leaveRequests 表 + 审批流 |
|
| | 请假审批 | ❌ | 无 | 新增 leaveRequests 表 |
|
||||||
| | 校园动态/班级圈 | ❌ | 无 | 新增 posts 表 + 评论/点赞 |
|
| | 校园动态/班级圈 | ❌ | 无 | 新增 posts 表 |
|
||||||
| **AI 赵能** | AI 对话助手 | ✅ | /api/ai/chat 路由 + createAiChatCompletion,Zod 校验 | — |
|
| **AI 赋能** | AI 对话助手 | ✅ | /api/ai/chat + createAiChatCompletion | — |
|
||||||
| | AI 辅助出题 | ✅ | exams/ai-pipeline.ts 完整实现 | — |
|
| | AI 辅助出题 | ✅ | exams/ai-pipeline.ts 完整实现 | — |
|
||||||
| | AI 批改辅助 | ❌ | 无 | 接入 AI 评分 prompt + 教师终审 |
|
| | AI 批改辅助 | ❌ | 无 | 接入 AI 评分 prompt + 教师终审 |
|
||||||
| | AI 学情分析 | ❌ | 无 | 基于作业数据生成学习路径 |
|
| | AI 学情分析 | ❌ | 无 | 基于作业数据生成学习路径 |
|
||||||
| | AI 备课助手 | ❌ | 无 | 根据教材章节生成教案 |
|
| | AI 备课助手 | ❌ | 无 | 根据教材章节生成教案 |
|
||||||
| | AI 多模型配置 | ✅ | aiProviders 表 + upsertAiProviderAction,支持多 provider | — |
|
| | AI 多模型配置 | ✅ | aiProviders 表 + upsertAiProviderAction,requirePermission(AI_CONFIGURE) | — |
|
||||||
| | AI API Key 加密 | ✅ | encryptAiApiKey/decryptAiApiKey,AES 加密 | — |
|
| | AI API Key 加密 | ✅ | AES 加密 | — |
|
||||||
| **考勤管理** | 学生考勤 | ❌ | 无 | 新增 attendanceRecords 表 + 登记界面 |
|
| **考勤管理** | 学生考勤 | ❌ | 无 | 新增 attendanceRecords 表 |
|
||||||
| | 教师考勤 | ❌ | 无 | 同上 |
|
| | 教师考勤 | ❌ | 无 | 同上 |
|
||||||
| | 考勤统计 | ❌ | 无 | 聚合查询 + 报表 |
|
| | 考勤统计 | ❌ | 无 | 聚合查询 + 报表 |
|
||||||
| | 考勤规则配置 | ❌ | 无 | 新增 attendanceRules 配置 |
|
| | 考勤规则配置 | ❌ | 无 | 新增 attendanceRules 配置 |
|
||||||
@@ -113,16 +123,16 @@
|
|||||||
|
|
||||||
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
||||||
|----------|------------|------|-------------|----------|
|
|----------|------------|------|-------------|----------|
|
||||||
| **消息通知** | 站内通知 | ❌ | 无通知系统 | 新增 notifications 表 + 轮询/WebSocket 推送 |
|
| **消息通知** | 站内通知 | ❌ | 无通知系统 | 新增 notifications 表 + 推送 |
|
||||||
| | 邮件通知 | ❌ | 无 | 集成 nodemailer/Resend |
|
| | 邮件通知 | ❌ | 无 | 集成 nodemailer/Resend |
|
||||||
| | 短信通知 | ❌ | 无 | 集成短信网关 SDK |
|
| | 短信通知 | ❌ | 无 | 集成短信网关 |
|
||||||
| | 微信/钉钉推送 | ❌ | 无 | 集成 webhook |
|
| | 微信/钉钉推送 | ❌ | 无 | 集成 webhook |
|
||||||
| | 通知偏好管理 | ❌ | 无 | 新增 notificationPreferences 表 |
|
| | 通知偏好管理 | ❌ | 无 | 新增 notificationPreferences 表 |
|
||||||
| **日志审计** | 操作日志 | ❌ | 完全缺失 | 新增 auditLogs 表 + action 拦截器 |
|
| **日志审计** | 操作日志 | ❌ | 完全缺失 | 新增 auditLogs 表 + action 拦截器 |
|
||||||
| | 登录日志 | ❌ | 无 | 新增 loginLogs 表 + NextAuth event 回调 |
|
| | 登录日志 | ❌ | 无 | 新增 loginLogs 表 + NextAuth event |
|
||||||
| | 数据变更日志 | ❌ | 无 | Drizzle middleware 或 trigger |
|
| | 数据变更日志 | ❌ | 无 | Drizzle middleware 或 trigger |
|
||||||
| | 日志查询/导出 | ❌ | 无 | 管理员日志查询页面 |
|
| | 日志查询/导出 | ❌ | 无 | 管理员日志查询页面 |
|
||||||
| **文件管理** | 文件上传 | ❌ | 无文件上传能力 | 新增 upload API + 本地/OSS 存储 |
|
| **文件管理** | 文件上传 | ❌ | 无文件上传能力 | 新增 upload API + 存储 |
|
||||||
| | 文件预览 | ❌ | 无 | 集成文件预览服务 |
|
| | 文件预览 | ❌ | 无 | 集成文件预览服务 |
|
||||||
| | 文件存储策略 | ❌ | 无 | 抽象 StorageProvider 接口 |
|
| | 文件存储策略 | ❌ | 无 | 抽象 StorageProvider 接口 |
|
||||||
| | 文件权限控制 | ❌ | 无 | 文件访问鉴权中间件 |
|
| | 文件权限控制 | ❌ | 无 | 文件访问鉴权中间件 |
|
||||||
@@ -131,12 +141,12 @@
|
|||||||
| | 搜索过滤 | ❌ | 无 | 搜索结果筛选器 |
|
| | 搜索过滤 | ❌ | 无 | 搜索结果筛选器 |
|
||||||
| **导入导出** | Excel 导入 | ❌ | 无 | ExcelJS 解析 + 校验 |
|
| **导入导出** | Excel 导入 | ❌ | 无 | ExcelJS 解析 + 校验 |
|
||||||
| | Excel/PDF 导出 | ❌ | 无 | ExcelJS/PDFKit 生成 |
|
| | Excel/PDF 导出 | ❌ | 无 | ExcelJS/PDFKit 生成 |
|
||||||
| | 导入校验与错误报告 | ❌ | 无 | 行级校验 + 错误报告下载 |
|
| | 导入校验与错误报告 | ❌ | 无 | 行级校验 + 错误报告 |
|
||||||
| **数据看板** | 管理员仪表盘 | ✅ | getAdminDashboardData:userCount/classCount/activeSessions/userRoleCounts | — |
|
| **数据看板** | 管理员仪表盘 | ✅ | getAdminDashboardData + AdminDashboardView | — |
|
||||||
| | 教师仪表盘 | ✅ | TeacherDashboardData:classes/schedule/assignments/submissions/gradeTrends | — |
|
| | 教师仪表盘 | ✅ | TeacherDashboardView + 9 个子组件 | — |
|
||||||
| | 学生仪表盘 | ✅ | StudentDashboardProps:dueSoonCount/overdueCount/gradedCount/todaySchedule/grades | — |
|
| | 学生仪表盘 | ✅ | StudentDashboard + 5 个子组件 | — |
|
||||||
| | 家长仪表盘 | ⚠️ | 路由存在但组件为空壳 | 接入子女数据 |
|
| | 家长仪表盘 | ⚠️ | 路由存在但组件为空壳 | 接入子女数据 |
|
||||||
| | 自定义看板 | ❌ | 无 | 拖拽布局 + localStorage 持久化 |
|
| | 自定义看板 | ❌ | 无 | 拖拽布局 + localStorage |
|
||||||
|
|
||||||
### 非功能性模块
|
### 非功能性模块
|
||||||
|
|
||||||
@@ -144,28 +154,28 @@
|
|||||||
|----------|------------|------|-------------|----------|
|
|----------|------------|------|-------------|----------|
|
||||||
| **国际化** | 多语言框架 | ❌ | 无 i18n 集成 | 集成 next-intl |
|
| **国际化** | 多语言框架 | ❌ | 无 i18n 集成 | 集成 next-intl |
|
||||||
| | 语言切换 | ❌ | 无 | 语言选择器 + URL 前缀 |
|
| | 语言切换 | ❌ | 无 | 语言选择器 + URL 前缀 |
|
||||||
| | 日期/数字本地化 | ⚠️ | formatDate 支持 locale 参数(默认 zh-CN),但无用户偏好 | 绑定用户语言偏好 |
|
| | 日期/数字本地化 | ⚠️ | formatDate 支持 locale 参数(默认 zh-CN) | 绑定用户语言偏好 |
|
||||||
| **多租户/多校区** | 租户隔离 | ❌ | 无 | 行级 tenantId 或 schema 隔离 |
|
| **多租户/多校区** | 租户隔离 | ❌ | 无 | 行级 tenantId 或 schema 隔离 |
|
||||||
| | 校区资源映射 | ❌ | 无 | 跨校区共享规则 |
|
| | 校区资源映射 | ❌ | 无 | 跨校区共享规则 |
|
||||||
| | 统一管理后台 | ❌ | 无 | 集团管理视图 |
|
| | 统一管理后台 | ❌ | 无 | 集团管理视图 |
|
||||||
| **深色主题** | 主题切换 | ✅ | ThemeProvider(next-themes) + theme-preferences-card | — |
|
| **深色主题** | 主题切换 | ✅ | ThemeProvider(next-themes) + ThemePreferencesCard | — |
|
||||||
| | 主题色定制 | ❌ | 无 | CSS 变量动态注入 |
|
| | 主题色定制 | ❌ | 无 | CSS 变量动态注入 |
|
||||||
| **无障碍访问** | 键盘导航 | ⚠️ | 部分组件支持,但非系统性 | 全面键盘测试 + 修复 |
|
| **无障碍访问** | 键盘导航 | ⚠️ | 部分组件支持,但非系统性 | 全面键盘测试 |
|
||||||
| | ARIA 标注 | ⚠️ | icon 按钮 aria-label 已加,但非全覆盖 | 系统性 ARIA 审计 |
|
| | ARIA 标注 | ⚠️ | icon 按钮 aria-label 已加,非全覆盖 | 系统性 ARIA 审计 |
|
||||||
| | 屏幕阅读器兼容 | ❌ | 未测试 | NVDA/VoiceOver 测试 |
|
| | 屏幕阅读器兼容 | ❌ | 未测试 | NVDA/VoiceOver 测试 |
|
||||||
| | 跳转链接 | ✅ | layout.tsx 有 skip-link + id="main-content" | — |
|
| | 跳转链接 | ✅ | layout.tsx 有 skip-link | — |
|
||||||
| **性能优化** | 页面懒加载 | ✅ | Next.js App Router 自动代码分割 | — |
|
| **性能优化** | 页面懒加载 | ✅ | Next.js App Router 自动代码分割 | — |
|
||||||
| | 图片优化 | ✅ | next/image 使用 | — |
|
| | 图片优化 | ✅ | next/image 使用 | — |
|
||||||
| | 缓存策略 | ⚠️ | 部分页面 SSR,但无系统性 ISR/SSG 策略 | 关键页面配置 revalidate |
|
| | 缓存策略 | ⚠️ | 部分页面 SSR,无系统性 ISR/SSG | 关键页面配置 revalidate |
|
||||||
| | 性能监控 | ❌ | 无 Web Vitals 采集 | 集成 next/web-vitals + 上报 |
|
| | 性能监控 | ❌ | 无 Web Vitals 采集 | 集成 next/web-vitals |
|
||||||
| **自动化测试** | 单元测试 | ✅ | Vitest 5 文件 19 用例 | 扩展覆盖率 |
|
| **自动化测试** | 单元测试 | ✅ | Vitest 5 文件 19 用例 | 扩展覆盖率 |
|
||||||
| | 集成测试 | ✅ | Vitest 7 文件 38 用例 | 扩展覆盖率 |
|
| | 集成测试 | ✅ | Vitest 7 文件 38 用例 | 扩展覆盖率 |
|
||||||
| | E2E 测试 | ⚠️ | Playwright 3 个 spec 文件,但需数据库环境运行 | 完善 CI 环境配置 |
|
| | E2E 测试 | ⚠️ | Playwright 3 个 spec,需数据库环境 | 完善 CI 环境配置 |
|
||||||
| | 视觉回归测试 | ❌ | 无 | 集成 Chromatic |
|
| | 视觉回归测试 | ❌ | 无 | 集成 Chromatic |
|
||||||
| **CI/CD** | 持续集成 | ✅ | .gitea/workflows/ci.yml:lint + typecheck + test | — |
|
| **CI/CD** | 持续集成 | ✅ | .gitea/workflows/ci.yml | — |
|
||||||
| | 持续部署 | ✅ | Dockerfile + CI 自动构建部署 | — |
|
| | 持续部署 | ✅ | Dockerfile + CI 自动构建部署 | — |
|
||||||
| | 预览环境 | ❌ | 无 | PR 预览部署 |
|
| | 预览环境 | ❌ | 无 | PR 预览部署 |
|
||||||
| **数据备份** | 数据库定时备份 | ❌ | 无 | cron + mysqldump 脚本 |
|
| **数据备份** | 数据库定时备份 | ❌ | 无 | cron + mysqldump |
|
||||||
| | 备份恢复演练 | ❌ | 无 | 定期恢复测试 |
|
| | 备份恢复演练 | ❌ | 无 | 定期恢复测试 |
|
||||||
| | 灾备方案 | ❌ | 无 | 异地容灾规划 |
|
| | 灾备方案 | ❌ | 无 | 异地容灾规划 |
|
||||||
|
|
||||||
@@ -173,23 +183,24 @@
|
|||||||
|
|
||||||
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
||||||
|----------|------------|------|-------------|----------|
|
|----------|------------|------|-------------|----------|
|
||||||
| **隐私合规** | 隐私政策与用户协议 | ❌ | 无隐私政策页面,注册无同意勾选 | 新增 consent 页 + 注册流程集成 |
|
| **隐私合规** | 隐私政策与用户协议 | ❌ | 无隐私政策页面 | 新增 consent 页 |
|
||||||
| | 未成年人信息保护 | ❌ | 无年龄判断、无监护人同意流程 | 注册时年龄校验 + 监护人字段 |
|
| | 未成年人信息保护 | ❌ | 无年龄判断、无监护人同意流程 | 注册时年龄校验 |
|
||||||
| | 数据保留策略 | ❌ | 无 | 新增 dataRetentionPolicies 配置 |
|
| | 数据保留策略 | ❌ | 无 | 新增 dataRetentionPolicies |
|
||||||
| | 用户数据导出/删除 | ❌ | 无 | GDPR 式数据操作 API |
|
| | 用户数据导出/删除 | ❌ | 无 | GDPR 式数据操作 API |
|
||||||
| **数据加密** | 传输加密 | ✅ | Next.js 默认 HTTPS,生产环境应配 HSTS | 部署时配置 HSTS 头 |
|
| **数据加密** | 传输加密 | ✅ | Next.js 默认 HTTPS | 部署时配 HSTS |
|
||||||
| | 存储加密 | ✅ | AI API Key AES 加密,密码 bcrypt 哈希 | — |
|
| | 存储加密 | ✅ | AI API Key AES 加密,密码 bcrypt | — |
|
||||||
| | 密码哈希 | ✅ | NextAuth 默认 bcrypt | — |
|
| | 密码哈希 | ✅ | NextAuth 默认 bcrypt | — |
|
||||||
| **操作安全** | CSRF 防护 | ✅ | NextAuth SameSite Cookie + Server Action CSRF 保护 | — |
|
| **操作安全** | CSRF 防护 | ✅ | NextAuth SameSite Cookie + Server Action | — |
|
||||||
| | XSS 防护 | ✅ | React 自动转义 + rehype-sanitize 净化 HTML | — |
|
| | XSS 防护 | ✅ | React 自动转义 + rehype-sanitize | — |
|
||||||
| | SQL 注入防护 | ✅ | Drizzle ORM 参数化查询 | — |
|
| | SQL 注入防护 | ✅ | Drizzle ORM 参数化查询 | — |
|
||||||
| | 速率限制 | ❌ | 无 | 集成 next-rate-limit 或 upstash/ratelimit |
|
| | 速率限制 | ❌ | 无 | 集成 upstash/ratelimit |
|
||||||
| | 会话管理 | ✅ | JWT 过期策略 + NextAuth session 管理 | — |
|
| | 会话管理 | ✅ | JWT 过期策略 + NextAuth | — |
|
||||||
|
| | **Server Action 权限校验** | ✅ | **v2 修复:全部 57+ Server Action 均使用 requirePermission/requireAuth** | — |
|
||||||
| **敏感信息脱敏** | 日志脱敏 | ❌ | 无日志系统 | 日志框架内置脱敏 |
|
| **敏感信息脱敏** | 日志脱敏 | ❌ | 无日志系统 | 日志框架内置脱敏 |
|
||||||
| | 前端脱敏 | ❌ | 无 | 手机号/邮箱掩码组件 |
|
| | 前端脱敏 | ❌ | 无 | 手机号/邮箱掩码组件 |
|
||||||
| | 导出脱敏 | ❌ | 无导出功能 | 导出时可选脱敏 |
|
| | 导出脱敏 | ❌ | 无导出功能 | 导出时可选脱敏 |
|
||||||
| **安全审计** | 漏洞扫描 | ❌ | 无 | 集成 OWASP ZAP 或 Snyk |
|
| **安全审计** | 漏洞扫描 | ❌ | 无 | 集成 OWASP ZAP/Snyk |
|
||||||
| | 依赖审计 | ⚠️ | npm audit 可用但未集成 CI | CI 增加 npm audit 步骤 |
|
| | 依赖审计 | ⚠️ | npm audit 可用但未集成 CI | CI 增加 npm audit |
|
||||||
| | 渗透测试 | ❌ | 无 | 上线前第三方测试 |
|
| | 渗透测试 | ❌ | 无 | 上线前第三方测试 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -198,49 +209,46 @@
|
|||||||
|
|
||||||
### Phase 1: P0 缺口补齐(MVP 必须项)
|
### Phase 1: P0 缺口补齐(MVP 必须项)
|
||||||
|
|
||||||
> 目标:将 P0 完成率从 65% 提升到 100%
|
> 目标:将 P0 完成率从 69% 提升到 100%
|
||||||
|
|
||||||
| 序号 | 功能 | 所属模块 | 工作量 | 理由 |
|
| 序号 | 功能 | 所属模块 | 工作量 | 理由 |
|
||||||
|------|------|---------|--------|------|
|
|------|------|---------|--------|------|
|
||||||
| 1 | **通知公告系统** | 家校沟通 | 大 | P0 缺失最严重项,学校运营核心需求;需 DB 表 + API + 三级发布 + 已读回执 |
|
| 1 | **通知公告系统** | 家校沟通 | 大 | P0 缺失最严重项,学校运营核心需求 |
|
||||||
| 2 | **操作日志 + 登录日志** | 日志审计 | 大 | P0 合规底线,无日志则无法追溯问题;需 DB 表 + action 拦截 + NextAuth event |
|
| 2 | **操作日志 + 登录日志** | 日志审计 | 大 | P0 合规底线,无日志则无法追溯 |
|
||||||
| 3 | **成绩录入 + 查询 + 统计报表** | 成绩分析 | 大 | P0 教务核心闭环缺失;需 gradeRecords 表 + 录入 UI + 聚合查询 + 图表 |
|
| 3 | **成绩录入 + 查询 + 统计报表** | 成绩分析 | 大 | P0 教务核心闭环缺失 |
|
||||||
| 4 | **文件上传 + 权限控制** | 文件管理 | 中 | P0 基础能力,题目/教材/通知均需附件;需 upload API + 存储抽象 + 鉴权 |
|
| 4 | **文件上传 + 权限控制** | 文件管理 | 中 | P0 基础能力,题目/教材/通知需附件 |
|
||||||
| 5 | **课程计划管理** | 教务排课 | 中 | P0 排课前置条件;需 coursePlans 表 + 管理界面 |
|
| 5 | **课程计划管理** | 教务排课 | 中 | P0 排课前置条件 |
|
||||||
| 6 | **隐私政策 + 用户同意** | 隐私合规 | 小 | P0 合规底线;需 consent 页面 + 注册流程集成 |
|
| 6 | **隐私政策 + 用户同意** | 隐私合规 | 小 | P0 合规底线 |
|
||||||
| 7 | **未成年人信息保护** | 隐私合规 | 小 | P0 K12 强制要求;需年龄校验 + 监护人字段 |
|
| 7 | **未成年人信息保护** | 隐私合规 | 小 | P0 K12 强制要求 |
|
||||||
|
| 8 | **修复 13 个幽灵导航路由** | 布局 | 小 | 用户点击 404,影响体验 |
|
||||||
|
|
||||||
### Phase 2: P1 关键增强(上线前推荐)
|
### Phase 2: P1 关键增强(上线前推荐)
|
||||||
|
|
||||||
> 目标:产品达到可上线标准
|
|
||||||
|
|
||||||
| 序号 | 功能 | 所属模块 | 理由 |
|
| 序号 | 功能 | 所属模块 | 理由 |
|
||||||
|------|------|---------|------|
|
|------|------|---------|------|
|
||||||
| 1 | **站内消息系统** | 家校沟通 | 教师与家长沟通核心渠道 |
|
| 1 | 站内消息系统 | 家校沟通 | 教师与家长沟通核心渠道 |
|
||||||
| 2 | **家长端仪表盘** | 家校沟通 | 家长核心入口,当前为空壳 |
|
| 2 | 家长端仪表盘 | 家校沟通 | 家长核心入口,当前为空壳 |
|
||||||
| 3 | **Excel 批量导入** | 导入导出 | 开学季批量导入学生/教师刚需 |
|
| 3 | Excel 批量导入 | 导入导出 | 开学季批量导入学生/教师 |
|
||||||
| 4 | **Excel/PDF 导出** | 导入导出 | 成绩单/名单导出刚需 |
|
| 4 | Excel/PDF 导出 | 导入导出 | 成绩单/名单导出 |
|
||||||
| 5 | **排课规则 + 自动排课** | 教务排课 | 手动排课效率极低,自动排课是核心竞争力 |
|
| 5 | 排课规则 + 自动排课 | 教务排课 | 手动排课效率极低 |
|
||||||
| 6 | **课表调整/代课** | 教务排课 | 日常调课是高频操作 |
|
| 6 | 课表调整/代课 | 教务排课 | 日常调课高频操作 |
|
||||||
| 7 | **速率限制** | 操作安全 | 防暴力破解,API 安全基线 |
|
| 7 | 速率限制 | 操作安全 | 防暴力破解 |
|
||||||
| 8 | **成绩趋势 + 对比分析** | 成绩分析 | 教学质量分析核心 |
|
| 8 | 成绩趋势 + 对比分析 | 成绩分析 | 教学质量分析核心 |
|
||||||
| 9 | **成绩导出** | 成绩分析 | 家长会/教研会必备 |
|
| 9 | 成绩导出 | 成绩分析 | 家长会/教研会必备 |
|
||||||
| 10 | **学生考勤** | 考勤管理 | 日常管理刚需 |
|
| 10 | 学生考勤 | 考勤管理 | 日常管理刚需 |
|
||||||
| 11 | **用户批量导入** | 用户与权限 | 开学季批量注册 |
|
| 11 | 用户批量导入 | 用户与权限 | 开学季批量注册 |
|
||||||
| 12 | **密码安全策略** | 用户与权限 | 安全基线 |
|
| 12 | 密码安全策略 | 用户与权限 | 安全基线 |
|
||||||
| 13 | **数据变更日志** | 日志审计 | 争议追溯 |
|
| 13 | 数据变更日志 | 日志审计 | 争议追溯 |
|
||||||
| 14 | **日志查询/导出** | 日志审计 | 管理员日常使用 |
|
| 14 | 日志查询/导出 | 日志审计 | 管理员日常使用 |
|
||||||
| 15 | **文件预览 + 存储策略** | 文件管理 | 用户体验提升 |
|
| 15 | 文件预览 + 存储策略 | 文件管理 | 用户体验提升 |
|
||||||
| 16 | **全文检索** | 全局搜索 | 题库/教材量大后必须 |
|
| 16 | 全文检索 | 全局搜索 | 题库/教材量大后必须 |
|
||||||
| 17 | **依赖审计集成 CI** | 安全审计 | 安全基线 |
|
| 17 | 依赖审计集成 CI | 安全审计 | 安全基线 |
|
||||||
| 18 | **数据库定时备份** | 数据备份 | 数据安全底线 |
|
| 18 | 数据库定时备份 | 数据备份 | 数据安全底线 |
|
||||||
| 19 | **E2E 测试完善** | 自动化测试 | 上线前回归保障 |
|
| 19 | E2E 测试完善 | 自动化测试 | 上线前回归保障 |
|
||||||
| 20 | **通知偏好管理** | 消息通知 | 用户体验 |
|
| 20 | 通知偏好管理 | 消息通知 | 用户体验 |
|
||||||
|
|
||||||
### Phase 3: P2 迭代优化(竞争力提升)
|
### Phase 3: P2 迭代优化(竞争力提升)
|
||||||
|
|
||||||
> 目标:差异化竞争力与用户体验精细化
|
|
||||||
|
|
||||||
| 序号 | 功能 | 所属模块 | 理由 |
|
| 序号 | 功能 | 所属模块 | 理由 |
|
||||||
|------|------|---------|------|
|
|------|------|---------|------|
|
||||||
| 1 | 国际化(i18n) | 非功能性 | 海外学校/国际学校市场 |
|
| 1 | 国际化(i18n) | 非功能性 | 海外学校/国际学校市场 |
|
||||||
@@ -264,14 +272,24 @@
|
|||||||
|
|
||||||
| 状态 | P0 | P1 | P2 | 合计 |
|
| 状态 | P0 | P1 | P2 | 合计 |
|
||||||
|------|-----|-----|-----|------|
|
|------|-----|-----|-----|------|
|
||||||
| ✅ 已完成 | 36 | 12 | 2 | **50** |
|
| ✅ 已完成 | 38 | 12 | 2 | **52** |
|
||||||
| ⚠️ 部分完成 | 10 | 8 | 1 | **19** |
|
| ⚠️ 部分完成 | 10 | 8 | 1 | **19** |
|
||||||
| ❌ 未实现 | 9 | 28 | 27 | **64** |
|
| ❌ 未实现 | 7 | 28 | 27 | **62** |
|
||||||
| **合计** | **55** | **48** | **30** | **133** |
|
| **合计** | **55** | **48** | **30** | **133** |
|
||||||
|
|
||||||
| 完成率 | P0 | P1 | P2 | 总体 |
|
| 完成率 | P0 | P1 | P2 | 总体 |
|
||||||
|--------|-----|-----|-----|------|
|
|--------|-----|-----|-----|------|
|
||||||
| 按已完成计 | 65% | 25% | 7% | **38%** |
|
| 按已完成计 | 69% | 25% | 7% | **39%** |
|
||||||
| 含部分完成 | 83% | 42% | 10% | **51%** |
|
| 含部分完成 | 87% | 42% | 10% | **53%** |
|
||||||
|
|
||||||
> **结论**:项目 P0 核心功能完成度约 65%(严格)/ 83%(含部分),主要缺口集中在**家校沟通(通知公告)**、**日志审计**、**成绩分析**三个 P0 模块。建议优先补齐 Phase 1 的 7 项 P0 缺口,再推进 Phase 2 的 P1 增强。
|
### v1 → v2 改善
|
||||||
|
|
||||||
|
| 指标 | v1 | v2 | 变化 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| P0 完成率(严格) | 65% | 69% | +4% |
|
||||||
|
| P0 完成率(含部分) | 83% | 87% | +4% |
|
||||||
|
| 安全漏洞数 | 15 个 Server Action 无权限 | 0 | **全部修复** |
|
||||||
|
| 架构图覆盖率 | ~40% | 100% | **全量补全** |
|
||||||
|
| 幽灵路由 | 未发现 | 13 个 | **新发现** |
|
||||||
|
|
||||||
|
> **结论**:项目 P0 核心功能完成度约 69%(严格)/ 87%(含部分),较 v1 提升 4%。安全漏洞全部修复。架构图已全量补全。主要缺口集中在**通知公告**、**日志审计**、**成绩分析**三个 P0 模块。建议优先补齐 Phase 1 的 8 项 P0 缺口。
|
||||||
|
|||||||
1159
package-lock.json
generated
1159
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,11 @@
|
|||||||
"test:e2e:full-routes": "playwright test tests/e2e/full-route-regression.spec.ts",
|
"test:e2e:full-routes": "playwright test tests/e2e/full-route-regression.spec.ts",
|
||||||
"db:seed": "npx tsx scripts/seed.ts",
|
"db:seed": "npx tsx scripts/seed.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate"
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"audit": "npm audit --audit-level=moderate",
|
||||||
|
"audit:report": "npm audit --json > audit-report.json",
|
||||||
|
"backup": "bash scripts/backup-db.sh",
|
||||||
|
"restore": "bash scripts/restore-db.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -42,6 +46,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.3.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
@@ -55,6 +60,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
|
|||||||
19
scripts/audit.ps1
Normal file
19
scripts/audit.ps1
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 依赖安全审计脚本 (Windows PowerShell)
|
||||||
|
# 用法: .\scripts\audit.ps1
|
||||||
|
|
||||||
|
Write-Host "Running npm audit..."
|
||||||
|
$exitCode = 0
|
||||||
|
try {
|
||||||
|
npm audit --audit-level=moderate
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
} catch {
|
||||||
|
$exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
Write-Host "Security vulnerabilities found!"
|
||||||
|
npm audit --json | Out-File -FilePath audit-report.json -Encoding utf8
|
||||||
|
exit $exitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "No vulnerabilities found."
|
||||||
17
scripts/audit.sh
Normal file
17
scripts/audit.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 依赖安全审计脚本
|
||||||
|
# 用法: ./scripts/audit.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running npm audit..."
|
||||||
|
npm audit --audit-level=moderate
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
if [ $EXIT_CODE -ne 0 ]; then
|
||||||
|
echo "Security vulnerabilities found!"
|
||||||
|
npm audit --json > audit-report.json
|
||||||
|
exit $EXIT_CODE
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "No vulnerabilities found."
|
||||||
46
scripts/backup-db.sh
Normal file
46
scripts/backup-db.sh
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# MySQL 数据库备份脚本
|
||||||
|
# 用法: ./backup-db.sh
|
||||||
|
# 需要 .env 中配置 DATABASE_URL 或 DB_* 环境变量
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-30}"
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
|
BACKUP_FILE="${BACKUP_DIR}/db_backup_${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
|
# 从 DATABASE_URL 解析连接信息
|
||||||
|
# 格式: mysql://user:password@host:port/dbname
|
||||||
|
DATABASE_URL="${DATABASE_URL:-}"
|
||||||
|
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
echo "ERROR: DATABASE_URL not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 解析 URL
|
||||||
|
DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p')
|
||||||
|
DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p')
|
||||||
|
DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p')
|
||||||
|
DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
|
||||||
|
DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p')
|
||||||
|
|
||||||
|
echo "Backing up database: $DB_NAME from $DB_HOST:$DB_PORT"
|
||||||
|
|
||||||
|
# 创建备份目录
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# 执行备份
|
||||||
|
mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" | gzip > "$BACKUP_FILE"
|
||||||
|
|
||||||
|
echo "Backup created: $BACKUP_FILE"
|
||||||
|
echo "Size: $(du -h $BACKUP_FILE | cut -f1)"
|
||||||
|
|
||||||
|
# 清理旧备份
|
||||||
|
find "$BACKUP_DIR" -name "db_backup_*.sql.gz" -mtime +$RETENTION_DAYS -delete
|
||||||
|
echo "Cleaned up backups older than $RETENTION_DAYS days"
|
||||||
|
|
||||||
|
# 列出当前备份
|
||||||
|
echo "Current backups:"
|
||||||
|
ls -lh "$BACKUP_DIR"/db_backup_*.sql.gz 2>/dev/null | tail -10
|
||||||
34
scripts/create-db.ts
Normal file
34
scripts/create-db.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import "dotenv/config"
|
||||||
|
import mysql from "mysql2/promise"
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.DATABASE_URL
|
||||||
|
if (!url) {
|
||||||
|
console.error("DATABASE_URL not set")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect without specifying a database
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const dbName = urlObj.pathname.replace("/", "")
|
||||||
|
const conn = await mysql.createConnection({
|
||||||
|
host: urlObj.hostname,
|
||||||
|
port: Number(urlObj.port),
|
||||||
|
user: urlObj.username,
|
||||||
|
password: decodeURIComponent(urlObj.password),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conn.execute(
|
||||||
|
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||||
|
)
|
||||||
|
console.log(`Database '${dbName}' created (or already exists)`)
|
||||||
|
} finally {
|
||||||
|
await conn.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
34
scripts/restore-db.sh
Normal file
34
scripts/restore-db.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# MySQL 数据库恢复脚本
|
||||||
|
# 用法: ./restore-db.sh <backup_file>
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Usage: ./restore-db.sh <backup_file>"
|
||||||
|
echo "Available backups:"
|
||||||
|
ls -lh backups/db_backup_*.sql.gz 2>/dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_FILE="$1"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-}"
|
||||||
|
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
echo "ERROR: DATABASE_URL not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 解析 URL (同 backup-db.sh)
|
||||||
|
DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p')
|
||||||
|
DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p')
|
||||||
|
DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p')
|
||||||
|
DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
|
||||||
|
DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p')
|
||||||
|
|
||||||
|
echo "Restoring database: $DB_NAME from $BACKUP_FILE"
|
||||||
|
|
||||||
|
# 解压并恢复
|
||||||
|
gunzip -c "$BACKUP_FILE" | mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME"
|
||||||
|
|
||||||
|
echo "Restore completed successfully."
|
||||||
8
scripts/test-backup.sh
Normal file
8
scripts/test-backup.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 测试备份恢复流程
|
||||||
|
set -e
|
||||||
|
echo "=== Testing database backup ==="
|
||||||
|
./scripts/backup-db.sh
|
||||||
|
LATEST_BACKUP=$(ls -t backups/db_backup_*.sql.gz | head -1)
|
||||||
|
echo "=== Latest backup: $LATEST_BACKUP ==="
|
||||||
|
echo "=== Backup test passed ==="
|
||||||
128
src/app/(auth)/privacy/page.tsx
Normal file
128
src/app/(auth)/privacy/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Metadata } from "next"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "隐私政策 - Next_Edu",
|
||||||
|
description: "Next_Edu 隐私政策与个人信息保护说明",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_CLASS = "space-y-2"
|
||||||
|
const HEADING_CLASS = "text-base font-semibold text-foreground"
|
||||||
|
const TEXT_CLASS = "text-sm leading-relaxed text-muted-foreground"
|
||||||
|
const LIST_CLASS = "ml-4 list-disc space-y-1 text-sm leading-relaxed text-muted-foreground"
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-6 py-8">
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">隐私政策</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
最近更新日期:2026 年 6 月 16 日
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>引言</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Next_Edu(以下简称“我们”)是一款面向 K12 教育场景的智慧教务管理系统,我们高度重视用户个人信息保护。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>一、信息收集说明</h2>
|
||||||
|
<p className={TEXT_CLASS}>
|
||||||
|
为提供教学服务,我们会收集以下类型的信息:
|
||||||
|
</p>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>账户信息:姓名、邮箱、密码(加密存储)、手机号码。</li>
|
||||||
|
<li>身份信息:角色(学生/教师/家长等)、年级、班级、出生日期。</li>
|
||||||
|
<li>未成年人保护信息:监护人姓名、联系电话、与未成年人关系。</li>
|
||||||
|
<li>学习数据:作业作答、考试成绩、学习行为记录。</li>
|
||||||
|
<li>设备信息:浏览器类型、访问日志(用于安全与运维)。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>二、信息使用说明</h2>
|
||||||
|
<p className={TEXT_CLASS}>收集的信息将用于:</p>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>提供注册、登录、班级管理与教学功能。</li>
|
||||||
|
<li>生成学习报告与学情分析,辅助教学决策。</li>
|
||||||
|
<li>在监护人授权下,向家长推送子女学习情况。</li>
|
||||||
|
<li>保障账户安全、防范风险与合规审计。</li>
|
||||||
|
<li>改进产品体验,不会用于与教学无关的商业用途。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>三、信息保护措施</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>密码使用 bcrypt 算法加密存储,AI 服务密钥使用 AES 加密。</li>
|
||||||
|
<li>采用基于角色的访问控制(RBAC)与数据范围(DataScope)行级过滤。</li>
|
||||||
|
<li>HTTPS 传输加密,数据库访问受网络与权限隔离。</li>
|
||||||
|
<li>仅授权人员在最小必要原则下访问用户数据,并留存审计日志。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>四、用户权利</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>查询权:您可在个人资料页查看我们持有的您的信息。</li>
|
||||||
|
<li>更正权:您可随时修改姓名、手机、地址等个人资料。</li>
|
||||||
|
<li>删除权:您可申请注销账户,我们将在合理期限内删除相关数据。</li>
|
||||||
|
<li>撤回同意权:监护人可随时撤回对未成年人使用服务的同意。</li>
|
||||||
|
<li>数据可携带权:您可申请导出您的学习数据。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>五、Cookie 政策</h2>
|
||||||
|
<p className={TEXT_CLASS}>
|
||||||
|
我们使用 Cookie 与本地存储维持登录会话、记住偏好设置。会话 Cookie
|
||||||
|
在您登出后失效;持久化 Cookie 仅用于必要的功能体验,不用于跨站追踪广告。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>六、未成年人保护条款</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>未满 14 周岁的用户须在监护人陪同下注册,并填写监护人信息。</li>
|
||||||
|
<li>14 周岁以上不满 18 周岁的用户,注册时须确认已获得监护人同意。</li>
|
||||||
|
<li>我们对未成年人个人信息采取更严格的访问控制与加密存储。</li>
|
||||||
|
<li>不会向未成年人推送与其学习无关的商业信息。</li>
|
||||||
|
<li>监护人可通过联系方式申请查阅、更正或删除未成年人的信息。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>七、联系方式</h2>
|
||||||
|
<p className={TEXT_CLASS}>
|
||||||
|
如您对本隐私政策有任何疑问、建议或投诉,可通过以下方式联系我们:
|
||||||
|
</p>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>邮箱:privacy@next-edu.example.com</li>
|
||||||
|
<li>客服电话:400-000-0000(工作日 9:00-18:00)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t pt-4 text-center">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="text-sm font-medium text-primary underline underline-offset-4 hover:opacity-80"
|
||||||
|
>
|
||||||
|
返回注册
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,12 +11,27 @@ export const metadata: Metadata = {
|
|||||||
description: "Create an account",
|
description: "Create an account",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADULT_AGE = 18
|
||||||
|
|
||||||
const normalizeBcryptHash = (value: string) => {
|
const normalizeBcryptHash = (value: string) => {
|
||||||
if (value.startsWith("$2")) return value
|
if (value.startsWith("$2")) return value
|
||||||
if (value.startsWith("$")) return `$2b${value}`
|
if (value.startsWith("$")) return `$2b${value}`
|
||||||
return `$2b$${value}`
|
return `$2b$${value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calcAge(birth: string): number | null {
|
||||||
|
if (!birth) return null
|
||||||
|
const birthDate = new Date(birth)
|
||||||
|
if (Number.isNaN(birthDate.getTime())) return null
|
||||||
|
const now = new Date()
|
||||||
|
let age = now.getFullYear() - birthDate.getFullYear()
|
||||||
|
const monthDiff = now.getMonth() - birthDate.getMonth()
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
|
||||||
|
age -= 1
|
||||||
|
}
|
||||||
|
return age >= 0 ? age : null
|
||||||
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
async function registerAction(formData: FormData): Promise<ActionState> {
|
async function registerAction(formData: FormData): Promise<ActionState> {
|
||||||
"use server"
|
"use server"
|
||||||
@@ -33,11 +48,23 @@ export default function RegisterPage() {
|
|||||||
const name = String(formData.get("name") ?? "").trim()
|
const name = String(formData.get("name") ?? "").trim()
|
||||||
const email = String(formData.get("email") ?? "").trim().toLowerCase()
|
const email = String(formData.get("email") ?? "").trim().toLowerCase()
|
||||||
const password = String(formData.get("password") ?? "")
|
const password = String(formData.get("password") ?? "")
|
||||||
|
const birthDateRaw = String(formData.get("birthDate") ?? "").trim()
|
||||||
|
const guardianName = String(formData.get("guardianName") ?? "").trim()
|
||||||
|
const guardianPhone = String(formData.get("guardianPhone") ?? "").trim()
|
||||||
|
const guardianRelation = String(formData.get("guardianRelation") ?? "").trim()
|
||||||
|
|
||||||
if (!email) return { success: false, message: "请输入邮箱" }
|
if (!email) return { success: false, message: "请输入邮箱" }
|
||||||
if (!password) return { success: false, message: "请输入密码" }
|
if (!password) return { success: false, message: "请输入密码" }
|
||||||
if (password.length < 6) return { success: false, message: "密码至少 6 位" }
|
if (password.length < 6) return { success: false, message: "密码至少 6 位" }
|
||||||
|
|
||||||
|
const age = calcAge(birthDateRaw)
|
||||||
|
const isMinor = age !== null && age < ADULT_AGE
|
||||||
|
if (isMinor) {
|
||||||
|
if (!guardianName) return { success: false, message: "未成年人须填写监护人姓名" }
|
||||||
|
if (!guardianPhone) return { success: false, message: "未成年人须填写监护人电话" }
|
||||||
|
if (!guardianRelation) return { success: false, message: "未成年人须选择监护人关系" }
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await db.query.users.findFirst({
|
const existing = await db.query.users.findFirst({
|
||||||
where: eq(users.email, email),
|
where: eq(users.email, email),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
@@ -51,6 +78,12 @@ export default function RegisterPage() {
|
|||||||
name: name.length ? name : null,
|
name: name.length ? name : null,
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
birthDate: birthDateRaw ? new Date(birthDateRaw) : null,
|
||||||
|
age: age ?? null,
|
||||||
|
guardianName: guardianName || null,
|
||||||
|
guardianPhone: guardianPhone || null,
|
||||||
|
guardianRelation: guardianRelation || null,
|
||||||
|
consentAcceptedAt: new Date(),
|
||||||
})
|
})
|
||||||
const roleRow = await db.query.roles.findFirst({
|
const roleRow = await db.query.roles.findFirst({
|
||||||
where: eq(roles.name, "student"),
|
where: eq(roles.name, "student"),
|
||||||
|
|||||||
116
src/app/(auth)/terms/page.tsx
Normal file
116
src/app/(auth)/terms/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Metadata } from "next"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "用户协议 - Next_Edu",
|
||||||
|
description: "Next_Edu 用户服务协议",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_CLASS = "space-y-2"
|
||||||
|
const HEADING_CLASS = "text-base font-semibold text-foreground"
|
||||||
|
const TEXT_CLASS = "text-sm leading-relaxed text-muted-foreground"
|
||||||
|
const LIST_CLASS = "ml-4 list-disc space-y-1 text-sm leading-relaxed text-muted-foreground"
|
||||||
|
|
||||||
|
export default function TermsPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-6 py-8">
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">用户协议</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
最近更新日期:2026 年 6 月 16 日
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>引言</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
欢迎使用 Next_Edu 智慧教务管理系统(以下简称“本服务”)。请仔细阅读并同意本协议后,方可注册和使用本服务。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>一、服务说明</h2>
|
||||||
|
<p className={TEXT_CLASS}>
|
||||||
|
本服务面向 K12 学校、教师、学生及家长,提供考试管理、作业批改、题库管理、教材与知识点体系、班级与学校管理、AI 辅助教学等功能。
|
||||||
|
我们保留对服务内容进行更新、调整的权利。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>二、用户注册</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>用户须使用真实邮箱注册,并对账户密码的安全负责。</li>
|
||||||
|
<li>未成年人注册须在监护人陪同下完成,并填写监护人信息或确认已获得监护人同意。</li>
|
||||||
|
<li>注册时须同意《隐私政策》与本《用户协议》。</li>
|
||||||
|
<li>禁止转让、出借账户,因账户保管不当造成的损失由用户自行承担。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>三、用户行为规范</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>不得利用本服务从事违法、违规或侵犯他人权益的行为。</li>
|
||||||
|
<li>不得上传或传播涉黄、涉暴、涉政、侵权或有害内容。</li>
|
||||||
|
<li>不得破坏系统安全、尝试未授权访问或干扰其他用户使用。</li>
|
||||||
|
<li>不得批量抓取、爬取平台数据用于商业用途。</li>
|
||||||
|
<li>教师与家长应引导未成年人正确、合理使用本服务。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>四、知识产权</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>本服务的软件、界面、文案、图标等知识产权归我们或权利人所有。</li>
|
||||||
|
<li>用户上传的题目、教材内容等,知识产权归原作者所有;用户授权我们在服务范围内存储、展示与处理。</li>
|
||||||
|
<li>未经书面许可,不得复制、改编、传播本服务中的受保护内容。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>五、免责声明</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>本服务按“现状”提供,我们不保证服务持续可用或完全无错误。</li>
|
||||||
|
<li>AI 生成的题目与解析仅供参考,可能存在偏差,使用者应自行审核。</li>
|
||||||
|
<li>因不可抗力、网络故障、第三方服务中断等原因造成的损失,我们不承担责任。</li>
|
||||||
|
<li>用户因违反本协议造成的后果,由用户自行承担。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>六、服务变更、中断与终止</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>我们可基于运营需要调整、暂停或终止部分或全部服务,并尽量提前公告。</li>
|
||||||
|
<li>用户违反本协议的,我们可限制、暂停或终止其账户。</li>
|
||||||
|
<li>用户可申请注销账户,注销后相关数据将按隐私政策处理。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={SECTION_CLASS}>
|
||||||
|
<h2 className={HEADING_CLASS}>七、法律适用与争议解决</h2>
|
||||||
|
<ul className={LIST_CLASS}>
|
||||||
|
<li>本协议的订立、执行与解释适用中华人民共和国法律。</li>
|
||||||
|
<li>因本协议或本服务产生的争议,双方应友好协商解决;协商不成的,可向我们所在地有管辖权的人民法院提起诉讼。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t pt-4 text-center">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="text-sm font-medium text-primary underline underline-offset-4 hover:opacity-80"
|
||||||
|
>
|
||||||
|
返回注册
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/app/(dashboard)/admin/announcements/[id]/page.tsx
Normal file
36
src/app/(dashboard)/admin/announcements/[id]/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { getAnnouncementById } from "@/modules/announcements/data-access"
|
||||||
|
import { getGrades } from "@/modules/school/data-access"
|
||||||
|
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function EditAnnouncementPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const [announcement, grades] = await Promise.all([
|
||||||
|
getAnnouncementById(id),
|
||||||
|
getGrades(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!announcement) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Edit Announcement</h2>
|
||||||
|
<p className="text-muted-foreground">Update the announcement details below.</p>
|
||||||
|
</div>
|
||||||
|
<AnnouncementForm
|
||||||
|
mode="edit"
|
||||||
|
announcement={announcement}
|
||||||
|
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/app/(dashboard)/admin/announcements/page.tsx
Normal file
39
src/app/(dashboard)/admin/announcements/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getAnnouncements } from "@/modules/announcements/data-access"
|
||||||
|
import { getGrades } from "@/modules/school/data-access"
|
||||||
|
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
|
||||||
|
import type { AnnouncementStatus } from "@/modules/announcements/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidStatus = (v?: string): v is AnnouncementStatus =>
|
||||||
|
v === "draft" || v === "published" || v === "archived"
|
||||||
|
|
||||||
|
export default async function AdminAnnouncementsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const statusParam = getParam(sp, "status")
|
||||||
|
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||||
|
|
||||||
|
const [announcements, grades] = await Promise.all([
|
||||||
|
getAnnouncements({ status }),
|
||||||
|
getGrades(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminAnnouncementsView
|
||||||
|
announcements={announcements}
|
||||||
|
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
|
initialStatus={status}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/app/(dashboard)/admin/attendance/page.tsx
Normal file
71
src/app/(dashboard)/admin/attendance/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { BarChart3, ClipboardList } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getAttendanceRecords } from "@/modules/attendance/data-access"
|
||||||
|
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
|
||||||
|
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminAttendancePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
const classId = getParam(sp, "classId")
|
||||||
|
const status = getParam(sp, "status")
|
||||||
|
const date = getParam(sp, "date")
|
||||||
|
|
||||||
|
const classes = await getAdminClasses()
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
|
||||||
|
const result = await getAttendanceRecords({
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
currentUserId: ctx.userId,
|
||||||
|
classId: classId && classId !== "all" ? classId : undefined,
|
||||||
|
status: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined,
|
||||||
|
date: date && date.length > 0 ? date : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Attendance Overview</h2>
|
||||||
|
<p className="text-muted-foreground">View all attendance records across the school.</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/teacher/attendance/stats">
|
||||||
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
|
Statistics
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AttendanceFilters classes={classOptions} />
|
||||||
|
|
||||||
|
{result.items.length === 0 && !classId && !status && !date ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No attendance records"
|
||||||
|
description="There are no attendance records yet."
|
||||||
|
icon={ClipboardList}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AttendanceRecordList records={result.items} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx
Normal file
69
src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import {
|
||||||
|
getDataChangeLogs,
|
||||||
|
getDataChangeStats,
|
||||||
|
getDataChangeTableOptions,
|
||||||
|
} from "@/modules/audit/data-access"
|
||||||
|
import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-table"
|
||||||
|
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||||
|
import type { DataChangeAction } from "@/modules/audit/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DataChangeLogsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||||
|
const tableName = getParam(params, "tableName") ?? undefined
|
||||||
|
const action = (getParam(params, "action") as DataChangeAction | undefined) ?? undefined
|
||||||
|
const startDate = getParam(params, "startDate") ?? undefined
|
||||||
|
const endDate = getParam(params, "endDate") ?? undefined
|
||||||
|
|
||||||
|
const [result, tableOptions, stats] = await Promise.all([
|
||||||
|
getDataChangeLogs({ page, tableName, action, startDate, endDate }),
|
||||||
|
getDataChangeTableOptions(),
|
||||||
|
getDataChangeStats(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const exportParams: Record<string, string> = {}
|
||||||
|
if (tableName) exportParams.tableName = tableName
|
||||||
|
if (action) exportParams.action = action
|
||||||
|
if (startDate) exportParams.startDate = startDate
|
||||||
|
if (endDate) exportParams.endDate = endDate
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Data Change Logs</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Track all data mutations (create/update/delete) across system tables for compliance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AuditLogExportButton exportType="dataChange" params={exportParams} />
|
||||||
|
</div>
|
||||||
|
<DataChangeLogTable
|
||||||
|
items={result.items}
|
||||||
|
page={result.page}
|
||||||
|
pageSize={result.pageSize}
|
||||||
|
total={result.total}
|
||||||
|
totalPages={result.totalPages}
|
||||||
|
tableOptions={tableOptions}
|
||||||
|
stats={stats}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx
Normal file
59
src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { getLoginLogs } from "@/modules/audit/data-access"
|
||||||
|
import { LoginLogView } from "@/modules/audit/components/login-log-view"
|
||||||
|
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||||
|
import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LoginLogsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||||
|
const action = (getParam(params, "action") as LoginLogAction | undefined) ?? undefined
|
||||||
|
const status = (getParam(params, "status") as LoginLogStatus | undefined) ?? undefined
|
||||||
|
const startDate = getParam(params, "startDate") ?? undefined
|
||||||
|
const endDate = getParam(params, "endDate") ?? undefined
|
||||||
|
|
||||||
|
const result = await getLoginLogs({ page, action, status, startDate, endDate })
|
||||||
|
|
||||||
|
const exportParams: Record<string, string> = {}
|
||||||
|
if (action) exportParams.action = action
|
||||||
|
if (status) exportParams.status = status
|
||||||
|
if (startDate) exportParams.startDate = startDate
|
||||||
|
if (endDate) exportParams.endDate = endDate
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Login Logs</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Monitor all authentication events including sign in, sign out, and sign up.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AuditLogExportButton exportType="login" params={exportParams} />
|
||||||
|
</div>
|
||||||
|
<LoginLogView
|
||||||
|
items={result.items}
|
||||||
|
page={result.page}
|
||||||
|
pageSize={result.pageSize}
|
||||||
|
total={result.total}
|
||||||
|
totalPages={result.totalPages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/app/(dashboard)/admin/audit-logs/page.tsx
Normal file
65
src/app/(dashboard)/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { getAuditLogs, getAuditModuleOptions } from "@/modules/audit/data-access"
|
||||||
|
import { AuditLogView } from "@/modules/audit/components/audit-log-view"
|
||||||
|
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||||
|
import type { AuditLogStatus } from "@/modules/audit/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AuditLogsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const page = Number(getParam(params, "page") ?? "1") || 1
|
||||||
|
const moduleFilter = getParam(params, "module") ?? undefined
|
||||||
|
const action = getParam(params, "action") ?? undefined
|
||||||
|
const status = (getParam(params, "status") as AuditLogStatus | undefined) ?? undefined
|
||||||
|
const startDate = getParam(params, "startDate") ?? undefined
|
||||||
|
const endDate = getParam(params, "endDate") ?? undefined
|
||||||
|
|
||||||
|
const [result, moduleOptions] = await Promise.all([
|
||||||
|
getAuditLogs({ page, module: moduleFilter, action, status, startDate, endDate }),
|
||||||
|
getAuditModuleOptions(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const exportParams: Record<string, string> = {}
|
||||||
|
if (moduleFilter) exportParams.module = moduleFilter
|
||||||
|
if (action) exportParams.action = action
|
||||||
|
if (status) exportParams.status = status
|
||||||
|
if (startDate) exportParams.startDate = startDate
|
||||||
|
if (endDate) exportParams.endDate = endDate
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Audit Logs</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Track all user operations across the system for security and compliance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AuditLogExportButton exportType="audit" params={exportParams} />
|
||||||
|
</div>
|
||||||
|
<AuditLogView
|
||||||
|
items={result.items}
|
||||||
|
page={result.page}
|
||||||
|
pageSize={result.pageSize}
|
||||||
|
total={result.total}
|
||||||
|
totalPages={result.totalPages}
|
||||||
|
moduleOptions={moduleOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx
Normal file
45
src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||||
|
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||||
|
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
|
||||||
|
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function EditCoursePlanPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const [plan, classes, subjects, teachers, academicYears] = await Promise.all([
|
||||||
|
getCoursePlanById(id),
|
||||||
|
getAdminClasses(),
|
||||||
|
getSubjectOptions(),
|
||||||
|
getStaffOptions(),
|
||||||
|
getAcademicYears(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!plan) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Edit Course Plan</h2>
|
||||||
|
<p className="text-muted-foreground">Update the course plan details below.</p>
|
||||||
|
</div>
|
||||||
|
<CoursePlanForm
|
||||||
|
mode="edit"
|
||||||
|
plan={plan}
|
||||||
|
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
|
subjects={subjects}
|
||||||
|
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
||||||
|
academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))}
|
||||||
|
backHref={`/admin/course-plans/${plan.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/app/(dashboard)/admin/course-plans/[id]/page.tsx
Normal file
27
src/app/(dashboard)/admin/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||||
|
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function CoursePlanDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const plan = await getCoursePlanById(id)
|
||||||
|
|
||||||
|
if (!plan) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-6 p-8">
|
||||||
|
<CoursePlanDetail
|
||||||
|
plan={plan}
|
||||||
|
editHref={`/admin/course-plans/${plan.id}/edit`}
|
||||||
|
backHref="/admin/course-plans"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/app/(dashboard)/admin/course-plans/create/page.tsx
Normal file
32
src/app/(dashboard)/admin/course-plans/create/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
|
||||||
|
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||||
|
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function CreateCoursePlanPage() {
|
||||||
|
const [classes, subjects, teachers, academicYears] = await Promise.all([
|
||||||
|
getAdminClasses(),
|
||||||
|
getSubjectOptions(),
|
||||||
|
getStaffOptions(),
|
||||||
|
getAcademicYears(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">New Course Plan</h2>
|
||||||
|
<p className="text-muted-foreground">Create a new course teaching plan.</p>
|
||||||
|
</div>
|
||||||
|
<CoursePlanForm
|
||||||
|
mode="create"
|
||||||
|
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
|
subjects={subjects}
|
||||||
|
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
||||||
|
academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))}
|
||||||
|
backHref="/admin/course-plans"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/app/(dashboard)/admin/course-plans/page.tsx
Normal file
45
src/app/(dashboard)/admin/course-plans/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||||
|
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||||
|
import type { CoursePlanStatus } from "@/modules/course-plans/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidStatus = (v?: string): v is CoursePlanStatus =>
|
||||||
|
v === "planning" || v === "active" || v === "completed" || v === "paused"
|
||||||
|
|
||||||
|
export default async function AdminCoursePlansPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const statusParam = getParam(sp, "status")
|
||||||
|
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||||
|
|
||||||
|
const plans = await getCoursePlans({ status })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Course Plans</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage course teaching plans and weekly schedules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CoursePlanList
|
||||||
|
plans={plans}
|
||||||
|
canManage
|
||||||
|
createHref="/admin/course-plans/create"
|
||||||
|
detailHrefBuilder={(id) => `/admin/course-plans/${id}`}
|
||||||
|
initialStatus={status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/app/(dashboard)/admin/files/page.tsx
Normal file
19
src/app/(dashboard)/admin/files/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import {
|
||||||
|
getFileAttachmentsWithFilters,
|
||||||
|
getFileStats,
|
||||||
|
} from "@/modules/files/data-access"
|
||||||
|
import { AdminFilesView } from "@/modules/files/components/admin-files-view"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AdminFilesPage() {
|
||||||
|
await requirePermission(Permissions.FILE_READ)
|
||||||
|
const [files, stats] = await Promise.all([
|
||||||
|
getFileAttachmentsWithFilters({ limit: 200 }),
|
||||||
|
getFileStats(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return <AdminFilesView files={files} stats={stats} />
|
||||||
|
}
|
||||||
51
src/app/(dashboard)/admin/scheduling/auto/page.tsx
Normal file
51
src/app/(dashboard)/admin/scheduling/auto/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { CalendarClock, ClipboardList, Settings2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getAdminClassesForScheduling } from "@/modules/scheduling/actions"
|
||||||
|
import { AutoSchedulePanel } from "@/modules/scheduling/components/auto-schedule-panel"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AdminSchedulingAutoPage() {
|
||||||
|
const classes = await getAdminClassesForScheduling()
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Auto Schedule</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Generate a weekly schedule automatically based on configured rules and subject
|
||||||
|
assignments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/admin/scheduling/rules">
|
||||||
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
|
Configure Rules
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{classOptions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardList}
|
||||||
|
title="No classes available"
|
||||||
|
description="Please create classes before running auto scheduling."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AutoSchedulePanel classes={classOptions} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<CalendarClock className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Applying a new schedule will replace the existing schedule for the selected class.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/app/(dashboard)/admin/scheduling/changes/page.tsx
Normal file
91
src/app/(dashboard)/admin/scheduling/changes/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { PlusCircle, ClipboardList } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import {
|
||||||
|
getAdminClassesForScheduling,
|
||||||
|
getScheduleChanges,
|
||||||
|
} from "@/modules/scheduling/actions"
|
||||||
|
import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list"
|
||||||
|
import { ScheduleConflictsView } from "@/modules/scheduling/components/schedule-conflicts-view"
|
||||||
|
import type { ScheduleChangeStatus } from "@/modules/scheduling/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidStatus = (v?: string): v is ScheduleChangeStatus =>
|
||||||
|
v === "pending" || v === "approved" || v === "rejected" || v === "completed"
|
||||||
|
|
||||||
|
export default async function AdminSchedulingChangesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const statusParam = getParam(sp, "status")
|
||||||
|
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||||
|
const classIdParam = getParam(sp, "classId")
|
||||||
|
const classId = classIdParam && classIdParam !== "all" ? classIdParam : undefined
|
||||||
|
|
||||||
|
const [classes, items] = await Promise.all([
|
||||||
|
getAdminClassesForScheduling(),
|
||||||
|
getScheduleChanges({ status, classId }),
|
||||||
|
])
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Review, approve, or reject schedule change and substitute teacher requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/teacher/schedule-changes">
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
New Request
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 && !status && !classId ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardList}
|
||||||
|
title="No schedule change requests"
|
||||||
|
description="There are no schedule change requests yet."
|
||||||
|
action={{
|
||||||
|
label: "New Request",
|
||||||
|
href: "/teacher/schedule-changes",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScheduleChangeList items={items} canApprove />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Conflict Detection</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Detect time overlaps in an existing class schedule.
|
||||||
|
</p>
|
||||||
|
{classOptions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardList}
|
||||||
|
title="No classes available"
|
||||||
|
description="Please create classes before checking conflicts."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScheduleConflictsView classes={classOptions} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/app/(dashboard)/admin/scheduling/rules/page.tsx
Normal file
47
src/app/(dashboard)/admin/scheduling/rules/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { CalendarCog, ClipboardList } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import {
|
||||||
|
getAdminClassesForScheduling,
|
||||||
|
getSchedulingRules,
|
||||||
|
} from "@/modules/scheduling/actions"
|
||||||
|
import { SchedulingRulesForm } from "@/modules/scheduling/components/scheduling-rules-form"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AdminSchedulingRulesPage() {
|
||||||
|
const [classes, existingRules] = await Promise.all([
|
||||||
|
getAdminClassesForScheduling(),
|
||||||
|
getSchedulingRules(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Scheduling Rules</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Configure daily hour limits, break windows, and balancing preferences for each class.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{classOptions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardList}
|
||||||
|
title="No classes available"
|
||||||
|
description="Please create classes before configuring scheduling rules."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SchedulingRulesForm classes={classOptions} existingRules={existingRules} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<CalendarCog className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Tip: rules saved without selecting a specific class become the global default.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
src/app/(dashboard)/admin/users/import/page.tsx
Normal file
134
src/app/(dashboard)/admin/users/import/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Metadata } from "next"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { UserImportDialog } from "@/modules/users/components/user-import-dialog"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "批量导入用户 - Next_Edu",
|
||||||
|
description: "通过 Excel 批量导入用户",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserImportPage() {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link href="/admin/dashboard">
|
||||||
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
|
返回
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">批量导入用户</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
通过 Excel 文件批量创建用户账号,支持学生自动加入班级。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UserImportDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileSpreadsheet className="h-5 w-5 text-primary" />
|
||||||
|
<CardTitle className="text-base">导入说明</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>使用 Excel 批量导入用户的步骤</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">1</span>
|
||||||
|
<p>点击「批量导入用户」按钮,下载导入模板。</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">2</span>
|
||||||
|
<p>按模板格式填写用户信息(姓名、邮箱、角色、手机、班级邀请码)。</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">3</span>
|
||||||
|
<p>上传填写好的 Excel 文件,系统将解析并预览数据。</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">4</span>
|
||||||
|
<p>确认预览数据无误后,点击「确认导入」完成批量创建。</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info className="h-5 w-5 text-amber-500" />
|
||||||
|
<CardTitle className="text-base">注意事项</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>导入前请仔细阅读</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>• 默认密码为 <code className="rounded bg-muted px-1 py-0.5 text-xs">123456</code>,请提示用户首次登录后修改。</p>
|
||||||
|
<p>• 邮箱必须唯一,重复邮箱将被跳过并记录在错误报告中。</p>
|
||||||
|
<p>• 角色可选:admin / teacher / student / parent / grade_head / teaching_head。</p>
|
||||||
|
<p>• 班级邀请码仅对 student 角色有效,填写后学生将自动加入对应班级。</p>
|
||||||
|
<p>• 单次最多导入 10MB 的文件,建议单次不超过 500 条记录。</p>
|
||||||
|
<p>• 导入完成后将显示成功数、失败数及详细错误信息。</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-primary" />
|
||||||
|
<CardTitle className="text-base">模板字段说明</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Excel 模板各列含义与要求</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="py-2 pr-4 text-left font-medium">列名</th>
|
||||||
|
<th className="py-2 pr-4 text-left font-medium">是否必填</th>
|
||||||
|
<th className="py-2 pr-4 text-left font-medium">说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-medium">姓名</td>
|
||||||
|
<td className="py-2 pr-4">必填</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">用户姓名</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-medium">邮箱</td>
|
||||||
|
<td className="py-2 pr-4">必填</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">登录账号,需符合邮箱格式且唯一</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-medium">角色</td>
|
||||||
|
<td className="py-2 pr-4">必填</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">admin / teacher / student / parent / grade_head / teaching_head</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-medium">手机</td>
|
||||||
|
<td className="py-2 pr-4">选填</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">联系电话</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-medium">班级邀请码</td>
|
||||||
|
<td className="py-2 pr-4">选填</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">仅 student 角色有效,6 位邀请码</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/app/(dashboard)/announcements/page.tsx
Normal file
20
src/app/(dashboard)/announcements/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { getAnnouncements } from "@/modules/announcements/data-access"
|
||||||
|
import { AnnouncementList } from "@/modules/announcements/components/announcement-list"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AnnouncementsPage() {
|
||||||
|
const announcements = await getAnnouncements({ status: "published" })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Announcements</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Stay up to date with the latest school announcements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AnnouncementList announcements={announcements} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/app/(dashboard)/messages/[id]/page.tsx
Normal file
30
src/app/(dashboard)/messages/[id]/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { getMessageById, markMessageAsRead } from "@/modules/messaging/data-access"
|
||||||
|
import { MessageDetail } from "@/modules/messaging/components/message-detail"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function MessageDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const message = await getMessageById(id, ctx.userId)
|
||||||
|
if (!message) notFound()
|
||||||
|
|
||||||
|
// Auto-mark as read when viewed by the receiver
|
||||||
|
if (!message.isRead && message.receiverId === ctx.userId) {
|
||||||
|
await markMessageAsRead(id, ctx.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col p-8">
|
||||||
|
<MessageDetail message={message} currentUserId={ctx.userId} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/app/(dashboard)/messages/compose/page.tsx
Normal file
34
src/app/(dashboard)/messages/compose/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { getRecipients } from "@/modules/messaging/data-access"
|
||||||
|
import { MessageCompose } from "@/modules/messaging/components/message-compose"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ComposeMessagePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ parentId?: string; receiverId?: string; subject?: string }>
|
||||||
|
}) {
|
||||||
|
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||||||
|
const sp = await searchParams
|
||||||
|
|
||||||
|
const recipients = await getRecipients(ctx.userId, ctx.dataScope)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col p-8">
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Compose Message</h2>
|
||||||
|
<p className="text-muted-foreground">Send a message to another user.</p>
|
||||||
|
</div>
|
||||||
|
<MessageCompose
|
||||||
|
recipients={recipients}
|
||||||
|
parentMessageId={sp.parentId}
|
||||||
|
defaultReceiverId={sp.receiverId}
|
||||||
|
defaultSubject={sp.subject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/app/(dashboard)/messages/page.tsx
Normal file
31
src/app/(dashboard)/messages/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { getMessages, getNotifications } from "@/modules/messaging/data-access"
|
||||||
|
import { MessageList } from "@/modules/messaging/components/message-list"
|
||||||
|
import { NotificationList } from "@/modules/messaging/components/notification-list"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function MessagesPage() {
|
||||||
|
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||||
|
|
||||||
|
const [messagesResult, notificationsResult] = await Promise.all([
|
||||||
|
getMessages({ userId: ctx.userId, type: "all", page: 1, pageSize: 50 }),
|
||||||
|
getNotifications(ctx.userId, { page: 1, pageSize: 20 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Messages</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your inbox and stay updated with notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageList messages={messagesResult.items} currentUserId={ctx.userId} />
|
||||||
|
|
||||||
|
<NotificationList notifications={notificationsResult.items} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/app/(dashboard)/parent/attendance/page.tsx
Normal file
61
src/app/(dashboard)/parent/attendance/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||||
|
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { CalendarCheck } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ParentAttendancePage() {
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Children Attendance</h2>
|
||||||
|
<p className="text-muted-foreground">View your children's attendance records.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="No children linked"
|
||||||
|
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||||
|
icon={CalendarCheck}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id))
|
||||||
|
)
|
||||||
|
|
||||||
|
const validSummaries = summaries.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Children Attendance</h2>
|
||||||
|
<p className="text-muted-foreground">View your children's attendance records.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validSummaries.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No attendance records"
|
||||||
|
description="Your children don't have any attendance records yet."
|
||||||
|
icon={CalendarCheck}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{validSummaries.map((summary) => (
|
||||||
|
<div key={summary.studentId} className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||||
|
<StudentAttendanceView summary={summary} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/app/(dashboard)/parent/children/[studentId]/page.tsx
Normal file
71
src/app/(dashboard)/parent/children/[studentId]/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { parentStudentRelations } from "@/shared/db/schema"
|
||||||
|
import { getChildDashboardData } from "@/modules/parent/data-access"
|
||||||
|
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
|
||||||
|
import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { ShieldAlert } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ChildDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ studentId: string }>
|
||||||
|
}) {
|
||||||
|
const { studentId } = await params
|
||||||
|
const ctx = await requireAuth()
|
||||||
|
|
||||||
|
// Verify the student is linked to the current parent
|
||||||
|
const [relation] = await db
|
||||||
|
.select({
|
||||||
|
id: parentStudentRelations.id,
|
||||||
|
relation: parentStudentRelations.relation,
|
||||||
|
})
|
||||||
|
.from(parentStudentRelations)
|
||||||
|
.where(eq(parentStudentRelations.studentId, studentId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldAlert}
|
||||||
|
title="Access denied"
|
||||||
|
description="This student is not linked to your account. Please contact the school administrator if you believe this is an error."
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-check the parent owns this relation
|
||||||
|
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldAlert}
|
||||||
|
title="Access denied"
|
||||||
|
description="You do not have permission to view this student's data."
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = await getChildDashboardData(studentId, relation.relation)
|
||||||
|
if (!child) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 space-y-6">
|
||||||
|
<ChildDetailHeader child={child} />
|
||||||
|
<ChildDetailPanel child={child} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
export default function ParentDashboardPage() {
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
|
import { getParentDashboardData } from "@/modules/parent/data-access"
|
||||||
|
import { ParentDashboard } from "@/modules/parent/components/parent-dashboard"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ParentDashboardPage() {
|
||||||
|
const ctx = await requireAuth()
|
||||||
|
const data = await getParentDashboardData(ctx.userId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 md:p-8">
|
||||||
<h1 className="text-2xl font-bold">Parent Dashboard</h1>
|
<ParentDashboard data={data} />
|
||||||
<p className="text-muted-foreground">Welcome, Parent!</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/app/(dashboard)/parent/grades/page.tsx
Normal file
61
src/app/(dashboard)/parent/grades/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||||
|
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { GraduationCap } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ParentGradesPage() {
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Children Grades</h2>
|
||||||
|
<p className="text-muted-foreground">View your children's grade records.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="No children linked"
|
||||||
|
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||||
|
icon={GraduationCap}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id))
|
||||||
|
)
|
||||||
|
|
||||||
|
const validSummaries = summaries.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Children Grades</h2>
|
||||||
|
<p className="text-muted-foreground">View your children's grade records.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validSummaries.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No grade records"
|
||||||
|
description="Your children don't have any grade records yet."
|
||||||
|
icon={GraduationCap}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{validSummaries.map((summary) => (
|
||||||
|
<div key={summary.studentId} className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||||
|
<StudentGradeSummary summary={summary} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { AdminSettingsView } from "@/modules/settings/components/admin-settings-
|
|||||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||||
import { getUserProfile } from "@/modules/users/data-access"
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -19,8 +20,13 @@ export default async function SettingsPage() {
|
|||||||
if (!userProfile) redirect("/login")
|
if (!userProfile) redirect("/login")
|
||||||
|
|
||||||
const permissions = session.user.permissions ?? []
|
const permissions = session.user.permissions ?? []
|
||||||
|
const notificationPrefs = await getNotificationPreferences(userId)
|
||||||
|
|
||||||
if (permissions.includes(Permissions.SETTINGS_ADMIN)) return <AdminSettingsView user={userProfile} />
|
if (permissions.includes(Permissions.SETTINGS_ADMIN)) {
|
||||||
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) return <StudentSettingsView user={userProfile} />
|
return <AdminSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||||
return <TeacherSettingsView user={userProfile} />
|
}
|
||||||
|
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) {
|
||||||
|
return <StudentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||||
|
}
|
||||||
|
return <TeacherSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/app/(dashboard)/settings/security/page.tsx
Normal file
50
src/app/(dashboard)/settings/security/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { Lock } from "lucide-react"
|
||||||
|
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Security Settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SecuritySettingsPage() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user) redirect("/login")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock className="h-7 w-7 text-muted-foreground" />
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Security</h1>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Manage your password and account security settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<PasswordChangeForm />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security Tips</CardTitle>
|
||||||
|
<CardDescription>Best practices to keep your account safe.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li>Use a unique password that you don't reuse across other sites.</li>
|
||||||
|
<li>Avoid common words, names, or sequential patterns.</li>
|
||||||
|
<li>Change your password periodically.</li>
|
||||||
|
<li>Your account will be temporarily locked after multiple failed login attempts.</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/app/(dashboard)/student/attendance/page.tsx
Normal file
40
src/app/(dashboard)/student/attendance/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||||
|
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { CalendarCheck } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function StudentAttendancePage() {
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
const summary = await getStudentAttendanceSummary(ctx.userId)
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">My Attendance</h2>
|
||||||
|
<p className="text-muted-foreground">View your attendance records.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="No user found"
|
||||||
|
description="Unable to load your student profile."
|
||||||
|
icon={CalendarCheck}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">My Attendance</h2>
|
||||||
|
<p className="text-muted-foreground">View your attendance records and statistics.</p>
|
||||||
|
</div>
|
||||||
|
<StudentAttendanceView summary={summary} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/app/(dashboard)/student/grades/page.tsx
Normal file
40
src/app/(dashboard)/student/grades/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||||
|
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { GraduationCap } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function StudentGradesPage() {
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
const summary = await getStudentGradeSummary(ctx.userId)
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
|
||||||
|
<p className="text-muted-foreground">View your grade records.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="No user found"
|
||||||
|
description="Unable to load your student profile."
|
||||||
|
icon={GraduationCap}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
|
||||||
|
<p className="text-muted-foreground">View your grade records.</p>
|
||||||
|
</div>
|
||||||
|
<StudentGradeSummary summary={summary} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/app/(dashboard)/teacher/attendance/page.tsx
Normal file
83
src/app/(dashboard)/teacher/attendance/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getAttendanceRecords } from "@/modules/attendance/data-access"
|
||||||
|
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
|
||||||
|
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TeacherAttendancePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
const classId = getParam(sp, "classId")
|
||||||
|
const status = getParam(sp, "status")
|
||||||
|
const date = getParam(sp, "date")
|
||||||
|
|
||||||
|
const classes = await getTeacherClasses()
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
|
||||||
|
const result = await getAttendanceRecords({
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
currentUserId: ctx.userId,
|
||||||
|
classId: classId && classId !== "all" ? classId : undefined,
|
||||||
|
status: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined,
|
||||||
|
date: date && date.length > 0 ? date : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Attendance</h2>
|
||||||
|
<p className="text-muted-foreground">Manage student attendance records.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/teacher/attendance/stats">
|
||||||
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
|
Statistics
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/teacher/attendance/sheet">
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
Take Attendance
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AttendanceFilters classes={classOptions} />
|
||||||
|
|
||||||
|
{result.items.length === 0 && !classId && !status && !date ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No attendance records"
|
||||||
|
description="Start by taking attendance for your classes."
|
||||||
|
icon={ClipboardList}
|
||||||
|
action={{
|
||||||
|
label: "Take Attendance",
|
||||||
|
href: "/teacher/attendance/sheet",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AttendanceRecordList records={result.items} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/app/(dashboard)/teacher/attendance/sheet/page.tsx
Normal file
49
src/app/(dashboard)/teacher/attendance/sheet/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"
|
||||||
|
import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AttendanceSheetPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
|
||||||
|
const defaultClassId = getParam(sp, "classId")
|
||||||
|
const defaultDate = getParam(sp, "date")
|
||||||
|
|
||||||
|
const classes = await getTeacherClasses()
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
|
||||||
|
let students: Array<{ id: string; name: string; email: string }> = []
|
||||||
|
if (defaultClassId) {
|
||||||
|
students = await getClassStudentsForAttendance(defaultClassId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Take Attendance</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Select a class and date, then mark attendance for each student.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AttendanceSheet
|
||||||
|
classes={classOptions}
|
||||||
|
students={students}
|
||||||
|
defaultClassId={defaultClassId}
|
||||||
|
defaultDate={defaultDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/app/(dashboard)/teacher/attendance/stats/page.tsx
Normal file
120
src/app/(dashboard)/teacher/attendance/stats/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
|
||||||
|
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
|
||||||
|
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { BarChart3 } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AttendanceStatsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
|
||||||
|
const classId = getParam(sp, "classId")
|
||||||
|
const startDate = getParam(sp, "startDate")
|
||||||
|
const endDate = getParam(sp, "endDate")
|
||||||
|
|
||||||
|
const classes = await getTeacherClasses()
|
||||||
|
|
||||||
|
if (classes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
|
||||||
|
<p className="text-muted-foreground">View class attendance statistics.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="No classes"
|
||||||
|
description="You don't have any classes yet."
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetClassId = classId ?? classes[0].id
|
||||||
|
|
||||||
|
const summary = await getClassAttendanceStats(
|
||||||
|
targetClassId,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
|
||||||
|
<p className="text-muted-foreground">View class attendance statistics and trends.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatsClassSelector
|
||||||
|
classes={classOptions}
|
||||||
|
currentClassId={targetClassId}
|
||||||
|
startDate={startDate ?? ""}
|
||||||
|
endDate={endDate ?? ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{summary ? (
|
||||||
|
<>
|
||||||
|
<AttendanceStatsCard stats={summary.stats} />
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">Student Records</h3>
|
||||||
|
<AttendanceRecordList records={summary.studentRecords} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No data"
|
||||||
|
description="No attendance data available for this class."
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsClassSelector({
|
||||||
|
classes,
|
||||||
|
currentClassId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}: {
|
||||||
|
classes: Array<{ id: string; name: string }>
|
||||||
|
currentClassId: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
}) {
|
||||||
|
const dateParams = `${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}`
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{classes.map((c) => (
|
||||||
|
<a
|
||||||
|
key={c.id}
|
||||||
|
href={`/teacher/attendance/stats?classId=${c.id}${dateParams}`}
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
c.id === currentClassId
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "bg-card hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/app/(dashboard)/teacher/course-plans/[id]/page.tsx
Normal file
26
src/app/(dashboard)/teacher/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||||
|
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function TeacherCoursePlanDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const plan = await getCoursePlanById(id)
|
||||||
|
|
||||||
|
if (!plan) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-6 p-8">
|
||||||
|
<CoursePlanDetail
|
||||||
|
plan={plan}
|
||||||
|
backHref="/teacher/course-plans"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/app/(dashboard)/teacher/course-plans/page.tsx
Normal file
49
src/app/(dashboard)/teacher/course-plans/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||||
|
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||||
|
import type { CoursePlanStatus } from "@/modules/course-plans/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidStatus = (v?: string): v is CoursePlanStatus =>
|
||||||
|
v === "planning" || v === "active" || v === "completed" || v === "paused"
|
||||||
|
|
||||||
|
export default async function TeacherCoursePlansPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const session = await auth()
|
||||||
|
const teacherId = String(session?.user?.id ?? "")
|
||||||
|
|
||||||
|
const sp = await searchParams
|
||||||
|
const statusParam = getParam(sp, "status")
|
||||||
|
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||||
|
|
||||||
|
const plans = teacherId
|
||||||
|
? await getCoursePlans({ teacherId, status })
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">My Course Plans</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
View your course teaching plans and weekly schedules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CoursePlanList
|
||||||
|
plans={plans}
|
||||||
|
detailHrefBuilder={(id) => `/teacher/course-plans/${id}`}
|
||||||
|
initialStatus={status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
259
src/app/(dashboard)/teacher/grades/analytics/page.tsx
Normal file
259
src/app/(dashboard)/teacher/grades/analytics/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { BarChart3, ArrowLeft } from "lucide-react"
|
||||||
|
import { asc } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { subjects } from "@/shared/db/schema"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getGrades } from "@/modules/school/data-access"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getClassComparison,
|
||||||
|
getGradeDistribution,
|
||||||
|
getGradeTrend,
|
||||||
|
getSubjectComparison,
|
||||||
|
} from "@/modules/grades/data-access-analytics"
|
||||||
|
import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart"
|
||||||
|
import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart"
|
||||||
|
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
|
||||||
|
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GradeAnalyticsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
const classId = getParam(sp, "classId")
|
||||||
|
const subjectId = getParam(sp, "subjectId")
|
||||||
|
const gradeId = getParam(sp, "gradeId")
|
||||||
|
|
||||||
|
const [classes, allGrades, allSubjects] = await Promise.all([
|
||||||
|
getTeacherClasses(),
|
||||||
|
getGrades(),
|
||||||
|
db.query.subjects.findMany({
|
||||||
|
orderBy: [asc(subjects.order), asc(subjects.name)],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (classes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Trend analysis, class comparisons, and score distributions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="No classes"
|
||||||
|
description="You don't have any classes yet."
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetClassId = classId ?? classes[0].id
|
||||||
|
const targetSubjectId =
|
||||||
|
subjectId && subjectId !== "all" ? subjectId : undefined
|
||||||
|
const targetGradeId = gradeId ?? allGrades[0]?.id
|
||||||
|
|
||||||
|
// Run analytics queries in parallel
|
||||||
|
const [trend, distribution, subjectComparison, classComparison] =
|
||||||
|
await Promise.all([
|
||||||
|
getGradeTrend({
|
||||||
|
classId: targetClassId,
|
||||||
|
subjectId: targetSubjectId,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
currentUserId: ctx.userId,
|
||||||
|
}),
|
||||||
|
getGradeDistribution({
|
||||||
|
classId: targetClassId,
|
||||||
|
subjectId: targetSubjectId,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
currentUserId: ctx.userId,
|
||||||
|
}),
|
||||||
|
getSubjectComparison({
|
||||||
|
classId: targetClassId,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
}),
|
||||||
|
targetGradeId
|
||||||
|
? getClassComparison({
|
||||||
|
gradeId: targetGradeId,
|
||||||
|
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||||
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Trend analysis, class comparisons, and score distributions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/teacher/grades">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Grades
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnalyticsFilters
|
||||||
|
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
|
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
|
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
|
||||||
|
currentClassId={targetClassId}
|
||||||
|
currentSubjectId={subjectId ?? "all"}
|
||||||
|
currentGradeId={targetGradeId ?? ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<GradeTrendChart data={trend} />
|
||||||
|
<GradeDistributionChart data={distribution} />
|
||||||
|
<SubjectComparisonChart data={subjectComparison} />
|
||||||
|
<ClassComparisonChart data={classComparison} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyticsFiltersProps {
|
||||||
|
classes: Array<{ id: string; name: string }>
|
||||||
|
grades: Array<{ id: string; name: string }>
|
||||||
|
subjects: Array<{ id: string; name: string }>
|
||||||
|
currentClassId: string
|
||||||
|
currentSubjectId: string
|
||||||
|
currentGradeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalyticsFilters({
|
||||||
|
classes,
|
||||||
|
grades,
|
||||||
|
subjects,
|
||||||
|
currentClassId,
|
||||||
|
currentSubjectId,
|
||||||
|
currentGradeId,
|
||||||
|
}: AnalyticsFiltersProps) {
|
||||||
|
const buildHref = (overrides: {
|
||||||
|
classId?: string
|
||||||
|
subjectId?: string
|
||||||
|
gradeId?: string
|
||||||
|
}) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set(
|
||||||
|
"classId",
|
||||||
|
overrides.classId !== undefined ? overrides.classId : currentClassId
|
||||||
|
)
|
||||||
|
params.set(
|
||||||
|
"subjectId",
|
||||||
|
overrides.subjectId !== undefined ? overrides.subjectId : currentSubjectId
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
overrides.gradeId !== undefined
|
||||||
|
? overrides.gradeId
|
||||||
|
: currentGradeId
|
||||||
|
) {
|
||||||
|
params.set(
|
||||||
|
"gradeId",
|
||||||
|
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return `/teacher/grades/analytics?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Class</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{classes.map((c) => (
|
||||||
|
<a
|
||||||
|
key={c.id}
|
||||||
|
href={buildHref({ classId: c.id })}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
c.id === currentClassId
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "bg-background hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Subject</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<a
|
||||||
|
href={buildHref({ subjectId: "all" })}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
currentSubjectId === "all"
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "bg-background hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</a>
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<a
|
||||||
|
key={s.id}
|
||||||
|
href={buildHref({ subjectId: s.id })}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
s.id === currentSubjectId
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "bg-background hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
Grade (for class comparison)
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{grades.map((g) => (
|
||||||
|
<a
|
||||||
|
key={g.id}
|
||||||
|
href={buildHref({ gradeId: g.id })}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
g.id === currentGradeId
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "bg-background hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/app/(dashboard)/teacher/grades/entry/page.tsx
Normal file
52
src/app/(dashboard)/teacher/grades/entry/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { subjects } from "@/shared/db/schema"
|
||||||
|
import { asc } from "drizzle-orm"
|
||||||
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
|
||||||
|
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BatchEntryPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
|
const sp = await searchParams
|
||||||
|
|
||||||
|
const defaultClassId = getParam(sp, "classId")
|
||||||
|
const defaultSubjectId = getParam(sp, "subjectId")
|
||||||
|
|
||||||
|
const [classes, allSubjects] = await Promise.all([
|
||||||
|
getTeacherClasses(),
|
||||||
|
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||||
|
|
||||||
|
let students: Array<{ id: string; name: string; email: string }> = []
|
||||||
|
if (defaultClassId) {
|
||||||
|
students = await getClassStudentsForEntry(defaultClassId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h2>
|
||||||
|
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BatchGradeEntry
|
||||||
|
classes={classOptions}
|
||||||
|
subjects={subjectOptions}
|
||||||
|
students={students}
|
||||||
|
defaultClassId={defaultClassId}
|
||||||
|
defaultSubjectId={defaultSubjectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/app/(dashboard)/teacher/grades/page.tsx
Normal file
101
src/app/(dashboard)/teacher/grades/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { subjects } from "@/shared/db/schema"
|
||||||
|
import { asc } from "drizzle-orm"
|
||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getGradeRecords } from "@/modules/grades/data-access"
|
||||||
|
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
|
||||||
|
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
|
||||||
|
import { ExportButton } from "@/modules/grades/components/export-button"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TeacherGradesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
const classId = getParam(sp, "classId")
|
||||||
|
const subjectId = getParam(sp, "subjectId")
|
||||||
|
const type = getParam(sp, "type")
|
||||||
|
const semester = getParam(sp, "semester")
|
||||||
|
|
||||||
|
const [classes, allSubjects] = await Promise.all([
|
||||||
|
getTeacherClasses(),
|
||||||
|
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const records = await getGradeRecords({
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
currentUserId: ctx.userId,
|
||||||
|
classId: classId && classId !== "all" ? classId : undefined,
|
||||||
|
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
|
||||||
|
type: type && type !== "all" ? (type as "exam" | "quiz" | "homework" | "other") : undefined,
|
||||||
|
semester: semester && semester !== "all" ? (semester as "1" | "2") : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
|
||||||
|
<p className="text-muted-foreground">Manage student grade records.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/teacher/grades/stats">
|
||||||
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
|
Statistics
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/teacher/grades/entry">
|
||||||
|
<ClipboardList className="mr-2 h-4 w-4" />
|
||||||
|
Batch Entry
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<ExportButton
|
||||||
|
classId={classId && classId !== "all" ? classId : ""}
|
||||||
|
subjectId={subjectId && subjectId !== "all" ? subjectId : undefined}
|
||||||
|
variant="outline"
|
||||||
|
/>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/teacher/grades/entry">
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
Record Grades
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
|
||||||
|
|
||||||
|
{records.length === 0 && !classId && !subjectId ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No grade records"
|
||||||
|
description="Start by recording grades for your classes."
|
||||||
|
icon={ClipboardList}
|
||||||
|
action={{
|
||||||
|
label: "Record Grades",
|
||||||
|
href: "/teacher/grades/entry",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GradeRecordList records={records} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
src/app/(dashboard)/teacher/grades/stats/page.tsx
Normal file
139
src/app/(dashboard)/teacher/grades/stats/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { subjects } from "@/shared/db/schema"
|
||||||
|
import { asc } from "drizzle-orm"
|
||||||
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getClassGradeStatsWithMeta, getClassRanking } from "@/modules/grades/data-access"
|
||||||
|
import { ClassGradeReport } from "@/modules/grades/components/class-grade-report"
|
||||||
|
import { ExportButton } from "@/modules/grades/components/export-button"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { BarChart3 } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function StatsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
|
const sp = await searchParams
|
||||||
|
|
||||||
|
const classId = getParam(sp, "classId")
|
||||||
|
const subjectId = getParam(sp, "subjectId")
|
||||||
|
|
||||||
|
const [classes, allSubjects] = await Promise.all([
|
||||||
|
getTeacherClasses(),
|
||||||
|
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (classes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
|
||||||
|
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="No classes"
|
||||||
|
description="You don't have any classes yet."
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetClassId = classId ?? classes[0].id
|
||||||
|
const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined
|
||||||
|
|
||||||
|
const [stats, ranking] = await Promise.all([
|
||||||
|
getClassGradeStatsWithMeta(targetClassId, targetSubjectId),
|
||||||
|
getClassRanking(targetClassId, targetSubjectId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
|
||||||
|
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||||
|
</div>
|
||||||
|
<ExportButton
|
||||||
|
classId={targetClassId}
|
||||||
|
subjectId={targetSubjectId}
|
||||||
|
variant="outline"
|
||||||
|
label="导出成绩"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatsClassSelector
|
||||||
|
classes={classOptions}
|
||||||
|
subjects={subjectOptions}
|
||||||
|
currentClassId={targetClassId}
|
||||||
|
currentSubjectId={subjectId ?? "all"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClassGradeReport stats={stats} ranking={ranking} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsClassSelector({
|
||||||
|
classes,
|
||||||
|
subjects,
|
||||||
|
currentClassId,
|
||||||
|
currentSubjectId,
|
||||||
|
}: {
|
||||||
|
classes: Array<{ id: string; name: string }>
|
||||||
|
subjects: Array<{ id: string; name: string }>
|
||||||
|
currentClassId: string
|
||||||
|
currentSubjectId: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{classes.map((c) => (
|
||||||
|
<a
|
||||||
|
key={c.id}
|
||||||
|
href={`/teacher/grades/stats?classId=${c.id}${currentSubjectId !== "all" ? `&subjectId=${currentSubjectId}` : ""}`}
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
c.id === currentClassId
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "bg-card hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
href={`/teacher/grades/stats?classId=${currentClassId}`}
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
currentSubjectId === "all"
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "bg-card hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Subjects
|
||||||
|
</a>
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<a
|
||||||
|
key={s.id}
|
||||||
|
href={`/teacher/grades/stats?classId=${currentClassId}&subjectId=${s.id}`}
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
s.id === currentSubjectId
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "bg-card hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/app/(dashboard)/teacher/schedule-changes/page.tsx
Normal file
69
src/app/(dashboard)/teacher/schedule-changes/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { ClipboardList } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import {
|
||||||
|
getAdminClassesForScheduling,
|
||||||
|
getScheduleChanges,
|
||||||
|
getTeachersForScheduling,
|
||||||
|
} from "@/modules/scheduling/actions"
|
||||||
|
import { ScheduleChangeForm } from "@/modules/scheduling/components/schedule-change-form"
|
||||||
|
import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function TeacherScheduleChangesPage() {
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
// Teachers see only their own requests; admins landing here see all.
|
||||||
|
const requesterId = ctx.roles.includes("admin") ? undefined : ctx.userId
|
||||||
|
|
||||||
|
const [classes, teachers, items] = await Promise.all([
|
||||||
|
getAdminClassesForScheduling(),
|
||||||
|
getTeachersForScheduling(),
|
||||||
|
getScheduleChanges({ requesterId }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||||
|
const teacherOptions = teachers.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name ?? "Unknown",
|
||||||
|
email: t.email ?? "",
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Submit a schedule change or substitute teacher request, and track its status.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{classOptions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardList}
|
||||||
|
title="No classes available"
|
||||||
|
description="There are no classes available to request schedule changes for."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<ScheduleChangeForm classes={classOptions} teachers={teacherOptions} />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">My Requests</h3>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardList}
|
||||||
|
title="No requests yet"
|
||||||
|
description="Your submitted schedule change requests will appear here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScheduleChangeList items={items} canApprove={false} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai"
|
import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai"
|
||||||
|
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -16,11 +17,24 @@ export async function POST(req: Request) {
|
|||||||
const userId = String(session?.user?.id ?? "").trim()
|
const userId = String(session?.user?.id ?? "").trim()
|
||||||
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
|
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
// Rate limit AI chat per user
|
||||||
|
const limitKey = rateLimitKey("ai-chat", userId)
|
||||||
|
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT })
|
||||||
|
if (!limit.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Rate limit exceeded. Please slow down." },
|
||||||
|
{ status: 429, headers: rateLimitHeaders(limit) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json().catch(() => null)
|
const body = await req.json().catch(() => null)
|
||||||
const input = parseAiChatPayload(body)
|
const input = parseAiChatPayload(body)
|
||||||
const result = await createAiChatCompletion(input)
|
const result = await createAiChatCompletion(input)
|
||||||
return NextResponse.json({ success: true, content: result.content, usage: result.usage })
|
return NextResponse.json(
|
||||||
|
{ success: true, content: result.content, usage: result.usage },
|
||||||
|
{ headers: rateLimitHeaders(limit) }
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = getAiErrorMessage(e)
|
const message = getAiErrorMessage(e)
|
||||||
return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) })
|
return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) })
|
||||||
|
|||||||
135
src/app/api/export/route.ts
Normal file
135
src/app/api/export/route.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { exportUsersToExcel } from "@/modules/users/import-export"
|
||||||
|
import { exportGradeRecordsToExcel } from "@/modules/grades/export"
|
||||||
|
import {
|
||||||
|
exportAuditLogsAction,
|
||||||
|
exportDataChangeLogsAction,
|
||||||
|
exportLoginLogsAction,
|
||||||
|
} from "@/modules/audit/actions"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type ExportType = "grades" | "users" | "attendance" | "audit" | "login" | "dataChange"
|
||||||
|
|
||||||
|
const isExportType = (v: string): v is ExportType =>
|
||||||
|
v === "grades" || v === "users" || v === "attendance" || v === "audit" || v === "login" || v === "dataChange"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await requireAuth()
|
||||||
|
|
||||||
|
const body = (await req.json()) as {
|
||||||
|
type?: string
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = body.type ?? ""
|
||||||
|
if (!isExportType(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Invalid export type" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = body.params ?? {}
|
||||||
|
let buffer: Buffer
|
||||||
|
let filename: string
|
||||||
|
|
||||||
|
if (type === "grades") {
|
||||||
|
buffer = await exportGradeRecordsToExcel({
|
||||||
|
classId: String(params.classId ?? ""),
|
||||||
|
subjectId: params.subjectId ? String(params.subjectId) : undefined,
|
||||||
|
examId: params.examId ? String(params.examId) : undefined,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
})
|
||||||
|
filename = `grades_export_${formatDateForFile()}.xlsx`
|
||||||
|
} else if (type === "users") {
|
||||||
|
buffer = await exportUsersToExcel({
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
role: params.role ? String(params.role) : undefined,
|
||||||
|
})
|
||||||
|
filename = `users_export_${formatDateForFile()}.xlsx`
|
||||||
|
} else if (type === "audit" || type === "login" || type === "dataChange") {
|
||||||
|
// Audit-related exports require AUDIT_LOG_READ permission
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: e.message },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringParams = Object.fromEntries(
|
||||||
|
Object.entries(params).filter(([, v]) => typeof v === "string")
|
||||||
|
) as Record<string, string>
|
||||||
|
|
||||||
|
if (type === "audit") {
|
||||||
|
const result = await exportAuditLogsAction(stringParams)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: result.message ?? "Export failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
buffer = result.data.buffer
|
||||||
|
filename = result.data.filename
|
||||||
|
} else if (type === "login") {
|
||||||
|
const result = await exportLoginLogsAction(stringParams)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: result.message ?? "Export failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
buffer = result.data.buffer
|
||||||
|
filename = result.data.filename
|
||||||
|
} else {
|
||||||
|
const result = await exportDataChangeLogsAction(stringParams)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: result.message ?? "Export failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
buffer = result.data.buffer
|
||||||
|
filename = result.data.filename
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Attendance export not implemented" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(buffer), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type":
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Content-Length": String(buffer.length),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Export failed"
|
||||||
|
const status = message.includes("Permission denied") || message.includes("auth_required")
|
||||||
|
? 401
|
||||||
|
: 500
|
||||||
|
return NextResponse.json({ success: false, message }, { status })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForFile(): string {
|
||||||
|
const now = new Date()
|
||||||
|
const y = now.getFullYear()
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, "0")
|
||||||
|
const d = String(now.getDate()).padStart(2, "0")
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
87
src/app/api/files/[id]/route.ts
Normal file
87
src/app/api/files/[id]/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { unlink } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import {
|
||||||
|
deleteFileAttachment,
|
||||||
|
getFileAttachment,
|
||||||
|
} from "@/modules/files/data-access"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/files/[id]
|
||||||
|
* 获取文件信息(需要登录)
|
||||||
|
*/
|
||||||
|
export async function GET(_req: Request, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
await requireAuth()
|
||||||
|
const { id } = await ctx.params
|
||||||
|
|
||||||
|
const file = await getFileAttachment(id)
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "File not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, file })
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Failed to fetch file"
|
||||||
|
const status = message.includes("auth_required") ? 401 : 500
|
||||||
|
return NextResponse.json({ success: false, message }, { status })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/files/[id]
|
||||||
|
* 删除文件(需要 FILE_DELETE 权限)
|
||||||
|
*/
|
||||||
|
export async function DELETE(_req: Request, ctx: RouteContext) {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.FILE_DELETE)
|
||||||
|
const { id } = await ctx.params
|
||||||
|
|
||||||
|
const file = await getFileAttachment(id)
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "File not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除磁盘文件(静默失败,记录仍会被删除)
|
||||||
|
const absolutePath = path.join(process.cwd(), "public", file.storagePath)
|
||||||
|
try {
|
||||||
|
await unlink(absolutePath)
|
||||||
|
} catch {
|
||||||
|
// 文件可能已不存在,忽略错误
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await deleteFileAttachment(id)
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Failed to delete file record" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "File deleted" })
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: e.message },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const message = e instanceof Error ? e.message : "Failed to delete file"
|
||||||
|
return NextResponse.json({ success: false, message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/api/files/batch-delete/route.ts
Normal file
58
src/app/api/files/batch-delete/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { storageProvider } from "@/shared/lib/storage-provider"
|
||||||
|
import {
|
||||||
|
deleteFileAttachments,
|
||||||
|
getFileAttachmentsByIds,
|
||||||
|
} from "@/modules/files/data-access"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/files/batch-delete
|
||||||
|
* 批量删除文件(需要 FILE_DELETE 权限)
|
||||||
|
* Body: { ids: string[] }
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.FILE_DELETE)
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => null)) as { ids?: unknown } | null
|
||||||
|
const ids = Array.isArray(body?.ids) ? body!.ids.filter((x): x is string => typeof x === "string") : []
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "No file ids provided" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先查出文件记录,以便删除磁盘文件
|
||||||
|
const files = await getFileAttachmentsByIds(ids)
|
||||||
|
|
||||||
|
// 删除磁盘文件(静默失败)
|
||||||
|
await Promise.all(
|
||||||
|
files.map((f) => storageProvider.delete(f.storagePath).catch(() => undefined))
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await deleteFileAttachments(ids)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: result.success,
|
||||||
|
message: `Deleted ${result.deletedCount} file(s)`,
|
||||||
|
deletedCount: result.deletedCount,
|
||||||
|
failedIds: result.failedIds,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: e.message },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const message = e instanceof Error ? e.message : "Failed to batch delete files"
|
||||||
|
return NextResponse.json({ success: false, message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/app/api/import/route.ts
Normal file
62
src/app/api/import/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { parseExcel } from "@/shared/lib/excel"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.USER_MANAGE)
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get("file")
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "No file provided" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "File is empty" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "File size exceeds 10MB limit" },
|
||||||
|
{ status: 413 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerName = file.name.toLowerCase()
|
||||||
|
if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Only .xlsx and .xls files are supported" },
|
||||||
|
{ status: 415 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = Buffer.from(await file.arrayBuffer())
|
||||||
|
const sheets = await parseExcel(bytes)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sheets,
|
||||||
|
fileName: file.name,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Import failed"
|
||||||
|
const status = message.includes("Permission denied") || message.includes("auth_required")
|
||||||
|
? 401
|
||||||
|
: 500
|
||||||
|
return NextResponse.json({ success: false, message }, { status })
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/api/rate-limit-test/route.ts
Normal file
48
src/app/api/rate-limit-test/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test endpoint for the in-memory rate limiter.
|
||||||
|
* Applies a strict 5-requests-per-minute limit per user so you can
|
||||||
|
* observe 429 responses quickly during manual testing.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* curl -X GET http://localhost:3000/api/rate-limit-test \
|
||||||
|
* -H "Cookie: next-auth.session-token=<token>"
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = String(session?.user?.id ?? "").trim()
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitKey = rateLimitKey("test", userId)
|
||||||
|
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE })
|
||||||
|
|
||||||
|
if (!limit.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Rate limit exceeded",
|
||||||
|
remaining: limit.remaining,
|
||||||
|
retryAfterMs: limit.retryAfterMs,
|
||||||
|
},
|
||||||
|
{ status: 429, headers: rateLimitHeaders(limit) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Request allowed",
|
||||||
|
remaining: limit.remaining,
|
||||||
|
resetTime: new Date(limit.resetTime).toISOString(),
|
||||||
|
},
|
||||||
|
{ headers: rateLimitHeaders(limit) }
|
||||||
|
)
|
||||||
|
}
|
||||||
275
src/app/api/search/route.ts
Normal file
275
src/app/api/search/route.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { and, desc, eq, like, or, sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
announcements,
|
||||||
|
exams,
|
||||||
|
questions,
|
||||||
|
textbooks,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchType = "all" | "question" | "textbook" | "exam" | "announcement"
|
||||||
|
|
||||||
|
const isSearchType = (v: string): v is SearchType =>
|
||||||
|
v === "all" || v === "question" || v === "textbook" || v === "exam" || v === "announcement"
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
interface SearchResultItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
snippet: string
|
||||||
|
type: "question" | "textbook" | "exam" | "announcement"
|
||||||
|
href: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
success: boolean
|
||||||
|
query: string
|
||||||
|
type: SearchType
|
||||||
|
results: SearchResultItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/search?q=keyword&type=all&page=1
|
||||||
|
* 全文检索:questions / textbooks / exams / announcements
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
await requireAuth()
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const q = (searchParams.get("q") ?? "").trim()
|
||||||
|
const typeRaw = (searchParams.get("type") ?? "all").trim()
|
||||||
|
const type: SearchType = isSearchType(typeRaw) ? typeRaw : "all"
|
||||||
|
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1)
|
||||||
|
const pageSize = Math.min(
|
||||||
|
50,
|
||||||
|
Math.max(1, Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)) || DEFAULT_PAGE_SIZE)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json<SearchResponse>({
|
||||||
|
success: true,
|
||||||
|
query: q,
|
||||||
|
type,
|
||||||
|
results: [],
|
||||||
|
total: 0,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const kw = `%${q}%`
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
const results: SearchResultItem[] = []
|
||||||
|
|
||||||
|
// 并行查询各类型
|
||||||
|
const tasks: Promise<SearchResultItem[]>[] = []
|
||||||
|
|
||||||
|
if (type === "all" || type === "question") {
|
||||||
|
tasks.push(searchQuestions(kw, pageSize))
|
||||||
|
}
|
||||||
|
if (type === "all" || type === "textbook") {
|
||||||
|
tasks.push(searchTextbooks(kw, pageSize))
|
||||||
|
}
|
||||||
|
if (type === "all" || type === "exam") {
|
||||||
|
tasks.push(searchExams(kw, pageSize))
|
||||||
|
}
|
||||||
|
if (type === "all" || type === "announcement") {
|
||||||
|
tasks.push(searchAnnouncements(kw, pageSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = await Promise.all(tasks)
|
||||||
|
for (const group of grouped) {
|
||||||
|
results.push(...group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 createdAt 降序排序后分页
|
||||||
|
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
const total = results.length
|
||||||
|
const paged = results.slice(offset, offset + pageSize)
|
||||||
|
|
||||||
|
return NextResponse.json<SearchResponse>({
|
||||||
|
success: true,
|
||||||
|
query: q,
|
||||||
|
type,
|
||||||
|
results: paged,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Search failed"
|
||||||
|
const status = message.includes("auth_required") ? 401 : 500
|
||||||
|
return NextResponse.json({ success: false, message }, { status })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchQuestions(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: questions.id,
|
||||||
|
content: questions.content,
|
||||||
|
type: questions.type,
|
||||||
|
createdAt: questions.createdAt,
|
||||||
|
})
|
||||||
|
.from(questions)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
// JSON 内容字段转换为文本进行模糊匹配
|
||||||
|
sql`CAST(${questions.content} AS CHAR) LIKE ${kw}`,
|
||||||
|
like(sql`CAST(${questions.content} AS CHAR)`, kw)
|
||||||
|
)!
|
||||||
|
)
|
||||||
|
.orderBy(desc(questions.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
return rows.map((r) => {
|
||||||
|
const text = extractTextFromJson(r.content)
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
title: truncate(text, 80) || `Question (${r.type})`,
|
||||||
|
snippet: truncate(text, 200),
|
||||||
|
type: "question" as const,
|
||||||
|
href: `/admin/questions?id=${r.id}`,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchTextbooks(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: textbooks.id,
|
||||||
|
title: textbooks.title,
|
||||||
|
subject: textbooks.subject,
|
||||||
|
grade: textbooks.grade,
|
||||||
|
publisher: textbooks.publisher,
|
||||||
|
createdAt: textbooks.createdAt,
|
||||||
|
})
|
||||||
|
.from(textbooks)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(textbooks.title, kw),
|
||||||
|
like(textbooks.subject, kw),
|
||||||
|
like(textbooks.publisher, kw)
|
||||||
|
)!
|
||||||
|
)
|
||||||
|
.orderBy(desc(textbooks.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
snippet: [r.subject, r.grade, r.publisher].filter(Boolean).join(" · "),
|
||||||
|
type: "textbook" as const,
|
||||||
|
href: `/admin/textbooks?id=${r.id}`,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchExams(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: exams.id,
|
||||||
|
title: exams.title,
|
||||||
|
description: exams.description,
|
||||||
|
status: exams.status,
|
||||||
|
createdAt: exams.createdAt,
|
||||||
|
})
|
||||||
|
.from(exams)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(exams.title, kw),
|
||||||
|
like(exams.description, kw)
|
||||||
|
)!
|
||||||
|
)
|
||||||
|
.orderBy(desc(exams.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
snippet: r.description ?? `Status: ${r.status ?? "draft"}`,
|
||||||
|
type: "exam" as const,
|
||||||
|
href: `/admin/exams?id=${r.id}`,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchAnnouncements(kw: string, limit: number): Promise<SearchResultItem[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: announcements.id,
|
||||||
|
title: announcements.title,
|
||||||
|
content: announcements.content,
|
||||||
|
type: announcements.type,
|
||||||
|
status: announcements.status,
|
||||||
|
createdAt: announcements.createdAt,
|
||||||
|
})
|
||||||
|
.from(announcements)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(announcements.status, "published"),
|
||||||
|
or(
|
||||||
|
like(announcements.title, kw),
|
||||||
|
like(announcements.content, kw)
|
||||||
|
)!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(announcements.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
snippet: truncate(stripHtml(r.content), 200),
|
||||||
|
type: "announcement" as const,
|
||||||
|
href: `/announcements/${r.id}`,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromJson(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content
|
||||||
|
try {
|
||||||
|
return JSON.stringify(content)
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
const t = s.trim()
|
||||||
|
if (t.length <= max) return t
|
||||||
|
return t.slice(0, max) + "..."
|
||||||
|
}
|
||||||
128
src/app/api/upload/route.ts
Normal file
128
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
import { mkdir, writeFile } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
|
import {
|
||||||
|
generateStoragePath,
|
||||||
|
isAllowedMimeType,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
} from "@/shared/lib/file-storage"
|
||||||
|
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||||
|
import { createFileAttachment } from "@/modules/files/data-access"
|
||||||
|
import type { FileTargetType } from "@/modules/files/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const VALID_TARGET_TYPES: FileTargetType[] = [
|
||||||
|
"exam",
|
||||||
|
"textbook",
|
||||||
|
"question",
|
||||||
|
"announcement",
|
||||||
|
]
|
||||||
|
|
||||||
|
const isTargetType = (v: string | null): v is FileTargetType =>
|
||||||
|
v !== null && (VALID_TARGET_TYPES as readonly string[]).includes(v)
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await requireAuth()
|
||||||
|
const uploaderId = ctx.userId
|
||||||
|
|
||||||
|
// Rate limit uploads per user
|
||||||
|
const limitKey = rateLimitKey("upload", uploaderId)
|
||||||
|
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.UPLOAD })
|
||||||
|
if (!limit.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Upload rate limit exceeded. Please try again later." },
|
||||||
|
{ status: 429, headers: rateLimitHeaders(limit) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get("file")
|
||||||
|
const targetType = formData.get("targetType")
|
||||||
|
const targetId = formData.get("targetId")
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "No file provided" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "File is empty" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "File size exceeds 10MB limit" },
|
||||||
|
{ status: 413 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = file.type || "application/octet-stream"
|
||||||
|
if (!isAllowedMimeType(mimeType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: `File type ${mimeType} is not allowed` },
|
||||||
|
{ status: 415 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalName = file.name || "unnamed"
|
||||||
|
const storagePath = generateStoragePath(originalName)
|
||||||
|
const absoluteDir = path.join(process.cwd(), "public", path.dirname(storagePath))
|
||||||
|
await mkdir(absoluteDir, { recursive: true })
|
||||||
|
|
||||||
|
const bytes = Buffer.from(await file.arrayBuffer())
|
||||||
|
const absolutePath = path.join(process.cwd(), "public", storagePath)
|
||||||
|
await writeFile(absolutePath, bytes)
|
||||||
|
|
||||||
|
const url = `/${storagePath}`
|
||||||
|
const id = createId()
|
||||||
|
const filename = path.basename(storagePath)
|
||||||
|
|
||||||
|
const created = await createFileAttachment({
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
originalName,
|
||||||
|
mimeType,
|
||||||
|
size: file.size,
|
||||||
|
storagePath,
|
||||||
|
url,
|
||||||
|
uploaderId,
|
||||||
|
targetType: isTargetType(typeof targetType === "string" ? targetType : null)
|
||||||
|
? (targetType as string)
|
||||||
|
: null,
|
||||||
|
targetId: typeof targetId === "string" && targetId.length > 0 ? targetId : null,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Failed to persist file record" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
id: created.id,
|
||||||
|
url: created.url,
|
||||||
|
filename: created.filename,
|
||||||
|
originalName: created.originalName,
|
||||||
|
size: created.size,
|
||||||
|
mimeType: created.mimeType,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Upload failed"
|
||||||
|
const status = message.includes("Permission denied") || message.includes("auth_required")
|
||||||
|
? 401
|
||||||
|
: 500
|
||||||
|
return NextResponse.json({ success: false, message }, { status })
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/auth.ts
186
src/auth.ts
@@ -1,7 +1,14 @@
|
|||||||
import { compare } from "bcryptjs"
|
import { compare } from "bcryptjs"
|
||||||
import NextAuth from "next-auth"
|
import NextAuth from "next-auth"
|
||||||
import Credentials from "next-auth/providers/credentials"
|
import Credentials from "next-auth/providers/credentials"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
import { resolvePermissions } from "@/shared/lib/permissions"
|
import { resolvePermissions } from "@/shared/lib/permissions"
|
||||||
|
import { logLoginEvent } from "@/shared/lib/login-logger"
|
||||||
|
import {
|
||||||
|
PASSWORD_RULES,
|
||||||
|
isAccountLocked,
|
||||||
|
} from "@/shared/lib/password-policy"
|
||||||
|
import { RATE_LIMIT_RULES, rateLimit, rateLimitKey, resetRateLimit } from "@/shared/lib/rate-limit"
|
||||||
|
|
||||||
const normalizeRole = (value: unknown) => {
|
const normalizeRole = (value: unknown) => {
|
||||||
const role = String(value ?? "").trim().toLowerCase()
|
const role = String(value ?? "").trim().toLowerCase()
|
||||||
@@ -25,6 +32,103 @@ const normalizeBcryptHash = (value: string) => {
|
|||||||
return `$2b$${value}`
|
return `$2b$${value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the client IP from request headers (best-effort, used for
|
||||||
|
* rate-limit keying only — not stored).
|
||||||
|
*/
|
||||||
|
const resolveClientIp = async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const { headers } = await import("next/headers")
|
||||||
|
const headerList = await headers()
|
||||||
|
return (
|
||||||
|
headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
|
headerList.get("x-real-ip") ??
|
||||||
|
"unknown"
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a password_security row for a user.
|
||||||
|
*/
|
||||||
|
const getOrCreatePasswordSecurity = async (
|
||||||
|
db: typeof import("@/shared/db")["db"],
|
||||||
|
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||||
|
userId: string
|
||||||
|
) => {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(passwordSecurity)
|
||||||
|
.where(eq(passwordSecurity.userId, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const { createId } = await import("@paralleldrive/cuid2")
|
||||||
|
const id = createId()
|
||||||
|
await db.insert(passwordSecurity).values({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
failedLoginAttempts: 0,
|
||||||
|
})
|
||||||
|
const [created] = await db
|
||||||
|
.select()
|
||||||
|
.from(passwordSecurity)
|
||||||
|
.where(eq(passwordSecurity.userId, userId))
|
||||||
|
.limit(1)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment failed login attempts and lock the account if the threshold
|
||||||
|
* is reached.
|
||||||
|
*/
|
||||||
|
const recordFailedLogin = async (
|
||||||
|
db: typeof import("@/shared/db")["db"],
|
||||||
|
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||||
|
userId: string
|
||||||
|
): Promise<{ locked: boolean; lockedUntil: Date | null }> => {
|
||||||
|
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
|
||||||
|
const nextAttempts = current.failedLoginAttempts + 1
|
||||||
|
const shouldLock = nextAttempts >= PASSWORD_RULES.maxLoginAttempts
|
||||||
|
const lockedUntil = shouldLock
|
||||||
|
? new Date(Date.now() + PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
|
||||||
|
: null
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(passwordSecurity)
|
||||||
|
.set({
|
||||||
|
failedLoginAttempts: nextAttempts,
|
||||||
|
lockedUntil,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(passwordSecurity.userId, userId))
|
||||||
|
|
||||||
|
return { locked: shouldLock, lockedUntil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset failed login attempts on successful login.
|
||||||
|
*/
|
||||||
|
const resetFailedLogin = async (
|
||||||
|
db: typeof import("@/shared/db")["db"],
|
||||||
|
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||||
|
userId: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
|
||||||
|
if (current.failedLoginAttempts === 0 && !current.lockedUntil) return
|
||||||
|
await db
|
||||||
|
.update(passwordSecurity)
|
||||||
|
.set({
|
||||||
|
failedLoginAttempts: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(passwordSecurity.userId, userId))
|
||||||
|
}
|
||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
@@ -41,8 +145,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
const password = String(credentials?.password ?? "")
|
const password = String(credentials?.password ?? "")
|
||||||
if (!email || !password) return null
|
if (!email || !password) return null
|
||||||
|
|
||||||
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
|
// Rate limit by IP + email to slow brute-force attempts
|
||||||
import("drizzle-orm"),
|
const clientIp = await resolveClientIp()
|
||||||
|
const loginLimitKey = rateLimitKey("login", `${clientIp}:${email}`)
|
||||||
|
const limit = rateLimit({
|
||||||
|
key: loginLimitKey,
|
||||||
|
...RATE_LIMIT_RULES.LOGIN,
|
||||||
|
})
|
||||||
|
if (!limit.success) {
|
||||||
|
await logLoginEvent({
|
||||||
|
userEmail: email,
|
||||||
|
action: "signin",
|
||||||
|
status: "failure",
|
||||||
|
errorMessage: "Rate limit exceeded",
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ db }, { users, roles, usersToRoles, passwordSecurity }] = await Promise.all([
|
||||||
import("@/shared/db"),
|
import("@/shared/db"),
|
||||||
import("@/shared/db/schema"),
|
import("@/shared/db/schema"),
|
||||||
])
|
])
|
||||||
@@ -52,12 +172,43 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
})
|
})
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
|
// Account lockout check
|
||||||
|
const security = await getOrCreatePasswordSecurity(db, passwordSecurity, user.id)
|
||||||
|
const lastFailedAt = security.lockedUntil
|
||||||
|
? new Date(security.lockedUntil.getTime() - PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
|
||||||
|
: null
|
||||||
|
if (isAccountLocked(security.failedLoginAttempts, lastFailedAt)) {
|
||||||
|
await logLoginEvent({
|
||||||
|
userId: user.id,
|
||||||
|
userEmail: email,
|
||||||
|
action: "signin",
|
||||||
|
status: "failure",
|
||||||
|
errorMessage: "Account locked",
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const storedPassword = user.password ?? null
|
const storedPassword = user.password ?? null
|
||||||
if (!storedPassword) return null
|
if (!storedPassword) return null
|
||||||
const normalizedPassword = normalizeBcryptHash(storedPassword)
|
const normalizedPassword = normalizeBcryptHash(storedPassword)
|
||||||
if (!normalizedPassword.startsWith("$2")) return null
|
if (!normalizedPassword.startsWith("$2")) return null
|
||||||
const ok = await compare(password, normalizedPassword)
|
const ok = await compare(password, normalizedPassword)
|
||||||
if (!ok) return null
|
|
||||||
|
if (!ok) {
|
||||||
|
await recordFailedLogin(db, passwordSecurity, user.id)
|
||||||
|
await logLoginEvent({
|
||||||
|
userId: user.id,
|
||||||
|
userEmail: email,
|
||||||
|
action: "signin",
|
||||||
|
status: "failure",
|
||||||
|
errorMessage: "Invalid credentials",
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful login: reset counters and rate limit
|
||||||
|
await resetFailedLogin(db, passwordSecurity, user.id)
|
||||||
|
resetRateLimit(loginLimitKey)
|
||||||
|
|
||||||
const roleRows = await db
|
const roleRows = await db
|
||||||
.select({ name: roles.name })
|
.select({ name: roles.name })
|
||||||
@@ -136,4 +287,33 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
events: {
|
||||||
|
async signIn({ user }) {
|
||||||
|
await logLoginEvent({
|
||||||
|
userId: user.id,
|
||||||
|
userEmail: user.email ?? "",
|
||||||
|
action: "signin",
|
||||||
|
status: "success",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async signOut(message) {
|
||||||
|
// NextAuth v5 signOut event receives the session/token info
|
||||||
|
const userId =
|
||||||
|
(message as { userId?: string })?.userId ??
|
||||||
|
(message as { token?: { id?: string } })?.token?.id ??
|
||||||
|
""
|
||||||
|
const userEmail =
|
||||||
|
(message as { token?: { email?: string } })?.token?.email ??
|
||||||
|
(message as { session?: { user?: { email?: string } } })?.session?.user?.email ??
|
||||||
|
""
|
||||||
|
if (userEmail) {
|
||||||
|
await logLoginEvent({
|
||||||
|
userId: userId || undefined,
|
||||||
|
userEmail,
|
||||||
|
action: "signout",
|
||||||
|
status: "success",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
242
src/modules/announcements/actions.ts
Normal file
242
src/modules/announcements/actions.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { announcements } from "@/shared/db/schema"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
|
import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
|
||||||
|
import { getAnnouncements, getAnnouncementById } from "./data-access"
|
||||||
|
import type { GetAnnouncementsParams, Announcement } from "./types"
|
||||||
|
|
||||||
|
export async function createAnnouncementAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||||||
|
|
||||||
|
const parsed = CreateAnnouncementSchema.safeParse({
|
||||||
|
title: formData.get("title"),
|
||||||
|
content: formData.get("content"),
|
||||||
|
type: formData.get("type") || undefined,
|
||||||
|
status: formData.get("status") || undefined,
|
||||||
|
targetGradeId: formData.get("targetGradeId") || undefined,
|
||||||
|
targetClassId: formData.get("targetClassId") || undefined,
|
||||||
|
publishedAt: formData.get("publishedAt") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = parsed.data
|
||||||
|
const isPublished = input.status === "published"
|
||||||
|
const publishedAt = isPublished
|
||||||
|
? input.publishedAt
|
||||||
|
? new Date(input.publishedAt)
|
||||||
|
: new Date()
|
||||||
|
: input.publishedAt
|
||||||
|
? new Date(input.publishedAt)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const id = createId()
|
||||||
|
await db.insert(announcements).values({
|
||||||
|
id,
|
||||||
|
title: input.title,
|
||||||
|
content: input.content,
|
||||||
|
type: input.type,
|
||||||
|
status: input.status,
|
||||||
|
targetGradeId: input.targetGradeId,
|
||||||
|
targetClassId: input.targetClassId,
|
||||||
|
authorId: ctx.userId,
|
||||||
|
publishedAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/admin/announcements")
|
||||||
|
revalidatePath("/announcements")
|
||||||
|
|
||||||
|
return { success: true, message: "Announcement created", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: e.message }
|
||||||
|
}
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAnnouncementAction(
|
||||||
|
id: string,
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||||||
|
|
||||||
|
const existing = await getAnnouncementById(id)
|
||||||
|
if (!existing) return { success: false, message: "Announcement not found" }
|
||||||
|
|
||||||
|
const parsed = UpdateAnnouncementSchema.safeParse({
|
||||||
|
title: formData.get("title"),
|
||||||
|
content: formData.get("content"),
|
||||||
|
type: formData.get("type") || undefined,
|
||||||
|
status: formData.get("status") || undefined,
|
||||||
|
targetGradeId: formData.get("targetGradeId") || undefined,
|
||||||
|
targetClassId: formData.get("targetClassId") || undefined,
|
||||||
|
publishedAt: formData.get("publishedAt") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = parsed.data
|
||||||
|
const wasPublished = existing.status === "published"
|
||||||
|
const isPublished = input.status === "published"
|
||||||
|
const publishedAt = isPublished
|
||||||
|
? existing.publishedAt
|
||||||
|
? new Date(existing.publishedAt)
|
||||||
|
: new Date()
|
||||||
|
: input.publishedAt
|
||||||
|
? new Date(input.publishedAt)
|
||||||
|
: null
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(announcements)
|
||||||
|
.set({
|
||||||
|
title: input.title,
|
||||||
|
content: input.content,
|
||||||
|
type: input.type,
|
||||||
|
status: input.status,
|
||||||
|
targetGradeId: input.targetGradeId,
|
||||||
|
targetClassId: input.targetClassId,
|
||||||
|
publishedAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(announcements.id, id))
|
||||||
|
|
||||||
|
revalidatePath("/admin/announcements")
|
||||||
|
revalidatePath(`/admin/announcements/${id}`)
|
||||||
|
revalidatePath("/announcements")
|
||||||
|
void wasPublished
|
||||||
|
|
||||||
|
return { success: true, message: "Announcement updated", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: e.message }
|
||||||
|
}
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAnnouncementAction(id: string): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||||||
|
|
||||||
|
const existing = await getAnnouncementById(id)
|
||||||
|
if (!existing) return { success: false, message: "Announcement not found" }
|
||||||
|
|
||||||
|
await db.delete(announcements).where(eq(announcements.id, id))
|
||||||
|
|
||||||
|
revalidatePath("/admin/announcements")
|
||||||
|
revalidatePath("/announcements")
|
||||||
|
|
||||||
|
return { success: true, message: "Announcement deleted" }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: e.message }
|
||||||
|
}
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishAnnouncementAction(id: string): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||||||
|
|
||||||
|
const existing = await getAnnouncementById(id)
|
||||||
|
if (!existing) return { success: false, message: "Announcement not found" }
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(announcements)
|
||||||
|
.set({
|
||||||
|
status: "published",
|
||||||
|
publishedAt: existing.publishedAt ? new Date(existing.publishedAt) : new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(announcements.id, id))
|
||||||
|
|
||||||
|
revalidatePath("/admin/announcements")
|
||||||
|
revalidatePath(`/admin/announcements/${id}`)
|
||||||
|
revalidatePath("/announcements")
|
||||||
|
|
||||||
|
return { success: true, message: "Announcement published" }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: e.message }
|
||||||
|
}
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveAnnouncementAction(id: string): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||||||
|
|
||||||
|
const existing = await getAnnouncementById(id)
|
||||||
|
if (!existing) return { success: false, message: "Announcement not found" }
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(announcements)
|
||||||
|
.set({
|
||||||
|
status: "archived",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(announcements.id, id))
|
||||||
|
|
||||||
|
revalidatePath("/admin/announcements")
|
||||||
|
revalidatePath(`/admin/announcements/${id}`)
|
||||||
|
revalidatePath("/announcements")
|
||||||
|
|
||||||
|
return { success: true, message: "Announcement archived" }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: e.message }
|
||||||
|
}
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnnouncementsAction(
|
||||||
|
params?: GetAnnouncementsParams
|
||||||
|
): Promise<ActionState<Announcement[]>> {
|
||||||
|
try {
|
||||||
|
await requireAuth()
|
||||||
|
const data = await getAnnouncements(params)
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: e.message }
|
||||||
|
}
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { PlusCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||||
|
|
||||||
|
import { AnnouncementForm } from "./announcement-form"
|
||||||
|
import { AnnouncementList } from "./announcement-list"
|
||||||
|
import type { Announcement, AnnouncementStatus } from "../types"
|
||||||
|
|
||||||
|
export function AdminAnnouncementsView({
|
||||||
|
announcements,
|
||||||
|
grades = [],
|
||||||
|
classes = [],
|
||||||
|
initialStatus,
|
||||||
|
}: {
|
||||||
|
announcements: Announcement[]
|
||||||
|
grades?: { id: string; name: string }[]
|
||||||
|
classes?: { id: string; name: string }[]
|
||||||
|
initialStatus?: AnnouncementStatus
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setCreateOpen(open)
|
||||||
|
if (!open) router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Announcements</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Create and manage school-wide announcements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
New Announcement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnnouncementList
|
||||||
|
announcements={announcements}
|
||||||
|
canManage
|
||||||
|
initialStatus={initialStatus}
|
||||||
|
detailHrefBuilder={(id) => `/admin/announcements/${id}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Announcement</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<AnnouncementForm mode="create" grades={grades} classes={classes} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
src/modules/announcements/components/announcement-card.tsx
Normal file
79
src/modules/announcements/components/announcement-card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import type { Announcement } from "../types"
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<Announcement["status"], string> = {
|
||||||
|
draft: "Draft",
|
||||||
|
published: "Published",
|
||||||
|
archived: "Archived",
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<
|
||||||
|
Announcement["status"],
|
||||||
|
"default" | "secondary" | "outline"
|
||||||
|
> = {
|
||||||
|
draft: "secondary",
|
||||||
|
published: "default",
|
||||||
|
archived: "outline",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<Announcement["type"], string> = {
|
||||||
|
school: "School",
|
||||||
|
grade: "Grade",
|
||||||
|
class: "Class",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementCard({
|
||||||
|
announcement,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
announcement: Announcement
|
||||||
|
href?: string
|
||||||
|
}) {
|
||||||
|
const card = useMemo(
|
||||||
|
() => (
|
||||||
|
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="line-clamp-2 text-base">{announcement.title}</CardTitle>
|
||||||
|
<Badge variant={STATUS_VARIANT[announcement.status]} className="shrink-0">
|
||||||
|
{STATUS_LABEL[announcement.status]}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="line-clamp-3 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{announcement.content}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{TYPE_LABEL[announcement.type]}
|
||||||
|
</Badge>
|
||||||
|
<span>
|
||||||
|
{announcement.publishedAt
|
||||||
|
? `Published ${formatDate(announcement.publishedAt)}`
|
||||||
|
: `Updated ${formatDate(announcement.updatedAt)}`}
|
||||||
|
</span>
|
||||||
|
{announcement.authorName ? (
|
||||||
|
<span className="ml-auto">by {announcement.authorName}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
[announcement]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="block h-full">
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return card
|
||||||
|
}
|
||||||
206
src/modules/announcements/components/announcement-detail.tsx
Normal file
206
src/modules/announcements/components/announcement-detail.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowLeft,
|
||||||
|
Megaphone,
|
||||||
|
Pencil,
|
||||||
|
Send,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import {
|
||||||
|
archiveAnnouncementAction,
|
||||||
|
deleteAnnouncementAction,
|
||||||
|
publishAnnouncementAction,
|
||||||
|
} from "../actions"
|
||||||
|
import type { Announcement } from "../types"
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<Announcement["status"], string> = {
|
||||||
|
draft: "Draft",
|
||||||
|
published: "Published",
|
||||||
|
archived: "Archived",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<Announcement["type"], string> = {
|
||||||
|
school: "School",
|
||||||
|
grade: "Grade",
|
||||||
|
class: "Class",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementDetail({
|
||||||
|
announcement,
|
||||||
|
canManage,
|
||||||
|
editHref,
|
||||||
|
backHref,
|
||||||
|
}: {
|
||||||
|
announcement: Announcement
|
||||||
|
canManage?: boolean
|
||||||
|
editHref?: string
|
||||||
|
backHref?: string
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await publishAnnouncementAction(announcement.id)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to publish")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to publish")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await archiveAnnouncementAction(announcement.id)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to archive")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to archive")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await deleteAnnouncementAction(announcement.id)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
router.push("/admin/announcements")
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to delete")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
setDeleteOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{backHref ? (
|
||||||
|
<Button asChild variant="ghost" size="icon">
|
||||||
|
<a href={backHref}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Announcement</h2>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{announcement.status !== "published" ? (
|
||||||
|
<Button onClick={handlePublish} disabled={isWorking} variant="outline">
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{announcement.status !== "archived" ? (
|
||||||
|
<Button onClick={handleArchive} disabled={isWorking} variant="outline">
|
||||||
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{editHref ? (
|
||||||
|
<Button asChild>
|
||||||
|
<a href={editHref}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
disabled={isWorking}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{TYPE_LABEL[announcement.type]}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="capitalize">{STATUS_LABEL[announcement.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">{announcement.title}</CardTitle>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Megaphone className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{announcement.publishedAt
|
||||||
|
? `Published ${formatDate(announcement.publishedAt)}`
|
||||||
|
: `Created ${formatDate(announcement.createdAt)}`}
|
||||||
|
</span>
|
||||||
|
{announcement.authorName ? <span>by {announcement.authorName}</span> : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">{announcement.content}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete announcement</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{announcement.title}".
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
201
src/modules/announcements/components/announcement-form.tsx
Normal file
201
src/modules/announcements/components/announcement-form.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
|
||||||
|
import { createAnnouncementAction, updateAnnouncementAction } from "../actions"
|
||||||
|
import type { Announcement } from "../types"
|
||||||
|
|
||||||
|
type Mode = "create" | "edit"
|
||||||
|
|
||||||
|
export function AnnouncementForm({
|
||||||
|
mode,
|
||||||
|
announcement,
|
||||||
|
grades = [],
|
||||||
|
classes = [],
|
||||||
|
}: {
|
||||||
|
mode: Mode
|
||||||
|
announcement?: Announcement
|
||||||
|
grades?: { id: string; name: string }[]
|
||||||
|
classes?: { id: string; name: string }[]
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
|
const [type, setType] = useState<string>(announcement?.type ?? "school")
|
||||||
|
const [status, setStatus] = useState<string>(announcement?.status ?? "draft")
|
||||||
|
const [targetGradeId, setTargetGradeId] = useState<string>(announcement?.targetGradeId ?? "")
|
||||||
|
const [targetClassId, setTargetClassId] = useState<string>(announcement?.targetClassId ?? "")
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData) => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
formData.set("type", type)
|
||||||
|
formData.set("status", status)
|
||||||
|
if (type === "grade" && targetGradeId) {
|
||||||
|
formData.set("targetGradeId", targetGradeId)
|
||||||
|
}
|
||||||
|
if (type === "class" && targetClassId) {
|
||||||
|
formData.set("targetClassId", targetClassId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res =
|
||||||
|
mode === "create"
|
||||||
|
? await createAnnouncementAction(null, formData)
|
||||||
|
: announcement
|
||||||
|
? await updateAnnouncementAction(announcement.id, null, formData)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
toast.error("Invalid form state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
router.push("/admin/announcements")
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to save announcement")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save announcement")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{mode === "create" ? "New Announcement" : "Edit Announcement"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
placeholder="Announcement title"
|
||||||
|
defaultValue={announcement?.title ?? ""}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="content">Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
placeholder="Write the announcement content..."
|
||||||
|
className="min-h-[160px]"
|
||||||
|
defaultValue={announcement?.content ?? ""}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="school">School</SelectItem>
|
||||||
|
<SelectItem value="grade">Grade</SelectItem>
|
||||||
|
<SelectItem value="class">Class</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="type" value={type} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select value={status} onValueChange={setStatus}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="published">Published</SelectItem>
|
||||||
|
<SelectItem value="archived">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="status" value={status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === "grade" ? (
|
||||||
|
<div className="grid gap-2 md:col-span-2">
|
||||||
|
<Label>Target Grade</Label>
|
||||||
|
<Select value={targetGradeId} onValueChange={setTargetGradeId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a grade (optional)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{grades.map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="targetGradeId" value={targetGradeId} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === "class" ? (
|
||||||
|
<div className="grid gap-2 md:col-span-2">
|
||||||
|
<Label>Target Class</Label>
|
||||||
|
<Select value={targetClassId} onValueChange={setTargetClassId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a class (optional)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="targetClassId" value={targetClassId} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/admin/announcements")}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking}>
|
||||||
|
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
src/modules/announcements/components/announcement-list.tsx
Normal file
108
src/modules/announcements/components/announcement-list.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import { Megaphone } from "lucide-react"
|
||||||
|
|
||||||
|
import { AnnouncementCard } from "./announcement-card"
|
||||||
|
import type { Announcement, AnnouncementStatus } from "../types"
|
||||||
|
|
||||||
|
type Filter = "all" | AnnouncementStatus
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "published", label: "Published" },
|
||||||
|
{ value: "draft", label: "Draft" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AnnouncementList({
|
||||||
|
announcements,
|
||||||
|
canManage,
|
||||||
|
createHref,
|
||||||
|
detailHrefBuilder,
|
||||||
|
initialStatus,
|
||||||
|
}: {
|
||||||
|
announcements: Announcement[]
|
||||||
|
canManage?: boolean
|
||||||
|
createHref?: string
|
||||||
|
detailHrefBuilder?: (id: string) => string
|
||||||
|
initialStatus?: Filter
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [filter, setFilter] = useState<Filter>(initialStatus ?? "all")
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === "all") return announcements
|
||||||
|
return announcements.filter((a) => a.status === filter)
|
||||||
|
}, [announcements, filter])
|
||||||
|
|
||||||
|
const handleFilterChange = (value: string) => {
|
||||||
|
setFilter(value as Filter)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (value !== "all") params.set("status", value)
|
||||||
|
const qs = params.toString()
|
||||||
|
router.replace(qs ? `?${qs}` : "?")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Select value={filter} onValueChange={handleFilterChange}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{canManage && createHref ? (
|
||||||
|
<Button asChild>
|
||||||
|
<a href={createHref}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Announcement
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No announcements"
|
||||||
|
description={
|
||||||
|
announcements.length === 0
|
||||||
|
? "There are no announcements yet."
|
||||||
|
: "No announcements match the current filter."
|
||||||
|
}
|
||||||
|
icon={Megaphone}
|
||||||
|
className="h-auto border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filtered.map((a) => (
|
||||||
|
<AnnouncementCard
|
||||||
|
key={a.id}
|
||||||
|
announcement={a}
|
||||||
|
href={detailHrefBuilder ? detailHrefBuilder(a.id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/modules/announcements/data-access.ts
Normal file
120
src/modules/announcements/data-access.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { and, desc, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { announcements, users } from "@/shared/db/schema"
|
||||||
|
import type {
|
||||||
|
Announcement,
|
||||||
|
AnnouncementStatus,
|
||||||
|
AnnouncementType,
|
||||||
|
GetAnnouncementsParams,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const toIso = (d: Date | null | undefined): string | null =>
|
||||||
|
d ? d.toISOString() : null
|
||||||
|
|
||||||
|
const mapRow = (
|
||||||
|
row: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
type: "school" | "grade" | "class"
|
||||||
|
status: "draft" | "published" | "archived"
|
||||||
|
targetGradeId: string | null
|
||||||
|
targetClassId: string | null
|
||||||
|
authorId: string
|
||||||
|
authorName: string | null
|
||||||
|
publishedAt: Date | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
): Announcement => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
content: row.content,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
targetGradeId: row.targetGradeId,
|
||||||
|
targetClassId: row.targetClassId,
|
||||||
|
authorId: row.authorId,
|
||||||
|
authorName: row.authorName,
|
||||||
|
publishedAt: toIso(row.publishedAt),
|
||||||
|
createdAt: toIso(row.createdAt) as string,
|
||||||
|
updatedAt: toIso(row.updatedAt) as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getAnnouncements = cache(
|
||||||
|
async (params?: GetAnnouncementsParams): Promise<Announcement[]> => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, params?.page ?? 1)
|
||||||
|
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
|
||||||
|
const conditions = []
|
||||||
|
if (params?.status) {
|
||||||
|
conditions.push(eq(announcements.status, params.status as AnnouncementStatus))
|
||||||
|
}
|
||||||
|
if (params?.type) {
|
||||||
|
conditions.push(eq(announcements.type, params.type as AnnouncementType))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: announcements.id,
|
||||||
|
title: announcements.title,
|
||||||
|
content: announcements.content,
|
||||||
|
type: announcements.type,
|
||||||
|
status: announcements.status,
|
||||||
|
targetGradeId: announcements.targetGradeId,
|
||||||
|
targetClassId: announcements.targetClassId,
|
||||||
|
authorId: announcements.authorId,
|
||||||
|
authorName: users.name,
|
||||||
|
publishedAt: announcements.publishedAt,
|
||||||
|
createdAt: announcements.createdAt,
|
||||||
|
updatedAt: announcements.updatedAt,
|
||||||
|
})
|
||||||
|
.from(announcements)
|
||||||
|
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(announcements.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset)
|
||||||
|
|
||||||
|
return rows.map(mapRow)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getAnnouncementById = cache(
|
||||||
|
async (id: string): Promise<Announcement | null> => {
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: announcements.id,
|
||||||
|
title: announcements.title,
|
||||||
|
content: announcements.content,
|
||||||
|
type: announcements.type,
|
||||||
|
status: announcements.status,
|
||||||
|
targetGradeId: announcements.targetGradeId,
|
||||||
|
targetClassId: announcements.targetClassId,
|
||||||
|
authorId: announcements.authorId,
|
||||||
|
authorName: users.name,
|
||||||
|
publishedAt: announcements.publishedAt,
|
||||||
|
createdAt: announcements.createdAt,
|
||||||
|
updatedAt: announcements.updatedAt,
|
||||||
|
})
|
||||||
|
.from(announcements)
|
||||||
|
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||||
|
.where(eq(announcements.id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return row ? mapRow(row) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
45
src/modules/announcements/schema.ts
Normal file
45
src/modules/announcements/schema.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const CreateAnnouncementSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().trim().min(1).max(255),
|
||||||
|
content: z.string().trim().min(1),
|
||||||
|
type: z.enum(["school", "grade", "class"]).optional(),
|
||||||
|
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||||
|
targetGradeId: z.string().trim().optional().nullable(),
|
||||||
|
targetClassId: z.string().trim().optional().nullable(),
|
||||||
|
publishedAt: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform((v) => ({
|
||||||
|
title: v.title,
|
||||||
|
content: v.content,
|
||||||
|
type: v.type ?? "school",
|
||||||
|
status: v.status ?? "draft",
|
||||||
|
targetGradeId: v.targetGradeId && v.targetGradeId.length > 0 ? v.targetGradeId : null,
|
||||||
|
targetClassId: v.targetClassId && v.targetClassId.length > 0 ? v.targetClassId : null,
|
||||||
|
publishedAt: v.publishedAt && v.publishedAt.length > 0 ? v.publishedAt : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export type CreateAnnouncementInput = z.infer<typeof CreateAnnouncementSchema>
|
||||||
|
|
||||||
|
export const UpdateAnnouncementSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().trim().min(1).max(255),
|
||||||
|
content: z.string().trim().min(1),
|
||||||
|
type: z.enum(["school", "grade", "class"]).optional(),
|
||||||
|
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||||
|
targetGradeId: z.string().trim().optional().nullable(),
|
||||||
|
targetClassId: z.string().trim().optional().nullable(),
|
||||||
|
publishedAt: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform((v) => ({
|
||||||
|
title: v.title,
|
||||||
|
content: v.content,
|
||||||
|
type: v.type ?? "school",
|
||||||
|
status: v.status ?? "draft",
|
||||||
|
targetGradeId: v.targetGradeId && v.targetGradeId.length > 0 ? v.targetGradeId : null,
|
||||||
|
targetClassId: v.targetClassId && v.targetClassId.length > 0 ? v.targetClassId : null,
|
||||||
|
publishedAt: v.publishedAt && v.publishedAt.length > 0 ? v.publishedAt : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export type UpdateAnnouncementInput = z.infer<typeof UpdateAnnouncementSchema>
|
||||||
27
src/modules/announcements/types.ts
Normal file
27
src/modules/announcements/types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export type AnnouncementStatus = "draft" | "published" | "archived"
|
||||||
|
|
||||||
|
export type AnnouncementType = "school" | "grade" | "class"
|
||||||
|
|
||||||
|
export interface Announcement {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
type: AnnouncementType
|
||||||
|
status: AnnouncementStatus
|
||||||
|
targetGradeId: string | null
|
||||||
|
targetClassId: string | null
|
||||||
|
authorId: string
|
||||||
|
authorName: string | null
|
||||||
|
publishedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnnouncementListItem = Announcement
|
||||||
|
|
||||||
|
export type GetAnnouncementsParams = {
|
||||||
|
status?: AnnouncementStatus
|
||||||
|
type?: AnnouncementType
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
271
src/modules/attendance/actions.ts
Normal file
271
src/modules/attendance/actions.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"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 {
|
||||||
|
RecordAttendanceSchema,
|
||||||
|
BatchRecordAttendanceSchema,
|
||||||
|
UpdateAttendanceSchema,
|
||||||
|
AttendanceRuleSchema,
|
||||||
|
} from "./schema"
|
||||||
|
import {
|
||||||
|
createAttendanceRecord,
|
||||||
|
batchCreateAttendanceRecords,
|
||||||
|
updateAttendanceRecord,
|
||||||
|
deleteAttendanceRecord,
|
||||||
|
getAttendanceRecords,
|
||||||
|
getClassAttendanceForDate,
|
||||||
|
getAttendanceRules,
|
||||||
|
upsertAttendanceRules,
|
||||||
|
} from "./data-access"
|
||||||
|
import {
|
||||||
|
getStudentAttendanceSummary,
|
||||||
|
getClassAttendanceStats,
|
||||||
|
} from "./data-access-stats"
|
||||||
|
import type { AttendanceQueryParams, AttendanceListItem } from "./types"
|
||||||
|
|
||||||
|
export async function recordAttendanceAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||||
|
|
||||||
|
const parsed = RecordAttendanceSchema.safeParse({
|
||||||
|
studentId: formData.get("studentId"),
|
||||||
|
classId: formData.get("classId"),
|
||||||
|
date: formData.get("date"),
|
||||||
|
status: formData.get("status"),
|
||||||
|
remark: formData.get("remark") || undefined,
|
||||||
|
scheduleId: formData.get("scheduleId") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await createAttendanceRecord(parsed.data, ctx.userId)
|
||||||
|
revalidatePath("/teacher/attendance")
|
||||||
|
return { success: true, message: "Attendance recorded", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchRecordAttendanceAction(
|
||||||
|
prevState: ActionState<number> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<number>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||||
|
|
||||||
|
const recordsJson = formData.get("recordsJson")
|
||||||
|
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
|
||||||
|
return { success: false, message: "Missing records data" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = BatchRecordAttendanceSchema.safeParse({
|
||||||
|
records: JSON.parse(recordsJson),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await batchCreateAttendanceRecords(parsed.data, ctx.userId)
|
||||||
|
revalidatePath("/teacher/attendance")
|
||||||
|
return { success: true, message: `Recorded attendance for ${count} students`, data: count }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAttendanceAction(
|
||||||
|
id: string,
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||||
|
|
||||||
|
const parsed = UpdateAttendanceSchema.safeParse({
|
||||||
|
status: formData.get("status") || undefined,
|
||||||
|
remark: formData.get("remark") || undefined,
|
||||||
|
scheduleId: formData.get("scheduleId") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAttendanceRecord(id, parsed.data)
|
||||||
|
revalidatePath("/teacher/attendance")
|
||||||
|
return { success: true, message: "Attendance updated" }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAttendanceAction(
|
||||||
|
id: string
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||||
|
await deleteAttendanceRecord(id)
|
||||||
|
revalidatePath("/teacher/attendance")
|
||||||
|
return { success: true, message: "Attendance record deleted" }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttendanceAction(
|
||||||
|
params: AttendanceQueryParams
|
||||||
|
): Promise<ActionState<{ items: AttendanceListItem[]; total: number; page: number; pageSize: number; totalPages: number }>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ATTENDANCE_READ)
|
||||||
|
const result = await getAttendanceRecords({
|
||||||
|
...params,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
currentUserId: ctx.userId,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: result.items,
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStudentAttendanceAction(
|
||||||
|
studentId: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string
|
||||||
|
): Promise<ActionState<Awaited<ReturnType<typeof getStudentAttendanceSummary>>>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ATTENDANCE_READ)
|
||||||
|
|
||||||
|
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||||
|
return { success: false, message: "Can only view your own attendance" }
|
||||||
|
}
|
||||||
|
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||||
|
return { success: false, message: "Can only view your children's attendance" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await getStudentAttendanceSummary(studentId, startDate, endDate)
|
||||||
|
return { success: true, data: summary }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClassAttendanceStatsAction(
|
||||||
|
classId: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string
|
||||||
|
): Promise<ActionState<Awaited<ReturnType<typeof getClassAttendanceStats>>>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||||
|
const result = await getClassAttendanceStats(classId, startDate, endDate)
|
||||||
|
return { success: true, data: result }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClassAttendanceForDateAction(
|
||||||
|
classId: string,
|
||||||
|
date: string
|
||||||
|
): Promise<ActionState<AttendanceListItem[]>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||||
|
const records = await getClassAttendanceForDate(classId, date)
|
||||||
|
return { success: true, data: records }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAttendanceRulesAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||||
|
|
||||||
|
const parsed = AttendanceRuleSchema.safeParse({
|
||||||
|
classId: formData.get("classId"),
|
||||||
|
lateThresholdMinutes: formData.get("lateThresholdMinutes") || undefined,
|
||||||
|
earlyLeaveThresholdMinutes: formData.get("earlyLeaveThresholdMinutes") || undefined,
|
||||||
|
enableAutoMark: formData.get("enableAutoMark") === "true",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await upsertAttendanceRules(parsed.data)
|
||||||
|
revalidatePath("/teacher/attendance")
|
||||||
|
return { success: true, message: "Attendance rules saved", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttendanceRulesAction(
|
||||||
|
classId?: string
|
||||||
|
): Promise<ActionState<Awaited<ReturnType<typeof getAttendanceRules>>>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||||
|
const rules = await getAttendanceRules(classId)
|
||||||
|
return { success: true, data: rules }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/modules/attendance/components/attendance-filters.tsx
Normal file
97
src/modules/attendance/components/attendance-filters.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useCallback } from "react"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
|
||||||
|
type Option = { id: string; name: string }
|
||||||
|
|
||||||
|
interface AttendanceFiltersProps {
|
||||||
|
classes: Option[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "present", label: "Present" },
|
||||||
|
{ value: "absent", label: "Absent" },
|
||||||
|
{ value: "late", label: "Late" },
|
||||||
|
{ value: "early_leave", label: "Early Leave" },
|
||||||
|
{ value: "excused", label: "Excused" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const updateParam = useCallback(
|
||||||
|
(key: string, value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
if (value && value !== "all") {
|
||||||
|
params.set(key, value)
|
||||||
|
} else {
|
||||||
|
params.delete(key)
|
||||||
|
}
|
||||||
|
router.push(`?${params.toString()}`)
|
||||||
|
},
|
||||||
|
[router, searchParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
const classId = searchParams.get("classId") ?? "all"
|
||||||
|
const status = searchParams.get("status") ?? "all"
|
||||||
|
const date = searchParams.get("date") ?? ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs">Class</Label>
|
||||||
|
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="All classes" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All classes</SelectItem>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs">Status</Label>
|
||||||
|
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs">Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => updateParam("date", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/modules/attendance/components/attendance-record-list.tsx
Normal file
130
src/modules/attendance/components/attendance-record-list.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import { deleteAttendanceAction } from "../actions"
|
||||||
|
import {
|
||||||
|
ATTENDANCE_STATUS_COLORS,
|
||||||
|
ATTENDANCE_STATUS_LABELS,
|
||||||
|
type AttendanceListItem,
|
||||||
|
} from "../types"
|
||||||
|
|
||||||
|
export function AttendanceRecordList({ records }: { records: AttendanceListItem[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return
|
||||||
|
setIsDeleting(true)
|
||||||
|
const result = await deleteAttendanceAction(deleteId)
|
||||||
|
setIsDeleting(false)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
setDeleteId(null)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No attendance records found.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Student</TableHead>
|
||||||
|
<TableHead>Class</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Remark</TableHead>
|
||||||
|
<TableHead>Recorded By</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-12"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="font-medium">{r.studentName}</TableCell>
|
||||||
|
<TableCell>{r.className}</TableCell>
|
||||||
|
<TableCell>{r.date}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
|
||||||
|
{ATTENDANCE_STATUS_LABELS[r.status]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-muted-foreground">
|
||||||
|
{r.remark ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => setDeleteId(r.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Attendance Record</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this attendance record? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/modules/attendance/components/attendance-rules-form.tsx
Normal file
148
src/modules/attendance/components/attendance-rules-form.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useFormStatus } from "react-dom"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
|
||||||
|
import { saveAttendanceRulesAction } from "../actions"
|
||||||
|
import type { AttendanceRule } from "../types"
|
||||||
|
|
||||||
|
type Option = { id: string; name: string }
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
return (
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Saving..." : "Save Rules"}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceRulesForm({
|
||||||
|
classes,
|
||||||
|
existingRules,
|
||||||
|
}: {
|
||||||
|
classes: Option[]
|
||||||
|
existingRules: AttendanceRule[]
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [classId, setClassId] = useState(classes[0]?.id ?? "")
|
||||||
|
const [lateThreshold, setLateThreshold] = useState("15")
|
||||||
|
const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15")
|
||||||
|
const [enableAutoMark, setEnableAutoMark] = useState(false)
|
||||||
|
|
||||||
|
const handleClassChange = (id: string) => {
|
||||||
|
setClassId(id)
|
||||||
|
const rule = existingRules.find((r) => r.classId === id)
|
||||||
|
if (rule) {
|
||||||
|
setLateThreshold(String(rule.lateThresholdMinutes ?? 15))
|
||||||
|
setEarlyLeaveThreshold(String(rule.earlyLeaveThresholdMinutes ?? 15))
|
||||||
|
setEnableAutoMark(rule.enableAutoMark ?? false)
|
||||||
|
} else {
|
||||||
|
setLateThreshold("15")
|
||||||
|
setEarlyLeaveThreshold("15")
|
||||||
|
setEnableAutoMark(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData) => {
|
||||||
|
if (!classId) {
|
||||||
|
toast.error("Please select a class")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formData.set("classId", classId)
|
||||||
|
formData.set("lateThresholdMinutes", lateThreshold)
|
||||||
|
formData.set("earlyLeaveThresholdMinutes", earlyLeaveThreshold)
|
||||||
|
formData.set("enableAutoMark", enableAutoMark ? "true" : "false")
|
||||||
|
|
||||||
|
const result = await saveAttendanceRulesAction(null, formData)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to save rules")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Attendance Rules</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Class</Label>
|
||||||
|
<Select value={classId} onValueChange={handleClassChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a class" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="lateThresholdMinutes">Late Threshold (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="lateThresholdMinutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={lateThreshold}
|
||||||
|
onChange={(e) => setLateThreshold(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="earlyLeaveThresholdMinutes">Early Leave Threshold (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="earlyLeaveThresholdMinutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={earlyLeaveThreshold}
|
||||||
|
onChange={(e) => setEarlyLeaveThreshold(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enableAutoMark"
|
||||||
|
checked={enableAutoMark}
|
||||||
|
onCheckedChange={(v) => setEnableAutoMark(v === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enableAutoMark" className="cursor-pointer">
|
||||||
|
Enable auto-marking (mark present automatically when student checks in on time)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<SubmitButton />
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
src/modules/attendance/components/attendance-sheet.tsx
Normal file
215
src/modules/attendance/components/attendance-sheet.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useFormStatus } from "react-dom"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { CalendarDays } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
|
||||||
|
import { batchRecordAttendanceAction } from "../actions"
|
||||||
|
import {
|
||||||
|
ATTENDANCE_STATUS_LABELS,
|
||||||
|
type AttendanceStatus,
|
||||||
|
} from "../types"
|
||||||
|
|
||||||
|
type Option = { id: string; name: string }
|
||||||
|
type Student = { id: string; name: string; email: string }
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: AttendanceStatus[] = [
|
||||||
|
"present",
|
||||||
|
"absent",
|
||||||
|
"late",
|
||||||
|
"early_leave",
|
||||||
|
"excused",
|
||||||
|
]
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
return (
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Saving..." : "Save Attendance"}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceSheet({
|
||||||
|
classes,
|
||||||
|
students,
|
||||||
|
defaultClassId,
|
||||||
|
defaultDate,
|
||||||
|
}: {
|
||||||
|
classes: Option[]
|
||||||
|
students: Student[]
|
||||||
|
defaultClassId?: string
|
||||||
|
defaultDate?: string
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
||||||
|
const [date, setDate] = useState(defaultDate ?? today)
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, AttendanceStatus>>({})
|
||||||
|
|
||||||
|
const handleStatusChange = (studentId: string, status: AttendanceStatus) => {
|
||||||
|
setStatuses((prev) => ({ ...prev, [studentId]: status }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAllPresent = () => {
|
||||||
|
const all: Record<string, AttendanceStatus> = {}
|
||||||
|
for (const s of students) all[s.id] = "present"
|
||||||
|
setStatuses(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData) => {
|
||||||
|
if (!classId || !date) {
|
||||||
|
toast.error("Please select class and date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = students.map((s) => ({
|
||||||
|
studentId: s.id,
|
||||||
|
classId,
|
||||||
|
date,
|
||||||
|
status: statuses[s.id] ?? "present",
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (records.length === 0) {
|
||||||
|
toast.error("No students to record attendance for")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.set("recordsJson", JSON.stringify(records))
|
||||||
|
|
||||||
|
const result = await batchRecordAttendanceAction(null, formData)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
router.push("/teacher/attendance")
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to save attendance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Attendance Sheet</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Class</Label>
|
||||||
|
<Select value={classId} onValueChange={setClassId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a class" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="date">Date</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{students.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No students in this class. Select a class to load students.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{students.length} students
|
||||||
|
</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={markAllPresent}>
|
||||||
|
Mark All Present
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Student</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead className="w-48">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{students.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={statuses[s.id] ?? "present"}
|
||||||
|
onValueChange={(v) => handleStatusChange(s.id, v as AttendanceStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_OPTIONS.map((st) => (
|
||||||
|
<SelectItem key={st} value={st}>
|
||||||
|
{ATTENDANCE_STATUS_LABELS[st]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<SubmitButton />
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/modules/attendance/components/attendance-stats-card.tsx
Normal file
100
src/modules/attendance/components/attendance-stats-card.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
LogOut,
|
||||||
|
FileText,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react"
|
||||||
|
import type { AttendanceStats } from "../types"
|
||||||
|
|
||||||
|
interface StatItemProps {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
icon: React.ReactNode
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatItem({ label, value, icon, hint }: StatItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 rounded-lg border bg-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold">{value}</span>
|
||||||
|
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
|
||||||
|
if (!stats || stats.total === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Attendance Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">No attendance data available.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Attendance Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<StatItem
|
||||||
|
label="Total Records"
|
||||||
|
value={stats.total}
|
||||||
|
icon={<Users className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
label="Present"
|
||||||
|
value={stats.present}
|
||||||
|
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
label="Absent"
|
||||||
|
value={stats.absent}
|
||||||
|
icon={<XCircle className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
label="Late"
|
||||||
|
value={stats.late}
|
||||||
|
icon={<Clock className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
label="Early Leave"
|
||||||
|
value={stats.earlyLeave}
|
||||||
|
icon={<LogOut className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
label="Excused"
|
||||||
|
value={stats.excused}
|
||||||
|
icon={<FileText className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
label="Present Rate"
|
||||||
|
value={`${stats.presentRate.toFixed(1)}%`}
|
||||||
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
|
hint="Present / Total"
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
label="Late Rate"
|
||||||
|
value={`${stats.lateRate.toFixed(1)}%`}
|
||||||
|
icon={<Clock className="h-4 w-4" />}
|
||||||
|
hint="Late / Total"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
src/modules/attendance/components/student-attendance-view.tsx
Normal file
104
src/modules/attendance/components/student-attendance-view.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { CalendarCheck } from "lucide-react"
|
||||||
|
|
||||||
|
import { AttendanceStatsCard } from "./attendance-stats-card"
|
||||||
|
import {
|
||||||
|
ATTENDANCE_STATUS_COLORS,
|
||||||
|
ATTENDANCE_STATUS_LABELS,
|
||||||
|
type StudentAttendanceSummary,
|
||||||
|
} from "../types"
|
||||||
|
|
||||||
|
export function StudentAttendanceView({
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
summary: StudentAttendanceSummary | null
|
||||||
|
}) {
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No data"
|
||||||
|
description="Student attendance summary is not available."
|
||||||
|
icon={CalendarCheck}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{summary.studentName}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Records</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{summary.stats.total}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AttendanceStatsCard stats={summary.stats} />
|
||||||
|
|
||||||
|
{summary.recentRecords.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No attendance records"
|
||||||
|
description="There are no attendance records for this student yet."
|
||||||
|
icon={CalendarCheck}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Attendance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Class</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Remark</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{summary.recentRecords.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="font-medium">{r.date}</TableCell>
|
||||||
|
<TableCell>{r.className}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
|
||||||
|
{ATTENDANCE_STATUS_LABELS[r.status]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{r.remark ?? "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
src/modules/attendance/data-access-stats.ts
Normal file
145
src/modules/attendance/data-access-stats.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, asc, desc, eq, gte, lte } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { attendanceRecords, classes, users } from "@/shared/db/schema"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AttendanceListItem,
|
||||||
|
AttendanceStats,
|
||||||
|
ClassAttendanceSummary,
|
||||||
|
StudentAttendanceSummary,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const EMPTY_STATS: AttendanceStats = {
|
||||||
|
total: 0,
|
||||||
|
present: 0,
|
||||||
|
absent: 0,
|
||||||
|
late: 0,
|
||||||
|
earlyLeave: 0,
|
||||||
|
excused: 0,
|
||||||
|
presentRate: 0,
|
||||||
|
lateRate: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeStats = (rows: { status: string }[]): AttendanceStats => {
|
||||||
|
if (rows.length === 0) return EMPTY_STATS
|
||||||
|
const stats: AttendanceStats = { ...EMPTY_STATS, total: rows.length }
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.status === "present") stats.present += 1
|
||||||
|
else if (r.status === "absent") stats.absent += 1
|
||||||
|
else if (r.status === "late") stats.late += 1
|
||||||
|
else if (r.status === "early_leave") stats.earlyLeave += 1
|
||||||
|
else if (r.status === "excused") stats.excused += 1
|
||||||
|
}
|
||||||
|
stats.presentRate = Math.round((stats.present / stats.total) * 10000) / 100
|
||||||
|
stats.lateRate = Math.round((stats.late / stats.total) * 10000) / 100
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeDate = (d: Date | string | null): string =>
|
||||||
|
d ? new Date(d).toISOString().slice(0, 10) : ""
|
||||||
|
|
||||||
|
export async function getStudentAttendanceSummary(
|
||||||
|
studentId: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string
|
||||||
|
): Promise<StudentAttendanceSummary | null> {
|
||||||
|
const [student] = await db
|
||||||
|
.select({ name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, studentId))
|
||||||
|
.limit(1)
|
||||||
|
if (!student) return null
|
||||||
|
|
||||||
|
const conditions = [eq(attendanceRecords.studentId, studentId)]
|
||||||
|
if (startDate) conditions.push(gte(attendanceRecords.date, new Date(startDate)))
|
||||||
|
if (endDate) conditions.push(lte(attendanceRecords.date, new Date(endDate)))
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
record: attendanceRecords,
|
||||||
|
className: classes.name,
|
||||||
|
})
|
||||||
|
.from(attendanceRecords)
|
||||||
|
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(attendanceRecords.date))
|
||||||
|
|
||||||
|
const stats = computeStats(rows.map((r) => ({ status: r.record.status })))
|
||||||
|
|
||||||
|
const recentRecords: AttendanceListItem[] = rows.slice(0, 20).map((r) => ({
|
||||||
|
id: r.record.id,
|
||||||
|
studentId: r.record.studentId,
|
||||||
|
studentName: student.name ?? "Unknown",
|
||||||
|
classId: r.record.classId,
|
||||||
|
className: r.className ?? "Unknown",
|
||||||
|
scheduleId: r.record.scheduleId ?? null,
|
||||||
|
date: serializeDate(r.record.date),
|
||||||
|
status: r.record.status,
|
||||||
|
remark: r.record.remark ?? null,
|
||||||
|
recordedBy: r.record.recordedBy,
|
||||||
|
recorderName: "Unknown",
|
||||||
|
createdAt: r.record.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
studentId,
|
||||||
|
studentName: student.name ?? "Unknown",
|
||||||
|
stats,
|
||||||
|
recentRecords,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClassAttendanceStats(
|
||||||
|
classId: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string
|
||||||
|
): Promise<ClassAttendanceSummary | null> {
|
||||||
|
const [classRow] = await db
|
||||||
|
.select({ id: classes.id, name: classes.name })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, classId))
|
||||||
|
.limit(1)
|
||||||
|
if (!classRow) return null
|
||||||
|
|
||||||
|
const conditions = [eq(attendanceRecords.classId, classId)]
|
||||||
|
if (startDate) conditions.push(gte(attendanceRecords.date, new Date(startDate)))
|
||||||
|
if (endDate) conditions.push(lte(attendanceRecords.date, new Date(endDate)))
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
record: attendanceRecords,
|
||||||
|
studentName: users.name,
|
||||||
|
})
|
||||||
|
.from(attendanceRecords)
|
||||||
|
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(users.name))
|
||||||
|
|
||||||
|
const stats = computeStats(rows.map((r) => ({ status: r.record.status })))
|
||||||
|
|
||||||
|
const studentRecords: AttendanceListItem[] = rows.map((r) => ({
|
||||||
|
id: r.record.id,
|
||||||
|
studentId: r.record.studentId,
|
||||||
|
studentName: r.studentName ?? "Unknown",
|
||||||
|
classId: r.record.classId,
|
||||||
|
className: classRow.name,
|
||||||
|
scheduleId: r.record.scheduleId ?? null,
|
||||||
|
date: serializeDate(r.record.date),
|
||||||
|
status: r.record.status,
|
||||||
|
remark: r.record.remark ?? null,
|
||||||
|
recordedBy: r.record.recordedBy,
|
||||||
|
recorderName: "Unknown",
|
||||||
|
createdAt: r.record.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
classId,
|
||||||
|
className: classRow.name,
|
||||||
|
date: startDate ?? endDate ?? new Date().toISOString().slice(0, 10),
|
||||||
|
stats,
|
||||||
|
studentRecords,
|
||||||
|
}
|
||||||
|
}
|
||||||
271
src/modules/attendance/data-access.ts
Normal file
271
src/modules/attendance/data-access.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, asc, count, desc, eq, gte, inArray, lte, or, sql, type SQL } from "drizzle-orm"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
attendanceRecords,
|
||||||
|
attendanceRules,
|
||||||
|
classes,
|
||||||
|
classEnrollments,
|
||||||
|
users,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AttendanceListItem,
|
||||||
|
AttendanceQueryParams,
|
||||||
|
AttendanceRule,
|
||||||
|
PaginatedAttendanceResult,
|
||||||
|
} from "./types"
|
||||||
|
import type {
|
||||||
|
AttendanceRuleInput,
|
||||||
|
BatchRecordAttendanceInput,
|
||||||
|
RecordAttendanceInput,
|
||||||
|
UpdateAttendanceInput,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
const buildScopeFilter = (scope: DataScope): SQL | null => {
|
||||||
|
if (scope.type === "all") return null
|
||||||
|
if (scope.type === "class_taught") {
|
||||||
|
return scope.classIds.length > 0
|
||||||
|
? inArray(attendanceRecords.classId, scope.classIds)
|
||||||
|
: sql`1=0`
|
||||||
|
}
|
||||||
|
if (scope.type === "grade_managed") return sql`1=0`
|
||||||
|
if (scope.type === "class_members") return null
|
||||||
|
if (scope.type === "children") {
|
||||||
|
return scope.childrenIds.length > 0
|
||||||
|
? inArray(attendanceRecords.studentId, scope.childrenIds)
|
||||||
|
: sql`1=0`
|
||||||
|
}
|
||||||
|
if (scope.type === "owned") return eq(attendanceRecords.studentId, scope.userId)
|
||||||
|
return sql`1=0`
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeDate = (d: Date | string | null): string =>
|
||||||
|
d ? new Date(d).toISOString().slice(0, 10) : ""
|
||||||
|
|
||||||
|
const mapListItem = (
|
||||||
|
r: typeof attendanceRecords.$inferSelect,
|
||||||
|
studentName: string | null,
|
||||||
|
className: string | null,
|
||||||
|
recorderName: string
|
||||||
|
): AttendanceListItem => ({
|
||||||
|
id: r.id,
|
||||||
|
studentId: r.studentId,
|
||||||
|
studentName: studentName ?? "Unknown",
|
||||||
|
classId: r.classId,
|
||||||
|
className: className ?? "Unknown",
|
||||||
|
scheduleId: r.scheduleId ?? null,
|
||||||
|
date: serializeDate(r.date),
|
||||||
|
status: r.status,
|
||||||
|
remark: r.remark ?? null,
|
||||||
|
recordedBy: r.recordedBy,
|
||||||
|
recorderName,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolveRecorderNames = async (rows: { record: typeof attendanceRecords.$inferSelect }[]) => {
|
||||||
|
const ids = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
if (ids.length > 0) {
|
||||||
|
const recorders = await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, ids))
|
||||||
|
for (const r of recorders) map.set(r.id, r.name ?? "Unknown")
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttendanceRecords(
|
||||||
|
params: AttendanceQueryParams & { scope: DataScope; currentUserId?: string }
|
||||||
|
): Promise<PaginatedAttendanceResult> {
|
||||||
|
const page = Math.max(1, params.page ?? 1)
|
||||||
|
const pageSize = Math.max(1, Math.min(100, params.pageSize ?? 20))
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
|
||||||
|
const scopeFilter = buildScopeFilter(params.scope)
|
||||||
|
if (scopeFilter) conditions.push(scopeFilter)
|
||||||
|
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||||
|
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
|
||||||
|
}
|
||||||
|
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
||||||
|
if (params.studentId) conditions.push(eq(attendanceRecords.studentId, params.studentId))
|
||||||
|
if (params.date) conditions.push(eq(attendanceRecords.date, new Date(params.date)))
|
||||||
|
if (params.startDate) conditions.push(gte(attendanceRecords.date, new Date(params.startDate)))
|
||||||
|
if (params.endDate) conditions.push(lte(attendanceRecords.date, new Date(params.endDate)))
|
||||||
|
if (params.status) conditions.push(eq(attendanceRecords.status, params.status))
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||||
|
|
||||||
|
const [totalRow] = await db.select({ c: count() }).from(attendanceRecords).where(where)
|
||||||
|
const total = Number(totalRow?.c ?? 0)
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
record: attendanceRecords,
|
||||||
|
studentName: users.name,
|
||||||
|
className: classes.name,
|
||||||
|
})
|
||||||
|
.from(attendanceRecords)
|
||||||
|
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
|
||||||
|
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(attendanceRecords.date), desc(attendanceRecords.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((page - 1) * pageSize)
|
||||||
|
|
||||||
|
const recorderMap = await resolveRecorderNames(rows)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((r) =>
|
||||||
|
mapListItem(r.record, r.studentName, r.className, recorderMap.get(r.record.recordedBy) ?? "Unknown")
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClassAttendanceForDate(
|
||||||
|
classId: string,
|
||||||
|
date: string
|
||||||
|
): Promise<AttendanceListItem[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
record: attendanceRecords,
|
||||||
|
studentName: users.name,
|
||||||
|
className: classes.name,
|
||||||
|
})
|
||||||
|
.from(attendanceRecords)
|
||||||
|
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
|
||||||
|
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
|
||||||
|
.where(and(eq(attendanceRecords.classId, classId), eq(attendanceRecords.date, new Date(date))))
|
||||||
|
.orderBy(asc(users.name))
|
||||||
|
|
||||||
|
return rows.map((r) => mapListItem(r.record, r.studentName, r.className, "Unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAttendanceRecord(
|
||||||
|
data: RecordAttendanceInput,
|
||||||
|
recordedBy: string
|
||||||
|
): Promise<string> {
|
||||||
|
const id = createId()
|
||||||
|
await db.insert(attendanceRecords).values({
|
||||||
|
id,
|
||||||
|
studentId: data.studentId,
|
||||||
|
classId: data.classId,
|
||||||
|
scheduleId: data.scheduleId ?? null,
|
||||||
|
date: new Date(data.date),
|
||||||
|
status: data.status,
|
||||||
|
remark: data.remark ?? null,
|
||||||
|
recordedBy,
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchCreateAttendanceRecords(
|
||||||
|
data: BatchRecordAttendanceInput,
|
||||||
|
recordedBy: string
|
||||||
|
): Promise<number> {
|
||||||
|
if (data.records.length === 0) return 0
|
||||||
|
const rows = data.records.map((r) => ({
|
||||||
|
id: createId(),
|
||||||
|
studentId: r.studentId,
|
||||||
|
classId: r.classId,
|
||||||
|
scheduleId: r.scheduleId ?? null,
|
||||||
|
date: new Date(r.date),
|
||||||
|
status: r.status,
|
||||||
|
remark: r.remark ?? null,
|
||||||
|
recordedBy,
|
||||||
|
}))
|
||||||
|
await db.insert(attendanceRecords).values(rows)
|
||||||
|
return rows.length
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAttendanceRecord(
|
||||||
|
id: string,
|
||||||
|
data: UpdateAttendanceInput
|
||||||
|
): Promise<void> {
|
||||||
|
const update: Record<string, unknown> = { updatedAt: new Date() }
|
||||||
|
if (data.status !== undefined) update.status = data.status
|
||||||
|
if (data.remark !== undefined) update.remark = data.remark
|
||||||
|
if (data.scheduleId !== undefined) update.scheduleId = data.scheduleId
|
||||||
|
await db.update(attendanceRecords).set(update).where(eq(attendanceRecords.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAttendanceRecord(id: string): Promise<void> {
|
||||||
|
await db.delete(attendanceRecords).where(eq(attendanceRecords.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClassStudentsForAttendance(
|
||||||
|
classId: string
|
||||||
|
): Promise<Array<{ id: string; name: string; email: string }>> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: users.id, name: users.name, email: users.email })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||||
|
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||||
|
.orderBy(asc(users.name))
|
||||||
|
|
||||||
|
return rows.map((r) => ({ id: r.id, name: r.name ?? "Unknown", email: r.email }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttendanceRules(classId?: string): Promise<AttendanceRule[]> {
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
if (classId) {
|
||||||
|
const classCondition = or(eq(attendanceRules.classId, classId), sql`${attendanceRules.classId} IS NULL`)
|
||||||
|
if (classCondition) conditions.push(classCondition)
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(attendanceRules)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(attendanceRules.createdAt))
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
classId: r.classId ?? null,
|
||||||
|
lateThresholdMinutes: r.lateThresholdMinutes ?? null,
|
||||||
|
earlyLeaveThresholdMinutes: r.earlyLeaveThresholdMinutes ?? null,
|
||||||
|
enableAutoMark: r.enableAutoMark ?? null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<string> {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(attendanceRules)
|
||||||
|
.where(eq(attendanceRules.classId, data.classId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(attendanceRules)
|
||||||
|
.set({
|
||||||
|
lateThresholdMinutes: data.lateThresholdMinutes ?? 15,
|
||||||
|
earlyLeaveThresholdMinutes: data.earlyLeaveThresholdMinutes ?? 15,
|
||||||
|
enableAutoMark: data.enableAutoMark ?? false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(attendanceRules.id, existing.id))
|
||||||
|
return existing.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = createId()
|
||||||
|
await db.insert(attendanceRules).values({
|
||||||
|
id,
|
||||||
|
classId: data.classId,
|
||||||
|
lateThresholdMinutes: data.lateThresholdMinutes ?? 15,
|
||||||
|
earlyLeaveThresholdMinutes: data.earlyLeaveThresholdMinutes ?? 15,
|
||||||
|
enableAutoMark: data.enableAutoMark ?? false,
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
43
src/modules/attendance/schema.ts
Normal file
43
src/modules/attendance/schema.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const AttendanceStatusEnum = z.enum([
|
||||||
|
"present",
|
||||||
|
"absent",
|
||||||
|
"late",
|
||||||
|
"early_leave",
|
||||||
|
"excused",
|
||||||
|
])
|
||||||
|
|
||||||
|
export const RecordAttendanceSchema = z.object({
|
||||||
|
studentId: z.string().min(1),
|
||||||
|
classId: z.string().min(1),
|
||||||
|
date: z.string().min(1),
|
||||||
|
status: AttendanceStatusEnum,
|
||||||
|
remark: z.string().optional(),
|
||||||
|
scheduleId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RecordAttendanceInput = z.infer<typeof RecordAttendanceSchema>
|
||||||
|
|
||||||
|
export const BatchRecordAttendanceSchema = z.object({
|
||||||
|
records: z.array(RecordAttendanceSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type BatchRecordAttendanceInput = z.infer<typeof BatchRecordAttendanceSchema>
|
||||||
|
|
||||||
|
export const UpdateAttendanceSchema = z.object({
|
||||||
|
status: AttendanceStatusEnum.optional(),
|
||||||
|
remark: z.string().optional(),
|
||||||
|
scheduleId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type UpdateAttendanceInput = z.infer<typeof UpdateAttendanceSchema>
|
||||||
|
|
||||||
|
export const AttendanceRuleSchema = z.object({
|
||||||
|
classId: z.string().min(1),
|
||||||
|
lateThresholdMinutes: z.coerce.number().int().min(0).optional(),
|
||||||
|
earlyLeaveThresholdMinutes: z.coerce.number().int().min(0).optional(),
|
||||||
|
enableAutoMark: z.coerce.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AttendanceRuleInput = z.infer<typeof AttendanceRuleSchema>
|
||||||
103
src/modules/attendance/types.ts
Normal file
103
src/modules/attendance/types.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export type AttendanceStatus = "present" | "absent" | "late" | "early_leave" | "excused"
|
||||||
|
|
||||||
|
export interface AttendanceRecord {
|
||||||
|
id: string
|
||||||
|
studentId: string
|
||||||
|
classId: string
|
||||||
|
scheduleId: string | null
|
||||||
|
date: string
|
||||||
|
status: AttendanceStatus
|
||||||
|
remark: string | null
|
||||||
|
recordedBy: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceListItem {
|
||||||
|
id: string
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
scheduleId: string | null
|
||||||
|
date: string
|
||||||
|
status: AttendanceStatus
|
||||||
|
remark: string | null
|
||||||
|
recordedBy: string
|
||||||
|
recorderName: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceStats {
|
||||||
|
total: number
|
||||||
|
present: number
|
||||||
|
absent: number
|
||||||
|
late: number
|
||||||
|
earlyLeave: number
|
||||||
|
excused: number
|
||||||
|
presentRate: number
|
||||||
|
lateRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentAttendanceSummary {
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
stats: AttendanceStats
|
||||||
|
recentRecords: AttendanceListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassAttendanceSummary {
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
date: string
|
||||||
|
stats: AttendanceStats
|
||||||
|
studentRecords: AttendanceListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceRule {
|
||||||
|
id: string
|
||||||
|
classId: string | null
|
||||||
|
lateThresholdMinutes: number | null
|
||||||
|
earlyLeaveThresholdMinutes: number | null
|
||||||
|
enableAutoMark: boolean | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceQueryParams {
|
||||||
|
classId?: string
|
||||||
|
studentId?: string
|
||||||
|
date?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
status?: AttendanceStatus
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedAttendanceResult {
|
||||||
|
items: AttendanceListItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ATTENDANCE_STATUS_LABELS: Record<AttendanceStatus, string> = {
|
||||||
|
present: "Present",
|
||||||
|
absent: "Absent",
|
||||||
|
late: "Late",
|
||||||
|
early_leave: "Early Leave",
|
||||||
|
excused: "Excused",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ATTENDANCE_STATUS_COLORS: Record<
|
||||||
|
AttendanceStatus,
|
||||||
|
"default" | "secondary" | "destructive" | "outline"
|
||||||
|
> = {
|
||||||
|
present: "default",
|
||||||
|
absent: "destructive",
|
||||||
|
late: "secondary",
|
||||||
|
early_leave: "outline",
|
||||||
|
excused: "outline",
|
||||||
|
}
|
||||||
212
src/modules/audit/actions.ts
Normal file
212
src/modules/audit/actions.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { exportToExcel } from "@/shared/lib/excel"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAuditLogsForExport,
|
||||||
|
getDataChangeLogs,
|
||||||
|
getDataChangeLogsForExport,
|
||||||
|
getDataChangeStats,
|
||||||
|
getDataChangeTableOptions,
|
||||||
|
getLoginLogsForExport,
|
||||||
|
} from "./data-access"
|
||||||
|
import type {
|
||||||
|
AuditLogQueryParams,
|
||||||
|
DataChangeLog,
|
||||||
|
DataChangeLogQueryParams,
|
||||||
|
DataChangeStat,
|
||||||
|
LoginLogQueryParams,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
export async function getDataChangeLogsAction(
|
||||||
|
params?: DataChangeLogQueryParams
|
||||||
|
): Promise<
|
||||||
|
ActionState<{
|
||||||
|
items: DataChangeLog[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
tableOptions: string[]
|
||||||
|
stats: DataChangeStat[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
const [result, tableOptions, stats] = await Promise.all([
|
||||||
|
getDataChangeLogs(params),
|
||||||
|
getDataChangeTableOptions(),
|
||||||
|
getDataChangeStats(),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: result.items,
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
tableOptions,
|
||||||
|
stats,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAuditLogsAction(
|
||||||
|
params?: AuditLogQueryParams
|
||||||
|
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
const items = await getAuditLogsForExport(params)
|
||||||
|
|
||||||
|
const buffer = await exportToExcel({
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
name: "Audit Logs",
|
||||||
|
columns: [
|
||||||
|
{ header: "User ID", key: "userId", width: 22 },
|
||||||
|
{ header: "User Name", key: "userName", width: 18 },
|
||||||
|
{ header: "Module", key: "module", width: 16 },
|
||||||
|
{ header: "Action", key: "action", width: 22 },
|
||||||
|
{ header: "Target ID", key: "targetId", width: 22 },
|
||||||
|
{ header: "Target Type", key: "targetType", width: 16 },
|
||||||
|
{ header: "Detail", key: "detail", width: 40 },
|
||||||
|
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||||
|
{ header: "Status", key: "status", width: 10 },
|
||||||
|
{ header: "Created At", key: "createdAt", width: 22 },
|
||||||
|
],
|
||||||
|
rows: items.map((r) => ({
|
||||||
|
userId: r.userId,
|
||||||
|
userName: r.userName,
|
||||||
|
module: r.module,
|
||||||
|
action: r.action,
|
||||||
|
targetId: r.targetId ?? "",
|
||||||
|
targetType: r.targetType ?? "",
|
||||||
|
detail: r.detail ?? "",
|
||||||
|
ipAddress: r.ipAddress ?? "",
|
||||||
|
status: r.status,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { buffer, filename: `audit_logs_${formatDateForFile()}.xlsx` },
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportLoginLogsAction(
|
||||||
|
params?: LoginLogQueryParams
|
||||||
|
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
const items = await getLoginLogsForExport(params)
|
||||||
|
|
||||||
|
const buffer = await exportToExcel({
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
name: "Login Logs",
|
||||||
|
columns: [
|
||||||
|
{ header: "User ID", key: "userId", width: 22 },
|
||||||
|
{ header: "User Email", key: "userEmail", width: 26 },
|
||||||
|
{ header: "Action", key: "action", width: 12 },
|
||||||
|
{ header: "Status", key: "status", width: 10 },
|
||||||
|
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||||
|
{ header: "User Agent", key: "userAgent", width: 40 },
|
||||||
|
{ header: "Error Message", key: "errorMessage", width: 30 },
|
||||||
|
{ header: "Created At", key: "createdAt", width: 22 },
|
||||||
|
],
|
||||||
|
rows: items.map((r) => ({
|
||||||
|
userId: r.userId ?? "",
|
||||||
|
userEmail: r.userEmail,
|
||||||
|
action: r.action,
|
||||||
|
status: r.status,
|
||||||
|
ipAddress: r.ipAddress ?? "",
|
||||||
|
userAgent: r.userAgent ?? "",
|
||||||
|
errorMessage: r.errorMessage ?? "",
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { buffer, filename: `login_logs_${formatDateForFile()}.xlsx` },
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDataChangeLogsAction(
|
||||||
|
params?: DataChangeLogQueryParams
|
||||||
|
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
const items = await getDataChangeLogsForExport(params)
|
||||||
|
|
||||||
|
const buffer = await exportToExcel({
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
name: "Data Change Logs",
|
||||||
|
columns: [
|
||||||
|
{ header: "Table Name", key: "tableName", width: 22 },
|
||||||
|
{ header: "Record ID", key: "recordId", width: 22 },
|
||||||
|
{ header: "Action", key: "action", width: 10 },
|
||||||
|
{ header: "Old Value", key: "oldValue", width: 50 },
|
||||||
|
{ header: "New Value", key: "newValue", width: 50 },
|
||||||
|
{ header: "Changed By", key: "changedBy", width: 22 },
|
||||||
|
{ header: "Changed By Name", key: "changedByName", width: 18 },
|
||||||
|
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||||
|
{ header: "Created At", key: "createdAt", width: 22 },
|
||||||
|
],
|
||||||
|
rows: items.map((r) => ({
|
||||||
|
tableName: r.tableName,
|
||||||
|
recordId: r.recordId,
|
||||||
|
action: r.action,
|
||||||
|
oldValue: r.oldValue ?? "",
|
||||||
|
newValue: r.newValue ?? "",
|
||||||
|
changedBy: r.changedBy,
|
||||||
|
changedByName: r.changedByName,
|
||||||
|
ipAddress: r.ipAddress ?? "",
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { buffer, filename: `data_change_logs_${formatDateForFile()}.xlsx` },
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForFile(d = new Date()): string {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(d.getDate()).padStart(2, "0")
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
89
src/modules/audit/components/audit-log-export-button.tsx
Normal file
89
src/modules/audit/components/audit-log-export-button.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Download, Loader2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
|
interface AuditLogExportButtonProps {
|
||||||
|
exportType: "audit" | "login" | "dataChange"
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
label?: string
|
||||||
|
variant?: "default" | "outline" | "secondary" | "ghost" | "destructive"
|
||||||
|
size?: "default" | "sm" | "lg" | "icon"
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_MAP = {
|
||||||
|
audit: "exportAuditLogsAction",
|
||||||
|
login: "exportLoginLogsAction",
|
||||||
|
dataChange: "exportDataChangeLogsAction",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function AuditLogExportButton({
|
||||||
|
exportType,
|
||||||
|
params,
|
||||||
|
label = "Export Excel",
|
||||||
|
variant = "outline",
|
||||||
|
size = "sm",
|
||||||
|
className,
|
||||||
|
}: AuditLogExportButtonProps) {
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setIsPending(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/export", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: exportType, params: params ?? {} }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
toast.error(body?.message || "Export failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read filename from Content-Disposition header
|
||||||
|
const disposition = res.headers.get("Content-Disposition") || ""
|
||||||
|
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
|
||||||
|
const filename = filenameMatch?.[1] ?? `export_${Date.now()}.xlsx`
|
||||||
|
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success("Export ready")
|
||||||
|
} catch {
|
||||||
|
toast.error("Export failed")
|
||||||
|
} finally {
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void handleExport()}
|
||||||
|
data-action-name={ACTION_MAP[exportType]}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/modules/audit/components/audit-log-filters.tsx
Normal file
100
src/modules/audit/components/audit-log-filters.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
|
||||||
|
interface AuditLogFiltersProps {
|
||||||
|
moduleOptions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
||||||
|
const [module, setModule] = useQueryState("module", parseAsString.withOptions({ shallow: false }))
|
||||||
|
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
|
||||||
|
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||||
|
const [startDate, setStartDate] = useQueryState(
|
||||||
|
"startDate",
|
||||||
|
parseAsString.withOptions({ shallow: false })
|
||||||
|
)
|
||||||
|
const [endDate, setEndDate] = useQueryState(
|
||||||
|
"endDate",
|
||||||
|
parseAsString.withOptions({ shallow: false })
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasFilters = Boolean(module || action || status || startDate || endDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
|
||||||
|
<Select value={module || "all"} onValueChange={(val) => setModule(val === "all" ? null : val)}>
|
||||||
|
<SelectTrigger className="w-[160px] bg-background">
|
||||||
|
<SelectValue placeholder="Module" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Any Module</SelectItem>
|
||||||
|
{moduleOptions.map((m) => (
|
||||||
|
<SelectItem key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Action..."
|
||||||
|
className="w-full md:w-[180px] bg-background"
|
||||||
|
value={action || ""}
|
||||||
|
onChange={(e) => setAction(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||||
|
<SelectTrigger className="w-[140px] bg-background">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Any Status</SelectItem>
|
||||||
|
<SelectItem value="success">Success</SelectItem>
|
||||||
|
<SelectItem value="failure">Failure</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
value={startDate || ""}
|
||||||
|
onChange={(e) => setStartDate(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
value={endDate || ""}
|
||||||
|
onChange={(e) => setEndDate(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setModule(null)
|
||||||
|
setAction(null)
|
||||||
|
setStatus(null)
|
||||||
|
setStartDate(null)
|
||||||
|
setEndDate(null)
|
||||||
|
}}
|
||||||
|
className="h-10 px-3"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
<X className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
src/modules/audit/components/audit-log-table.tsx
Normal file
156
src/modules/audit/components/audit-log-table.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import type { AuditLog } from "../types"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
interface AuditLogTableProps {
|
||||||
|
items: AuditLog[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogTable({
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
}: AuditLogTableProps) {
|
||||||
|
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||||
|
const end = Math.min(page * pageSize, total)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/40">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Module</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
<TableHead>Target</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
||||||
|
No audit logs found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{log.userName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{log.userId}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{log.module}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{log.action}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.targetId ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-mono">{log.targetId}</span>
|
||||||
|
{log.targetType && (
|
||||||
|
<span className="text-xs text-muted-foreground">{log.targetType}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={log.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||||
|
{log.ipAddress ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(log.createdAt, "zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-2 py-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{total > 0 ? (
|
||||||
|
<>
|
||||||
|
Showing <span className="font-medium">{start}</span>-
|
||||||
|
<span className="font-medium">{end}</span> of{" "}
|
||||||
|
<span className="font-medium">{total}</span> logs
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"No logs"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Page {page} of {Math.max(totalPages, 1)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Previous page</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Next page</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: "success" | "failure" }) {
|
||||||
|
const variant: BadgeProps["variant"] = status === "success" ? "default" : "destructive"
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"capitalize",
|
||||||
|
status === "success" && "bg-green-600 hover:bg-green-700 border-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/modules/audit/components/audit-log-view.tsx
Normal file
61
src/modules/audit/components/audit-log-view.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { AuditLogTable } from "./audit-log-table"
|
||||||
|
import { AuditLogFilters } from "./audit-log-filters"
|
||||||
|
import type { AuditLog } from "../types"
|
||||||
|
|
||||||
|
interface AuditLogViewProps {
|
||||||
|
items: AuditLog[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
moduleOptions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLogViewInner({
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
moduleOptions,
|
||||||
|
}: AuditLogViewProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
if (newPage <= 1) {
|
||||||
|
params.delete("page")
|
||||||
|
} else {
|
||||||
|
params.set("page", String(newPage))
|
||||||
|
}
|
||||||
|
const query = params.toString()
|
||||||
|
router.push(query ? `?${query}` : "?")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AuditLogFilters moduleOptions={moduleOptions} />
|
||||||
|
<AuditLogTable
|
||||||
|
items={items}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogView(props: AuditLogViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AuditLogViewInner {...props} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
325
src/modules/audit/components/data-change-log-table.tsx
Normal file
325
src/modules/audit/components/data-change-log-table.tsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, Fragment, Suspense } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import type { DataChangeLog, DataChangeStat } from "../types"
|
||||||
|
|
||||||
|
interface DataChangeLogTableProps {
|
||||||
|
items: DataChangeLog[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
tableOptions: string[]
|
||||||
|
stats: DataChangeStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataChangeLogTableInner({
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
tableOptions,
|
||||||
|
stats,
|
||||||
|
}: DataChangeLogTableProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
if (newPage <= 1) {
|
||||||
|
params.delete("page")
|
||||||
|
} else {
|
||||||
|
params.set("page", String(newPage))
|
||||||
|
}
|
||||||
|
const query = params.toString()
|
||||||
|
router.push(query ? `?${query}` : "?")
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||||
|
const end = Math.min(page * pageSize, total)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/40">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Table</TableHead>
|
||||||
|
<TableHead>Record ID</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
<TableHead>Changed By</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
<TableHead className="w-12" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
||||||
|
No data change logs found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((log) => (
|
||||||
|
<Fragment key={log.id}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{log.tableName}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{log.recordId}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ActionBadge action={log.action} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{log.changedByName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{log.changedBy}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||||
|
{log.ipAddress ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(log.createdAt, "zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||||
|
>
|
||||||
|
{expandedId === log.id ? "Hide" : "View"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expandedId === log.id && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="bg-muted/30">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
||||||
|
Old Value
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
|
||||||
|
{log.oldValue ?? "—"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
||||||
|
New Value
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
|
||||||
|
{log.newValue ?? "—"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-2 py-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{total > 0 ? (
|
||||||
|
<>
|
||||||
|
Showing <span className="font-medium">{start}</span>-
|
||||||
|
<span className="font-medium">{end}</span> of{" "}
|
||||||
|
<span className="font-medium">{total}</span> logs
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"No logs"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Page {page} of {Math.max(totalPages, 1)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Previous page</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Next page</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataChangeLogFilters({
|
||||||
|
tableOptions,
|
||||||
|
stats,
|
||||||
|
}: {
|
||||||
|
tableOptions: string[]
|
||||||
|
stats: DataChangeStat[]
|
||||||
|
}) {
|
||||||
|
const [tableName, setTableName] = useQueryState(
|
||||||
|
"tableName",
|
||||||
|
parseAsString.withOptions({ shallow: false })
|
||||||
|
)
|
||||||
|
const [action, setAction] = useQueryState(
|
||||||
|
"action",
|
||||||
|
parseAsString.withOptions({ shallow: false })
|
||||||
|
)
|
||||||
|
const [startDate, setStartDate] = useQueryState(
|
||||||
|
"startDate",
|
||||||
|
parseAsString.withOptions({ shallow: false })
|
||||||
|
)
|
||||||
|
const [endDate, setEndDate] = useQueryState(
|
||||||
|
"endDate",
|
||||||
|
parseAsString.withOptions({ shallow: false })
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasFilters = Boolean(tableName || action || startDate || endDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stats.slice(0, 8).map((s) => (
|
||||||
|
<Badge key={s.tableName} variant="secondary" className="gap-1">
|
||||||
|
<span className="font-mono text-xs">{s.tableName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">·</span>
|
||||||
|
<span className="text-xs">{s.count}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
|
||||||
|
<Select
|
||||||
|
value={tableName || "all"}
|
||||||
|
onValueChange={(val) => setTableName(val === "all" ? null : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] bg-background">
|
||||||
|
<SelectValue placeholder="Table" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Any Table</SelectItem>
|
||||||
|
{tableOptions.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={action || "all"}
|
||||||
|
onValueChange={(val) => setAction(val === "all" ? null : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] bg-background">
|
||||||
|
<SelectValue placeholder="Action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Any Action</SelectItem>
|
||||||
|
<SelectItem value="create">Create</SelectItem>
|
||||||
|
<SelectItem value="update">Update</SelectItem>
|
||||||
|
<SelectItem value="delete">Delete</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
value={startDate || ""}
|
||||||
|
onChange={(e) => setStartDate(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
value={endDate || ""}
|
||||||
|
onChange={(e) => setEndDate(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setTableName(null)
|
||||||
|
setAction(null)
|
||||||
|
setStartDate(null)
|
||||||
|
setEndDate(null)
|
||||||
|
}}
|
||||||
|
className="h-10 px-3"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
<X className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionBadge({ action }: { action: "create" | "update" | "delete" }) {
|
||||||
|
const variant: BadgeProps["variant"] =
|
||||||
|
action === "create" ? "default" : action === "update" ? "secondary" : "destructive"
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"capitalize",
|
||||||
|
action === "create" && "bg-green-600 hover:bg-green-700 border-transparent",
|
||||||
|
action === "delete" && "bg-red-600 hover:bg-red-700 border-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataChangeLogTable(props: DataChangeLogTableProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DataChangeLogTableInner {...props} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/modules/audit/components/login-log-filters.tsx
Normal file
85
src/modules/audit/components/login-log-filters.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
|
||||||
|
export function LoginLogFilters() {
|
||||||
|
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
|
||||||
|
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||||
|
const [startDate, setStartDate] = useQueryState(
|
||||||
|
"startDate",
|
||||||
|
parseAsString.withOptions({ shallow: false })
|
||||||
|
)
|
||||||
|
const [endDate, setEndDate] = useQueryState(
|
||||||
|
"endDate",
|
||||||
|
parseAsString.withOptions({ shallow: false })
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasFilters = Boolean(action || status || startDate || endDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
|
||||||
|
<Select value={action || "all"} onValueChange={(val) => setAction(val === "all" ? null : val)}>
|
||||||
|
<SelectTrigger className="w-[140px] bg-background">
|
||||||
|
<SelectValue placeholder="Action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Any Action</SelectItem>
|
||||||
|
<SelectItem value="signin">Sign In</SelectItem>
|
||||||
|
<SelectItem value="signout">Sign Out</SelectItem>
|
||||||
|
<SelectItem value="signup">Sign Up</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||||
|
<SelectTrigger className="w-[140px] bg-background">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Any Status</SelectItem>
|
||||||
|
<SelectItem value="success">Success</SelectItem>
|
||||||
|
<SelectItem value="failure">Failure</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
value={startDate || ""}
|
||||||
|
onChange={(e) => setStartDate(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
value={endDate || ""}
|
||||||
|
onChange={(e) => setEndDate(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setAction(null)
|
||||||
|
setStatus(null)
|
||||||
|
setStartDate(null)
|
||||||
|
setEndDate(null)
|
||||||
|
}}
|
||||||
|
className="h-10 px-3"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
<X className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
150
src/modules/audit/components/login-log-table.tsx
Normal file
150
src/modules/audit/components/login-log-table.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import type { LoginLog } from "../types"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
interface LoginLogTableProps {
|
||||||
|
items: LoginLog[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginLogTable({
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
}: LoginLogTableProps) {
|
||||||
|
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||||
|
const end = Math.min(page * pageSize, total)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/40">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
<TableHead>User Agent</TableHead>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||||
|
No login logs found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{log.userEmail}</span>
|
||||||
|
{log.userId && (
|
||||||
|
<span className="text-xs text-muted-foreground">{log.userId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="capitalize font-mono text-xs">
|
||||||
|
{log.action}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={log.status} />
|
||||||
|
{log.errorMessage && (
|
||||||
|
<div className="mt-1 text-xs text-destructive">{log.errorMessage}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||||
|
{log.ipAddress ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[280px] truncate text-xs text-muted-foreground">
|
||||||
|
{log.userAgent ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(log.createdAt, "zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-2 py-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{total > 0 ? (
|
||||||
|
<>
|
||||||
|
Showing <span className="font-medium">{start}</span>-
|
||||||
|
<span className="font-medium">{end}</span> of{" "}
|
||||||
|
<span className="font-medium">{total}</span> logs
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"No logs"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Page {page} of {Math.max(totalPages, 1)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Previous page</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Next page</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: "success" | "failure" }) {
|
||||||
|
const variant: BadgeProps["variant"] = status === "success" ? "default" : "destructive"
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"capitalize",
|
||||||
|
status === "success" && "bg-green-600 hover:bg-green-700 border-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/modules/audit/components/login-log-view.tsx
Normal file
59
src/modules/audit/components/login-log-view.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { LoginLogTable } from "./login-log-table"
|
||||||
|
import { LoginLogFilters } from "./login-log-filters"
|
||||||
|
import type { LoginLog } from "../types"
|
||||||
|
|
||||||
|
interface LoginLogViewProps {
|
||||||
|
items: LoginLog[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginLogViewInner({
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
}: LoginLogViewProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
if (newPage <= 1) {
|
||||||
|
params.delete("page")
|
||||||
|
} else {
|
||||||
|
params.set("page", String(newPage))
|
||||||
|
}
|
||||||
|
const query = params.toString()
|
||||||
|
router.push(query ? `?${query}` : "?")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<LoginLogFilters />
|
||||||
|
<LoginLogTable
|
||||||
|
items={items}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginLogView(props: LoginLogViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginLogViewInner {...props} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
260
src/modules/audit/data-access.ts
Normal file
260
src/modules/audit/data-access.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, asc, desc, eq, gte, lte, count, like } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
|
||||||
|
import type {
|
||||||
|
AuditLog,
|
||||||
|
AuditLogQueryParams,
|
||||||
|
DataChangeLog,
|
||||||
|
DataChangeLogQueryParams,
|
||||||
|
DataChangeStat,
|
||||||
|
LoginLog,
|
||||||
|
LoginLogQueryParams,
|
||||||
|
PaginatedResult,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const toIso = (d: Date) => d.toISOString()
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20
|
||||||
|
const MAX_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
const clampPageSize = (size?: number) => {
|
||||||
|
if (!size || size <= 0) return DEFAULT_PAGE_SIZE
|
||||||
|
return Math.min(size, MAX_PAGE_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampPage = (page?: number) => {
|
||||||
|
if (!page || page <= 0) return 1
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLogs(
|
||||||
|
params?: AuditLogQueryParams
|
||||||
|
): Promise<PaginatedResult<AuditLog>> {
|
||||||
|
const page = clampPage(params?.page)
|
||||||
|
const pageSize = clampPageSize(params?.pageSize)
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
|
||||||
|
const conditions = []
|
||||||
|
if (params?.userId) conditions.push(eq(auditLogs.userId, params.userId))
|
||||||
|
if (params?.module) conditions.push(eq(auditLogs.module, params.module))
|
||||||
|
if (params?.action) conditions.push(like(auditLogs.action, `%${params.action}%`))
|
||||||
|
if (params?.status) conditions.push(eq(auditLogs.status, params.status))
|
||||||
|
if (params?.startDate) conditions.push(gte(auditLogs.createdAt, new Date(params.startDate)))
|
||||||
|
if (params?.endDate) conditions.push(lte(auditLogs.createdAt, new Date(params.endDate)))
|
||||||
|
|
||||||
|
const where = conditions.length ? and(...conditions) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows, totalRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(auditLogs)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(auditLogs.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ value: count() }).from(auditLogs).where(where),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total = Number(totalRows[0]?.value ?? 0)
|
||||||
|
return {
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
userId: r.userId,
|
||||||
|
userName: r.userName,
|
||||||
|
action: r.action,
|
||||||
|
module: r.module,
|
||||||
|
targetId: r.targetId ?? null,
|
||||||
|
targetType: r.targetType ?? null,
|
||||||
|
detail: r.detail ?? null,
|
||||||
|
ipAddress: r.ipAddress ?? null,
|
||||||
|
userAgent: r.userAgent ?? null,
|
||||||
|
status: r.status as "success" | "failure",
|
||||||
|
createdAt: toIso(r.createdAt),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoginLogs(
|
||||||
|
params?: LoginLogQueryParams
|
||||||
|
): Promise<PaginatedResult<LoginLog>> {
|
||||||
|
const page = clampPage(params?.page)
|
||||||
|
const pageSize = clampPageSize(params?.pageSize)
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
|
||||||
|
const conditions = []
|
||||||
|
if (params?.userId) conditions.push(eq(loginLogs.userId, params.userId))
|
||||||
|
if (params?.action) conditions.push(eq(loginLogs.action, params.action))
|
||||||
|
if (params?.status) conditions.push(eq(loginLogs.status, params.status))
|
||||||
|
if (params?.startDate) conditions.push(gte(loginLogs.createdAt, new Date(params.startDate)))
|
||||||
|
if (params?.endDate) conditions.push(lte(loginLogs.createdAt, new Date(params.endDate)))
|
||||||
|
|
||||||
|
const where = conditions.length ? and(...conditions) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows, totalRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(loginLogs)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(loginLogs.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ value: count() }).from(loginLogs).where(where),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total = Number(totalRows[0]?.value ?? 0)
|
||||||
|
return {
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
userId: r.userId ?? null,
|
||||||
|
userEmail: r.userEmail,
|
||||||
|
action: r.action as "signin" | "signout" | "signup",
|
||||||
|
status: r.status as "success" | "failure",
|
||||||
|
ipAddress: r.ipAddress ?? null,
|
||||||
|
userAgent: r.userAgent ?? null,
|
||||||
|
errorMessage: r.errorMessage ?? null,
|
||||||
|
createdAt: toIso(r.createdAt),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditModuleOptions(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.selectDistinct({ module: auditLogs.module })
|
||||||
|
.from(auditLogs)
|
||||||
|
.orderBy(asc(auditLogs.module))
|
||||||
|
return rows.map((r) => r.module)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataChangeLogs(
|
||||||
|
params?: DataChangeLogQueryParams
|
||||||
|
): Promise<PaginatedResult<DataChangeLog>> {
|
||||||
|
const page = clampPage(params?.page)
|
||||||
|
const pageSize = clampPageSize(params?.pageSize)
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
|
||||||
|
const conditions = []
|
||||||
|
if (params?.tableName) conditions.push(eq(dataChangeLogs.tableName, params.tableName))
|
||||||
|
if (params?.recordId) conditions.push(eq(dataChangeLogs.recordId, params.recordId))
|
||||||
|
if (params?.action) conditions.push(eq(dataChangeLogs.action, params.action))
|
||||||
|
if (params?.userId) conditions.push(eq(dataChangeLogs.changedBy, params.userId))
|
||||||
|
if (params?.startDate) conditions.push(gte(dataChangeLogs.createdAt, new Date(params.startDate)))
|
||||||
|
if (params?.endDate) conditions.push(lte(dataChangeLogs.createdAt, new Date(params.endDate)))
|
||||||
|
|
||||||
|
const where = conditions.length ? and(...conditions) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows, totalRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(dataChangeLogs)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(dataChangeLogs.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ value: count() }).from(dataChangeLogs).where(where),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total = Number(totalRows[0]?.value ?? 0)
|
||||||
|
return {
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
tableName: r.tableName,
|
||||||
|
recordId: r.recordId,
|
||||||
|
action: r.action as "create" | "update" | "delete",
|
||||||
|
oldValue: r.oldValue ?? null,
|
||||||
|
newValue: r.newValue ?? null,
|
||||||
|
changedBy: r.changedBy,
|
||||||
|
changedByName: r.changedByName,
|
||||||
|
ipAddress: r.ipAddress ?? null,
|
||||||
|
createdAt: toIso(r.createdAt),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataChangeStats(): Promise<DataChangeStat[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
tableName: dataChangeLogs.tableName,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(dataChangeLogs)
|
||||||
|
.groupBy(dataChangeLogs.tableName)
|
||||||
|
.orderBy(desc(count()))
|
||||||
|
return rows.map((r) => ({ tableName: r.tableName, count: Number(r.count) }))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataChangeTableOptions(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.selectDistinct({ tableName: dataChangeLogs.tableName })
|
||||||
|
.from(dataChangeLogs)
|
||||||
|
.orderBy(asc(dataChangeLogs.tableName))
|
||||||
|
return rows.map((r) => r.tableName)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export-ready: fetch all audit logs matching params (no pagination cap).
|
||||||
|
*/
|
||||||
|
export async function getAuditLogsForExport(
|
||||||
|
params?: AuditLogQueryParams
|
||||||
|
): Promise<AuditLog[]> {
|
||||||
|
const result = await getAuditLogs({ ...params, page: 1, pageSize: 100 })
|
||||||
|
return result.items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export-ready: fetch all login logs matching params (no pagination cap).
|
||||||
|
*/
|
||||||
|
export async function getLoginLogsForExport(
|
||||||
|
params?: LoginLogQueryParams
|
||||||
|
): Promise<LoginLog[]> {
|
||||||
|
const result = await getLoginLogs({ ...params, page: 1, pageSize: 100 })
|
||||||
|
return result.items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export-ready: fetch all data change logs matching params.
|
||||||
|
*/
|
||||||
|
export async function getDataChangeLogsForExport(
|
||||||
|
params?: DataChangeLogQueryParams
|
||||||
|
): Promise<DataChangeLog[]> {
|
||||||
|
const result = await getDataChangeLogs({ ...params, page: 1, pageSize: 100 })
|
||||||
|
return result.items
|
||||||
|
}
|
||||||
91
src/modules/audit/types.ts
Normal file
91
src/modules/audit/types.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
export type AuditLogStatus = "success" | "failure"
|
||||||
|
|
||||||
|
export type LoginLogAction = "signin" | "signout" | "signup"
|
||||||
|
export type LoginLogStatus = "success" | "failure"
|
||||||
|
|
||||||
|
export type DataChangeAction = "create" | "update" | "delete"
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
userName: string
|
||||||
|
action: string
|
||||||
|
module: string
|
||||||
|
targetId: string | null
|
||||||
|
targetType: string | null
|
||||||
|
detail: string | null
|
||||||
|
ipAddress: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
status: AuditLogStatus
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLog {
|
||||||
|
id: string
|
||||||
|
userId: string | null
|
||||||
|
userEmail: string
|
||||||
|
action: LoginLogAction
|
||||||
|
status: LoginLogStatus
|
||||||
|
ipAddress: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
errorMessage: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataChangeLog {
|
||||||
|
id: string
|
||||||
|
tableName: string
|
||||||
|
recordId: string
|
||||||
|
action: DataChangeAction
|
||||||
|
oldValue: string | null
|
||||||
|
newValue: string | null
|
||||||
|
changedBy: string
|
||||||
|
changedByName: string
|
||||||
|
ipAddress: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataChangeStat {
|
||||||
|
tableName: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogQueryParams {
|
||||||
|
userId?: string
|
||||||
|
module?: string
|
||||||
|
action?: string
|
||||||
|
status?: AuditLogStatus
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLogQueryParams {
|
||||||
|
userId?: string
|
||||||
|
action?: LoginLogAction
|
||||||
|
status?: LoginLogStatus
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataChangeLogQueryParams {
|
||||||
|
tableName?: string
|
||||||
|
recordId?: string
|
||||||
|
action?: DataChangeAction
|
||||||
|
userId?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
@@ -7,36 +7,79 @@ import { toast } from "sonner"
|
|||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Loader2, Github } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
|
const ADULT_AGE = 18
|
||||||
|
|
||||||
|
function calcAge(birth: string): number | null {
|
||||||
|
if (!birth) return null
|
||||||
|
const birthDate = new Date(birth)
|
||||||
|
if (Number.isNaN(birthDate.getTime())) return null
|
||||||
|
const now = new Date()
|
||||||
|
let age = now.getFullYear() - birthDate.getFullYear()
|
||||||
|
const monthDiff = now.getMonth() - birthDate.getMonth()
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
|
||||||
|
age -= 1
|
||||||
|
}
|
||||||
|
return age >= 0 ? age : null
|
||||||
|
}
|
||||||
|
|
||||||
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
|
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
registerAction: (formData: FormData) => Promise<ActionState>
|
registerAction: (formData: FormData) => Promise<ActionState>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
|
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
|
||||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||||
|
const [birthDate, setBirthDate] = React.useState<string>("")
|
||||||
|
const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false)
|
||||||
|
const [agreedGuardian, setAgreedGuardian] = React.useState<boolean>(false)
|
||||||
|
const [guardianRelation, setGuardianRelation] = React.useState<string>("")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const age = React.useMemo(() => calcAge(birthDate), [birthDate])
|
||||||
|
const isMinor = age !== null && age < ADULT_AGE
|
||||||
|
|
||||||
async function onSubmit(event: React.SyntheticEvent) {
|
async function onSubmit(event: React.SyntheticEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
|
if (!agreedTerms) {
|
||||||
|
toast.error("请阅读并同意《隐私政策》和《用户协议》后再注册")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isMinor && !agreedGuardian) {
|
||||||
|
toast.error("未成年人注册须确认已获得监护人同意")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isMinor && !guardianRelation) {
|
||||||
|
toast.error("请选择监护人与您的关系")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const form = event.currentTarget as HTMLFormElement
|
const form = event.currentTarget as HTMLFormElement
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
const res = await registerAction(formData)
|
const res = await registerAction(formData)
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message || "Account created")
|
toast.success(res.message || "账户创建成功")
|
||||||
router.push("/login")
|
router.push("/login")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to create account")
|
toast.error(res.message || "注册失败")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to create account")
|
toast.error("注册失败")
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -45,21 +88,19 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
<div className="flex flex-col space-y-2 text-center">
|
<div className="flex flex-col space-y-2 text-center">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">创建账户</h1>
|
||||||
Create an account
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Enter your email below to create your account
|
填写以下信息以创建您的账户
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">Full Name</Label>
|
<Label htmlFor="name">姓名</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="John Doe"
|
placeholder="请输入姓名"
|
||||||
type="text"
|
type="text"
|
||||||
autoCapitalize="words"
|
autoCapitalize="words"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
@@ -68,7 +109,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">邮箱</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
@@ -81,7 +122,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">密码</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
@@ -90,39 +131,127 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="birthDate">出生日期</Label>
|
||||||
|
<Input
|
||||||
|
id="birthDate"
|
||||||
|
name="birthDate"
|
||||||
|
type="date"
|
||||||
|
disabled={isLoading}
|
||||||
|
value={birthDate}
|
||||||
|
onChange={(e) => setBirthDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{age !== null && (
|
||||||
|
<p className="text-xs text-muted-foreground">当前年龄:{age} 岁</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMinor && (
|
||||||
|
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
||||||
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
|
||||||
|
检测到您是未成年人,请填写监护人信息
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="guardianName">监护人姓名</Label>
|
||||||
|
<Input
|
||||||
|
id="guardianName"
|
||||||
|
name="guardianName"
|
||||||
|
placeholder="请输入监护人姓名"
|
||||||
|
type="text"
|
||||||
|
disabled={isLoading}
|
||||||
|
required={isMinor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="guardianPhone">监护人电话</Label>
|
||||||
|
<Input
|
||||||
|
id="guardianPhone"
|
||||||
|
name="guardianPhone"
|
||||||
|
placeholder="请输入监护人手机号"
|
||||||
|
type="tel"
|
||||||
|
disabled={isLoading}
|
||||||
|
required={isMinor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="guardianRelation">监护人与您的关系</Label>
|
||||||
|
<Select value={guardianRelation} onValueChange={setGuardianRelation}>
|
||||||
|
<SelectTrigger id="guardianRelation">
|
||||||
|
<SelectValue placeholder="请选择关系" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="父亲">父亲</SelectItem>
|
||||||
|
<SelectItem value="母亲">母亲</SelectItem>
|
||||||
|
<SelectItem value="其他法定监护人">其他法定监护人</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="guardianRelation"
|
||||||
|
value={guardianRelation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="agreeTerms"
|
||||||
|
checked={agreedTerms}
|
||||||
|
onCheckedChange={(v) => setAgreedTerms(v === true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal">
|
||||||
|
我已阅读并同意
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
|
||||||
|
>
|
||||||
|
《隐私政策》
|
||||||
|
</Link>
|
||||||
|
和
|
||||||
|
<Link
|
||||||
|
href="/terms"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
|
||||||
|
>
|
||||||
|
《用户协议》
|
||||||
|
</Link>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMinor && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="agreeGuardian"
|
||||||
|
checked={agreedGuardian}
|
||||||
|
onCheckedChange={(v) => setAgreedGuardian(v === true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal">
|
||||||
|
我确认已获得监护人同意使用本服务
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button disabled={isLoading}>
|
<Button disabled={isLoading}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
Create Account
|
创建账户
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
|
||||||
Or continue with
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" type="button" disabled={isLoading}>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Github className="mr-2 h-4 w-4" />
|
|
||||||
)}{" "}
|
|
||||||
GitHub
|
|
||||||
</Button>
|
|
||||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||||
Already have an account?{" "}
|
已有账户?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="underline underline-offset-4 hover:text-primary"
|
className="underline underline-offset-4 hover:text-primary"
|
||||||
>
|
>
|
||||||
Sign in
|
立即登录
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
265
src/modules/course-plans/actions.ts
Normal file
265
src/modules/course-plans/actions.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"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 {
|
||||||
|
CreateCoursePlanSchema,
|
||||||
|
UpdateCoursePlanSchema,
|
||||||
|
CreateCoursePlanItemSchema,
|
||||||
|
UpdateCoursePlanItemSchema,
|
||||||
|
} from "./schema"
|
||||||
|
import {
|
||||||
|
getCoursePlans,
|
||||||
|
getCoursePlanById,
|
||||||
|
createCoursePlan,
|
||||||
|
updateCoursePlan,
|
||||||
|
deleteCoursePlan,
|
||||||
|
createCoursePlanItem,
|
||||||
|
updateCoursePlanItem,
|
||||||
|
deleteCoursePlanItem,
|
||||||
|
} from "./data-access"
|
||||||
|
import type { CoursePlanWithItems, GetCoursePlansParams, CoursePlanListItem } from "./types"
|
||||||
|
|
||||||
|
const handleError = (e: unknown): ActionState<never> => {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const revalidatePlanPaths = (id?: string) => {
|
||||||
|
revalidatePath("/admin/course-plans")
|
||||||
|
revalidatePath("/teacher/course-plans")
|
||||||
|
if (id) {
|
||||||
|
revalidatePath(`/admin/course-plans/${id}`)
|
||||||
|
revalidatePath(`/teacher/course-plans/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCoursePlanAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const parsed = CreateCoursePlanSchema.safeParse({
|
||||||
|
classId: formData.get("classId"),
|
||||||
|
subjectId: formData.get("subjectId"),
|
||||||
|
teacherId: formData.get("teacherId"),
|
||||||
|
academicYearId: formData.get("academicYearId") || undefined,
|
||||||
|
semester: formData.get("semester") || undefined,
|
||||||
|
totalHours: formData.get("totalHours") || undefined,
|
||||||
|
weeklyHours: formData.get("weeklyHours") || undefined,
|
||||||
|
startDate: formData.get("startDate") || undefined,
|
||||||
|
endDate: formData.get("endDate") || undefined,
|
||||||
|
syllabus: formData.get("syllabus") || undefined,
|
||||||
|
objectives: formData.get("objectives") || undefined,
|
||||||
|
status: formData.get("status") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await createCoursePlan(parsed.data, ctx.userId)
|
||||||
|
revalidatePlanPaths(id)
|
||||||
|
return { success: true, message: "Course plan created", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCoursePlanAction(
|
||||||
|
id: string,
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const existing = await getCoursePlanById(id)
|
||||||
|
if (!existing) return { success: false, message: "Course plan not found" }
|
||||||
|
|
||||||
|
const parsed = UpdateCoursePlanSchema.safeParse({
|
||||||
|
classId: formData.get("classId") || undefined,
|
||||||
|
subjectId: formData.get("subjectId") || undefined,
|
||||||
|
teacherId: formData.get("teacherId") || undefined,
|
||||||
|
academicYearId: formData.get("academicYearId") || undefined,
|
||||||
|
semester: formData.get("semester") || undefined,
|
||||||
|
totalHours: formData.get("totalHours") || undefined,
|
||||||
|
completedHours: formData.get("completedHours") || undefined,
|
||||||
|
weeklyHours: formData.get("weeklyHours") || undefined,
|
||||||
|
startDate: formData.get("startDate") || undefined,
|
||||||
|
endDate: formData.get("endDate") || undefined,
|
||||||
|
syllabus: formData.get("syllabus") || undefined,
|
||||||
|
objectives: formData.get("objectives") || undefined,
|
||||||
|
status: formData.get("status") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateCoursePlan(id, parsed.data)
|
||||||
|
revalidatePlanPaths(id)
|
||||||
|
return { success: true, message: "Course plan updated", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCoursePlanAction(
|
||||||
|
id: string
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const existing = await getCoursePlanById(id)
|
||||||
|
if (!existing) return { success: false, message: "Course plan not found" }
|
||||||
|
|
||||||
|
await deleteCoursePlan(id)
|
||||||
|
revalidatePlanPaths()
|
||||||
|
return { success: true, message: "Course plan deleted" }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoursePlansAction(
|
||||||
|
params?: GetCoursePlansParams
|
||||||
|
): Promise<ActionState<CoursePlanListItem[]>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||||
|
const data = await getCoursePlans(params)
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoursePlanAction(
|
||||||
|
id: string
|
||||||
|
): Promise<ActionState<CoursePlanWithItems>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||||
|
const data = await getCoursePlanById(id)
|
||||||
|
if (!data) return { success: false, message: "Course plan not found" }
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCoursePlanItemAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const parsed = CreateCoursePlanItemSchema.safeParse({
|
||||||
|
planId: formData.get("planId"),
|
||||||
|
week: formData.get("week") || undefined,
|
||||||
|
topic: formData.get("topic"),
|
||||||
|
content: formData.get("content") || undefined,
|
||||||
|
hours: formData.get("hours") || undefined,
|
||||||
|
textbookChapter: formData.get("textbookChapter") || undefined,
|
||||||
|
notes: formData.get("notes") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = await createCoursePlanItem(parsed.data)
|
||||||
|
revalidatePlanPaths(parsed.data.planId)
|
||||||
|
return { success: true, message: "Week plan added", data: itemId }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCoursePlanItemAction(
|
||||||
|
id: string,
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const parsed = UpdateCoursePlanItemSchema.safeParse({
|
||||||
|
week: formData.get("week") || undefined,
|
||||||
|
topic: formData.get("topic") || undefined,
|
||||||
|
content: formData.get("content") || undefined,
|
||||||
|
hours: formData.get("hours") || undefined,
|
||||||
|
textbookChapter: formData.get("textbookChapter") || undefined,
|
||||||
|
notes: formData.get("notes") || undefined,
|
||||||
|
isCompleted:
|
||||||
|
formData.get("isCompleted") === "true"
|
||||||
|
? true
|
||||||
|
: formData.get("isCompleted") === "false"
|
||||||
|
? false
|
||||||
|
: undefined,
|
||||||
|
completedAt: formData.get("completedAt") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateCoursePlanItem(id, parsed.data)
|
||||||
|
return { success: true, message: "Week plan updated", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCoursePlanItemAction(
|
||||||
|
id: string
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
await deleteCoursePlanItem(id)
|
||||||
|
return { success: true, message: "Week plan deleted" }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleCoursePlanItemCompletedAction(
|
||||||
|
id: string,
|
||||||
|
completed: boolean
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
await updateCoursePlanItem(id, {
|
||||||
|
isCompleted: completed,
|
||||||
|
completedAt: completed ? new Date().toISOString().slice(0, 10) : null,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: completed ? "Marked as completed" : "Marked as incomplete",
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
258
src/modules/course-plans/components/course-plan-detail.tsx
Normal file
258
src/modules/course-plans/components/course-plan-detail.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
import { usePermission } from "@/shared/hooks/use-permission"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import { CoursePlanProgress } from "./course-plan-progress"
|
||||||
|
import { CoursePlanItemEditor } from "./course-plan-item-editor"
|
||||||
|
import { deleteCoursePlanAction } from "../actions"
|
||||||
|
import type { CoursePlanWithItems, CoursePlanStatus } from "../types"
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
||||||
|
planning: "Planning",
|
||||||
|
active: "Active",
|
||||||
|
completed: "Completed",
|
||||||
|
paused: "Paused",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoursePlanDetail({
|
||||||
|
plan,
|
||||||
|
editHref,
|
||||||
|
backHref,
|
||||||
|
}: {
|
||||||
|
plan: CoursePlanWithItems
|
||||||
|
editHref?: string
|
||||||
|
backHref?: string
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermission()
|
||||||
|
const canManage = hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
const [editingItem, setEditingItem] = useState<CoursePlanWithItems["items"][number] | undefined>()
|
||||||
|
|
||||||
|
const completedItems = plan.items.filter((i) => i.isCompleted).length
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await deleteCoursePlanAction(plan.id)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
const base = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
|
||||||
|
router.push(base)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to delete")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
setDeleteOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateEditor = () => {
|
||||||
|
setEditingItem(undefined)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditEditor = (item: CoursePlanWithItems["items"][number]) => {
|
||||||
|
setEditingItem(item)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{backHref ? (
|
||||||
|
<Button asChild variant="ghost" size="icon">
|
||||||
|
<a href={backHref}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Course Plan</h2>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{editHref ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<a href={editHref}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={() => setDeleteOpen(true)} disabled={isWorking} variant="destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{plan.className ?? "No class"}</Badge>
|
||||||
|
<Badge variant="outline">{plan.subjectName ?? "Unknown subject"}</Badge>
|
||||||
|
<Badge>{STATUS_LABEL[plan.status]}</Badge>
|
||||||
|
<Badge variant="outline">Semester {plan.semester}</Badge>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">
|
||||||
|
{plan.subjectName ?? "Course Plan"} — {plan.className ?? "No Class"}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Teacher: {plan.teacherName ?? "Unassigned"}</span>
|
||||||
|
<span>· Created {formatDate(plan.createdAt)}</span>
|
||||||
|
{plan.startDate ? <span>· Start {formatDate(plan.startDate)}</span> : null}
|
||||||
|
{plan.endDate ? <span>· End {formatDate(plan.endDate)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<CoursePlanProgress
|
||||||
|
completedHours={plan.completedHours}
|
||||||
|
totalHours={plan.totalHours}
|
||||||
|
completedItems={completedItems}
|
||||||
|
totalItems={plan.items.length}
|
||||||
|
/>
|
||||||
|
{plan.syllabus ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-semibold">Syllabus</h4>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.syllabus}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{plan.objectives ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-semibold">Objectives</h4>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{plan.objectives}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle>Weekly Plan</CardTitle>
|
||||||
|
{canManage ? (
|
||||||
|
<Button onClick={openCreateEditor} size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Week
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{plan.items.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No weekly plans yet. {canManage ? "Click \"Add Week\" to create one." : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">Week</TableHead>
|
||||||
|
<TableHead>Topic</TableHead>
|
||||||
|
<TableHead className="w-20">Hours</TableHead>
|
||||||
|
<TableHead className="w-32">Chapter</TableHead>
|
||||||
|
<TableHead className="w-28">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{plan.items.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={canManage ? "cursor-pointer" : ""}
|
||||||
|
onClick={canManage ? () => openEditEditor(item) : undefined}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">{item.week}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">{item.topic}</p>
|
||||||
|
{item.content ? (
|
||||||
|
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{item.content}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{item.notes ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Note: {item.notes}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.hours}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{item.textbookChapter ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={item.isCompleted ? "default" : "secondary"}>
|
||||||
|
{item.isCompleted ? "Done" : "Pending"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CoursePlanItemEditor
|
||||||
|
planId={plan.id}
|
||||||
|
item={editingItem}
|
||||||
|
mode={editingItem ? "edit" : "create"}
|
||||||
|
open={editorOpen}
|
||||||
|
onOpenChange={setEditorOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete course plan</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete this course plan and all its weekly plans.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
284
src/modules/course-plans/components/course-plan-form.tsx
Normal file
284
src/modules/course-plans/components/course-plan-form.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
|
||||||
|
import { createCoursePlanAction, updateCoursePlanAction } from "../actions"
|
||||||
|
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
||||||
|
|
||||||
|
type Mode = "create" | "edit"
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoursePlanForm({
|
||||||
|
mode,
|
||||||
|
plan,
|
||||||
|
classes = [],
|
||||||
|
subjects = [],
|
||||||
|
teachers = [],
|
||||||
|
academicYears = [],
|
||||||
|
backHref,
|
||||||
|
}: {
|
||||||
|
mode: Mode
|
||||||
|
plan?: CoursePlanListItem
|
||||||
|
classes?: Option[]
|
||||||
|
subjects?: Option[]
|
||||||
|
teachers?: Option[]
|
||||||
|
academicYears?: Option[]
|
||||||
|
backHref?: string
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
|
const [classId, setClassId] = useState(plan?.classId ?? "")
|
||||||
|
const [subjectId, setSubjectId] = useState(plan?.subjectId ?? "")
|
||||||
|
const [teacherId, setTeacherId] = useState(plan?.teacherId ?? "")
|
||||||
|
const [semester, setSemester] = useState(plan?.semester ?? "1")
|
||||||
|
const [status, setStatus] = useState(plan?.status ?? "planning")
|
||||||
|
const [academicYearId, setAcademicYearId] = useState(plan?.academicYearId ?? "")
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData) => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
formData.set("classId", classId)
|
||||||
|
formData.set("subjectId", subjectId)
|
||||||
|
formData.set("teacherId", teacherId)
|
||||||
|
formData.set("semester", semester)
|
||||||
|
formData.set("status", status)
|
||||||
|
if (academicYearId) formData.set("academicYearId", academicYearId)
|
||||||
|
|
||||||
|
const res =
|
||||||
|
mode === "create"
|
||||||
|
? await createCoursePlanAction(null, formData)
|
||||||
|
: plan
|
||||||
|
? await updateCoursePlanAction(plan.id, null, formData)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
toast.error("Invalid form state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/course-plans" : "/admin/course-plans"
|
||||||
|
router.push(redirectBase)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to save course plan")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save course plan")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{mode === "create" ? "New Course Plan" : "Edit Course Plan"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Class</Label>
|
||||||
|
<Select value={classId} onValueChange={setClassId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a class" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="classId" value={classId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Subject</Label>
|
||||||
|
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a subject" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="subjectId" value={subjectId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Teacher</Label>
|
||||||
|
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a teacher" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{teachers.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="teacherId" value={teacherId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Academic Year</Label>
|
||||||
|
<Select value={academicYearId} onValueChange={setAcademicYearId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Optional" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{academicYears.map((y) => (
|
||||||
|
<SelectItem key={y.id} value={y.id}>
|
||||||
|
{y.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="academicYearId" value={academicYearId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Semester</Label>
|
||||||
|
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select semester" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">Semester 1</SelectItem>
|
||||||
|
<SelectItem value="2">Semester 2</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="semester" value={semester} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select value={status} onValueChange={(v) => setStatus(v as CoursePlanStatus)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="planning">Planning</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="paused">Paused</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="status" value={status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="totalHours">Total Hours</Label>
|
||||||
|
<Input
|
||||||
|
id="totalHours"
|
||||||
|
name="totalHours"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
defaultValue={plan?.totalHours ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="weeklyHours">Weekly Hours</Label>
|
||||||
|
<Input
|
||||||
|
id="weeklyHours"
|
||||||
|
name="weeklyHours"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
defaultValue={plan?.weeklyHours ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="startDate">Start Date</Label>
|
||||||
|
<Input
|
||||||
|
id="startDate"
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={plan?.startDate ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="endDate">End Date</Label>
|
||||||
|
<Input
|
||||||
|
id="endDate"
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={plan?.endDate ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="syllabus">Syllabus</Label>
|
||||||
|
<Textarea
|
||||||
|
id="syllabus"
|
||||||
|
name="syllabus"
|
||||||
|
placeholder="Teaching syllabus..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
defaultValue={plan?.syllabus ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="objectives">Objectives</Label>
|
||||||
|
<Textarea
|
||||||
|
id="objectives"
|
||||||
|
name="objectives"
|
||||||
|
placeholder="Teaching objectives..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
defaultValue={plan?.objectives ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(backHref ?? "/admin/course-plans")}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking}>
|
||||||
|
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
248
src/modules/course-plans/components/course-plan-item-editor.tsx
Normal file
248
src/modules/course-plans/components/course-plan-item-editor.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Check, Trash2, X } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCoursePlanItemAction,
|
||||||
|
updateCoursePlanItemAction,
|
||||||
|
deleteCoursePlanItemAction,
|
||||||
|
toggleCoursePlanItemCompletedAction,
|
||||||
|
} from "../actions"
|
||||||
|
import type { CoursePlanItem } from "../types"
|
||||||
|
|
||||||
|
interface CoursePlanItemEditorProps {
|
||||||
|
planId: string
|
||||||
|
item?: CoursePlanItem
|
||||||
|
mode: "create" | "edit"
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoursePlanItemEditor({
|
||||||
|
planId,
|
||||||
|
item,
|
||||||
|
mode,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: CoursePlanItemEditorProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData) => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
formData.set("planId", planId)
|
||||||
|
|
||||||
|
const res =
|
||||||
|
mode === "create"
|
||||||
|
? await createCoursePlanItemAction(null, formData)
|
||||||
|
: item
|
||||||
|
? await updateCoursePlanItemAction(item.id, null, formData)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
toast.error("Invalid form state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
onOpenChange(false)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to save week plan")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save week plan")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!item) return
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await deleteCoursePlanItemAction(item.id)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
onOpenChange(false)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to delete")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleComplete = async () => {
|
||||||
|
if (!item) return
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await toggleCoursePlanItemCompletedAction(item.id, !item.isCompleted)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to update")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{mode === "create" ? "Add Week Plan" : "Edit Week Plan"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="week">Week</Label>
|
||||||
|
<Input
|
||||||
|
id="week"
|
||||||
|
name="week"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={item?.week ?? 1}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="hours">Hours</Label>
|
||||||
|
<Input
|
||||||
|
id="hours"
|
||||||
|
name="hours"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={item?.hours ?? 2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="topic">Topic</Label>
|
||||||
|
<Input
|
||||||
|
id="topic"
|
||||||
|
name="topic"
|
||||||
|
placeholder="Week topic"
|
||||||
|
defaultValue={item?.topic ?? ""}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="content">Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
placeholder="Teaching content..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
defaultValue={item?.content ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="textbookChapter">Textbook Chapter</Label>
|
||||||
|
<Input
|
||||||
|
id="textbookChapter"
|
||||||
|
name="textbookChapter"
|
||||||
|
placeholder="e.g. Chapter 3"
|
||||||
|
defaultValue={item?.textbookChapter ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="completedAt">Completed At</Label>
|
||||||
|
<Input
|
||||||
|
id="completedAt"
|
||||||
|
name="completedAt"
|
||||||
|
type="date"
|
||||||
|
defaultValue={item?.completedAt ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
name="notes"
|
||||||
|
placeholder="Notes..."
|
||||||
|
defaultValue={item?.notes ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
{mode === "edit" && item ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleToggleComplete}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
{item.isCompleted ? (
|
||||||
|
<>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
Mark Incomplete
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
Mark Complete
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking}>
|
||||||
|
{isWorking ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
160
src/modules/course-plans/components/course-plan-list.tsx
Normal file
160
src/modules/course-plans/components/course-plan-list.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Plus, CalendarRange } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import { usePermission } from "@/shared/hooks/use-permission"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import { CoursePlanProgress } from "./course-plan-progress"
|
||||||
|
import type { CoursePlanListItem, CoursePlanStatus } from "../types"
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
||||||
|
planning: "Planning",
|
||||||
|
active: "Active",
|
||||||
|
completed: "Completed",
|
||||||
|
paused: "Paused",
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<CoursePlanStatus, "default" | "secondary" | "outline"> = {
|
||||||
|
planning: "secondary",
|
||||||
|
active: "default",
|
||||||
|
completed: "outline",
|
||||||
|
paused: "outline",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Filter = "all" | CoursePlanStatus
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "planning", label: "Planning" },
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "completed", label: "Completed" },
|
||||||
|
{ value: "paused", label: "Paused" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function CoursePlanList({
|
||||||
|
plans,
|
||||||
|
canManage,
|
||||||
|
createHref,
|
||||||
|
detailHrefBuilder,
|
||||||
|
initialStatus,
|
||||||
|
}: {
|
||||||
|
plans: CoursePlanListItem[]
|
||||||
|
canManage?: boolean
|
||||||
|
createHref?: string
|
||||||
|
detailHrefBuilder?: (id: string) => string
|
||||||
|
initialStatus?: Filter
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermission()
|
||||||
|
const canManageResolved = canManage ?? hasPermission(Permissions.COURSE_PLAN_MANAGE)
|
||||||
|
const [filter, setFilter] = useState<Filter>(initialStatus ?? "all")
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === "all") return plans
|
||||||
|
return plans.filter((p) => p.status === filter)
|
||||||
|
}, [plans, filter])
|
||||||
|
|
||||||
|
const handleFilterChange = (value: string) => {
|
||||||
|
setFilter(value as Filter)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (value !== "all") params.set("status", value)
|
||||||
|
const qs = params.toString()
|
||||||
|
router.replace(qs ? `?${qs}` : "?")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Select value={filter} onValueChange={handleFilterChange}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{canManageResolved && createHref ? (
|
||||||
|
<Button asChild>
|
||||||
|
<a href={createHref}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Course Plan
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No course plans"
|
||||||
|
description={
|
||||||
|
plans.length === 0
|
||||||
|
? "There are no course plans yet."
|
||||||
|
: "No course plans match the current filter."
|
||||||
|
}
|
||||||
|
icon={CalendarRange}
|
||||||
|
className="h-auto border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filtered.map((plan) => {
|
||||||
|
const href = detailHrefBuilder ? detailHrefBuilder(plan.id) : undefined
|
||||||
|
const card = (
|
||||||
|
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="line-clamp-2 text-base">
|
||||||
|
{plan.subjectName ?? "Unknown Subject"}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant={STATUS_VARIANT[plan.status]} className="shrink-0">
|
||||||
|
{STATUS_LABEL[plan.status]}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline">{plan.className ?? "No class"}</Badge>
|
||||||
|
<span>Semester {plan.semester}</span>
|
||||||
|
{plan.teacherName ? <span>· {plan.teacherName}</span> : null}
|
||||||
|
</div>
|
||||||
|
<CoursePlanProgress
|
||||||
|
completedHours={plan.completedHours}
|
||||||
|
totalHours={plan.totalHours}
|
||||||
|
showDetails={false}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Created {formatDate(plan.createdAt)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
|
||||||
|
return href ? (
|
||||||
|
<a key={plan.id} href={href} className="block h-full">
|
||||||
|
{card}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div key={plan.id}>{card}</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/modules/course-plans/components/course-plan-progress.tsx
Normal file
38
src/modules/course-plans/components/course-plan-progress.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Progress } from "@/shared/components/ui/progress"
|
||||||
|
|
||||||
|
interface CoursePlanProgressProps {
|
||||||
|
completedHours: number
|
||||||
|
totalHours: number
|
||||||
|
completedItems?: number
|
||||||
|
totalItems?: number
|
||||||
|
showDetails?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoursePlanProgress({
|
||||||
|
completedHours,
|
||||||
|
totalHours,
|
||||||
|
completedItems,
|
||||||
|
totalItems,
|
||||||
|
showDetails = true,
|
||||||
|
}: CoursePlanProgressProps) {
|
||||||
|
const hoursPercent = totalHours > 0 ? Math.round((completedHours / totalHours) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">Progress</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{completedHours} / {totalHours} hours ({hoursPercent}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={hoursPercent} className="h-2" />
|
||||||
|
{showDetails && typeof completedItems === "number" && typeof totalItems === "number" ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{completedItems} of {totalItems} week plans completed
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user