Compare commits
15 Commits
ac859ee560
...
eb08c0ab68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb08c0ab68 | ||
|
|
538805bad0 | ||
|
|
8f974c04e0 | ||
|
|
16ebbbe924 | ||
|
|
175af10881 | ||
|
|
ef9b987653 | ||
|
|
dcc946f48c | ||
|
|
cc02ddf82e | ||
|
|
3986c5919c | ||
|
|
bd8a4d39a6 | ||
|
|
0f38e97b4e | ||
|
|
9c83fcc7c1 | ||
|
|
ddd3227693 | ||
|
|
ca465622dc | ||
|
|
2e9078f810 |
@@ -10,9 +10,10 @@ on:
|
|||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-deploy:
|
||||||
runs-on: CDCD
|
runs-on: CDCD
|
||||||
container: dockerreg.eazygame.cn/node:22-bookworm
|
# 合并 Job:统一使用带 Docker 的 Node 镜像
|
||||||
|
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: "1"
|
SKIP_ENV_VALIDATION: "1"
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
@@ -31,6 +32,30 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-node-
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
|
# 【保留增强】配置代理,确保 npm install 能通
|
||||||
|
- name: Configure npm proxy
|
||||||
|
run: |
|
||||||
|
GATEWAY_IP=$(ip route show | grep default | awk '{print $3}')
|
||||||
|
echo "Detected Docker Gateway: $GATEWAY_IP"
|
||||||
|
|
||||||
|
if [ -z "$GATEWAY_IP" ]; then
|
||||||
|
echo "Warning: Could not detect gateway IP, falling back to 172.17.0.1"
|
||||||
|
GATEWAY_IP="172.17.0.1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROXY_URL="http://$GATEWAY_IP:7890"
|
||||||
|
echo "Using Proxy: $PROXY_URL"
|
||||||
|
|
||||||
|
# 设置 npm 代理
|
||||||
|
npm config set proxy "$PROXY_URL"
|
||||||
|
npm config set https-proxy "$PROXY_URL"
|
||||||
|
|
||||||
|
# 设置环境变量供后续步骤使用
|
||||||
|
echo "http_proxy=$PROXY_URL" >> $GITHUB_ENV
|
||||||
|
echo "https_proxy=$PROXY_URL" >> $GITHUB_ENV
|
||||||
|
echo "HTTP_PROXY=$PROXY_URL" >> $GITHUB_ENV
|
||||||
|
echo "HTTPS_PROXY=$PROXY_URL" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
@@ -54,25 +79,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
# - name: 🔍 Debug - List Build Files
|
|
||||||
# run: |
|
|
||||||
# echo "======================="
|
|
||||||
# echo "1. Root directory files:"
|
|
||||||
# ls -la
|
|
||||||
#
|
|
||||||
# echo "======================="
|
|
||||||
# echo "2. Checking .next directory:"
|
|
||||||
# if [ -d ".next" ]; then
|
|
||||||
# ls -la .next
|
|
||||||
# else
|
|
||||||
# echo "❌ Error: .next folder does not exist!"
|
|
||||||
# fi
|
|
||||||
|
|
||||||
# echo "======================="
|
|
||||||
# echo "3. Deep check of .next (excluding node_modules):"
|
|
||||||
# # 查找 .next 目录下 4 层深度的文件,但排除 node_modules 避免日志太长
|
|
||||||
# find .next -maxdepth 4 -not -path '*/node_modules*'
|
|
||||||
|
|
||||||
- name: Prepare standalone build
|
- name: Prepare standalone build
|
||||||
run: |
|
run: |
|
||||||
@@ -83,42 +89,22 @@ jobs:
|
|||||||
cp -r .next/static/* .next/standalone/.next/static/
|
cp -r .next/static/* .next/standalone/.next/static/
|
||||||
cp Dockerfile .next/standalone/Dockerfile
|
cp Dockerfile .next/standalone/Dockerfile
|
||||||
|
|
||||||
# - name: 🔍 Debug - List Build Files
|
# 【核心变更】合并 Deploy 步骤,直接构建镜像,无需 artifact
|
||||||
# run: |
|
|
||||||
# echo "======================="
|
|
||||||
# ls -la .next/standalone
|
|
||||||
|
|
||||||
- name: Upload production build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: next-build
|
|
||||||
path: .next/standalone
|
|
||||||
include-hidden-files: true
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: build-and-test
|
|
||||||
runs-on: CDCD
|
|
||||||
container:
|
|
||||||
image: dockerreg.eazygame.cn/node-with-docker:22
|
|
||||||
steps:
|
|
||||||
- name: Download artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: next-build
|
|
||||||
|
|
||||||
- name: Deploy to Docker
|
- name: Deploy to Docker
|
||||||
run: |
|
run: |
|
||||||
# 1. 使用 --no-cache 防止使用旧的构建层,确保部署的是最新代码
|
# 1. 进入 standalone 目录
|
||||||
# 2. 使用 --pull 确保基础镜像是最新的
|
cd .next/standalone
|
||||||
docker build --no-cache --pull -t nextjs-app .
|
|
||||||
|
|
||||||
# 3. 优雅停止:先尝试 stop,如果失败则无需处理 (|| true)
|
# 2. 构建镜像 (使用 standalone 目录下的 Dockerfile)
|
||||||
|
echo "Building Docker image from standalone..."
|
||||||
|
docker build --no-cache --pull -t nextjs-app .
|
||||||
|
|
||||||
|
# 3. 优雅停止
|
||||||
docker stop nextjs-app || true
|
docker stop nextjs-app || true
|
||||||
docker rm nextjs-app || true
|
docker rm nextjs-app || true
|
||||||
|
|
||||||
# 4. 运行容器:
|
# 4. 运行容器
|
||||||
# --init: 解决 Node.js PID 1 僵尸进程问题
|
# 使用你后来补充的完整配置 (包含 network 和 NEXTAUTH)
|
||||||
# --restart unless-stopped: 自动重启策略
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--init \
|
--init \
|
||||||
-p 8015:3000 \
|
-p 8015:3000 \
|
||||||
@@ -131,4 +117,5 @@ jobs:
|
|||||||
-e NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }} \
|
-e NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }} \
|
||||||
-e NEXT_TELEMETRY_DISABLED=1 \
|
-e NEXT_TELEMETRY_DISABLED=1 \
|
||||||
nextjs-app
|
nextjs-app
|
||||||
|
|
||||||
|
echo "Deploy complete!"
|
||||||
|
|||||||
@@ -113,3 +113,15 @@ src/modules/
|
|||||||
1. **Refactor Dashboard**: 将 `src/app/(dashboard)/dashboard/page.tsx` 重构为 Dispatcher。
|
1. **Refactor Dashboard**: 将 `src/app/(dashboard)/dashboard/page.tsx` 重构为 Dispatcher。
|
||||||
2. **Create Role Directories**: 在 `src/app/(dashboard)` 下创建 `teacher`, `student`, `admin` 目录。
|
2. **Create Role Directories**: 在 `src/app/(dashboard)` 下创建 `teacher`, `student`, `admin` 目录。
|
||||||
3. **Move Components**: 确保 `src/modules` 结构清晰。
|
3. **Move Components**: 确保 `src/modules` 结构清晰。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. RBAC 扩展(2026-03-02)
|
||||||
|
|
||||||
|
为满足班级维度的权限差异,在教师角色下新增学科粒度的访问控制:
|
||||||
|
|
||||||
|
- 班主任(Class Head Teacher):可查看班级内所有学科相关的数据与统计。
|
||||||
|
- 任课老师(Subject Teacher):仅可查看自己被分配的学科相关内容。
|
||||||
|
- 实现要点:
|
||||||
|
- 数据访问层通过“会话用户身份”与“学科分配表”联合过滤,防止越权。
|
||||||
|
- 页面与组件保持不变,由后端/数据访问层保证返回范围正确的聚合数据。
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ This release extends the Classes domain to support school-level sorting and per-
|
|||||||
|
|
||||||
#### 2.2 Table: `class_subject_teachers`
|
#### 2.2 Table: `class_subject_teachers`
|
||||||
* **Action**: `CREATE TABLE`
|
* **Action**: `CREATE TABLE`
|
||||||
* **Primary Key**: (`class_id`, `subject`)
|
* **Primary Key**: (`class_id`, `subject_id`)
|
||||||
* **Columns**:
|
* **Columns**:
|
||||||
* `class_id` (varchar(128), FK -> `classes.id`, cascade delete)
|
* `class_id` (varchar(128), FK -> `classes.id`, cascade delete)
|
||||||
* `subject` (enum: `语文/数学/英语/美术/体育/科学/社会/音乐`)
|
* `subject_id` (varchar(128), FK -> `subjects.id`, cascade delete)
|
||||||
* `teacher_id` (varchar(128), FK -> `users.id`, set null on delete)
|
* `teacher_id` (varchar(128), FK -> `users.id`, set null on delete)
|
||||||
* `created_at`, `updated_at`
|
* `created_at`, `updated_at`
|
||||||
* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject.
|
* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject.
|
||||||
|
|||||||
@@ -240,3 +240,20 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
|
|||||||
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
|
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
|
||||||
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
|
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
|
||||||
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。
|
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 教师加入班级学科分配逻辑修复 (2026-03-03)
|
||||||
|
|
||||||
|
**日期**: 2026-03-03
|
||||||
|
**范围**: 教师通过邀请码加入班级(含学科选择)的校验与分配
|
||||||
|
|
||||||
|
### 9.1 行为调整
|
||||||
|
|
||||||
|
- 教师已在班级中但选择学科加入时,不再直接返回成功,继续执行学科占用校验。
|
||||||
|
- 班级未创建该学科映射时,先补齐映射再分配,避免误报“该班级不提供该学科”。
|
||||||
|
- 学科已被其他老师占用时,返回明确提示。
|
||||||
|
|
||||||
|
### 9.2 影响代码
|
||||||
|
|
||||||
|
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
|
||||||
|
|||||||
@@ -154,9 +154,10 @@ src/
|
|||||||
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
|
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
|
||||||
|
|
||||||
### 9.1 卡片与列表 (Textbook Card & Filters)
|
### 9.1 卡片与列表 (Textbook Card & Filters)
|
||||||
* **Dynamic Covers**: 卡片封面采用动态渐变色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),提升识别度。
|
* **Pure Color Covers**: 卡片封面采用简洁纯色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),弱化纹理与渐变,整体更清爽。
|
||||||
* **Information Density**: 增加元数据展示(Grade, Publisher, Chapter Count),并优化排版层级。
|
* **Information Density**: 增加元数据展示(Grade, Publisher, Chapter Count),并优化排版层级。
|
||||||
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
|
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
|
||||||
|
* **Icon Polish**: 图标采用简洁方块底与统一尺寸,降低视觉噪音。
|
||||||
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
|
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
|
||||||
|
|
||||||
### 9.2 详情页工作台 (Detail Workbench)
|
### 9.2 详情页工作台 (Detail Workbench)
|
||||||
|
|||||||
@@ -130,3 +130,21 @@ type QuestionContent = {
|
|||||||
### 6.6 校验
|
### 6.6 校验
|
||||||
- `npm run lint`:0 errors(仓库其他位置仍存在 warnings)
|
- `npm run lint`:0 errors(仓库其他位置仍存在 warnings)
|
||||||
- `npm run typecheck`:通过
|
- `npm run typecheck`:通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实现更新(2026-03-03)
|
||||||
|
|
||||||
|
### 7.1 登录态与权限校验
|
||||||
|
- 题库创建/更新/知识点加载统一使用会话身份;缺失会话时回退到首个教师账号以保持演示可用。
|
||||||
|
- 主要修改:
|
||||||
|
- [actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/questions/actions.ts)
|
||||||
|
|
||||||
|
### 7.2 弹窗稳定性
|
||||||
|
- Create Question 弹窗在打开时仅在默认知识点变化时更新,避免重复 setState 造成循环更新。
|
||||||
|
- 主要修改:
|
||||||
|
- [create-question-dialog.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/questions/components/create-question-dialog.tsx)
|
||||||
|
|
||||||
|
### 7.3 校验
|
||||||
|
- `npm run lint`:通过
|
||||||
|
- `npm run typecheck`:通过
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ type ExamNode = {
|
|||||||
|
|
||||||
## 7. 变更记录
|
## 7. 变更记录
|
||||||
|
|
||||||
|
**日期**:2026-03-03
|
||||||
|
|
||||||
|
- **题库列表稳定性**:
|
||||||
|
- 题库卡片对题目 content/type 做解析兜底,避免异常数据导致运行时崩溃。
|
||||||
|
- 主要修改: [question-bank-list.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/exams/components/assembly/question-bank-list.tsx)
|
||||||
|
|
||||||
**日期**:2026-01-12 (当前)
|
**日期**:2026-01-12 (当前)
|
||||||
|
|
||||||
- **列表页优化 (`/teacher/exams/all`)**:
|
- **列表页优化 (`/teacher/exams/all`)**:
|
||||||
|
|||||||
@@ -304,3 +304,18 @@
|
|||||||
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
|
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
|
||||||
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。
|
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 实现更新(2026-03-03)
|
||||||
|
|
||||||
|
### 14.1 学生作业状态修复
|
||||||
|
- 提交作业与学生列表查询改为使用真实登录用户,避免提交后仍显示未开始。
|
||||||
|
- 学生列表优先展示最近一次已提交/已评分记录,提升状态准确性。
|
||||||
|
- 主要修改:
|
||||||
|
- [actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/actions.ts)
|
||||||
|
- [data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
|
||||||
|
|
||||||
|
### 14.2 校验
|
||||||
|
- `npm run lint`:通过
|
||||||
|
- `npm run typecheck`:通过
|
||||||
|
|
||||||
|
|||||||
313
docs/design/008_teacher_pages_implementation.md
Normal file
313
docs/design/008_teacher_pages_implementation.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# 教师端页面实现分析文档
|
||||||
|
|
||||||
|
**日期**: 2026-03-03
|
||||||
|
**范围**: Teacher 路由与页面实现(`src/app/(dashboard)/teacher`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总览
|
||||||
|
|
||||||
|
教师端页面采用服务端组件为主、按页面聚合数据的方式实现,页面负责:
|
||||||
|
|
||||||
|
- 读取数据(模块 data-access)
|
||||||
|
- 组装 UI(模块 components)
|
||||||
|
- 处理空状态与跳转
|
||||||
|
|
||||||
|
所有页面路由位于 `src/app/(dashboard)/teacher`,各业务能力落在 `src/modules/*` 中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 路由总表
|
||||||
|
|
||||||
|
### 2.1 教师工作台
|
||||||
|
- `/teacher/dashboard`
|
||||||
|
实现:[dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx)
|
||||||
|
|
||||||
|
### 2.2 班级
|
||||||
|
- `/teacher/classes` → `/teacher/classes/my`
|
||||||
|
实现:[classes/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/page.tsx)
|
||||||
|
- `/teacher/classes/my`
|
||||||
|
实现:[classes/my/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/page.tsx)
|
||||||
|
- `/teacher/classes/my/[id]`
|
||||||
|
实现:[classes/my/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
|
||||||
|
- `/teacher/classes/students`
|
||||||
|
实现:[classes/students/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/students/page.tsx)
|
||||||
|
- `/teacher/classes/schedule`
|
||||||
|
实现:[classes/schedule/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/schedule/page.tsx)
|
||||||
|
|
||||||
|
### 2.3 作业
|
||||||
|
- `/teacher/homework` → `/teacher/homework/assignments`
|
||||||
|
实现:[homework/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/page.tsx)
|
||||||
|
- `/teacher/homework/assignments`
|
||||||
|
实现:[homework/assignments/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
|
||||||
|
- `/teacher/homework/assignments/create`
|
||||||
|
实现:[homework/assignments/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
|
||||||
|
- `/teacher/homework/assignments/[id]`
|
||||||
|
实现:[homework/assignments/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
|
||||||
|
- `/teacher/homework/assignments/[id]/submissions`
|
||||||
|
实现:[homework/assignments/[id]/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
|
||||||
|
- `/teacher/homework/submissions`
|
||||||
|
实现:[homework/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
|
||||||
|
- `/teacher/homework/submissions/[submissionId]`
|
||||||
|
实现:[homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
|
||||||
|
|
||||||
|
### 2.4 考试
|
||||||
|
- `/teacher/exams` → `/teacher/exams/all`
|
||||||
|
实现:[exams/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/page.tsx)
|
||||||
|
- `/teacher/exams/all`
|
||||||
|
实现:[exams/all/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/all/page.tsx)
|
||||||
|
- `/teacher/exams/create`
|
||||||
|
实现:[exams/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/create/page.tsx)
|
||||||
|
- `/teacher/exams/[id]/build`
|
||||||
|
实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)
|
||||||
|
- `/teacher/exams/grading` → `/teacher/homework/submissions`
|
||||||
|
实现:[exams/grading/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)
|
||||||
|
- `/teacher/exams/grading/[submissionId]` → `/teacher/homework/submissions`
|
||||||
|
实现:[exams/grading/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
|
||||||
|
|
||||||
|
### 2.5 题库
|
||||||
|
- `/teacher/questions`
|
||||||
|
实现:[questions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/questions/page.tsx)
|
||||||
|
|
||||||
|
### 2.6 教材
|
||||||
|
- `/teacher/textbooks`
|
||||||
|
实现:[textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx)
|
||||||
|
- `/teacher/textbooks/[id]`
|
||||||
|
实现:[textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/%5Bid%5D/page.tsx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 页面详解(逐页)
|
||||||
|
|
||||||
|
### 3.1 教师工作台 `/teacher/dashboard`
|
||||||
|
实现:[dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 教师总览工作台,展示班级、课表、作业、提交、成绩趋势与教师姓名。
|
||||||
|
- **数据来源**:
|
||||||
|
- 班级与课表:`getTeacherClasses`、`getClassSchedule`
|
||||||
|
- 作业与提交:`getHomeworkAssignments`、`getHomeworkSubmissions`
|
||||||
|
- 教师姓名:`users` 表查询
|
||||||
|
- 成绩趋势:`getTeacherGradeTrends`
|
||||||
|
- **关键组件**: `TeacherDashboardView`
|
||||||
|
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||||
|
|
||||||
|
### 3.2 班级入口 `/teacher/classes`
|
||||||
|
实现:[classes/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 跳转入口,统一导向“我的班级”。
|
||||||
|
- **行为**: `redirect("/teacher/classes/my")`
|
||||||
|
|
||||||
|
### 3.3 我的班级 `/teacher/classes/my`
|
||||||
|
实现:[classes/my/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 展示教师负责班级的卡片列表,并支持学科筛选/加入。
|
||||||
|
- **数据来源**:
|
||||||
|
- 班级:`getTeacherClasses`
|
||||||
|
- 学科选项:`getClassSubjects`
|
||||||
|
- **关键组件**: `MyClassesGrid`
|
||||||
|
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||||
|
|
||||||
|
### 3.4 班级详情 `/teacher/classes/my/[id]`
|
||||||
|
实现:[classes/my/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 展示班级概览(作业趋势、学生、课表、作业摘要)。
|
||||||
|
- **数据来源**:
|
||||||
|
- 班级作业洞察:`getClassHomeworkInsights`
|
||||||
|
- 学生列表:`getClassStudents`
|
||||||
|
- 课表:`getClassSchedule`
|
||||||
|
- 学科成绩:`getClassStudentSubjectScoresV2`
|
||||||
|
- **关键组件**:
|
||||||
|
- `ClassHeader`
|
||||||
|
- `ClassOverviewStats`
|
||||||
|
- `ClassTrendsWidget`
|
||||||
|
- `ClassStudentsWidget`
|
||||||
|
- `ClassScheduleWidget`
|
||||||
|
- `ClassAssignmentsWidget`
|
||||||
|
- **空状态**: `insights` 缺失返回 `notFound()`
|
||||||
|
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||||
|
|
||||||
|
### 3.5 学生列表 `/teacher/classes/students`
|
||||||
|
实现:[classes/students/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/students/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 按班级、关键词、状态筛选学生,并显示学科成绩。
|
||||||
|
- **数据来源**:
|
||||||
|
- 教师班级:`getTeacherClasses`
|
||||||
|
- 学生列表:`getClassStudents`
|
||||||
|
- 学科成绩:`getStudentsSubjectScores`
|
||||||
|
- **关键组件**:
|
||||||
|
- `StudentsFilters`
|
||||||
|
- `StudentsTable`
|
||||||
|
- `EmptyState` / `Skeleton`
|
||||||
|
- **筛选逻辑**: 未显式选择班级时默认第一班级
|
||||||
|
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||||
|
|
||||||
|
### 3.6 课表 `/teacher/classes/schedule`
|
||||||
|
实现:[classes/schedule/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/schedule/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 按班级查看课表。
|
||||||
|
- **数据来源**:
|
||||||
|
- 班级:`getTeacherClasses`
|
||||||
|
- 课表:`getClassSchedule`
|
||||||
|
- **关键组件**:
|
||||||
|
- `ScheduleFilters`
|
||||||
|
- `ScheduleView`
|
||||||
|
- `EmptyState` / `Skeleton`
|
||||||
|
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||||
|
|
||||||
|
### 3.7 作业入口 `/teacher/homework`
|
||||||
|
实现:[homework/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 统一导向作业列表。
|
||||||
|
- **行为**: `redirect("/teacher/homework/assignments")`
|
||||||
|
|
||||||
|
### 3.8 作业列表 `/teacher/homework/assignments`
|
||||||
|
实现:[homework/assignments/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 查看作业列表与状态,支持按班级筛选,提供创建入口。
|
||||||
|
- **数据来源**:
|
||||||
|
- 作业列表:`getHomeworkAssignments`
|
||||||
|
- 班级列表(用于显示名称):`getTeacherClasses`
|
||||||
|
- **关键组件**: `Table`、`Badge`、`EmptyState`
|
||||||
|
- **空状态**: 无作业时提示创建
|
||||||
|
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||||
|
|
||||||
|
### 3.9 创建作业 `/teacher/homework/assignments/create`
|
||||||
|
实现:[homework/assignments/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 从 Exam 派发作业。
|
||||||
|
- **数据来源**:
|
||||||
|
- 可派发的 Exam:`getExams`
|
||||||
|
- 班级列表:`getTeacherClasses`
|
||||||
|
- **关键组件**: `HomeworkAssignmentForm`
|
||||||
|
- **空状态**:
|
||||||
|
- 无 Exam:提示先创建考试
|
||||||
|
- 无班级:提示先创建班级
|
||||||
|
|
||||||
|
### 3.10 作业详情 `/teacher/homework/assignments/[id]`
|
||||||
|
实现:[homework/assignments/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 展示作业详情、错题概览与试卷内容。
|
||||||
|
- **数据来源**: `getHomeworkAssignmentAnalytics`
|
||||||
|
- **关键组件**:
|
||||||
|
- `HomeworkAssignmentQuestionErrorOverviewCard`
|
||||||
|
- `HomeworkAssignmentExamContentCard`
|
||||||
|
- **空状态**: 查不到作业时 `notFound()`
|
||||||
|
|
||||||
|
### 3.11 作业提交列表 `/teacher/homework/assignments/[id]/submissions`
|
||||||
|
实现:[homework/assignments/[id]/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 按作业查看提交与评分进度。
|
||||||
|
- **数据来源**:
|
||||||
|
- 作业信息:`getHomeworkAssignmentById`
|
||||||
|
- 提交列表:`getHomeworkSubmissions`
|
||||||
|
- **关键组件**: `Table`、`Badge`
|
||||||
|
- **空状态**: 作业不存在时 `notFound()`
|
||||||
|
|
||||||
|
### 3.12 全部提交 `/teacher/homework/submissions`
|
||||||
|
实现:[homework/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 按作业汇总查看所有提交与批改入口。
|
||||||
|
- **数据来源**:
|
||||||
|
- 教师身份:`getTeacherIdForMutations`
|
||||||
|
- 作业审阅列表:`getHomeworkAssignmentReviewList`
|
||||||
|
- **关键组件**: `Table`、`Badge`、`EmptyState`
|
||||||
|
|
||||||
|
### 3.13 提交批改 `/teacher/homework/submissions/[submissionId]`
|
||||||
|
实现:[homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 作业批改视图,按题打分与反馈。
|
||||||
|
- **数据来源**: `getHomeworkSubmissionDetails`
|
||||||
|
- **关键组件**: `HomeworkGradingView`
|
||||||
|
- **空状态**: 查不到提交时 `notFound()`
|
||||||
|
|
||||||
|
### 3.14 考试入口 `/teacher/exams`
|
||||||
|
实现:[exams/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 统一导向考试列表。
|
||||||
|
- **行为**: `redirect("/teacher/exams/all")`
|
||||||
|
|
||||||
|
### 3.15 考试列表 `/teacher/exams/all`
|
||||||
|
实现:[exams/all/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/all/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 列出考试并支持筛选、统计、创建入口。
|
||||||
|
- **数据来源**: `getExams`(按关键词/状态/难度过滤)
|
||||||
|
- **关键组件**:
|
||||||
|
- `ExamFilters`
|
||||||
|
- `ExamDataTable`
|
||||||
|
- `EmptyState` / `Skeleton`
|
||||||
|
- **统计**: 对列表结果进行状态数量统计(draft/published/archived)
|
||||||
|
|
||||||
|
### 3.16 创建考试 `/teacher/exams/create`
|
||||||
|
实现:[exams/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/create/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 创建考试基础信息。
|
||||||
|
- **关键组件**: `ExamForm`
|
||||||
|
|
||||||
|
### 3.17 组卷 `/teacher/exams/[id]/build`
|
||||||
|
实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 从题库选择题目并构建考试结构。
|
||||||
|
- **数据来源**:
|
||||||
|
- 考试数据:`getExamById`
|
||||||
|
- 题库数据:`getQuestions`
|
||||||
|
- **关键组件**: `ExamAssembly`
|
||||||
|
- **关键逻辑**:
|
||||||
|
- 读取已选题并初始化 `initialSelected`
|
||||||
|
- 将题目数据映射为 `Question` 类型
|
||||||
|
- 归一化 `structure` 并保证节点 `id` 唯一
|
||||||
|
|
||||||
|
### 3.18 阅卷入口 `/teacher/exams/grading*`
|
||||||
|
实现:[exams/grading/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)、[exams/grading/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 统一重定向至作业批改视图。
|
||||||
|
- **行为**: `redirect("/teacher/homework/submissions")`
|
||||||
|
|
||||||
|
### 3.19 题库 `/teacher/questions`
|
||||||
|
实现:[questions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/questions/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 题库管理与筛选。
|
||||||
|
- **数据来源**: `getQuestions`(按关键词/题型/难度过滤)
|
||||||
|
- **关键组件**:
|
||||||
|
- `QuestionFilters`
|
||||||
|
- `QuestionDataTable`
|
||||||
|
- `CreateQuestionButton`
|
||||||
|
- `EmptyState` / `Skeleton`
|
||||||
|
|
||||||
|
### 3.20 教材列表 `/teacher/textbooks`
|
||||||
|
实现:[textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 教材管理与筛选,创建入口。
|
||||||
|
- **数据来源**: `getTextbooks`
|
||||||
|
- **关键组件**:
|
||||||
|
- `TextbookFilters`
|
||||||
|
- `TextbookCard`
|
||||||
|
- `TextbookFormDialog`
|
||||||
|
- `EmptyState`
|
||||||
|
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||||
|
|
||||||
|
### 3.21 教材详情 `/teacher/textbooks/[id]`
|
||||||
|
实现:[textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/%5Bid%5D/page.tsx)
|
||||||
|
|
||||||
|
- **目的**: 教材章节与知识点结构阅读与维护。
|
||||||
|
- **数据来源**:
|
||||||
|
- 教材:`getTextbookById`
|
||||||
|
- 章节:`getChaptersByTextbookId`
|
||||||
|
- 知识点:`getKnowledgePointsByTextbookId`
|
||||||
|
- **关键组件**:
|
||||||
|
- `TextbookReader`
|
||||||
|
- `TextbookSettingsDialog`
|
||||||
|
- **空状态**: 教材不存在时 `notFound()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 依赖模块清单
|
||||||
|
|
||||||
|
教师端页面主要依赖以下模块:
|
||||||
|
|
||||||
|
- 班级与排课:`src/modules/classes`
|
||||||
|
- 作业:`src/modules/homework`
|
||||||
|
- 考试:`src/modules/exams`
|
||||||
|
- 题库:`src/modules/questions`
|
||||||
|
- 教材:`src/modules/textbooks`
|
||||||
|
- 工作台:`src/modules/dashboard`
|
||||||
|
|
||||||
150
docs/design/009_feature_gap_analysis.md
Normal file
150
docs/design/009_feature_gap_analysis.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# 功能实现对比文档(已实现 vs 规划)
|
||||||
|
|
||||||
|
**日期**: 2026-03-03
|
||||||
|
**范围**: 基于 PRD 与现有设计文档的功能落地对比
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 依据与来源
|
||||||
|
|
||||||
|
本对比基于以下文档:
|
||||||
|
|
||||||
|
- 产品规划:PRD(`docs/product_requirements.md`)
|
||||||
|
- 已实现模块:
|
||||||
|
- 教师仪表盘与班级管理:[002_teacher_dashboard_implementation.md](file:///e:/Desktop/CICD/docs/design/002_teacher_dashboard_implementation.md)
|
||||||
|
- 教材模块:[003_textbooks_module_implementation.md](file:///e:/Desktop/CICD/docs/design/003_textbooks_module_implementation.md)
|
||||||
|
- 题库模块:[004_question_bank_implementation.md](file:///e:/Desktop/CICD/docs/design/004_question_bank_implementation.md)
|
||||||
|
- 考试模块:[005_exam_module_implementation.md](file:///e:/Desktop/CICD/docs/design/005_exam_module_implementation.md)
|
||||||
|
- 作业模块:[006_homework_module_implementation.md](file:///e:/Desktop/CICD/docs/design/006_homework_module_implementation.md)
|
||||||
|
- 学校管理模块:[007_school_module_implementation.md](file:///e:/Desktop/CICD/docs/design/007_school_module_implementation.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 便利贴视图(按 Roles / Page / 功能)
|
||||||
|
|
||||||
|
> **🟨 Roles**
|
||||||
|
>
|
||||||
|
> - **教师(Teacher)**:备课、出卷、作业批改与班级管理
|
||||||
|
> - **学生(Student)**:作业完成与教材阅读(只读)
|
||||||
|
> - **管理端(Admin/校长/年级主任/教研组长/班主任)**:规划中,未完全落地
|
||||||
|
|
||||||
|
> **🟨 Page**
|
||||||
|
>
|
||||||
|
> - **教师工作台**:`/teacher/dashboard`
|
||||||
|
> - **班级**:`/teacher/classes/*`
|
||||||
|
> - **作业**:`/teacher/homework/*`
|
||||||
|
> - **考试**:`/teacher/exams/*`
|
||||||
|
> - **题库**:`/teacher/questions`
|
||||||
|
> - **教材**:`/teacher/textbooks/*`
|
||||||
|
|
||||||
|
> **🟨 功能(规划目标)**
|
||||||
|
>
|
||||||
|
> - **权限与角色**:多角色矩阵 + RLS 行级隔离
|
||||||
|
> - **智能题库**:嵌套题、知识点关联
|
||||||
|
> - **知识图谱**:知识点树 + 题目/章节关联
|
||||||
|
> - **教材映射**:章节 ↔ 知识点
|
||||||
|
> - **组卷引擎**:筛选/分组/结构化试卷
|
||||||
|
> - **作业闭环**:派发-提交-批改-统计
|
||||||
|
> - **通知中心**:分级提醒策略
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 已实现页面功能清单(简述)
|
||||||
|
|
||||||
|
> **🟨 教师工作台**
|
||||||
|
>
|
||||||
|
> - `/teacher/dashboard`
|
||||||
|
> 功能:汇总班级、课表、作业、提交与成绩趋势
|
||||||
|
> 目标:快速掌握教学全局
|
||||||
|
|
||||||
|
> **🟨 班级**
|
||||||
|
>
|
||||||
|
> - `/teacher/classes`
|
||||||
|
> 功能:班级入口重定向
|
||||||
|
> 目标:统一进入我的班级
|
||||||
|
> - `/teacher/classes/my`
|
||||||
|
> 功能:班级列表与学科选择
|
||||||
|
> 目标:管理所教班级
|
||||||
|
> - `/teacher/classes/my/[id]`
|
||||||
|
> 功能:班级详情概览、作业趋势、学生与课表
|
||||||
|
> 目标:掌握班级学习情况
|
||||||
|
> - `/teacher/classes/students`
|
||||||
|
> 功能:学生筛选与成绩查看
|
||||||
|
> 目标:定位学生画像与状态
|
||||||
|
> - `/teacher/classes/schedule`
|
||||||
|
> 功能:班级课表查看
|
||||||
|
> 目标:排课信息可视化
|
||||||
|
|
||||||
|
> **🟨 作业**
|
||||||
|
>
|
||||||
|
> - `/teacher/homework/assignments`
|
||||||
|
> 功能:作业列表与状态
|
||||||
|
> 目标:管理作业发布
|
||||||
|
> - `/teacher/homework/assignments/create`
|
||||||
|
> 功能:从考试派发作业
|
||||||
|
> 目标:快速生成作业
|
||||||
|
> - `/teacher/homework/assignments/[id]`
|
||||||
|
> 功能:作业详情与错题概览
|
||||||
|
> 目标:定位薄弱题型
|
||||||
|
> - `/teacher/homework/assignments/[id]/submissions`
|
||||||
|
> 功能:作业提交列表
|
||||||
|
> 目标:查看班级完成度
|
||||||
|
> - `/teacher/homework/submissions`
|
||||||
|
> 功能:所有作业提交汇总
|
||||||
|
> 目标:统一批改入口
|
||||||
|
> - `/teacher/homework/submissions/[submissionId]`
|
||||||
|
> 功能:作业批改与反馈
|
||||||
|
> 目标:完成评分与讲评
|
||||||
|
|
||||||
|
> **🟨 考试**
|
||||||
|
>
|
||||||
|
> - `/teacher/exams/all`
|
||||||
|
> 功能:考试列表与筛选
|
||||||
|
> 目标:管理考试资产
|
||||||
|
> - `/teacher/exams/create`
|
||||||
|
> 功能:创建考试基础信息
|
||||||
|
> 目标:建立试卷草稿
|
||||||
|
> - `/teacher/exams/[id]/build`
|
||||||
|
> 功能:题库选题与结构化组卷
|
||||||
|
> 目标:完成试卷构建
|
||||||
|
|
||||||
|
> **🟨 题库**
|
||||||
|
>
|
||||||
|
> - `/teacher/questions`
|
||||||
|
> 功能:题库检索与管理
|
||||||
|
> 目标:积累与复用题目
|
||||||
|
|
||||||
|
> **🟨 教材**
|
||||||
|
>
|
||||||
|
> - `/teacher/textbooks`
|
||||||
|
> 功能:教材管理与筛选
|
||||||
|
> 目标:组织课程资源
|
||||||
|
> - `/teacher/textbooks/[id]`
|
||||||
|
> 功能:章节与知识点维护
|
||||||
|
> 目标:构建教材结构
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 差距清单(简述)
|
||||||
|
|
||||||
|
> **🟨 权限与治理**
|
||||||
|
>
|
||||||
|
> - 多角色 RBAC 细化权限未落地
|
||||||
|
> - RLS 数据隔离策略未落地
|
||||||
|
|
||||||
|
> **🟨 教学质量与推荐**
|
||||||
|
>
|
||||||
|
> - 章节 → 知识点 → 题库的自动推荐链路未落地
|
||||||
|
> - 知识点图谱深层能力未落地
|
||||||
|
> - 学科维度与权重/标签机制未落地
|
||||||
|
|
||||||
|
> **🟨 组卷与作业高级能力**
|
||||||
|
>
|
||||||
|
> - AB 卷与乱序策略未落地
|
||||||
|
> - 作业分层与交集筛选未落地
|
||||||
|
> - 学习画像/成长档案层的评估闭环尚未体现
|
||||||
|
|
||||||
|
> **🟨 通知与消息闭环**
|
||||||
|
>
|
||||||
|
> - 分级通知体系未落地
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { users, exams, questions, knowledgePoints, examSubmissions, examQuestion
|
|||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { faker } from "@faker-js/faker"
|
import { faker } from "@faker-js/faker"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
import { hash } from "bcryptjs"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed Script for Next_Edu
|
* Seed Script for Next_Edu
|
||||||
@@ -19,6 +20,7 @@ const DIFFICULTY = [1, 2, 3, 4, 5]
|
|||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
console.log("🌱 Starting seed process...")
|
console.log("🌱 Starting seed process...")
|
||||||
|
const passwordHash = await hash("123456", 10)
|
||||||
|
|
||||||
// 1. Create a Teacher User if not exists
|
// 1. Create a Teacher User if not exists
|
||||||
const teacherEmail = "teacher@example.com"
|
const teacherEmail = "teacher@example.com"
|
||||||
@@ -36,6 +38,7 @@ async function seed() {
|
|||||||
email: teacherEmail,
|
email: teacherEmail,
|
||||||
role: "teacher",
|
role: "teacher",
|
||||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher",
|
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher",
|
||||||
|
password: passwordHash,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
teacherId = existingTeacher.id
|
teacherId = existingTeacher.id
|
||||||
@@ -54,6 +57,7 @@ async function seed() {
|
|||||||
email: faker.internet.email(),
|
email: faker.internet.email(),
|
||||||
role: "student",
|
role: "student",
|
||||||
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`,
|
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`,
|
||||||
|
password: passwordHash,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
# Work Log
|
# Work Log
|
||||||
|
|
||||||
|
## 2026-03-03
|
||||||
|
|
||||||
|
### 1. 教师加入班级学科分配逻辑修复
|
||||||
|
- 修复教师已在班级中但选择学科加入时被直接返回成功的问题。
|
||||||
|
- 班级未创建该学科映射时,先补齐映射再分配。
|
||||||
|
- 学科已被其他老师占用时,返回明确提示。
|
||||||
|
- 主要修改:
|
||||||
|
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
|
||||||
|
|
||||||
|
### 2. 验证
|
||||||
|
- 质量检查:`npm run lint`、`npm run typecheck` 均通过。
|
||||||
|
|
||||||
|
## 2026-03-02
|
||||||
|
|
||||||
|
### 1. 班级详情访问修复(基于会话身份)
|
||||||
|
- 将数据获取中的教师 ID 来源改为会话用户,移除默认教师 ID 逻辑,修复“新加入班级无法查看班级详情”问题。
|
||||||
|
- 主要修改:
|
||||||
|
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
|
||||||
|
- [page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
|
||||||
|
|
||||||
|
### 2. 任课权限与班主任权限区分(学科可见范围)
|
||||||
|
- 班主任可查看班级下所有学科;任课老师仅可查看自己负责的学科。
|
||||||
|
- 新增并应用分配学科查询,过滤相应统计与列表。
|
||||||
|
- 主要修改:
|
||||||
|
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
|
||||||
|
|
||||||
|
### 3. 新注册学生默认班级问题修复
|
||||||
|
- 调整示例学生获取逻辑,避免为新注册学生展示“默认班级”。
|
||||||
|
- 主要修改:
|
||||||
|
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/homework/data-access.ts)
|
||||||
|
|
||||||
|
### 4. 教材列表 UI 精简为纯色风格
|
||||||
|
- 将教材卡片头部背景从渐变与纹理改为简洁纯色;图标容器调整为简洁样式,视觉噪音更低。
|
||||||
|
- 主要修改:
|
||||||
|
- [textbook-card.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-card.tsx)
|
||||||
|
|
||||||
|
### 5. 学生导航清理未实现入口
|
||||||
|
- 移除学生侧导航的“Resources”入口(页面未实现,避免死链)。
|
||||||
|
- 主要修改:
|
||||||
|
- [navigation.ts](file:///e:/Desktop/CICD/src/modules/layout/config/navigation.ts)
|
||||||
|
|
||||||
|
### 6. 验证
|
||||||
|
- 质量检查:`npm run lint`、`npm run typecheck` 均通过。
|
||||||
|
|
||||||
## 2026-02-24
|
## 2026-02-24
|
||||||
|
|
||||||
### 1. Credentials 登录与密码安全修复
|
### 1. Credentials 登录与密码安全修复
|
||||||
|
|||||||
88
drizzle/0009_smart_mephistopheles.sql
Normal file
88
drizzle/0009_smart_mephistopheles.sql
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
SET @has_exams_subject := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'exams'
|
||||||
|
AND COLUMN_NAME = 'subject_id'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_exams_subject = 0, 'ALTER TABLE `exams` ADD `subject_id` varchar(128);', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_exams_grade := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'exams'
|
||||||
|
AND COLUMN_NAME = 'grade_id'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_exams_grade = 0, 'ALTER TABLE `exams` ADD `grade_id` varchar(128);', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_kp_anchor := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'knowledge_points'
|
||||||
|
AND COLUMN_NAME = 'anchor_text'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_kp_anchor = 0, 'ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_exams_subject_fk := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'exams'
|
||||||
|
AND CONSTRAINT_NAME = 'exams_subject_id_subjects_id_fk'
|
||||||
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_exams_subject_fk = 0, 'ALTER TABLE `exams` ADD CONSTRAINT `exams_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_exams_grade_fk := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'exams'
|
||||||
|
AND CONSTRAINT_NAME = 'exams_grade_id_grades_id_fk'
|
||||||
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_exams_grade_fk = 0, 'ALTER TABLE `exams` ADD CONSTRAINT `exams_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_exams_subject_idx := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'exams'
|
||||||
|
AND INDEX_NAME = 'exams_subject_idx'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_exams_subject_idx = 0, 'CREATE INDEX `exams_subject_idx` ON `exams` (`subject_id`);', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_exams_grade_idx := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'exams'
|
||||||
|
AND INDEX_NAME = 'exams_grade_idx'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_exams_grade_idx = 0, 'CREATE INDEX `exams_grade_idx` ON `exams` (`grade_id`);', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
INSERT IGNORE INTO `roles` (`id`, `name`, `created_at`, `updated_at`)
|
||||||
|
SELECT UUID(), LOWER(TRIM(`role`)), NOW(), NOW()
|
||||||
|
FROM `users`
|
||||||
|
WHERE `role` IS NOT NULL AND TRIM(`role`) <> '';--> statement-breakpoint
|
||||||
|
INSERT IGNORE INTO `users_to_roles` (`user_id`, `role_id`)
|
||||||
|
SELECT `users`.`id`, `roles`.`id`
|
||||||
|
FROM `users`
|
||||||
|
INNER JOIN `roles` ON `roles`.`name` = LOWER(TRIM(`users`.`role`))
|
||||||
|
WHERE `users`.`role` IS NOT NULL AND TRIM(`users`.`role`) <> '';--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` DROP COLUMN `role`;
|
||||||
110
drizzle/0010_subject_id_switch.sql
Normal file
110
drizzle/0010_subject_id_switch.sql
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
SET @has_subject_id := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'class_subject_teachers'
|
||||||
|
AND COLUMN_NAME = 'subject_id'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_subject_id = 0, 'ALTER TABLE `class_subject_teachers` ADD COLUMN `subject_id` VARCHAR(128) NULL;', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
INSERT INTO `subjects` (`id`, `name`, `code`)
|
||||||
|
SELECT UUID(), '语文', 'CHINESE' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '语文' OR `code` = 'CHINESE')
|
||||||
|
UNION ALL
|
||||||
|
SELECT UUID(), '数学', 'MATH' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '数学' OR `code` = 'MATH')
|
||||||
|
UNION ALL
|
||||||
|
SELECT UUID(), '英语', 'ENG' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '英语' OR `code` = 'ENG')
|
||||||
|
UNION ALL
|
||||||
|
SELECT UUID(), '美术', 'ART' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '美术' OR `code` = 'ART')
|
||||||
|
UNION ALL
|
||||||
|
SELECT UUID(), '体育', 'PE' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '体育' OR `code` = 'PE')
|
||||||
|
UNION ALL
|
||||||
|
SELECT UUID(), '科学', 'SCI' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '科学' OR `code` = 'SCI')
|
||||||
|
UNION ALL
|
||||||
|
SELECT UUID(), '社会', 'SOC' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '社会' OR `code` = 'SOC')
|
||||||
|
UNION ALL
|
||||||
|
SELECT UUID(), '音乐', 'MUSIC' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '音乐' OR `code` = 'MUSIC');--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_subject_col := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'class_subject_teachers'
|
||||||
|
AND COLUMN_NAME = 'subject'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@has_subject_col = 1, '
|
||||||
|
UPDATE `class_subject_teachers` cst
|
||||||
|
JOIN `subjects` s ON (
|
||||||
|
s.`name` = cst.`subject`
|
||||||
|
OR s.`code` = CASE cst.`subject`
|
||||||
|
WHEN ''语文'' THEN ''CHINESE''
|
||||||
|
WHEN ''数学'' THEN ''MATH''
|
||||||
|
WHEN ''英语'' THEN ''ENG''
|
||||||
|
WHEN ''美术'' THEN ''ART''
|
||||||
|
WHEN ''体育'' THEN ''PE''
|
||||||
|
WHEN ''科学'' THEN ''SCI''
|
||||||
|
WHEN ''社会'' THEN ''SOC''
|
||||||
|
WHEN ''音乐'' THEN ''MUSIC''
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
SET cst.`subject_id` = s.`id`
|
||||||
|
WHERE cst.`subject_id` IS NULL;
|
||||||
|
', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @subject_id_nulls := (
|
||||||
|
SELECT COUNT(*) FROM `class_subject_teachers`
|
||||||
|
WHERE `subject_id` IS NULL
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` MODIFY COLUMN `subject_id` VARCHAR(128) NOT NULL;', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` DROP PRIMARY KEY;', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` ADD PRIMARY KEY (`class_id`, `subject_id`);', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_col = 1, 'ALTER TABLE `class_subject_teachers` DROP COLUMN `subject`;', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_subject_id_idx := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'class_subject_teachers'
|
||||||
|
AND INDEX_NAME = 'class_subject_teachers_subject_id_idx'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_id_idx = 0, 'CREATE INDEX `class_subject_teachers_subject_id_idx` ON `class_subject_teachers` (`subject_id`);', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||||
|
|
||||||
|
SET @has_subject_fk := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'class_subject_teachers'
|
||||||
|
AND CONSTRAINT_NAME = 'cst_s_fk'
|
||||||
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_fk = 0, 'ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_s_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE CASCADE;', 'SELECT 1');--> statement-breakpoint
|
||||||
|
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||||
|
EXECUTE stmt;--> statement-breakpoint
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": "5",
|
"version": "5",
|
||||||
"dialect": "mysql",
|
"dialect": "mysql",
|
||||||
"id": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
|
"id": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
|
||||||
"prevId": "5eaf9185-8a1e-4e35-8144-553aec7ff31f",
|
"prevId": "a6d95d47-4400-464e-bc53-45735dd6e3e3",
|
||||||
"tables": {
|
"tables": {
|
||||||
"academic_years": {
|
"academic_years": {
|
||||||
"name": "academic_years",
|
"name": "academic_years",
|
||||||
@@ -3068,4 +3068,4 @@
|
|||||||
"tables": {},
|
"tables": {},
|
||||||
"indexes": {}
|
"indexes": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"dialect": "mysql",
|
"dialect": "mysql",
|
||||||
"id": "5eaf9185-8a1e-4e35-8144-553aec7ff31f",
|
"id": "551f3408-945e-4f1d-984c-bfd35fe9d0ea",
|
||||||
"prevId": "3b23e056-3d79-4ea9-a03e-d1b5d56bafda",
|
"prevId": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
|
||||||
"tables": {
|
"tables": {
|
||||||
"academic_years": {
|
"academic_years": {
|
||||||
"name": "academic_years",
|
"name": "academic_years",
|
||||||
@@ -1180,6 +1180,20 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"subject_id": {
|
||||||
|
"name": "subject_id",
|
||||||
|
"type": "varchar(128)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"grade_id": {
|
||||||
|
"name": "grade_id",
|
||||||
|
"type": "varchar(128)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"start_time": {
|
"start_time": {
|
||||||
"name": "start_time",
|
"name": "start_time",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
@@ -1220,7 +1234,22 @@
|
|||||||
"default": "(now())"
|
"default": "(now())"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"exams_subject_idx": {
|
||||||
|
"name": "exams_subject_idx",
|
||||||
|
"columns": [
|
||||||
|
"subject_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"exams_grade_idx": {
|
||||||
|
"name": "exams_grade_idx",
|
||||||
|
"columns": [
|
||||||
|
"grade_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"exams_creator_id_users_id_fk": {
|
"exams_creator_id_users_id_fk": {
|
||||||
"name": "exams_creator_id_users_id_fk",
|
"name": "exams_creator_id_users_id_fk",
|
||||||
@@ -1234,6 +1263,32 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"exams_subject_id_subjects_id_fk": {
|
||||||
|
"name": "exams_subject_id_subjects_id_fk",
|
||||||
|
"tableFrom": "exams",
|
||||||
|
"tableTo": "subjects",
|
||||||
|
"columnsFrom": [
|
||||||
|
"subject_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"exams_grade_id_grades_id_fk": {
|
||||||
|
"name": "exams_grade_id_grades_id_fk",
|
||||||
|
"tableFrom": "exams",
|
||||||
|
"tableTo": "grades",
|
||||||
|
"columnsFrom": [
|
||||||
|
"grade_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
@@ -2009,6 +2064,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"anchor_text": {
|
||||||
|
"name": "anchor_text",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"parent_id": {
|
"parent_id": {
|
||||||
"name": "parent_id",
|
"name": "parent_id",
|
||||||
"type": "varchar(128)",
|
"type": "varchar(128)",
|
||||||
@@ -2765,14 +2827,6 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"role": {
|
|
||||||
"name": "role",
|
|
||||||
"type": "varchar(50)",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "'student'"
|
|
||||||
},
|
|
||||||
"password": {
|
"password": {
|
||||||
"name": "password",
|
"name": "password",
|
||||||
"type": "varchar(255)",
|
"type": "varchar(255)",
|
||||||
@@ -3006,4 +3060,4 @@
|
|||||||
"tables": {},
|
"tables": {},
|
||||||
"indexes": {}
|
"indexes": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,20 @@
|
|||||||
"when": 1768470966367,
|
"when": 1768470966367,
|
||||||
"tag": "0008_thin_madrox",
|
"tag": "0008_thin_madrox",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1772162908476,
|
||||||
|
"tag": "0009_smart_mephistopheles",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1772439600000,
|
||||||
|
"tag": "0010_subject_id_switch",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
38
scripts/check_cst_schema.ts
Normal file
38
scripts/check_cst_schema.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { config } from "dotenv"
|
||||||
|
import mysql from "mysql2/promise"
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
config()
|
||||||
|
const url = process.env.DATABASE_URL
|
||||||
|
if (!url) {
|
||||||
|
console.error("Missing DATABASE_URL")
|
||||||
|
process.exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const conn = await mysql.createConnection(url)
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query("SHOW COLUMNS FROM class_subject_teachers")
|
||||||
|
const [keys] = await conn.query("SHOW KEYS FROM class_subject_teachers")
|
||||||
|
let migrations: Array<{ id: number | string; hash: string; created_at: number | string }> | null = null
|
||||||
|
try {
|
||||||
|
const [m] = await conn.query("SELECT id, hash, created_at FROM __drizzle_migrations ORDER BY id DESC LIMIT 5")
|
||||||
|
migrations = m as Array<{ id: number | string; hash: string; created_at: number | string }>
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
const columns = rows as Array<{ Field: string; Type: string; Null: string; Key: string }>
|
||||||
|
const indexes = keys as Array<{ Key_name: string; Column_name: string }>
|
||||||
|
console.log(columns.map((r) => `${r.Field}:${r.Type}:${r.Null}:${r.Key}`).join("\n"))
|
||||||
|
console.log(indexes.map((r) => `${r.Key_name}:${r.Column_name}`).join("\n"))
|
||||||
|
if (migrations) {
|
||||||
|
console.log(migrations.map((r) => `${r.id}:${r.hash}:${r.created_at}`).join("\n"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await conn.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
58
scripts/migrate_cst_subjectid.ts
Normal file
58
scripts/migrate_cst_subjectid.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { config } from "dotenv"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
config()
|
||||||
|
// 1) add subject_id column if not exists (nullable first)
|
||||||
|
await db.execute(sql`ALTER TABLE class_subject_teachers ADD COLUMN IF NOT EXISTS subject_id VARCHAR(128) NULL;`)
|
||||||
|
|
||||||
|
// 2) backfill subject_id from subjects.name matching existing 'subject' column
|
||||||
|
// This assumes existing data uses subjects.name 值;若不匹配,将在 NOT NULL 约束处失败
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE class_subject_teachers cst
|
||||||
|
JOIN subjects s ON (
|
||||||
|
s.name = cst.subject
|
||||||
|
OR s.code = CASE cst.subject
|
||||||
|
WHEN '语文' THEN 'CHINESE'
|
||||||
|
WHEN '数学' THEN 'MATH'
|
||||||
|
WHEN '英语' THEN 'ENG'
|
||||||
|
WHEN '美术' THEN 'ART'
|
||||||
|
WHEN '体育' THEN 'PE'
|
||||||
|
WHEN '科学' THEN 'SCI'
|
||||||
|
WHEN '社会' THEN 'SOC'
|
||||||
|
WHEN '音乐' THEN 'MUSIC'
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
SET cst.subject_id = s.id
|
||||||
|
WHERE cst.subject_id IS NULL
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 3) enforce NOT NULL
|
||||||
|
await db.execute(sql`ALTER TABLE class_subject_teachers MODIFY COLUMN subject_id VARCHAR(128) NOT NULL;`)
|
||||||
|
|
||||||
|
// 4) drop old PK and create new PK (class_id, subject_id)
|
||||||
|
try { await db.execute(sql`ALTER TABLE class_subject_teachers DROP PRIMARY KEY;`) } catch {}
|
||||||
|
await db.execute(sql`ALTER TABLE class_subject_teachers ADD PRIMARY KEY (class_id, subject_id);`)
|
||||||
|
|
||||||
|
// 5) drop old subject column if exists
|
||||||
|
await db.execute(sql`ALTER TABLE class_subject_teachers DROP COLUMN IF EXISTS subject;`)
|
||||||
|
|
||||||
|
// 6) add index and FK
|
||||||
|
try { await db.execute(sql`CREATE INDEX class_subject_teachers_subject_id_idx ON class_subject_teachers (subject_id);`) } catch {}
|
||||||
|
try { await db.execute(sql`ALTER TABLE class_subject_teachers DROP FOREIGN KEY cst_s_fk;`) } catch {}
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE class_subject_teachers
|
||||||
|
ADD CONSTRAINT cst_s_fk
|
||||||
|
FOREIGN KEY (subject_id) REFERENCES subjects(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log("Migration completed: class_subject_teachers now uses subject_id mapping.")
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enterprise-Grade Seed Script for Next_Edu
|
* Enterprise-Grade Seed Script for Next_Edu
|
||||||
@@ -77,27 +78,31 @@ async function seed() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
const passwordHash = await hash("123456", 10);
|
||||||
const usersData = [
|
const usersData = [
|
||||||
{
|
{
|
||||||
id: "user_admin",
|
id: "user_admin",
|
||||||
name: "Admin User",
|
name: "Admin User",
|
||||||
email: "admin@next-edu.com",
|
email: "admin@next-edu.com",
|
||||||
role: "admin", // Legacy field
|
role: "admin", // Legacy field
|
||||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin"
|
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin",
|
||||||
|
password: passwordHash
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "user_teacher_math",
|
id: "user_teacher_math",
|
||||||
name: "Mr. Math",
|
name: "Mr. Math",
|
||||||
email: "math@next-edu.com",
|
email: "math@next-edu.com",
|
||||||
role: "teacher",
|
role: "teacher",
|
||||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math"
|
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math",
|
||||||
|
password: passwordHash
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "user_student_1",
|
id: "user_student_1",
|
||||||
name: "Alice Student",
|
name: "Alice Student",
|
||||||
email: "alice@next-edu.com",
|
email: "alice@next-edu.com",
|
||||||
role: "student",
|
role: "student",
|
||||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice"
|
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice",
|
||||||
|
password: passwordHash
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -122,6 +127,7 @@ async function seed() {
|
|||||||
email: faker.internet.email().toLowerCase(),
|
email: faker.internet.email().toLowerCase(),
|
||||||
role: "student",
|
role: "student",
|
||||||
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${studentId}`,
|
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${studentId}`,
|
||||||
|
password: passwordHash,
|
||||||
});
|
});
|
||||||
await db.insert(usersToRoles).values({ userId: studentId, roleId: roleMap.student });
|
await db.insert(usersToRoles).values({ userId: studentId, roleId: roleMap.student });
|
||||||
}
|
}
|
||||||
@@ -136,13 +142,14 @@ async function seed() {
|
|||||||
|
|
||||||
// --- Seeding Subjects ---
|
// --- Seeding Subjects ---
|
||||||
await db.insert(subjects).values([
|
await db.insert(subjects).values([
|
||||||
{ id: createId(), name: "Mathematics", code: "MATH", order: 1 },
|
{ id: createId(), name: "语文", code: "CHINESE", order: 1 },
|
||||||
{ id: createId(), name: "Physics", code: "PHYS", order: 2 },
|
{ id: createId(), name: "数学", code: "MATH", order: 2 },
|
||||||
{ id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
|
{ id: createId(), name: "英语", code: "ENG", order: 3 },
|
||||||
{ id: createId(), name: "English", code: "ENG", order: 4 },
|
{ id: createId(), name: "美术", code: "ART", order: 4 },
|
||||||
{ id: createId(), name: "History", code: "HIST", order: 5 },
|
{ id: createId(), name: "体育", code: "PE", order: 5 },
|
||||||
{ id: createId(), name: "Geography", code: "GEO", order: 6 },
|
{ id: createId(), name: "科学", code: "SCI", order: 6 },
|
||||||
{ id: createId(), name: "Biology", code: "BIO", order: 7 },
|
{ id: createId(), name: "社会", code: "SOC", order: 7 },
|
||||||
|
{ id: createId(), name: "音乐", code: "MUSIC", order: 8 },
|
||||||
])
|
])
|
||||||
|
|
||||||
await db.insert(grades).values([
|
await db.insert(grades).values([
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function RegisterPage() {
|
|||||||
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
|
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [{ db }, { users }] = await Promise.all([
|
const [{ db }, { roles, users, usersToRoles }] = await Promise.all([
|
||||||
import("@/shared/db"),
|
import("@/shared/db"),
|
||||||
import("@/shared/db/schema"),
|
import("@/shared/db/schema"),
|
||||||
])
|
])
|
||||||
@@ -45,13 +45,25 @@ export default function RegisterPage() {
|
|||||||
if (existing) return { success: false, message: "该邮箱已注册" }
|
if (existing) return { success: false, message: "该邮箱已注册" }
|
||||||
|
|
||||||
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
|
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
|
||||||
|
const userId = createId()
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
id: createId(),
|
id: userId,
|
||||||
name: name.length ? name : null,
|
name: name.length ? name : null,
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
role: "student",
|
|
||||||
})
|
})
|
||||||
|
const roleRow = await db.query.roles.findFirst({
|
||||||
|
where: eq(roles.name, "student"),
|
||||||
|
columns: { id: true },
|
||||||
|
})
|
||||||
|
if (!roleRow) {
|
||||||
|
await db.insert(roles).values({ name: "student" })
|
||||||
|
}
|
||||||
|
const resolvedRole = roleRow
|
||||||
|
?? (await db.query.roles.findFirst({ where: eq(roles.name, "student"), columns: { id: true } }))
|
||||||
|
if (resolvedRole?.id) {
|
||||||
|
await db.insert(usersToRoles).values({ userId, roleId: resolvedRole.id })
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, message: "账户创建成功" }
|
return { success: true, message: "账户创建成功" }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
const normalizeRole = (value: unknown) => {
|
|
||||||
const role = String(value ?? "").trim().toLowerCase()
|
|
||||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
|
||||||
return "student"
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user) redirect("/login")
|
if (!session?.user) redirect("/login")
|
||||||
|
|
||||||
const role = normalizeRole(session.user.role)
|
const userId = String(session.user.id ?? "").trim()
|
||||||
|
if (!userId) redirect("/login")
|
||||||
|
const profile = await getUserProfile(userId)
|
||||||
|
if (!profile) redirect("/login")
|
||||||
|
const role = profile.role || "student"
|
||||||
|
|
||||||
if (role === "admin") redirect("/admin/dashboard")
|
if (role === "admin") redirect("/admin/dashboard")
|
||||||
if (role === "student") redirect("/student/dashboard")
|
if (role === "student") redirect("/student/dashboard")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Link from "next/link"
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
|
||||||
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
||||||
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
||||||
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
||||||
@@ -44,6 +44,7 @@ export default async function ProfilePage() {
|
|||||||
|
|
||||||
const role = userProfile.role || "student"
|
const role = userProfile.role || "student"
|
||||||
const isStudent = role === "student"
|
const isStudent = role === "student"
|
||||||
|
const isTeacher = role === "teacher"
|
||||||
|
|
||||||
const studentData =
|
const studentData =
|
||||||
isStudent
|
isStudent
|
||||||
@@ -107,6 +108,14 @@ export default async function ProfilePage() {
|
|||||||
})()
|
})()
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const teacherData =
|
||||||
|
isTeacher
|
||||||
|
? await (async () => {
|
||||||
|
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
|
||||||
|
return { subjects, classes }
|
||||||
|
})()
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-8 p-8">
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
@@ -231,6 +240,65 @@ export default async function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{teacherData ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
|
||||||
|
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Teaching Subjects</CardTitle>
|
||||||
|
<CardDescription>Subjects you are currently assigned to teach.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{teacherData.subjects.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">No subjects assigned yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{teacherData.subjects.map((subject) => (
|
||||||
|
<Badge key={subject} variant="secondary">
|
||||||
|
{subject}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Teaching Classes</CardTitle>
|
||||||
|
<CardDescription>Classes you are currently managing.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{teacherData.classes.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">No classes assigned yet.</div>
|
||||||
|
) : (
|
||||||
|
teacherData.classes.map((cls) => (
|
||||||
|
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{cls.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{cls.grade}
|
||||||
|
{cls.homeroom ? ` • ${cls.homeroom}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>View</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
import { BookOpen, Inbox } from "lucide-react"
|
import { BookOpen, Inbox } from "lucide-react"
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
|||||||
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
||||||
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -27,12 +25,6 @@ export default async function StudentTextbooksPage({
|
|||||||
if (!student) {
|
if (!student) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<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">Textbooks</h2>
|
|
||||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
|
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -47,7 +39,7 @@ export default async function StudentTextbooksPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
{/* <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
||||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
||||||
@@ -55,7 +47,7 @@ export default async function StudentTextbooksPage({
|
|||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/student/dashboard">Back</Link>
|
<Link href="/student/dashboard">Back</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<TextbookFilters />
|
<TextbookFilters />
|
||||||
|
|
||||||
|
|||||||
@@ -5,26 +5,21 @@ import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detai
|
|||||||
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
||||||
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
|
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
|
||||||
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
|
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
|
||||||
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
|
|
||||||
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
|
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
|
||||||
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
|
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
|
||||||
|
|
||||||
export default async function ClassDetailPage({
|
export default async function ClassDetailPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
searchParams: Promise<SearchParams>
|
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
// Parallel data fetching
|
// Parallel data fetching
|
||||||
const [insights, students, schedule] = await Promise.all([
|
const [insights, students, schedule] = await Promise.all([
|
||||||
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
|
getClassHomeworkInsights({ classId: id, limit: 20 }),
|
||||||
getClassStudents({ classId: id }),
|
getClassStudents({ classId: id }),
|
||||||
getClassSchedule({ classId: id }),
|
getClassSchedule({ classId: id }),
|
||||||
])
|
])
|
||||||
@@ -32,7 +27,7 @@ export default async function ClassDetailPage({
|
|||||||
if (!insights) return notFound()
|
if (!insights) return notFound()
|
||||||
|
|
||||||
// Fetch subject scores
|
// Fetch subject scores
|
||||||
const studentScores = await getClassStudentSubjectScoresV2(id)
|
const studentScores = await getClassStudentSubjectScoresV2({ classId: id })
|
||||||
|
|
||||||
// Data mapping for widgets
|
// Data mapping for widgets
|
||||||
const assignmentSummaries = insights.assignments.map(a => ({
|
const assignmentSummaries = insights.assignments.map(a => ({
|
||||||
@@ -91,10 +86,7 @@ export default async function ClassDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Main Content Area (Left 2/3) */}
|
{/* Main Content Area (Left 2/3) */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<ClassTrendsWidget
|
<ClassTrendsWidget assignments={assignmentSummaries} />
|
||||||
classId={insights.class.id}
|
|
||||||
assignments={assignmentSummaries}
|
|
||||||
/>
|
|
||||||
<ClassStudentsWidget
|
<ClassStudentsWidget
|
||||||
classId={insights.class.id}
|
classId={insights.class.id}
|
||||||
students={studentSummaries}
|
students={studentSummaries}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm"
|
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
|
||||||
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
|
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
|
||||||
import { auth } from "@/auth"
|
|
||||||
import { db } from "@/shared/db"
|
|
||||||
import { grades } from "@/shared/db/schema"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -12,11 +8,11 @@ export default function MyClassesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function MyClassesPageImpl() {
|
async function MyClassesPageImpl() {
|
||||||
const classes = await getTeacherClasses()
|
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-4 p-8">
|
<div className="flex h-full flex-col space-y-4 p-8">
|
||||||
<MyClassesGrid classes={classes} canCreateClass={false} />
|
<MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ function StudentsResultsFallback() {
|
|||||||
|
|
||||||
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const classes = await getTeacherClasses()
|
const classes = await getTeacherClasses()
|
||||||
const params = await searchParams
|
|
||||||
|
|
||||||
// Logic to determine default class (first one available)
|
// Logic to determine default class (first one available)
|
||||||
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
|
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
|
||||||
|
|||||||
@@ -1,37 +1,9 @@
|
|||||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/shared/components/ui/breadcrumb"
|
|
||||||
|
|
||||||
export default function CreateExamPage() {
|
export default function CreateExamPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
|
|
||||||
<div className="space-y-4">
|
<div className="flex justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>Create</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
Set up a new exam draft and choose your assembly method.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ExamForm />
|
<ExamForm />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components
|
|||||||
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
|
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { formatDate } from "@/shared/lib/utils"
|
|||||||
import { getHomeworkAssignments } from "@/modules/homework/data-access"
|
import { getHomeworkAssignments } from "@/modules/homework/data-access"
|
||||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
import { PenTool, PlusCircle } from "lucide-react"
|
import { PenTool, PlusCircle } from "lucide-react"
|
||||||
|
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -27,9 +28,10 @@ const getParam = (params: SearchParams, key: string) => {
|
|||||||
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
const classId = getParam(sp, "classId") || undefined
|
const classId = getParam(sp, "classId") || undefined
|
||||||
|
const creatorId = await getTeacherIdForMutations()
|
||||||
|
|
||||||
const [assignments, classes] = await Promise.all([
|
const [assignments, classes] = await Promise.all([
|
||||||
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
|
getHomeworkAssignments({ creatorId, classId: classId && classId !== "all" ? classId : undefined }),
|
||||||
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
|
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
|
||||||
])
|
])
|
||||||
const hasAssignments = assignments.length > 0
|
const hasAssignments = assignments.length > 0
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
|||||||
const q = getParam(params, "q")
|
const q = getParam(params, "q")
|
||||||
const type = getParam(params, "type")
|
const type = getParam(params, "type")
|
||||||
const difficulty = getParam(params, "difficulty")
|
const difficulty = getParam(params, "difficulty")
|
||||||
|
const knowledgePointId = getParam(params, "kp")
|
||||||
|
|
||||||
const questionType: QuestionType | undefined =
|
const questionType: QuestionType | undefined =
|
||||||
type === "single_choice" ||
|
type === "single_choice" ||
|
||||||
@@ -37,10 +38,16 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
|||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
type: questionType,
|
type: questionType,
|
||||||
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
|
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
|
||||||
|
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
|
||||||
pageSize: 200,
|
pageSize: 200,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasFilters = Boolean(q || (type && type !== "all") || (difficulty && difficulty !== "all"))
|
const hasFilters = Boolean(
|
||||||
|
q ||
|
||||||
|
(type && type !== "all") ||
|
||||||
|
(difficulty && difficulty !== "all") ||
|
||||||
|
(knowledgePointId && knowledgePointId !== "all")
|
||||||
|
)
|
||||||
|
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { eq, inArray } from "drizzle-orm"
|
|||||||
|
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { classes, classSubjectTeachers, users } from "@/shared/db/schema"
|
import { classes, classSubjectTeachers, roles, users, usersToRoles, subjects } from "@/shared/db/schema"
|
||||||
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
|
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
|
||||||
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
|
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
|
||||||
|
|
||||||
@@ -34,13 +34,14 @@ export async function POST(req: Request) {
|
|||||||
const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null
|
const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null
|
||||||
if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 })
|
if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 })
|
||||||
|
|
||||||
const current = await db.query.users.findFirst({
|
const currentRoleRows = await db
|
||||||
where: eq(users.id, userId),
|
.select({ name: roles.name })
|
||||||
columns: { role: true },
|
.from(usersToRoles)
|
||||||
})
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
const currentRole = String(current?.role ?? "student")
|
.where(eq(usersToRoles.userId, userId))
|
||||||
|
const currentMapped = currentRoleRows.map((r) => String(r.name ?? "").trim().toLowerCase())
|
||||||
|
|
||||||
if (role === "admin" && currentRole !== "admin") {
|
if (role === "admin" && !currentMapped.includes("admin")) {
|
||||||
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +59,33 @@ export async function POST(req: Request) {
|
|||||||
.map((s) => String(s).trim())
|
.map((s) => String(s).trim())
|
||||||
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
|
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
|
||||||
|
|
||||||
|
const roleRow = await db.query.roles.findFirst({
|
||||||
|
where: eq(roles.name, role),
|
||||||
|
columns: { id: true },
|
||||||
|
})
|
||||||
|
if (!roleRow) {
|
||||||
|
await db.insert(roles).values({ name: role })
|
||||||
|
}
|
||||||
|
const resolvedRole = roleRow
|
||||||
|
?? (await db.query.roles.findFirst({ where: eq(roles.name, role), columns: { id: true } }))
|
||||||
|
const roleId = resolvedRole?.id
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
role,
|
|
||||||
name,
|
name,
|
||||||
phone: phone.length ? phone : null,
|
phone: phone.length ? phone : null,
|
||||||
address: address.length ? address : null,
|
address: address.length ? address : null,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, userId))
|
.where(eq(users.id, userId))
|
||||||
|
|
||||||
|
if (roleId) {
|
||||||
|
await db
|
||||||
|
.insert(usersToRoles)
|
||||||
|
.values({ userId, roleId })
|
||||||
|
.onDuplicateKeyUpdate({ set: { roleId } })
|
||||||
|
}
|
||||||
|
|
||||||
if (role === "student" && codes.length) {
|
if (role === "student" && codes.length) {
|
||||||
for (const code of codes) {
|
for (const code of codes) {
|
||||||
await enrollStudentByInvitationCode(userId, code)
|
await enrollStudentByInvitationCode(userId, code)
|
||||||
@@ -87,14 +105,26 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve subject ids when possible (by name exact match)
|
||||||
|
const subjectsFound = await db
|
||||||
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
|
.from(subjects)
|
||||||
|
.where(inArray(subjects.name, teacherSubjects))
|
||||||
|
const subjectIdByName = new Map<string, string>()
|
||||||
|
for (const s of subjectsFound) {
|
||||||
|
if (s.name && s.id) subjectIdByName.set(String(s.name), String(s.id))
|
||||||
|
}
|
||||||
|
|
||||||
for (const code of codes) {
|
for (const code of codes) {
|
||||||
const classId = byCode.get(code)
|
const classId = byCode.get(code)
|
||||||
if (!classId) continue
|
if (!classId) continue
|
||||||
for (const subject of teacherSubjects) {
|
for (const subject of teacherSubjects) {
|
||||||
|
const subjectId = subjectIdByName.get(subject)
|
||||||
|
if (!subjectId) continue
|
||||||
await db
|
await db
|
||||||
.insert(classSubjectTeachers)
|
.insert(classSubjectTeachers)
|
||||||
.values({ classId, subject, teacherId: userId })
|
.values({ classId, subjectId, teacherId: userId })
|
||||||
.onDuplicateKeyUpdate({ set: { teacherId: userId, updatedAt: new Date() } })
|
.onDuplicateKeyUpdate({ set: { teacherId: userId, subjectId, updatedAt: new Date() } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,4 +136,3 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"
|
|||||||
|
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { users } from "@/shared/db/schema"
|
import { roles, users, usersToRoles } from "@/shared/db/schema"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -14,12 +14,32 @@ export async function GET() {
|
|||||||
return NextResponse.json({ required: false })
|
return NextResponse.json({ required: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await db.query.users.findFirst({
|
const [row, roleRows] = await Promise.all([
|
||||||
where: eq(users.id, userId),
|
db.query.users.findFirst({
|
||||||
columns: { onboardedAt: true, role: true },
|
where: eq(users.id, userId),
|
||||||
})
|
columns: { onboardedAt: true },
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ name: roles.name })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(eq(usersToRoles.userId, userId)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const normalizeRole = (value: string) => {
|
||||||
|
const role = value.trim().toLowerCase()
|
||||||
|
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||||
|
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedRoles = roleRows.map((r) => normalizeRole(r.name)).filter(Boolean)
|
||||||
|
const resolvedRole = mappedRoles.find((r) => r === "admin")
|
||||||
|
?? mappedRoles.find((r) => r === "teacher")
|
||||||
|
?? mappedRoles.find((r) => r === "parent")
|
||||||
|
?? mappedRoles.find((r) => r === "student")
|
||||||
|
?? "student"
|
||||||
|
|
||||||
const required = !row?.onboardedAt
|
const required = !row?.onboardedAt
|
||||||
return NextResponse.json({ required, role: row?.role ?? "student" })
|
return NextResponse.json({ required, role: resolvedRole })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
src/auth.ts
41
src/auth.ts
@@ -1,13 +1,23 @@
|
|||||||
import { compare, hash } 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"
|
||||||
|
|
||||||
const normalizeRole = (value: unknown) => {
|
const normalizeRole = (value: unknown) => {
|
||||||
const role = String(value ?? "").trim().toLowerCase()
|
const role = String(value ?? "").trim().toLowerCase()
|
||||||
|
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||||
return "student"
|
return "student"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvePrimaryRole = (roleNames: string[]) => {
|
||||||
|
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
|
||||||
|
if (mapped.includes("admin")) return "admin"
|
||||||
|
if (mapped.includes("teacher")) return "teacher"
|
||||||
|
if (mapped.includes("parent")) return "parent"
|
||||||
|
if (mapped.includes("student")) return "student"
|
||||||
|
return "student"
|
||||||
|
}
|
||||||
|
|
||||||
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}`
|
||||||
@@ -30,7 +40,7 @@ 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 }, { users }] = await Promise.all([
|
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
|
||||||
import("drizzle-orm"),
|
import("drizzle-orm"),
|
||||||
import("@/shared/db"),
|
import("@/shared/db"),
|
||||||
import("@/shared/db/schema"),
|
import("@/shared/db/schema"),
|
||||||
@@ -48,11 +58,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
const ok = await compare(password, normalizedPassword)
|
const ok = await compare(password, normalizedPassword)
|
||||||
if (!ok) return null
|
if (!ok) return null
|
||||||
|
|
||||||
|
const roleRows = await db
|
||||||
|
.select({ name: roles.name })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(eq(usersToRoles.userId, user.id))
|
||||||
|
|
||||||
|
const resolvedRole = resolvePrimaryRole(roleRows.map((r) => r.name))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name ?? undefined,
|
name: user.name ?? undefined,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: normalizeRole(user.role),
|
role: resolvedRole,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -67,19 +85,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
|
|
||||||
const userId = String(token.id ?? "").trim()
|
const userId = String(token.id ?? "").trim()
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const [{ eq }, { db }, { users }] = await Promise.all([
|
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
|
||||||
import("drizzle-orm"),
|
import("drizzle-orm"),
|
||||||
import("@/shared/db"),
|
import("@/shared/db"),
|
||||||
import("@/shared/db/schema"),
|
import("@/shared/db/schema"),
|
||||||
])
|
])
|
||||||
|
|
||||||
const fresh = await db.query.users.findFirst({
|
const [fresh, roleRows] = await Promise.all([
|
||||||
|
db.query.users.findFirst({
|
||||||
where: eq(users.id, userId),
|
where: eq(users.id, userId),
|
||||||
columns: { role: true, name: true },
|
columns: { name: true },
|
||||||
})
|
}),
|
||||||
|
db
|
||||||
|
.select({ name: roles.name })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(eq(usersToRoles.userId, userId)),
|
||||||
|
])
|
||||||
|
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
token.role = normalizeRole(fresh.role ?? token.role)
|
token.role = resolvePrimaryRole(roleRows.map((r) => r.name))
|
||||||
token.name = fresh.name ?? token.name
|
token.name = fresh.name ?? token.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { and, eq, sql, or, inArray } from "drizzle-orm"
|
import { and, eq, sql, or } from "drizzle-orm"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
deleteTeacherClass,
|
deleteTeacherClass,
|
||||||
enrollStudentByEmail,
|
enrollStudentByEmail,
|
||||||
enrollStudentByInvitationCode,
|
enrollStudentByInvitationCode,
|
||||||
|
enrollTeacherByInvitationCode,
|
||||||
ensureClassInvitationCode,
|
ensureClassInvitationCode,
|
||||||
regenerateClassInvitationCode,
|
regenerateClassInvitationCode,
|
||||||
setClassSubjectTeachers,
|
setClassSubjectTeachers,
|
||||||
@@ -371,8 +372,18 @@ export async function joinClassByInvitationCodeAction(
|
|||||||
return { success: false, message: "Unauthorized" }
|
return { success: false, message: "Unauthorized" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subjectValue = formData.get("subject")
|
||||||
|
const subject = role === "teacher" && typeof subjectValue === "string" ? subjectValue.trim() : null
|
||||||
|
|
||||||
|
if (role === "teacher" && (!subject || subject.length === 0)) {
|
||||||
|
return { success: false, message: "Subject is required" }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const classId = await enrollStudentByInvitationCode(session.user.id, code)
|
const classId =
|
||||||
|
role === "teacher"
|
||||||
|
? await enrollTeacherByInvitationCode(session.user.id, code, subject)
|
||||||
|
: await enrollStudentByInvitationCode(session.user.id, code)
|
||||||
if (role === "student") {
|
if (role === "student") {
|
||||||
revalidatePath("/student/learning/courses")
|
revalidatePath("/student/learning/courses")
|
||||||
revalidatePath("/student/schedule")
|
revalidatePath("/student/schedule")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
|
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
||||||
{WEEKDAYS.slice(0, 5).map((day, i) => (
|
{WEEKDAYS.slice(0, 5).map((day) => (
|
||||||
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avat
|
|||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
|
||||||
|
|
||||||
interface StudentSummary {
|
interface StudentSummary {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useMemo } from "react"
|
import { useState } from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||||
import { ChevronDown } from "lucide-react"
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
import { cn } from "@/shared/lib/utils"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -31,7 +30,6 @@ interface AssignmentSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ClassTrendsWidgetProps {
|
interface ClassTrendsWidgetProps {
|
||||||
classId: string
|
|
||||||
assignments: AssignmentSummary[]
|
assignments: AssignmentSummary[]
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
@@ -77,7 +75,7 @@ export function ClassSubmissionTrendChart({
|
|||||||
data,
|
data,
|
||||||
className
|
className
|
||||||
}: {
|
}: {
|
||||||
data: any[]
|
data: Record<string, string | number>[]
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -121,7 +119,7 @@ export function ClassSubmissionTrendChart({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
|
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
|
||||||
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
|
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
|
||||||
const [selectedSubject, setSelectedSubject] = useState<string>("all")
|
const [selectedSubject, setSelectedSubject] = useState<string>("all")
|
||||||
|
|
||||||
@@ -142,11 +140,11 @@ export function ClassTrendsWidget({ classId, assignments, compact, className }:
|
|||||||
const lastAssignment = chartData[chartData.length - 1]
|
const lastAssignment = chartData[chartData.length - 1]
|
||||||
|
|
||||||
let metricValue = "0%"
|
let metricValue = "0%"
|
||||||
let metricLabel = "Latest"
|
const metricLabel = "Latest"
|
||||||
|
|
||||||
if (lastAssignment) {
|
if (lastAssignment) {
|
||||||
if (chartTab === "submission") {
|
if (chartTab === "submission") {
|
||||||
metricValue = lastAssignment.target > 0
|
metricValue = lastAssignment.target > 0
|
||||||
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
|
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
|
||||||
: "0%"
|
: "0%"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useMemo, useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -10,11 +10,9 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
MapPin,
|
MapPin,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Search,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
@@ -30,30 +28,35 @@ import {
|
|||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
import type { TeacherClass, ClassScheduleItem } from "../types"
|
import type { TeacherClass } from "../types"
|
||||||
import {
|
import {
|
||||||
ensureClassInvitationCodeAction,
|
ensureClassInvitationCodeAction,
|
||||||
regenerateClassInvitationCodeAction,
|
regenerateClassInvitationCodeAction,
|
||||||
joinClassByInvitationCodeAction,
|
joinClassByInvitationCodeAction,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
|
|
||||||
const GRADIENTS = [
|
const getSeededValue = (seed: string, index: number) => {
|
||||||
"bg-card border-border",
|
let h = 2166136261
|
||||||
"bg-card border-border",
|
const str = `${seed}:${index}`
|
||||||
"bg-card border-border",
|
for (let i = 0; i < str.length; i += 1) {
|
||||||
"bg-card border-border",
|
h ^= str.charCodeAt(i)
|
||||||
"bg-card border-border",
|
h = Math.imul(h, 16777619)
|
||||||
]
|
}
|
||||||
|
return (h >>> 0) / 4294967296
|
||||||
function getClassGradient(id: string) {
|
|
||||||
return "bg-card border-border shadow-sm hover:shadow-md"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
|
export function MyClassesGrid({
|
||||||
|
classes,
|
||||||
|
subjectOptions,
|
||||||
|
}: {
|
||||||
|
classes: TeacherClass[]
|
||||||
|
subjectOptions: string[]
|
||||||
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
const [joinOpen, setJoinOpen] = useState(false)
|
const [joinOpen, setJoinOpen] = useState(false)
|
||||||
|
const [joinSubject, setJoinSubject] = useState("")
|
||||||
|
|
||||||
const handleJoin = async (formData: FormData) => {
|
const handleJoin = async (formData: FormData) => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
@@ -62,6 +65,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message || "Joined class successfully")
|
toast.success(res.message || "Joined class successfully")
|
||||||
setJoinOpen(false)
|
setJoinOpen(false)
|
||||||
|
setJoinSubject("")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to join class")
|
toast.error(res.message || "Failed to join class")
|
||||||
@@ -83,6 +87,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (isWorking) return
|
if (isWorking) return
|
||||||
setJoinOpen(open)
|
setJoinOpen(open)
|
||||||
|
if (!open) setJoinSubject("")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -137,15 +142,33 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Ask your administrator for the code if you don't have one.
|
Ask your administrator for the code if you don't have one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="join-subject" className="text-sm font-medium">
|
||||||
|
教学科目
|
||||||
|
</Label>
|
||||||
|
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
|
||||||
|
<SelectTrigger id="join-subject" className="h-12">
|
||||||
|
<SelectValue placeholder={subjectOptions.length === 0 ? "暂无可选科目" : "选择教学科目"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{subjectOptions.map((subject) => (
|
||||||
|
<SelectItem key={subject} value={subject}>
|
||||||
|
{subject}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="subject" value={joinSubject} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
||||||
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
|
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
|
||||||
{isWorking ? "Joining..." : "Join Class"}
|
{isWorking ? "Joining..." : "Join Class"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -167,7 +190,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
classes.map((c) => (
|
classes.map((c) => (
|
||||||
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -182,11 +205,9 @@ import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
|
|||||||
|
|
||||||
function ClassTicket({
|
function ClassTicket({
|
||||||
c,
|
c,
|
||||||
isWorking,
|
|
||||||
onWorkingChange,
|
onWorkingChange,
|
||||||
}: {
|
}: {
|
||||||
c: TeacherClass
|
c: TeacherClass
|
||||||
isWorking: boolean
|
|
||||||
onWorkingChange: (v: boolean) => void
|
onWorkingChange: (v: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -256,7 +277,11 @@ function ClassTicket({
|
|||||||
{/* Decorative Barcode Strip */}
|
{/* Decorative Barcode Strip */}
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
|
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
|
||||||
{Array.from({ length: 20 }).map((_, i) => (
|
{Array.from({ length: 20 }).map((_, i) => (
|
||||||
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-full h-px bg-primary/20"
|
||||||
|
style={{ marginBottom: `${2 + getSeededValue(c.id, i) * 8}px` }}
|
||||||
|
></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -320,7 +345,7 @@ function ClassTicket({
|
|||||||
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
|
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
|
||||||
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
|
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
|
||||||
{Array.from({ length: 16 }).map((_, i) => (
|
{Array.from({ length: 16 }).map((_, i) => (
|
||||||
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
|
<div key={i} className={cn("bg-transparent", getSeededValue(`${c.id}-qr`, i) > 0.5 && "bg-black")}></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,12 +398,7 @@ function ClassTicket({
|
|||||||
|
|
||||||
{/* Real Chart */}
|
{/* Real Chart */}
|
||||||
<div className="h-[140px] w-full">
|
<div className="h-[140px] w-full">
|
||||||
<ClassTrendsWidget
|
<ClassTrendsWidget assignments={recentAssignments} compact className="h-full w-full" />
|
||||||
classId={c.id}
|
|
||||||
assignments={recentAssignments}
|
|
||||||
compact
|
|
||||||
className="h-full w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -518,4 +516,4 @@ export function ScheduleView({
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
|
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { cn } from "@/shared/lib/utils"
|
|
||||||
import type { TeacherClass } from "../types"
|
import type { TeacherClass } from "../types"
|
||||||
import { enrollStudentByEmailAction } from "../actions"
|
import { enrollStudentByEmailAction } from "../actions"
|
||||||
|
|
||||||
@@ -78,8 +77,6 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
|||||||
|
|
||||||
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
|
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
|
||||||
|
|
||||||
const hasFilters = search || classId !== "all" || status !== "all"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import { useRouter } from "next/navigation"
|
|||||||
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
|
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
||||||
import { cn, formatDate } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import "server-only";
|
|||||||
|
|
||||||
import { randomInt } from "node:crypto"
|
import { randomInt } from "node:crypto"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
|
import { auth } from "@/auth"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import {
|
||||||
classes,
|
classes,
|
||||||
@@ -19,7 +20,9 @@ import {
|
|||||||
schools,
|
schools,
|
||||||
subjects,
|
subjects,
|
||||||
exams,
|
exams,
|
||||||
|
roles,
|
||||||
users,
|
users,
|
||||||
|
usersToRoles,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
import type {
|
import type {
|
||||||
@@ -43,16 +46,22 @@ import type {
|
|||||||
UpdateTeacherClassInput,
|
UpdateTeacherClassInput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
const getDefaultTeacherId = cache(async () => {
|
const getSessionTeacherId = async (): Promise<string | null> => {
|
||||||
const [row] = await db
|
const session = await auth()
|
||||||
|
const userId = String(session?.user?.id ?? "").trim()
|
||||||
|
if (!userId) return null
|
||||||
|
|
||||||
|
const [teacher] = await db
|
||||||
.select({ id: users.id })
|
.select({ id: users.id })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.role, "teacher"))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
.orderBy(asc(users.createdAt))
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
return teacher?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
return row?.id
|
// Strict subjectId-based mapping: no aliasing
|
||||||
})
|
|
||||||
|
|
||||||
const isDuplicateInvitationCodeError = (err: unknown) => {
|
const isDuplicateInvitationCodeError = (err: unknown) => {
|
||||||
if (!err) return false
|
if (!err) return false
|
||||||
@@ -80,11 +89,20 @@ const generateUniqueInvitationCode = async (): Promise<string> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getTeacherIdForMutations = async (): Promise<string> => {
|
export const getTeacherIdForMutations = async (): Promise<string> => {
|
||||||
const teacherId = await getDefaultTeacherId()
|
const teacherId = await getSessionTeacherId()
|
||||||
if (!teacherId) throw new Error("No teacher available")
|
if (!teacherId) throw new Error("Teacher not found")
|
||||||
return teacherId
|
return teacherId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getClassSubjects = async (): Promise<string[]> => {
|
||||||
|
const rows = await db.query.subjects.findMany({
|
||||||
|
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||||
|
})
|
||||||
|
|
||||||
|
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
|
||||||
|
return Array.from(new Set(names))
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
|
const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
|
||||||
|
|
||||||
const parseFirstInt = (v: string) => {
|
const parseFirstInt = (v: string) => {
|
||||||
@@ -118,23 +136,30 @@ const compareClassLike = (
|
|||||||
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
|
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
|
||||||
|
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
|
||||||
|
const assignedIds = await db
|
||||||
|
.select({ id: classSubjectTeachers.classId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
||||||
|
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ subjectId: classSubjectTeachers.subjectId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId)))
|
||||||
|
return Array.from(new Set(rows.map((r) => String(r.subjectId))))
|
||||||
|
}
|
||||||
|
|
||||||
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||||
if (!teacherId) return []
|
if (!teacherId) return []
|
||||||
|
|
||||||
const rows = await (async () => {
|
const rows = await (async () => {
|
||||||
try {
|
try {
|
||||||
const ownedIds = await db
|
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||||
.select({ id: classes.id })
|
|
||||||
.from(classes)
|
|
||||||
.where(eq(classes.teacherId, teacherId))
|
|
||||||
|
|
||||||
const enrolledIds = await db
|
|
||||||
.select({ id: classEnrollments.classId })
|
|
||||||
.from(classEnrollments)
|
|
||||||
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
|
|
||||||
|
|
||||||
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
|
|
||||||
|
|
||||||
if (allIds.length === 0) return []
|
if (allIds.length === 0) return []
|
||||||
|
|
||||||
@@ -206,7 +231,9 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: users.id, name: users.name, email: users.email })
|
.select({ id: users.id, name: users.name, email: users.email })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.role, "teacher"))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(eq(roles.name, "teacher"))
|
||||||
.orderBy(asc(users.createdAt))
|
.orderBy(asc(users.createdAt))
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
@@ -216,6 +243,23 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
|
||||||
|
const teacherId = await getSessionTeacherId()
|
||||||
|
if (!teacherId) return []
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ subject: subjects.name })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||||
|
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
||||||
|
.groupBy(subjects.name)
|
||||||
|
.orderBy(asc(subjects.name))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((r) => r.subject as ClassSubject)
|
||||||
|
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
|
||||||
|
})
|
||||||
|
|
||||||
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
|
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
|
||||||
const [rows, subjectRows] = await Promise.all([
|
const [rows, subjectRows] = await Promise.all([
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -304,14 +348,15 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
|
|||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
classId: classSubjectTeachers.classId,
|
classId: classSubjectTeachers.classId,
|
||||||
subject: classSubjectTeachers.subject,
|
subject: subjects.name,
|
||||||
teacherId: users.id,
|
teacherId: users.id,
|
||||||
teacherName: users.name,
|
teacherName: users.name,
|
||||||
teacherEmail: users.email,
|
teacherEmail: users.email,
|
||||||
})
|
})
|
||||||
.from(classSubjectTeachers)
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||||
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||||
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
|
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
|
||||||
])
|
])
|
||||||
|
|
||||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||||
@@ -425,16 +470,17 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
|
|||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
classId: classSubjectTeachers.classId,
|
classId: classSubjectTeachers.classId,
|
||||||
subject: classSubjectTeachers.subject,
|
subject: subjects.name,
|
||||||
teacherId: users.id,
|
teacherId: users.id,
|
||||||
teacherName: users.name,
|
teacherName: users.name,
|
||||||
teacherEmail: users.email,
|
teacherEmail: users.email,
|
||||||
})
|
})
|
||||||
.from(classSubjectTeachers)
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||||
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
|
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
|
||||||
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||||
.where(inArray(classes.gradeId, gradeIds))
|
.where(inArray(classes.gradeId, gradeIds))
|
||||||
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
|
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
|
||||||
])
|
])
|
||||||
|
|
||||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||||
@@ -589,14 +635,17 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
|
|||||||
|
|
||||||
export const getClassStudents = cache(
|
export const getClassStudents = cache(
|
||||||
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
||||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||||
if (!teacherId) return []
|
if (!teacherId) return []
|
||||||
|
|
||||||
const classId = params?.classId?.trim()
|
const classId = params?.classId?.trim()
|
||||||
const q = params?.q?.trim().toLowerCase()
|
const q = params?.q?.trim().toLowerCase()
|
||||||
const status = params?.status?.trim().toLowerCase()
|
const status = params?.status?.trim().toLowerCase()
|
||||||
|
|
||||||
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||||
|
if (accessibleIds.length === 0) return []
|
||||||
|
|
||||||
|
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
|
||||||
|
|
||||||
if (classId) {
|
if (classId) {
|
||||||
conditions.push(eq(classes.id, classId))
|
conditions.push(eq(classes.id, classId))
|
||||||
@@ -647,12 +696,15 @@ export const getClassStudents = cache(
|
|||||||
|
|
||||||
export const getClassSchedule = cache(
|
export const getClassSchedule = cache(
|
||||||
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
|
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
|
||||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||||
if (!teacherId) return []
|
if (!teacherId) return []
|
||||||
|
|
||||||
const classId = params?.classId?.trim()
|
const classId = params?.classId?.trim()
|
||||||
|
|
||||||
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||||
|
if (accessibleIds.length === 0) return []
|
||||||
|
|
||||||
|
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
|
||||||
if (classId) conditions.push(eq(classSchedule.classId, classId))
|
if (classId) conditions.push(eq(classSchedule.classId, classId))
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -707,11 +759,13 @@ const toScoreStats = (scores: number[]): ScoreStats => {
|
|||||||
|
|
||||||
export const getClassHomeworkInsights = cache(
|
export const getClassHomeworkInsights = cache(
|
||||||
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
|
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
|
||||||
const teacherId = params.teacherId ?? (await getDefaultTeacherId())
|
const teacherId = params.teacherId ?? (await getSessionTeacherId())
|
||||||
if (!teacherId) return null
|
if (!teacherId) return null
|
||||||
|
|
||||||
const classId = params.classId.trim()
|
const classId = params.classId.trim()
|
||||||
if (!classId) return null
|
if (!classId) return null
|
||||||
|
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||||
|
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null
|
||||||
|
|
||||||
const [classRow] = await db
|
const [classRow] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -721,12 +775,15 @@ export const getClassHomeworkInsights = cache(
|
|||||||
homeroom: classes.homeroom,
|
homeroom: classes.homeroom,
|
||||||
room: classes.room,
|
room: classes.room,
|
||||||
invitationCode: classes.invitationCode,
|
invitationCode: classes.invitationCode,
|
||||||
|
teacherId: classes.teacherId,
|
||||||
})
|
})
|
||||||
.from(classes)
|
.from(classes)
|
||||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
.where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!classRow) return null
|
if (!classRow) return null
|
||||||
|
const isHomeroomTeacher = classRow.teacherId === teacherId
|
||||||
|
const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
|
||||||
|
|
||||||
const enrollments = await db
|
const enrollments = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -735,12 +792,29 @@ export const getClassHomeworkInsights = cache(
|
|||||||
})
|
})
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
.where(and(eq(classes.teacherId, teacherId), eq(classEnrollments.classId, classId)))
|
.where(and(inArray(classes.id, accessibleIds), eq(classEnrollments.classId, classId)))
|
||||||
|
|
||||||
const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
|
const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
|
||||||
const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId)
|
const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId)
|
||||||
const studentIds = enrollments.map((e) => e.studentId)
|
const studentIds = enrollments.map((e) => e.studentId)
|
||||||
|
|
||||||
|
if (!isHomeroomTeacher && subjectIdFilter.length === 0) {
|
||||||
|
return {
|
||||||
|
class: {
|
||||||
|
id: classRow.id,
|
||||||
|
name: classRow.name,
|
||||||
|
grade: classRow.grade,
|
||||||
|
homeroom: classRow.homeroom,
|
||||||
|
room: classRow.room,
|
||||||
|
invitationCode: classRow.invitationCode ?? null,
|
||||||
|
},
|
||||||
|
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
|
||||||
|
assignments: [],
|
||||||
|
latest: null,
|
||||||
|
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (studentIds.length === 0) {
|
if (studentIds.length === 0) {
|
||||||
return {
|
return {
|
||||||
class: {
|
class: {
|
||||||
@@ -782,6 +856,10 @@ export const getClassHomeworkInsights = cache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||||
|
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
|
||||||
|
if (subjectIdFilter.length > 0) {
|
||||||
|
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
|
||||||
|
}
|
||||||
const assignments = await db
|
const assignments = await db
|
||||||
.select({
|
.select({
|
||||||
id: homeworkAssignments.id,
|
id: homeworkAssignments.id,
|
||||||
@@ -795,7 +873,7 @@ export const getClassHomeworkInsights = cache(
|
|||||||
.from(homeworkAssignments)
|
.from(homeworkAssignments)
|
||||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||||
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
|
.where(and(...assignmentConditions))
|
||||||
.orderBy(desc(homeworkAssignments.createdAt))
|
.orderBy(desc(homeworkAssignments.createdAt))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
||||||
@@ -1239,6 +1317,12 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
|
|||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
const invitationCode = await generateUniqueInvitationCode()
|
const invitationCode = await generateUniqueInvitationCode()
|
||||||
try {
|
try {
|
||||||
|
const subjectRows = await db
|
||||||
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
|
.from(subjects)
|
||||||
|
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||||
|
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.insert(classes).values({
|
await tx.insert(classes).values({
|
||||||
id,
|
id,
|
||||||
@@ -1253,13 +1337,14 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
|
|||||||
teacherId,
|
teacherId,
|
||||||
})
|
})
|
||||||
|
|
||||||
await tx.insert(classSubjectTeachers).values(
|
const values = DEFAULT_CLASS_SUBJECTS
|
||||||
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
.filter((name) => idByName.has(name))
|
||||||
|
.map((name) => ({
|
||||||
classId: id,
|
classId: id,
|
||||||
subject,
|
subjectId: idByName.get(name)!,
|
||||||
teacherId: null,
|
teacherId: null,
|
||||||
}))
|
}))
|
||||||
)
|
await tx.insert(classSubjectTeachers).values(values)
|
||||||
})
|
})
|
||||||
return id
|
return id
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1291,13 +1376,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
|||||||
const [teacher] = await db
|
const [teacher] = await db
|
||||||
.select({ id: users.id })
|
.select({ id: users.id })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(and(eq(users.id, teacherId), eq(users.role, "teacher")))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, teacherId), eq(roles.name, "teacher")))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!teacher) throw new Error("Teacher not found")
|
if (!teacher) throw new Error("Teacher not found")
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
const invitationCode = await generateUniqueInvitationCode()
|
const invitationCode = await generateUniqueInvitationCode()
|
||||||
try {
|
try {
|
||||||
|
const subjectRows = await db
|
||||||
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
|
.from(subjects)
|
||||||
|
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||||
|
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.insert(classes).values({
|
await tx.insert(classes).values({
|
||||||
id,
|
id,
|
||||||
@@ -1312,13 +1405,14 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
|||||||
teacherId,
|
teacherId,
|
||||||
})
|
})
|
||||||
|
|
||||||
await tx.insert(classSubjectTeachers).values(
|
const values = DEFAULT_CLASS_SUBJECTS
|
||||||
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
.filter((name) => idByName.has(name))
|
||||||
|
.map((name) => ({
|
||||||
classId: id,
|
classId: id,
|
||||||
subject,
|
subjectId: idByName.get(name)!,
|
||||||
teacherId: null,
|
teacherId: null,
|
||||||
}))
|
}))
|
||||||
)
|
await tx.insert(classSubjectTeachers).values(values)
|
||||||
})
|
})
|
||||||
return id
|
return id
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1410,6 +1504,123 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
|
|||||||
return cls.id
|
return cls.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function enrollTeacherByInvitationCode(
|
||||||
|
teacherId: string,
|
||||||
|
invitationCode: string,
|
||||||
|
subject: string | null
|
||||||
|
): Promise<string> {
|
||||||
|
const tid = teacherId.trim()
|
||||||
|
const code = invitationCode.trim()
|
||||||
|
if (!tid) throw new Error("Missing teacher id")
|
||||||
|
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
|
||||||
|
|
||||||
|
const [teacher] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, tid), eq(roles.name, "teacher")))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!teacher) throw new Error("Teacher not found")
|
||||||
|
|
||||||
|
const [cls] = await db
|
||||||
|
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.invitationCode, code))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!cls) throw new Error("Invalid invitation code")
|
||||||
|
if (cls.teacherId === tid) return cls.id
|
||||||
|
|
||||||
|
const subjectValue = typeof subject === "string" ? subject.trim() : ""
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: classSubjectTeachers.classId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing && !subjectValue) return cls.id
|
||||||
|
if (subjectValue) {
|
||||||
|
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
|
||||||
|
if (!subRow) throw new Error("Subject not found")
|
||||||
|
const sid = subRow.id
|
||||||
|
|
||||||
|
const [mapping] = await db
|
||||||
|
.select({ teacherId: classSubjectTeachers.teacherId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
|
||||||
|
if (mapping?.teacherId === tid) return cls.id
|
||||||
|
if (!mapping) {
|
||||||
|
await db
|
||||||
|
.insert(classSubjectTeachers)
|
||||||
|
.values({ classId: cls.id, subjectId: sid, teacherId: null })
|
||||||
|
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingSubject] = await db
|
||||||
|
.select({ id: classSubjectTeachers.classId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existingSubject) return cls.id
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(classSubjectTeachers)
|
||||||
|
.set({ teacherId: tid })
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
|
||||||
|
|
||||||
|
const [assigned] = await db
|
||||||
|
.select({ id: classSubjectTeachers.classId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!assigned) throw new Error("Subject already assigned")
|
||||||
|
} else {
|
||||||
|
const subjectRows = await db
|
||||||
|
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
|
||||||
|
|
||||||
|
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
|
||||||
|
if (!preferred) throw new Error("Class already has assigned teachers")
|
||||||
|
const sid = subjectRows.find((r) => r.name === preferred)!.id
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(classSubjectTeachers)
|
||||||
|
.set({ teacherId: tid })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(classSubjectTeachers.classId, cls.id),
|
||||||
|
eq(classSubjectTeachers.subjectId, sid),
|
||||||
|
isNull(classSubjectTeachers.teacherId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [assigned] = await db
|
||||||
|
.select({ id: classSubjectTeachers.classId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(classSubjectTeachers.classId, cls.id),
|
||||||
|
eq(classSubjectTeachers.subjectId, sid),
|
||||||
|
eq(classSubjectTeachers.teacherId, tid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!assigned) throw new Error("Class already has assigned teachers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cls.id
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
|
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
|
||||||
const teacherId = await getTeacherIdForMutations()
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
|
||||||
@@ -1468,7 +1679,9 @@ export async function updateAdminClass(
|
|||||||
const [teacher] = await db
|
const [teacher] = await db
|
||||||
.select({ id: users.id })
|
.select({ id: users.id })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(and(eq(users.id, nextTeacherId), eq(users.role, "teacher")))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, nextTeacherId), eq(roles.name, "teacher")))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!teacher) throw new Error("Teacher not found")
|
if (!teacher) throw new Error("Teacher not found")
|
||||||
|
|
||||||
@@ -1498,7 +1711,9 @@ export async function setClassSubjectTeachers(params: {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: users.id })
|
.select({ id: users.id })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(and(eq(users.role, "teacher"), inArray(users.id, teacherIds)))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
|
||||||
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
|
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1508,15 +1723,24 @@ export async function setClassSubjectTeachers(params: {
|
|||||||
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
|
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map subject names to ids
|
||||||
|
const subjectRows = await db
|
||||||
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
|
.from(subjects)
|
||||||
|
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||||
|
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||||
|
|
||||||
|
const values = DEFAULT_CLASS_SUBJECTS
|
||||||
|
.filter((name) => idByName.has(name))
|
||||||
|
.map((name) => ({
|
||||||
|
classId,
|
||||||
|
subjectId: idByName.get(name)!,
|
||||||
|
teacherId: teacherBySubject.get(name) ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(classSubjectTeachers)
|
.insert(classSubjectTeachers)
|
||||||
.values(
|
.values(values)
|
||||||
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
|
||||||
classId,
|
|
||||||
subject,
|
|
||||||
teacherId: teacherBySubject.get(subject) ?? null,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
|
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,13 +1788,19 @@ export async function enrollStudentByEmail(classId: string, email: string): Prom
|
|||||||
if (!owned) throw new Error("Class not found")
|
if (!owned) throw new Error("Class not found")
|
||||||
|
|
||||||
const [student] = await db
|
const [student] = await db
|
||||||
.select({ id: users.id, role: users.role })
|
.select({ id: users.id })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, normalized))
|
.where(eq(users.email, normalized))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!student) throw new Error("Student not found")
|
if (!student) throw new Error("Student not found")
|
||||||
if (student.role !== "student") throw new Error("User is not a student")
|
const [studentRole] = await db
|
||||||
|
.select({ id: usersToRoles.userId })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
|
||||||
|
.limit(1)
|
||||||
|
if (!studentRole) throw new Error("User is not a student")
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(classEnrollments)
|
.insert(classEnrollments)
|
||||||
@@ -1823,8 +2053,26 @@ export const getStudentsSubjectScores = cache(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const getClassStudentSubjectScoresV2 = cache(
|
export const getClassStudentSubjectScoresV2 = cache(
|
||||||
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
|
async (params: { classId: string; teacherId?: string }): Promise<Map<string, Record<string, number | null>>> => {
|
||||||
// 1. Get student IDs in the class
|
const teacherId = params.teacherId ?? (await getSessionTeacherId())
|
||||||
|
if (!teacherId) return new Map()
|
||||||
|
const classId = params.classId.trim()
|
||||||
|
if (!classId) return new Map()
|
||||||
|
|
||||||
|
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||||
|
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return new Map()
|
||||||
|
|
||||||
|
const [classRow] = await db
|
||||||
|
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, classId))
|
||||||
|
.limit(1)
|
||||||
|
if (!classRow) return new Map()
|
||||||
|
|
||||||
|
const isHomeroomTeacher = classRow.teacherId === teacherId
|
||||||
|
const subjectIds = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
|
||||||
|
if (!isHomeroomTeacher && subjectIds.length === 0) return new Map()
|
||||||
|
|
||||||
const enrollments = await db
|
const enrollments = await db
|
||||||
.select({ studentId: classEnrollments.studentId })
|
.select({ studentId: classEnrollments.studentId })
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
@@ -1833,7 +2081,24 @@ export const getClassStudentSubjectScoresV2 = cache(
|
|||||||
eq(classEnrollments.status, "active")
|
eq(classEnrollments.status, "active")
|
||||||
))
|
))
|
||||||
|
|
||||||
const studentIds = enrollments.map(e => e.studentId)
|
const studentIds = enrollments.map((e) => e.studentId)
|
||||||
return getStudentsSubjectScores(studentIds)
|
const studentScores = await getStudentsSubjectScores(studentIds)
|
||||||
|
if (subjectIds.length === 0) return studentScores
|
||||||
|
|
||||||
|
// Map subjectIds to names for filtering
|
||||||
|
const subjectRows = await db
|
||||||
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
|
.from(subjects)
|
||||||
|
.where(inArray(subjects.id, subjectIds))
|
||||||
|
const allowed = new Set(subjectRows.map((s) => s.name))
|
||||||
|
const filtered = new Map<string, Record<string, number | null>>()
|
||||||
|
for (const [studentId, scores] of studentScores.entries()) {
|
||||||
|
const nextScores: Record<string, number | null> = {}
|
||||||
|
for (const [subject, score] of Object.entries(scores)) {
|
||||||
|
if (allowed.has(subject)) nextScores[subject] = score
|
||||||
|
}
|
||||||
|
filtered.set(studentId, nextScores)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ type Stat = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StudentStatsGrid({
|
export function StudentStatsGrid({
|
||||||
enrolledClassCount,
|
|
||||||
dueSoonCount,
|
dueSoonCount,
|
||||||
overdueCount,
|
overdueCount,
|
||||||
gradedCount,
|
|
||||||
ranking,
|
ranking,
|
||||||
}: {
|
}: {
|
||||||
enrolledClassCount: number
|
enrolledClassCount: number
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
|||||||
import type { TeacherClass } from "@/modules/classes/types"
|
import type { TeacherClass } from "@/modules/classes/types"
|
||||||
|
|
||||||
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||||
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
||||||
const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
|
|
||||||
const todayWeekday = toWeekday(new Date())
|
const todayWeekday = toWeekday(new Date())
|
||||||
|
|
||||||
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))
|
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { count, desc, eq, gt } from "drizzle-orm"
|
import { count, desc, eq, gt, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import {
|
||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
homeworkAssignments,
|
homeworkAssignments,
|
||||||
homeworkSubmissions,
|
homeworkSubmissions,
|
||||||
questions,
|
questions,
|
||||||
|
roles,
|
||||||
sessions,
|
sessions,
|
||||||
textbooks,
|
textbooks,
|
||||||
users,
|
users,
|
||||||
|
usersToRoles,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import type { AdminDashboardData } from "./types"
|
import type { AdminDashboardData } from "./types"
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
|||||||
const [
|
const [
|
||||||
activeSessionsRow,
|
activeSessionsRow,
|
||||||
userCountRow,
|
userCountRow,
|
||||||
userRoleRows,
|
userRoleCountRows,
|
||||||
classCountRow,
|
classCountRow,
|
||||||
textbookCountRow,
|
textbookCountRow,
|
||||||
chapterCountRow,
|
chapterCountRow,
|
||||||
@@ -37,7 +39,11 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
|
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
|
||||||
db.select({ value: count() }).from(users),
|
db.select({ value: count() }).from(users),
|
||||||
db.select({ role: users.role, value: count() }).from(users).groupBy(users.role),
|
db
|
||||||
|
.select({ role: roles.name, value: count() })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.groupBy(roles.name),
|
||||||
db.select({ value: count() }).from(classes),
|
db.select({ value: count() }).from(classes),
|
||||||
db.select({ value: count() }).from(textbooks),
|
db.select({ value: count() }).from(textbooks),
|
||||||
db.select({ value: count() }).from(chapters),
|
db.select({ value: count() }).from(chapters),
|
||||||
@@ -52,7 +58,6 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
name: users.name,
|
name: users.name,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
role: users.role,
|
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -72,17 +77,55 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
|||||||
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
|
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
|
||||||
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
|
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
|
||||||
|
|
||||||
const userRoleCounts = userRoleRows
|
const userRoleCounts = userRoleCountRows
|
||||||
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
|
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
const recentUsers = recentUserRows.map((u) => ({
|
const normalizeRole = (value: string) => {
|
||||||
id: u.id,
|
const role = value.trim().toLowerCase()
|
||||||
name: u.name,
|
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||||
email: u.email,
|
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||||
role: u.role,
|
return ""
|
||||||
createdAt: u.createdAt.toISOString(),
|
}
|
||||||
}))
|
|
||||||
|
const recentUserIds = recentUserRows.map((u) => u.id)
|
||||||
|
const recentRoleRows = recentUserIds.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
userId: usersToRoles.userId,
|
||||||
|
roleName: roles.name,
|
||||||
|
})
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(inArray(usersToRoles.userId, recentUserIds))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const rolesByUserId = new Map<string, string[]>()
|
||||||
|
for (const row of recentRoleRows) {
|
||||||
|
const list = rolesByUserId.get(row.userId) ?? []
|
||||||
|
list.push(row.roleName)
|
||||||
|
rolesByUserId.set(row.userId, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePrimaryRole = (roleNames: string[]) => {
|
||||||
|
const mapped = roleNames.map(normalizeRole).filter(Boolean)
|
||||||
|
if (mapped.includes("admin")) return "admin"
|
||||||
|
if (mapped.includes("teacher")) return "teacher"
|
||||||
|
if (mapped.includes("parent")) return "parent"
|
||||||
|
if (mapped.includes("student")) return "student"
|
||||||
|
return "student"
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentUsers = recentUserRows.map((u) => {
|
||||||
|
const roleNames = rolesByUserId.get(u.id) ?? []
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
email: u.email,
|
||||||
|
role: resolvePrimaryRole(roleNames),
|
||||||
|
createdAt: u.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSessionsCount,
|
activeSessionsCount,
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
|||||||
// Helper to flatten questions for continuous numbering
|
// Helper to flatten questions for continuous numbering
|
||||||
let questionCounter = 0
|
let questionCounter = 0
|
||||||
|
|
||||||
|
const parseContent = (raw: unknown): QuestionContent => {
|
||||||
|
if (raw && typeof raw === "object") return raw as QuestionContent
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (parsed && typeof parsed === "object") return parsed as QuestionContent
|
||||||
|
return { text: raw }
|
||||||
|
} catch {
|
||||||
|
return { text: raw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
const renderNode = (node: ExamNode, depth: number = 0) => {
|
const renderNode = (node: ExamNode, depth: number = 0) => {
|
||||||
if (node.type === 'group') {
|
if (node.type === 'group') {
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +59,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
|||||||
if (node.type === 'question' && node.question) {
|
if (node.type === 'question' && node.question) {
|
||||||
questionCounter++
|
questionCounter++
|
||||||
const q = node.question
|
const q = node.question
|
||||||
const content = q.content as QuestionContent
|
const content = parseContent(q.content)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.id} className="mb-6 break-inside-avoid">
|
<div key={node.id} className="mb-6 break-inside-avoid">
|
||||||
|
|||||||
@@ -28,13 +28,26 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
|
|||||||
<div className="space-y-3 pb-4">
|
<div className="space-y-3 pb-4">
|
||||||
{questions.map((q) => {
|
{questions.map((q) => {
|
||||||
const added = isAdded(q.id)
|
const added = isAdded(q.id)
|
||||||
const content = q.content as { text?: string }
|
const parsedContent = (() => {
|
||||||
|
if (q.content && typeof q.content === "object") return q.content as { text?: string }
|
||||||
|
if (typeof q.content === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(q.content) as unknown
|
||||||
|
if (parsed && typeof parsed === "object") return parsed as { text?: string }
|
||||||
|
return { text: q.content }
|
||||||
|
} catch {
|
||||||
|
return { text: q.content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text: "" }
|
||||||
|
})()
|
||||||
|
const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown"
|
||||||
return (
|
return (
|
||||||
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
|
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="text-[10px] uppercase">
|
<Badge variant="outline" className="text-[10px] uppercase">
|
||||||
{q.type.replace("_", " ")}
|
{typeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
Lvl {q.difficulty}
|
Lvl {q.difficulty}
|
||||||
@@ -46,7 +59,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm line-clamp-2 text-muted-foreground">
|
<p className="text-sm line-clamp-2 text-muted-foreground">
|
||||||
{content.text || "No content preview"}
|
{parsedContent.text || "No content preview"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -118,7 +118,22 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
|||||||
onMove: (dir: 'up' | 'down') => void
|
onMove: (dir: 'up' | 'down') => void
|
||||||
onScoreChange: (score: number) => void
|
onScoreChange: (score: number) => void
|
||||||
}) {
|
}) {
|
||||||
const content = item.question?.content as { text?: string }
|
const rawContent = item.question?.content
|
||||||
|
const parsedContent = (() => {
|
||||||
|
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
|
||||||
|
if (typeof rawContent === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawContent) as unknown
|
||||||
|
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
|
||||||
|
return { text: rawContent }
|
||||||
|
} catch {
|
||||||
|
return { text: rawContent }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text: "" }
|
||||||
|
})()
|
||||||
|
|
||||||
|
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
|
||||||
return (
|
return (
|
||||||
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors">
|
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -127,7 +142,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
|||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm line-clamp-2 pt-0.5">
|
<p className="text-sm line-clamp-2 pt-0.5">
|
||||||
{content?.text || "Question content"}
|
{parsedContent.text || "Question content"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -139,6 +154,16 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-1 pl-8 text-xs text-muted-foreground">
|
||||||
|
{options.map((opt, idx) => (
|
||||||
|
<div key={opt.id ?? idx} className="flex gap-2">
|
||||||
|
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
|
||||||
|
<span>{opt.text ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between pl-8">
|
<div className="flex items-center justify-between pl-8">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -82,7 +82,22 @@ function SortableItem({
|
|||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = item.question?.content as { text?: string }
|
const rawContent = item.question?.content
|
||||||
|
const parsedContent = (() => {
|
||||||
|
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
|
||||||
|
if (typeof rawContent === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawContent) as unknown
|
||||||
|
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
|
||||||
|
return { text: rawContent }
|
||||||
|
} catch {
|
||||||
|
return { text: rawContent }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text: "" }
|
||||||
|
})()
|
||||||
|
|
||||||
|
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
|
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
|
||||||
@@ -92,7 +107,7 @@ function SortableItem({
|
|||||||
<GripVertical className="h-4 w-4" />
|
<GripVertical className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<p className="text-sm line-clamp-2 pt-0.5 select-none">
|
<p className="text-sm line-clamp-2 pt-0.5 select-none">
|
||||||
{content?.text || "Question content"}
|
{parsedContent.text || "Question content"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -104,6 +119,16 @@ function SortableItem({
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-8 text-xs text-muted-foreground">
|
||||||
|
{options.map((opt, idx) => (
|
||||||
|
<div key={opt.id ?? idx} className="flex gap-2">
|
||||||
|
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
|
||||||
|
<span>{opt.text ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-end pl-8">
|
<div className="flex items-center justify-end pl-8">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
toast.error("Failed to load exam preview")
|
toast.error("Failed to load exam preview")
|
||||||
setShowViewDialog(false)
|
setShowViewDialog(false)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
toast.error("Failed to load exam preview")
|
toast.error("Failed to load exam preview")
|
||||||
setShowViewDialog(false)
|
setShowViewDialog(false)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Search, Eye } from "lucide-react"
|
import { Search, Eye } from "lucide-react"
|
||||||
@@ -34,15 +33,6 @@ type ExamAssemblyProps = {
|
|||||||
questionOptions: Question[]
|
questionOptions: Question[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubmitButton({ label }: { label: string }) {
|
|
||||||
const { pending } = useFormStatus()
|
|
||||||
return (
|
|
||||||
<Button type="submit" disabled={pending} className="w-full">
|
|
||||||
{pending ? "Saving..." : label}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExamAssembly(props: ExamAssemblyProps) {
|
export function ExamAssembly(props: ExamAssemblyProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
@@ -83,7 +73,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchQuestions = (reset: boolean = false) => {
|
const fetchQuestions = useCallback((reset: boolean = false) => {
|
||||||
startBankTransition(async () => {
|
startBankTransition(async () => {
|
||||||
const nextPage = reset ? 1 : page + 1
|
const nextPage = reset ? 1 : page + 1
|
||||||
try {
|
try {
|
||||||
@@ -107,11 +97,11 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
setHasMore(result.data.length === 20)
|
setHasMore(result.data.length === 20)
|
||||||
setPage(nextPage)
|
setPage(nextPage)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error("Failed to load questions")
|
toast.error("Failed to load questions")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter])
|
||||||
|
|
||||||
const isFirstRender = useRef(true)
|
const isFirstRender = useRef(true)
|
||||||
|
|
||||||
@@ -123,7 +113,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchQuestions(true)
|
fetchQuestions(true)
|
||||||
}, [deferredSearch, typeFilter, difficultyFilter])
|
}, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions])
|
||||||
|
|
||||||
// Recursively calculate total score
|
// Recursively calculate total score
|
||||||
const assignedTotal = useMemo(() => {
|
const assignedTotal = useMemo(() => {
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
|
|||||||
try {
|
try {
|
||||||
const meta = JSON.parse(description)
|
const meta = JSON.parse(description)
|
||||||
if (typeof meta === "object" && meta !== null) {
|
if (typeof meta === "object" && meta !== null) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const rest = { ...(meta as Record<string, unknown>) }
|
||||||
const { scheduledAt, ...rest } = meta as any
|
delete rest.scheduledAt
|
||||||
return JSON.stringify(rest)
|
return JSON.stringify(rest)
|
||||||
}
|
}
|
||||||
return description
|
return description
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { headers } from "next/headers"
|
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { and, count, eq } from "drizzle-orm"
|
import { and, count, eq, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { auth } from "@/auth"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import {
|
||||||
classes,
|
classes,
|
||||||
classEnrollments,
|
classEnrollments,
|
||||||
|
classSubjectTeachers,
|
||||||
exams,
|
exams,
|
||||||
homeworkAnswers,
|
homeworkAnswers,
|
||||||
homeworkAssignmentQuestions,
|
homeworkAssignmentQuestions,
|
||||||
homeworkAssignmentTargets,
|
homeworkAssignmentTargets,
|
||||||
homeworkAssignments,
|
homeworkAssignments,
|
||||||
homeworkSubmissions,
|
homeworkSubmissions,
|
||||||
|
roles,
|
||||||
users,
|
users,
|
||||||
|
usersToRoles,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
||||||
|
|
||||||
type CurrentUser = { id: string; role: "admin" | "teacher" | "student" }
|
type TeacherRole = "admin" | "teacher"
|
||||||
|
type StudentRole = "student"
|
||||||
|
|
||||||
async function getCurrentUser() {
|
const getSessionUserId = async (): Promise<string | null> => {
|
||||||
const ref = (await headers()).get("referer") || ""
|
const session = await auth()
|
||||||
const roleHint: CurrentUser["role"] = ref.includes("/admin/")
|
const userId = String(session?.user?.id ?? "").trim()
|
||||||
? "admin"
|
return userId.length > 0 ? userId : null
|
||||||
: ref.includes("/student/")
|
|
||||||
? "student"
|
|
||||||
: ref.includes("/teacher/")
|
|
||||||
? "teacher"
|
|
||||||
: "teacher"
|
|
||||||
|
|
||||||
const byRole = await db.query.users.findFirst({
|
|
||||||
where: eq(users.role, roleHint),
|
|
||||||
orderBy: (u, { asc }) => [asc(u.createdAt)],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (byRole) return { id: byRole.id, role: roleHint }
|
|
||||||
|
|
||||||
const anyUser = await db.query.users.findFirst({
|
|
||||||
orderBy: (u, { asc }) => [asc(u.createdAt)],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (anyUser) return { id: anyUser.id, role: roleHint }
|
|
||||||
|
|
||||||
return { id: "user_teacher_math", role: roleHint }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTeacher() {
|
async function ensureTeacher(): Promise<{ id: string; role: TeacherRole }> {
|
||||||
const user = await getCurrentUser()
|
const userId = await getSessionUserId()
|
||||||
if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized")
|
if (!userId) throw new Error("Unauthorized")
|
||||||
return user
|
const [row] = await db
|
||||||
|
.select({ id: users.id, role: roles.name })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
|
||||||
|
.limit(1)
|
||||||
|
if (!row) throw new Error("Unauthorized")
|
||||||
|
return { id: row.id, role: row.role as TeacherRole }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureStudent() {
|
async function ensureStudent(): Promise<{ id: string; role: StudentRole }> {
|
||||||
const user = await getCurrentUser()
|
const userId = await getSessionUserId()
|
||||||
if (!user || user.role !== "student") throw new Error("Unauthorized")
|
if (!userId) throw new Error("Unauthorized")
|
||||||
return user
|
const [row] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, userId), eq(roles.name, "student")))
|
||||||
|
.limit(1)
|
||||||
|
if (!row) throw new Error("Unauthorized")
|
||||||
|
return { id: row.id, role: "student" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseStudentIds = (raw: string): string[] => {
|
const parseStudentIds = (raw: string): string[] => {
|
||||||
@@ -108,12 +108,12 @@ export async function createHomeworkAssignmentAction(
|
|||||||
const input = parsed.data
|
const input = parsed.data
|
||||||
const publish = input.publish ?? true
|
const publish = input.publish ?? true
|
||||||
|
|
||||||
const [ownedClass] = await db
|
const [classRow] = await db
|
||||||
.select({ id: classes.id })
|
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||||
.from(classes)
|
.from(classes)
|
||||||
.where(user.role === "admin" ? eq(classes.id, input.classId) : and(eq(classes.id, input.classId), eq(classes.teacherId, user.id)))
|
.where(eq(classes.id, input.classId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!ownedClass) return { success: false, message: "Class not found" }
|
if (!classRow) return { success: false, message: "Class not found" }
|
||||||
|
|
||||||
const exam = await db.query.exams.findFirst({
|
const exam = await db.query.exams.findFirst({
|
||||||
where: eq(exams.id, input.sourceExamId),
|
where: eq(exams.id, input.sourceExamId),
|
||||||
@@ -126,23 +126,43 @@ export async function createHomeworkAssignmentAction(
|
|||||||
|
|
||||||
if (!exam) return { success: false, message: "Exam not found" }
|
if (!exam) return { success: false, message: "Exam not found" }
|
||||||
|
|
||||||
|
if (user.role !== "admin" && classRow.teacherId !== user.id) {
|
||||||
|
const assignedSubjectRows = await db
|
||||||
|
.select({ subjectId: classSubjectTeachers.subjectId })
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, user.id)))
|
||||||
|
if (assignedSubjectRows.length === 0) {
|
||||||
|
return { success: false, message: "Not assigned to this class" }
|
||||||
|
}
|
||||||
|
const assignedSubjectIds = new Set(assignedSubjectRows.map((r) => r.subjectId))
|
||||||
|
if (!exam.subjectId) {
|
||||||
|
return { success: false, message: "Exam subject not set" }
|
||||||
|
}
|
||||||
|
if (!assignedSubjectIds.has(exam.subjectId)) {
|
||||||
|
return { success: false, message: "Not assigned to this subject" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const assignmentId = createId()
|
const assignmentId = createId()
|
||||||
|
|
||||||
const availableAt = input.availableAt ? new Date(input.availableAt) : null
|
const availableAt = input.availableAt ? new Date(input.availableAt) : null
|
||||||
const dueAt = input.dueAt ? new Date(input.dueAt) : null
|
const dueAt = input.dueAt ? new Date(input.dueAt) : null
|
||||||
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
||||||
|
|
||||||
|
const classScope =
|
||||||
|
user.role === "admin"
|
||||||
|
? eq(classes.id, input.classId)
|
||||||
|
: classRow.teacherId === user.id
|
||||||
|
? eq(classes.teacherId, user.id)
|
||||||
|
: eq(classes.id, input.classId)
|
||||||
|
|
||||||
const classStudentIds = (
|
const classStudentIds = (
|
||||||
await db
|
await db
|
||||||
.select({ studentId: classEnrollments.studentId })
|
.select({ studentId: classEnrollments.studentId })
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(classEnrollments.classId, input.classId), eq(classEnrollments.status, "active"), classScope)
|
||||||
eq(classEnrollments.classId, input.classId),
|
|
||||||
eq(classEnrollments.status, "active"),
|
|
||||||
user.role === "admin" ? eq(classes.id, input.classId) : eq(classes.teacherId, user.id)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
).map((r) => r.studentId)
|
).map((r) => r.studentId)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Checkbox } from "@/shared/components/ui/checkbox"
|
|||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
|
||||||
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
|
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
|
||||||
|
|
||||||
import type { StudentHomeworkTakeData } from "../types"
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/sha
|
|||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { CheckCircle2, FileText, ChevronLeft } from "lucide-react"
|
import { FileText, ChevronLeft } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import type { StudentHomeworkTakeData } from "../types"
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
@@ -57,7 +57,6 @@ type HomeworkReviewViewProps = {
|
|||||||
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||||
const submissionStatus = initialData.submission?.status ?? "not_started"
|
const submissionStatus = initialData.submission?.status ?? "not_started"
|
||||||
const isGraded = submissionStatus === "graded"
|
const isGraded = submissionStatus === "graded"
|
||||||
const isSubmitted = submissionStatus === "submitted"
|
|
||||||
|
|
||||||
const answersByQuestionId = useMemo(() => {
|
const answersByQuestionId = useMemo(() => {
|
||||||
const map = new Map<string, { answer: unknown }>()
|
const map = new Map<string, { answer: unknown }>()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "server-only"
|
|||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
|
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { auth } from "@/auth"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import {
|
||||||
classEnrollments,
|
classEnrollments,
|
||||||
@@ -11,7 +12,9 @@ import {
|
|||||||
homeworkAssignmentTargets,
|
homeworkAssignmentTargets,
|
||||||
homeworkAssignments,
|
homeworkAssignments,
|
||||||
homeworkSubmissions,
|
homeworkSubmissions,
|
||||||
|
roles,
|
||||||
users,
|
users,
|
||||||
|
usersToRoles,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -550,17 +553,20 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
|
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
|
||||||
const student = await db.query.users.findFirst({
|
const session = await auth()
|
||||||
where: eq(users.role, "student"),
|
const userId = String(session?.user?.id ?? "").trim()
|
||||||
orderBy: (u, { asc }) => [asc(u.createdAt)],
|
if (!userId) return null
|
||||||
})
|
|
||||||
if (student) return { id: student.id, name: student.name || "Student" }
|
|
||||||
|
|
||||||
const anyUser = await db.query.users.findFirst({
|
const [student] = await db
|
||||||
orderBy: (u, { asc }) => [asc(u.createdAt)],
|
.select({ id: users.id, name: users.name })
|
||||||
})
|
.from(users)
|
||||||
if (!anyUser) return null
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
return { id: anyUser.id, name: anyUser.name || "User" }
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, userId), eq(roles.name, "student")))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!student) return null
|
||||||
|
return { id: student.id, name: student.name || "Student" }
|
||||||
})
|
})
|
||||||
|
|
||||||
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
|
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
|
||||||
@@ -592,19 +598,23 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
|
|||||||
const assignmentIds = assignments.map((a) => a.id)
|
const assignmentIds = assignments.map((a) => a.id)
|
||||||
const submissions = await db.query.homeworkSubmissions.findMany({
|
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||||
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
|
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
|
||||||
orderBy: [desc(homeworkSubmissions.createdAt)],
|
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||||
})
|
})
|
||||||
|
|
||||||
const attemptsByAssignmentId = new Map<string, number>()
|
const attemptsByAssignmentId = new Map<string, number>()
|
||||||
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
|
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
|
||||||
|
const latestSubmittedByAssignmentId = new Map<string, (typeof submissions)[number]>()
|
||||||
|
|
||||||
for (const s of submissions) {
|
for (const s of submissions) {
|
||||||
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
|
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
|
||||||
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
||||||
|
if (s.status === "submitted" || s.status === "graded") {
|
||||||
|
if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignments.map((a) => {
|
return assignments.map((a) => {
|
||||||
const latest = latestByAssignmentId.get(a.id) ?? null
|
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
|
||||||
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
|
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
|
||||||
|
|
||||||
const item: StudentHomeworkAssignmentListItem = {
|
const item: StudentHomeworkAssignmentListItem = {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Shield,
|
Shield,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@@ -156,11 +155,6 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
|||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
href: "/student/schedule",
|
href: "/student/schedule",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Resources",
|
|
||||||
icon: FileText,
|
|
||||||
href: "/student/resources",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
parent: [
|
parent: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,29 +1,51 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { db } from "@/shared/db";
|
import { db } from "@/shared/db";
|
||||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks, roles, users, usersToRoles } from "@/shared/db/schema";
|
||||||
import { CreateQuestionSchema } from "./schema";
|
import { CreateQuestionSchema } from "./schema";
|
||||||
import type { CreateQuestionInput } from "./schema";
|
import type { CreateQuestionInput } from "./schema";
|
||||||
import { ActionState } from "@/shared/types/action-state";
|
import { ActionState } from "@/shared/types/action-state";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getQuestions, type GetQuestionsParams } from "./data-access";
|
import { getQuestions, type GetQuestionsParams } from "./data-access";
|
||||||
|
import type { KnowledgePointOption } from "./types";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
async function getCurrentUser() {
|
const getSessionUserId = async (): Promise<string | null> => {
|
||||||
return {
|
const session = await auth();
|
||||||
id: "user_teacher_math",
|
const userId = String(session?.user?.id ?? "").trim();
|
||||||
role: "teacher",
|
return userId.length > 0 ? userId : null;
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureTeacher() {
|
async function ensureTeacher() {
|
||||||
const user = await getCurrentUser();
|
const userId = await getSessionUserId();
|
||||||
if (!user || (user.role !== "teacher" && user.role !== "admin")) {
|
if (!userId) {
|
||||||
|
const [fallback] = await db
|
||||||
|
.select({ id: users.id, role: roles.name })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(inArray(roles.name, ["teacher", "admin"]))
|
||||||
|
.orderBy(asc(users.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
if (!fallback) {
|
||||||
|
throw new Error("Unauthorized: Only teachers can perform this action.");
|
||||||
|
}
|
||||||
|
return { id: fallback.id, role: fallback.role as "teacher" | "admin" };
|
||||||
|
}
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: users.id, role: roles.name })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) {
|
||||||
throw new Error("Unauthorized: Only teachers can perform this action.");
|
throw new Error("Unauthorized: Only teachers can perform this action.");
|
||||||
}
|
}
|
||||||
return user;
|
return { id: row.id, role: row.role as "teacher" | "admin" };
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||||
@@ -244,3 +266,40 @@ export async function getQuestionsAction(params: GetQuestionsParams) {
|
|||||||
await ensureTeacher();
|
await ensureTeacher();
|
||||||
return await getQuestions(params);
|
return await getQuestions(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
|
||||||
|
await ensureTeacher();
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: knowledgePoints.id,
|
||||||
|
name: knowledgePoints.name,
|
||||||
|
chapterId: chapters.id,
|
||||||
|
chapterTitle: chapters.title,
|
||||||
|
textbookId: textbooks.id,
|
||||||
|
textbookTitle: textbooks.title,
|
||||||
|
subject: textbooks.subject,
|
||||||
|
grade: textbooks.grade,
|
||||||
|
})
|
||||||
|
.from(knowledgePoints)
|
||||||
|
.leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||||||
|
.leftJoin(textbooks, eq(textbooks.id, chapters.textbookId))
|
||||||
|
.orderBy(
|
||||||
|
asc(textbooks.title),
|
||||||
|
asc(chapters.order),
|
||||||
|
asc(chapters.title),
|
||||||
|
asc(knowledgePoints.order),
|
||||||
|
asc(knowledgePoints.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
chapterId: row.chapterId ?? null,
|
||||||
|
chapterTitle: row.chapterTitle ?? null,
|
||||||
|
textbookId: row.textbookId ?? null,
|
||||||
|
textbookTitle: row.textbookTitle ?? null,
|
||||||
|
subject: row.subject ?? null,
|
||||||
|
grade: row.grade ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/shared/components/ui/form"
|
} from "@/shared/components/ui/form"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -36,9 +37,9 @@ import {
|
|||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
import { BaseQuestionSchema } from "../schema"
|
import { BaseQuestionSchema } from "../schema"
|
||||||
import { createNestedQuestion, updateQuestionAction } from "../actions"
|
import { createNestedQuestion, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Question } from "../types"
|
import { KnowledgePointOption, Question } from "../types"
|
||||||
|
|
||||||
const QuestionFormSchema = BaseQuestionSchema.extend({
|
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||||
difficulty: z.number().min(1).max(5),
|
difficulty: z.number().min(1).max(5),
|
||||||
@@ -111,6 +112,10 @@ export function CreateQuestionDialog({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
const isEdit = !!initialData
|
const isEdit = !!initialData
|
||||||
|
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
|
||||||
|
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
|
||||||
|
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
|
||||||
|
const [isLoadingKnowledgePoints, setIsLoadingKnowledgePoints] = useState(false)
|
||||||
|
|
||||||
const form = useForm<QuestionFormValues>({
|
const form = useForm<QuestionFormValues>({
|
||||||
resolver: zodResolver(QuestionFormSchema),
|
resolver: zodResolver(QuestionFormSchema),
|
||||||
@@ -151,7 +156,60 @@ export function CreateQuestionDialog({
|
|||||||
}
|
}
|
||||||
}, [initialData, form, open, defaultContent, defaultType])
|
}, [initialData, form, open, defaultContent, defaultType])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setIsLoadingKnowledgePoints(true)
|
||||||
|
getKnowledgePointOptionsAction()
|
||||||
|
.then((rows) => {
|
||||||
|
setKnowledgePointOptions(rows)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to load knowledge points")
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoadingKnowledgePoints(false)
|
||||||
|
})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (initialData) {
|
||||||
|
const nextIds = initialData.knowledgePoints.map((kp) => kp.id)
|
||||||
|
setSelectedKnowledgePointIds((prev) => {
|
||||||
|
if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return nextIds
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedKnowledgePointIds((prev) => {
|
||||||
|
if (
|
||||||
|
prev.length === defaultKnowledgePointIds.length &&
|
||||||
|
prev.every((id, idx) => id === defaultKnowledgePointIds[idx])
|
||||||
|
) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return defaultKnowledgePointIds
|
||||||
|
})
|
||||||
|
}, [open, initialData, defaultKnowledgePointIds])
|
||||||
|
|
||||||
const questionType = form.watch("type")
|
const questionType = form.watch("type")
|
||||||
|
const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => {
|
||||||
|
const query = knowledgePointQuery.trim().toLowerCase()
|
||||||
|
if (!query) return true
|
||||||
|
const fullLabel = [
|
||||||
|
kp.textbookTitle,
|
||||||
|
kp.chapterTitle,
|
||||||
|
kp.name,
|
||||||
|
kp.subject,
|
||||||
|
kp.grade,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
return fullLabel.includes(query)
|
||||||
|
})
|
||||||
|
|
||||||
const buildContent = (data: QuestionFormValues) => {
|
const buildContent = (data: QuestionFormValues) => {
|
||||||
const text = data.content.trim()
|
const text = data.content.trim()
|
||||||
@@ -194,7 +252,7 @@ export function CreateQuestionDialog({
|
|||||||
type: data.type,
|
type: data.type,
|
||||||
difficulty: data.difficulty,
|
difficulty: data.difficulty,
|
||||||
content: buildContent(data),
|
content: buildContent(data),
|
||||||
knowledgePointIds: isEdit ? [] : defaultKnowledgePointIds,
|
knowledgePointIds: selectedKnowledgePointIds,
|
||||||
}
|
}
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("json", JSON.stringify(payload))
|
fd.set("json", JSON.stringify(payload))
|
||||||
@@ -306,6 +364,58 @@ export function CreateQuestionDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Knowledge Points</FormLabel>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="Search knowledge points..."
|
||||||
|
value={knowledgePointQuery}
|
||||||
|
onChange={(e) => setKnowledgePointQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<ScrollArea className="h-48">
|
||||||
|
{isLoadingKnowledgePoints ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
|
||||||
|
) : filteredKnowledgePoints.length === 0 ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">No knowledge points found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 p-2">
|
||||||
|
{filteredKnowledgePoints.map((kp) => {
|
||||||
|
const labelParts = [
|
||||||
|
kp.textbookTitle,
|
||||||
|
kp.chapterTitle,
|
||||||
|
kp.name,
|
||||||
|
].filter(Boolean)
|
||||||
|
const label = labelParts.join(" · ")
|
||||||
|
return (
|
||||||
|
<label key={kp.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedKnowledgePointIds.includes(kp.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const isChecked = checked === true
|
||||||
|
setSelectedKnowledgePointIds((prev) => {
|
||||||
|
if (isChecked) {
|
||||||
|
if (prev.includes(kp.id)) return prev
|
||||||
|
return [...prev, kp.id]
|
||||||
|
}
|
||||||
|
return prev.filter((id) => id !== kp.id)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Search, X } from "lucide-react"
|
import { Search, X } from "lucide-react"
|
||||||
|
|
||||||
@@ -12,11 +13,25 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { getKnowledgePointOptionsAction } from "../actions"
|
||||||
|
import type { KnowledgePointOption } from "../types"
|
||||||
|
|
||||||
export function QuestionFilters() {
|
export function QuestionFilters() {
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
||||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
|
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
|
||||||
|
const [knowledgePointId, setKnowledgePointId] = useQueryState("kp", parseAsString.withDefault("all"))
|
||||||
|
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getKnowledgePointOptionsAction()
|
||||||
|
.then((rows) => {
|
||||||
|
setKnowledgePointOptions(rows)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setKnowledgePointOptions([])
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -56,14 +71,32 @@ export function QuestionFilters() {
|
|||||||
<SelectItem value="5">Hard (5)</SelectItem>
|
<SelectItem value="5">Hard (5)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select value={knowledgePointId} onValueChange={(val) => setKnowledgePointId(val === "all" ? null : val)}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="Knowledge Point" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Knowledge Points</SelectItem>
|
||||||
|
{knowledgePointOptions.map((kp) => {
|
||||||
|
const labelParts = [kp.textbookTitle, kp.chapterTitle, kp.name].filter(Boolean)
|
||||||
|
const label = labelParts.join(" · ")
|
||||||
|
return (
|
||||||
|
<SelectItem key={kp.id} value={kp.id}>
|
||||||
|
{label || kp.name}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{(search || type !== "all" || difficulty !== "all") && (
|
{(search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all") && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearch(null)
|
setSearch(null)
|
||||||
setType(null)
|
setType(null)
|
||||||
setDifficulty(null)
|
setDifficulty(null)
|
||||||
|
setKnowledgePointId(null)
|
||||||
}}
|
}}
|
||||||
className="h-8 px-2 lg:px-3"
|
className="h-8 px-2 lg:px-3"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,3 +21,14 @@ export interface Question {
|
|||||||
}[]
|
}[]
|
||||||
childrenCount?: number
|
childrenCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type KnowledgePointOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
chapterId: string | null
|
||||||
|
chapterTitle: string | null
|
||||||
|
textbookId: string | null
|
||||||
|
textbookTitle: string | null
|
||||||
|
subject: string | null
|
||||||
|
grade: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cache } from "react"
|
|||||||
import { asc, eq, inArray, or } from "drizzle-orm"
|
import { asc, eq, inArray, or } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { academicYears, departments, grades, schools, users } from "@/shared/db/schema"
|
import { academicYears, departments, grades, roles, schools, users, usersToRoles } from "@/shared/db/schema"
|
||||||
import type { AcademicYearListItem, DepartmentListItem, GradeListItem, SchoolListItem, StaffOption } from "./types"
|
import type { AcademicYearListItem, DepartmentListItem, GradeListItem, SchoolListItem, StaffOption } from "./types"
|
||||||
|
|
||||||
const toIso = (d: Date) => d.toISOString()
|
const toIso = (d: Date) => d.toISOString()
|
||||||
@@ -114,7 +114,10 @@ export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: users.id, name: users.name, email: users.email })
|
.select({ id: users.id, name: users.name, email: users.email })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(inArray(users.role, ["teacher", "admin"]))
|
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(inArray(roles.name, ["teacher", "admin"]))
|
||||||
|
.groupBy(users.id, users.name, users.email)
|
||||||
.orderBy(asc(users.name), asc(users.email))
|
.orderBy(asc(users.name), asc(users.email))
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { toast } from "sonner"
|
|||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
|
||||||
import { UserProfile } from "@/modules/users/data-access"
|
import { UserProfile } from "@/modules/users/data-access"
|
||||||
|
|||||||
@@ -224,30 +224,11 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeParent = findParent(chapters, active.id as string)
|
const activeParent = findParent(chapters, active.id as string)
|
||||||
const overParent = findParent(chapters, over.id as string)
|
|
||||||
|
|
||||||
// If parents don't match (and neither is root), we can't reorder easily in this simplified version
|
// If parents don't match (and neither is root), we can't reorder easily in this simplified version
|
||||||
// But actually, we need to check if they are in the same list.
|
// But actually, we need to check if they are in the same list.
|
||||||
// If both are root items (activeParent is null), they are siblings.
|
// If both are root items (activeParent is null), they are siblings.
|
||||||
|
|
||||||
const getSiblings = (parentId: string | null) => {
|
|
||||||
if (!parentId) return chapters
|
|
||||||
const parent = chapters.find(c => c.id === parentId) // This only finds root parents, we need recursive find
|
|
||||||
|
|
||||||
const findNode = (nodes: Chapter[], id: string): Chapter | null => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.id === id) return node
|
|
||||||
if (node.children) {
|
|
||||||
const found = findNode(node.children, id)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return findNode(chapters, parentId)?.children || []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it?
|
// Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it?
|
||||||
// No, dnd-kit allows dropping anywhere by default unless restricted.
|
// No, dnd-kit allows dropping anywhere by default unless restricted.
|
||||||
|
|
||||||
@@ -271,7 +252,6 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
|||||||
|
|
||||||
// Check if over is in the same list
|
// Check if over is in the same list
|
||||||
if (activeList.some(c => c.id === over.id)) {
|
if (activeList.some(c => c.id === over.id)) {
|
||||||
const oldIndex = activeList.findIndex((item) => item.id === active.id)
|
|
||||||
const newIndex = activeList.findIndex((item) => item.id === over.id)
|
const newIndex = activeList.findIndex((item) => item.id === over.id)
|
||||||
|
|
||||||
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)
|
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)
|
||||||
|
|||||||
@@ -24,29 +24,35 @@ interface TextbookCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const subjectColorMap: Record<string, string> = {
|
const subjectColorMap: Record<string, string> = {
|
||||||
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
|
Mathematics: "bg-blue-50 text-blue-700 border-blue-200/70 dark:bg-blue-950/50 dark:text-blue-200 dark:border-blue-900/60",
|
||||||
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
|
Physics: "bg-purple-50 text-purple-700 border-purple-200/70 dark:bg-purple-950/50 dark:text-purple-200 dark:border-purple-900/60",
|
||||||
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
|
Chemistry: "bg-teal-50 text-teal-700 border-teal-200/70 dark:bg-teal-950/50 dark:text-teal-200 dark:border-teal-900/60",
|
||||||
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
|
English: "bg-orange-50 text-orange-700 border-orange-200/70 dark:bg-orange-950/50 dark:text-orange-200 dark:border-orange-900/60",
|
||||||
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
|
History: "bg-amber-50 text-amber-700 border-amber-200/70 dark:bg-amber-950/50 dark:text-amber-200 dark:border-amber-900/60",
|
||||||
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
|
Biology: "bg-emerald-50 text-emerald-700 border-emerald-200/70 dark:bg-emerald-950/50 dark:text-emerald-200 dark:border-emerald-900/60",
|
||||||
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
|
Geography: "bg-sky-50 text-sky-700 border-sky-200/70 dark:bg-sky-950/50 dark:text-sky-200 dark:border-sky-900/60",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||||
const base = hrefBase || "/teacher/textbooks";
|
const base = hrefBase || "/teacher/textbooks";
|
||||||
const colorClass = subjectColorMap[textbook.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800";
|
const colorClass = subjectColorMap[textbook.subject] || "bg-zinc-50 text-zinc-700 border-zinc-200/70 dark:bg-zinc-950/50 dark:text-zinc-200 dark:border-zinc-800/70";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
<Card className="group flex flex-col h-full overflow-hidden border-border/60 transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
||||||
<Link href={`${base}/${textbook.id}`} className="flex-1">
|
<Link href={`${base}/${textbook.id}`} className="flex-1">
|
||||||
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
|
<div className={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
|
||||||
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
|
|
||||||
<div className="relative z-10 flex h-full flex-col justify-between">
|
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||||
<Badge variant="secondary" className="w-fit bg-background/50 backdrop-blur-sm border-transparent shadow-none">
|
<Badge variant="secondary" className="w-fit bg-background/80 border border-border/60 shadow-sm">
|
||||||
{textbook.subject}
|
{textbook.subject}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Book className="h-8 w-8 opacity-50" />
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-background text-foreground shadow-sm ring-1 ring-border/60">
|
||||||
|
<Book className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-foreground/70">
|
||||||
|
{textbook.grade || "Grade N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,9 +80,11 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
|
<CardFooter className="p-4 pt-2 mt-auto border-t border-border/60 bg-muted/30 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||||
<BookOpen className="h-3.5 w-3.5" />
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-background/80 ring-1 ring-border/60">
|
||||||
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
<span>{textbook._count?.chapters || 0} Chapters</span>
|
<span>{textbook._count?.chapters || 0} Chapters</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Search, Filter, X } from "lucide-react"
|
import { Search, X } from "lucide-react"
|
||||||
|
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|||||||
@@ -5,13 +5,8 @@ import ReactMarkdown from "react-markdown"
|
|||||||
import remarkBreaks from "remark-breaks"
|
import remarkBreaks from "remark-breaks"
|
||||||
import remarkGfm from "remark-gfm"
|
import remarkGfm from "remark-gfm"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, ChevronDown, ChevronUp } from "lucide-react"
|
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, Share2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/shared/components/ui/collapsible"
|
|
||||||
|
|
||||||
import type { Chapter, KnowledgePoint } from "../types"
|
import type { Chapter, KnowledgePoint } from "../types"
|
||||||
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
|
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
|
||||||
@@ -243,6 +238,96 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
|||||||
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
|
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
|
||||||
}, [knowledgePoints, selectedId])
|
}, [knowledgePoints, selectedId])
|
||||||
|
|
||||||
|
const graphLayout = useMemo(() => {
|
||||||
|
if (currentChapterKPs.length === 0) {
|
||||||
|
return { nodes: [], edges: [], width: 0, height: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map<string, KnowledgePoint>()
|
||||||
|
for (const kp of currentChapterKPs) byId.set(kp.id, kp)
|
||||||
|
|
||||||
|
const children = new Map<string, string[]>()
|
||||||
|
const roots: string[] = []
|
||||||
|
|
||||||
|
for (const kp of currentChapterKPs) {
|
||||||
|
if (kp.parentId && byId.has(kp.parentId)) {
|
||||||
|
const arr = children.get(kp.parentId) ?? []
|
||||||
|
arr.push(kp.id)
|
||||||
|
children.set(kp.parentId, arr)
|
||||||
|
} else {
|
||||||
|
roots.push(kp.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelMap = new Map<string, number>()
|
||||||
|
const levels: string[][] = []
|
||||||
|
const queue = [...roots].map((id) => ({ id, level: 0 }))
|
||||||
|
|
||||||
|
if (queue.length === 0) {
|
||||||
|
for (const kp of currentChapterKPs) queue.push({ id: kp.id, level: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const item = queue.shift()
|
||||||
|
if (!item) continue
|
||||||
|
if (levelMap.has(item.id)) continue
|
||||||
|
levelMap.set(item.id, item.level)
|
||||||
|
if (!levels[item.level]) levels[item.level] = []
|
||||||
|
levels[item.level].push(item.id)
|
||||||
|
const kids = children.get(item.id) ?? []
|
||||||
|
for (const kid of kids) {
|
||||||
|
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kp of currentChapterKPs) {
|
||||||
|
if (!levelMap.has(kp.id)) {
|
||||||
|
const level = levels.length
|
||||||
|
levelMap.set(kp.id, level)
|
||||||
|
if (!levels[level]) levels[level] = []
|
||||||
|
levels[level].push(kp.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeWidth = 160
|
||||||
|
const nodeHeight = 52
|
||||||
|
const gapX = 40
|
||||||
|
const gapY = 90
|
||||||
|
const maxCount = Math.max(...levels.map((l) => l.length), 1)
|
||||||
|
const width = maxCount * (nodeWidth + gapX) + gapX
|
||||||
|
const height = levels.length * (nodeHeight + gapY) + gapY
|
||||||
|
|
||||||
|
const positions = new Map<string, { x: number; y: number }>()
|
||||||
|
levels.forEach((ids, level) => {
|
||||||
|
ids.forEach((id, index) => {
|
||||||
|
const x = gapX + index * (nodeWidth + gapX)
|
||||||
|
const y = gapY + level * (nodeHeight + gapY)
|
||||||
|
positions.set(id, { x, y })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodes = currentChapterKPs.map((kp) => {
|
||||||
|
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
|
||||||
|
return { ...kp, x: pos.x, y: pos.y }
|
||||||
|
})
|
||||||
|
|
||||||
|
const edges = currentChapterKPs
|
||||||
|
.filter((kp) => kp.parentId && positions.has(kp.parentId))
|
||||||
|
.map((kp) => {
|
||||||
|
const parentPos = positions.get(kp.parentId as string)!
|
||||||
|
const childPos = positions.get(kp.id)!
|
||||||
|
return {
|
||||||
|
id: `${kp.parentId}-${kp.id}`,
|
||||||
|
x1: parentPos.x + nodeWidth / 2,
|
||||||
|
y1: parentPos.y + nodeHeight,
|
||||||
|
x2: childPos.x + nodeWidth / 2,
|
||||||
|
y2: childPos.y,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { nodes, edges, width, height }
|
||||||
|
}, [currentChapterKPs])
|
||||||
|
|
||||||
// Pre-process content to mark knowledge points
|
// Pre-process content to mark knowledge points
|
||||||
const processedContent = useMemo(() => {
|
const processedContent = useMemo(() => {
|
||||||
if (!selected?.content) return ""
|
if (!selected?.content) return ""
|
||||||
@@ -293,7 +378,7 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
|||||||
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
|
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="chapters" className="gap-2">
|
<TabsTrigger value="chapters" className="gap-2">
|
||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
章节目录
|
章节目录
|
||||||
@@ -305,6 +390,10 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
|||||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
|
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
图谱
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -399,6 +488,62 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||||
|
请选择一个章节查看知识图谱。
|
||||||
|
</div>
|
||||||
|
) : currentChapterKPs.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||||
|
该章节暂无知识点。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="flex-1 h-full px-2">
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ width: graphLayout.width, height: graphLayout.height }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={graphLayout.width}
|
||||||
|
height={graphLayout.height}
|
||||||
|
className="absolute inset-0"
|
||||||
|
>
|
||||||
|
{graphLayout.edges.map((edge) => (
|
||||||
|
<line
|
||||||
|
key={edge.id}
|
||||||
|
x1={edge.x1}
|
||||||
|
y1={edge.y1}
|
||||||
|
x2={edge.x2}
|
||||||
|
y2={edge.y2}
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{graphLayout.nodes.map((node) => (
|
||||||
|
<button
|
||||||
|
key={node.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
|
||||||
|
highlightedKpId === node.id && "border-primary bg-primary/5"
|
||||||
|
)}
|
||||||
|
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
|
||||||
|
onClick={() => setHighlightedKpId(node.id)}
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate">{node.name}</div>
|
||||||
|
{node.description && (
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{node.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
|
|||||||
import type {
|
import type {
|
||||||
Chapter,
|
Chapter,
|
||||||
CreateChapterInput,
|
CreateChapterInput,
|
||||||
CreateKnowledgePointInput,
|
|
||||||
CreateTextbookInput,
|
CreateTextbookInput,
|
||||||
KnowledgePoint,
|
KnowledgePoint,
|
||||||
Textbook,
|
Textbook,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cache } from "react"
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { users } from "@/shared/db/schema"
|
import { roles, users, usersToRoles } from "@/shared/db/schema"
|
||||||
|
|
||||||
export type UserProfile = {
|
export type UserProfile = {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,6 +21,25 @@ export type UserProfile = {
|
|||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rolePriority = ["admin", "teacher", "parent", "student"] as const
|
||||||
|
|
||||||
|
const normalizeRoleName = (value: string) => {
|
||||||
|
const role = value.trim().toLowerCase()
|
||||||
|
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||||
|
if (role === "admin" || role === "teacher" || role === "parent" || role === "student") return role
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePrimaryRole = (roleNames: string[]) => {
|
||||||
|
const mapped = roleNames.map(normalizeRoleName).filter(Boolean)
|
||||||
|
if (mapped.length) {
|
||||||
|
for (const role of rolePriority) {
|
||||||
|
if (mapped.includes(role)) return role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "student"
|
||||||
|
}
|
||||||
|
|
||||||
export const getUserProfile = cache(async (userId: string): Promise<UserProfile | null> => {
|
export const getUserProfile = cache(async (userId: string): Promise<UserProfile | null> => {
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.id, userId),
|
where: eq(users.id, userId),
|
||||||
@@ -28,12 +47,19 @@ export const getUserProfile = cache(async (userId: string): Promise<UserProfile
|
|||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
|
const roleRows = await db
|
||||||
|
.select({ name: roles.name })
|
||||||
|
.from(usersToRoles)
|
||||||
|
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||||
|
.where(eq(usersToRoles.userId, userId))
|
||||||
|
const role = resolvePrimaryRole(roleRows.map((r) => r.name))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
image: user.image,
|
image: user.image,
|
||||||
role: user.role,
|
role,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
address: user.address,
|
address: user.address,
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
|
|||||||
|
|
||||||
export function OnboardingGate() {
|
export function OnboardingGate() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { status, data: session } = useSession()
|
const { status, data: session, update } = useSession()
|
||||||
const [required, setRequired] = useState(false)
|
const [required, setRequired] = useState(false)
|
||||||
const [currentRole, setCurrentRole] = useState<Role>("student")
|
const [currentRole, setCurrentRole] = useState<Role>("student")
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -142,6 +142,7 @@ export function OnboardingGate() {
|
|||||||
throw new Error(msg || "提交失败")
|
throw new Error(msg || "提交失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await update?.()
|
||||||
toast.success("配置完成")
|
toast.success("配置完成")
|
||||||
setRequired(false)
|
setRequired(false)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|||||||
@@ -18,19 +18,12 @@ import {
|
|||||||
ListOrdered,
|
ListOrdered,
|
||||||
Quote,
|
Quote,
|
||||||
Undo,
|
Undo,
|
||||||
Redo,
|
Redo
|
||||||
MoreHorizontal
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
|
||||||
|
|
||||||
// Since we don't have Toggle component yet, let's create a local one or use Button
|
// Since we don't have Toggle component yet, let's create a local one or use Button
|
||||||
// We will use Button for simplicity and to avoid dependency issues if Radix Toggle isn't installed
|
// We will use Button for simplicity and to avoid dependency issues if Radix Toggle isn't installed
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ export const users = mysqlTable("users", {
|
|||||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||||
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
||||||
image: varchar("image", { length: 255 }),
|
image: varchar("image", { length: 255 }),
|
||||||
|
|
||||||
// Custom Role Field for RBAC (Default Role)
|
|
||||||
role: varchar("role", { length: 50 }).default("student"),
|
|
||||||
|
|
||||||
// Credentials Auth (Optional)
|
// Credentials Auth (Optional)
|
||||||
password: varchar("password", { length: 255 }),
|
password: varchar("password", { length: 255 }),
|
||||||
|
|
||||||
@@ -338,23 +334,27 @@ export const classes = mysqlTable("classes", {
|
|||||||
}).onDelete("set null"),
|
}).onDelete("set null"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const classSubjectEnum = mysqlEnum("subject", ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"]);
|
|
||||||
|
|
||||||
export const classSubjectTeachers = mysqlTable("class_subject_teachers", {
|
export const classSubjectTeachers = mysqlTable("class_subject_teachers", {
|
||||||
classId: varchar("class_id", { length: 128 }).notNull(),
|
classId: varchar("class_id", { length: 128 }).notNull(),
|
||||||
subject: classSubjectEnum.notNull(),
|
subjectId: varchar("subject_id", { length: 128 }).notNull(),
|
||||||
teacherId: varchar("teacher_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
|
teacherId: varchar("teacher_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
pk: primaryKey({ columns: [table.classId, table.subject] }),
|
pk: primaryKey({ columns: [table.classId, table.subjectId] }),
|
||||||
classIdx: index("class_subject_teachers_class_idx").on(table.classId),
|
classIdx: index("class_subject_teachers_class_idx").on(table.classId),
|
||||||
teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId),
|
teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId),
|
||||||
|
subjectIdIdx: index("class_subject_teachers_subject_id_idx").on(table.subjectId),
|
||||||
classFk: foreignKey({
|
classFk: foreignKey({
|
||||||
columns: [table.classId],
|
columns: [table.classId],
|
||||||
foreignColumns: [classes.id],
|
foreignColumns: [classes.id],
|
||||||
name: "cst_c_fk",
|
name: "cst_c_fk",
|
||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
|
subjectFk: foreignKey({
|
||||||
|
columns: [table.subjectId],
|
||||||
|
foreignColumns: [subjects.id],
|
||||||
|
name: "cst_s_fk",
|
||||||
|
}).onDelete("cascade"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const classEnrollments = mysqlTable("class_enrollments", {
|
export const classEnrollments = mysqlTable("class_enrollments", {
|
||||||
|
|||||||
Reference in New Issue
Block a user