Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ade8d4346c |
@@ -419,6 +419,7 @@ export type ActionState<T = void> = {
|
|||||||
- 禁止在 CSS 中 `@import` 外部字体 URL(避免 CLS 与阻塞渲染)
|
- 禁止在 CSS 中 `@import` 外部字体 URL(避免 CLS 与阻塞渲染)
|
||||||
- 依赖:
|
- 依赖:
|
||||||
- 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益
|
- 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益
|
||||||
|
- **图表**:标准图表库统一使用 `recharts`(通过 `src/shared/components/ui/chart.tsx` 封装),禁止引入其他图表库(如 Chart.js / Highcharts)。
|
||||||
- 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback
|
- 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -199,33 +199,38 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
|
|||||||
**日期**: 2026-01-08
|
**日期**: 2026-01-08
|
||||||
**范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码
|
**范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码
|
||||||
|
|
||||||
#### 6.7.1 数据结构
|
---
|
||||||
- 表:`classes`
|
|
||||||
- 字段:`invitation_code`(varchar(6),unique,可为空)
|
|
||||||
- 迁移:`drizzle/0007_add_class_invitation_code.sql`
|
|
||||||
|
|
||||||
#### 6.7.2 教师端能力
|
## 7. 教师仪表盘体验优化 (2026-01-12)
|
||||||
- 在「我的班级」卡片中展示邀请码。
|
|
||||||
- 提供“刷新邀请码”操作:生成新的 6 位码并写入数据库(确保唯一性)。
|
|
||||||
|
|
||||||
#### 6.7.3 学生端能力
|
**目标**: 提升教师仪表盘的信息密度与易用性,优化核心指标展示,调整布局以符合教师工作流。
|
||||||
- 提供“通过邀请码加入班级”的入口,输入 6 位码后完成报名。
|
|
||||||
- 写库操作设计为幂等:重复提交同一个邀请码不会生成重复报名记录,已有记录会被更新为有效状态。
|
|
||||||
|
|
||||||
#### 6.7.4 Seed 支持
|
### 7.1 核心指标卡片重构 (TeacherStats)
|
||||||
- `scripts/seed.ts` 为示例班级补充 `invitationCode`,便于在开发环境直接验证加入流程。
|
- **原有问题**: 展示的总学生数、总课程数等静态指标对日常教学决策帮助有限。
|
||||||
|
- **优化方案**: 替换为高频动态指标,并增强视觉提示。
|
||||||
|
- **Needs Grading (待批改)**: 高亮显示待处理事项,使用 Amber 色彩引起注意。
|
||||||
|
- **Active Assignments (活跃作业)**: 显示当前发布的作业数量,反映教学负载。
|
||||||
|
- **Average Score (平均分)**: 展示近期作业平均分,快速了解学情。
|
||||||
|
- **Submission Rate (提交率)**: 展示整体作业完成度,反映学生参与度。
|
||||||
|
|
||||||
### 6.8 更新记录(2026-01-09)
|
### 7.2 布局调整 (Layout Restructuring)
|
||||||
|
- **原有问题**: "Needs Grading" 位于侧边栏,空间受限;"Homework" 列表占据主栏,信息密度低。
|
||||||
|
- **优化方案**:
|
||||||
|
- **Needs Grading 移至主栏**: 给予更多宽幅空间,展示详细的学生、作业信息及操作按钮。
|
||||||
|
- **Homework 移至侧边栏**: 改为紧凑列表视图,作为快速导航入口。
|
||||||
|
- **Schedule 优化**: 引入时间轴 (Timeline) 视图,支持滚动提示与当前状态指示。
|
||||||
|
|
||||||
#### 6.8.1 班级创建权限收紧
|
### 7.3 组件功能增强
|
||||||
- 目标:仅允许年级组长与 admin 创建班级。
|
- **RecentSubmissions (Needs Grading)**:
|
||||||
- 后端:`createTeacherClassAction` 增加权限校验,非 admin 必须是对应年级的 `gradeHead`;`createAdminClassAction` 强制仅 admin 可调用(`src/modules/classes/actions.ts`)。
|
- 升级为 Table 视图,展示头像、作业名、提交时间。
|
||||||
- 前端:教师端「My Classes」页基于当前用户是否为任一年级 `gradeHead` 计算 `canCreateClass`,并禁用创建入口(`src/app/(dashboard)/teacher/classes/my/page.tsx`、`src/modules/classes/components/my-classes-grid.tsx`)。
|
- 增加 "Grade" 快捷按钮,一键进入批改页面。
|
||||||
|
- 增加 "Late" 状态标记。
|
||||||
|
- **TeacherSchedule**:
|
||||||
|
- 采用垂直时间轴设计。
|
||||||
|
- 增加滚动提示 (Scroll Hint) 与 "No more classes" 状态提示。
|
||||||
|
- **TeacherHomeworkCard**:
|
||||||
|
- 优化为紧凑型列表,显示发布状态 (Published/Draft) 与截止日期。
|
||||||
|
|
||||||
#### 6.8.2 注册页面从演示提交改为真实注册
|
### 7.4 技术细节
|
||||||
- `/register` 增加服务端注册动作:校验输入、邮箱查重、插入 `users` 表,默认 `role=student`(`src/app/(auth)/register/page.tsx`)。
|
- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。
|
||||||
- 注册表单接入注册动作并展示成功/失败提示,成功后跳转至 `/login`(`src/modules/auth/components/register-form.tsx`)。
|
- 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。
|
||||||
|
|
||||||
#### 6.8.3 生产环境登录 UntrustedHost 修复
|
|
||||||
- 问题:服务器上访问 `/api/auth/session` 报 `[auth][error] UntrustedHost`。
|
|
||||||
- 修复:Auth.js 配置开启 `trustHost: true` 并显式设置 `secret`(`src/auth.ts`)。
|
|
||||||
|
|||||||
371
package-lock.json
generated
371
package-lock.json
generated
@@ -41,6 +41,7 @@
|
|||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -4178,6 +4179,42 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||||
|
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -4615,6 +4652,69 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -4712,6 +4812,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.49.0",
|
"version": "8.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
|
||||||
@@ -5916,6 +6022,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -5994,6 +6221,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||||
@@ -6490,6 +6723,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.43.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||||
|
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -7002,6 +7245,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/extend": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -7548,6 +7797,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -7596,6 +7855,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-alphabetical": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -10329,7 +10597,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-markdown": {
|
"node_modules/react-markdown": {
|
||||||
@@ -10359,6 +10626,29 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -10428,6 +10718,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -10553,6 +10888,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -11253,6 +11594,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -11784,6 +12131,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -46,8 +46,9 @@
|
|||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"recharts": "^3.6.0",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -56,8 +57,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^10.1.0",
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
|
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
|
||||||
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
|
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
|
||||||
import { getHomeworkAssignments, getHomeworkSubmissions } from "@/modules/homework/data-access";
|
import { getHomeworkAssignments, getHomeworkSubmissions, getTeacherGradeTrends } from "@/modules/homework/data-access";
|
||||||
|
import { db } from "@/shared/db";
|
||||||
|
import { users } from "@/shared/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function TeacherDashboardPage() {
|
export default async function TeacherDashboardPage() {
|
||||||
const teacherId = await getTeacherIdForMutations();
|
const teacherId = await getTeacherIdForMutations();
|
||||||
|
|
||||||
const [classes, schedule, assignments, submissions] = await Promise.all([
|
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
|
||||||
getTeacherClasses({ teacherId }),
|
getTeacherClasses({ teacherId }),
|
||||||
getClassSchedule({ teacherId }),
|
getClassSchedule({ teacherId }),
|
||||||
getHomeworkAssignments({ creatorId: teacherId }),
|
getHomeworkAssignments({ creatorId: teacherId }),
|
||||||
getHomeworkSubmissions({ creatorId: teacherId }),
|
getHomeworkSubmissions({ creatorId: teacherId }),
|
||||||
|
db.query.users.findFirst({
|
||||||
|
where: eq(users.id, teacherId),
|
||||||
|
columns: { name: true },
|
||||||
|
}),
|
||||||
|
getTeacherGradeTrends(teacherId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +29,8 @@ export default async function TeacherDashboardPage() {
|
|||||||
schedule,
|
schedule,
|
||||||
assignments,
|
assignments,
|
||||||
submissions,
|
submissions,
|
||||||
|
teacherName: teacherProfile?.name ?? "Teacher",
|
||||||
|
gradeTrends,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,67 +1,114 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Inbox, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { Inbox } from "lucide-react";
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table";
|
||||||
import { formatDate } from "@/shared/lib/utils";
|
import { formatDate } from "@/shared/lib/utils";
|
||||||
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
|
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
|
||||||
|
|
||||||
export function RecentSubmissions({ submissions }: { submissions: HomeworkSubmissionListItem[] }) {
|
export function RecentSubmissions({
|
||||||
|
submissions,
|
||||||
|
title = "Recent Submissions",
|
||||||
|
emptyTitle = "No New Submissions",
|
||||||
|
emptyDescription = "All caught up! There are no new submissions to review."
|
||||||
|
}: {
|
||||||
|
submissions: HomeworkSubmissionListItem[],
|
||||||
|
title?: string,
|
||||||
|
emptyTitle?: string,
|
||||||
|
emptyDescription?: string
|
||||||
|
}) {
|
||||||
const hasSubmissions = submissions.length > 0;
|
const hasSubmissions = submissions.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="col-span-4 lg:col-span-4">
|
<Card className="h-full flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Inbox className="h-4 w-4 text-muted-foreground" />
|
<Inbox className="h-5 w-5 text-primary" />
|
||||||
Recent Submissions
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-primary" asChild>
|
||||||
|
<Link href="/teacher/homework/submissions" className="flex items-center gap-1">
|
||||||
|
View All <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1">
|
||||||
{!hasSubmissions ? (
|
{!hasSubmissions ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
title="No New Submissions"
|
title={emptyTitle}
|
||||||
description="All caught up! There are no new submissions to review."
|
description={emptyDescription}
|
||||||
action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
|
action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
|
||||||
className="border-none h-[300px]"
|
className="border-none h-full min-h-[200px]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="rounded-md border">
|
||||||
{submissions.map((item) => (
|
<Table>
|
||||||
<div key={item.id} className="flex items-center justify-between group">
|
<TableHeader>
|
||||||
<div className="flex items-center space-x-4">
|
<TableRow className="bg-muted/50">
|
||||||
<Avatar className="h-9 w-9">
|
<TableHead className="w-[200px]">Student</TableHead>
|
||||||
<AvatarImage src={undefined} alt={item.studentName} />
|
<TableHead>Assignment</TableHead>
|
||||||
<AvatarFallback>{item.studentName.charAt(0)}</AvatarFallback>
|
<TableHead className="w-[140px]">Submitted</TableHead>
|
||||||
</Avatar>
|
<TableHead className="w-[100px] text-right">Action</TableHead>
|
||||||
<div className="space-y-1">
|
</TableRow>
|
||||||
<p className="text-sm font-medium leading-none">
|
</TableHeader>
|
||||||
{item.studentName}
|
<TableBody>
|
||||||
</p>
|
{submissions.map((item) => (
|
||||||
<p className="text-sm text-muted-foreground">
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-8 w-8 border">
|
||||||
|
<AvatarImage src={undefined} alt={item.studentName} />
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary text-xs">
|
||||||
|
{item.studentName.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-medium text-sm">{item.studentName}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/teacher/homework/submissions/${item.id}`}
|
href={`/teacher/homework/submissions/${item.id}`}
|
||||||
className="font-medium text-foreground hover:underline"
|
className="font-medium hover:text-primary hover:underline transition-colors block truncate max-w-[240px]"
|
||||||
|
title={item.assignmentTitle}
|
||||||
>
|
>
|
||||||
{item.assignmentTitle}
|
{item.assignmentTitle}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell>
|
||||||
</div>
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center space-x-2">
|
<span className="text-xs text-muted-foreground">
|
||||||
<div className="text-sm text-muted-foreground">
|
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
|
||||||
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
|
</span>
|
||||||
</div>
|
{item.isLate && (
|
||||||
{item.isLate && (
|
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
||||||
<span className="inline-flex items-center rounded-full border border-destructive px-2 py-0.5 text-xs font-semibold text-destructive transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
Late
|
||||||
Late
|
</Badge>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="text-right">
|
||||||
))}
|
<Button size="sm" variant="secondary" className="h-8 px-3" asChild>
|
||||||
|
<Link href={`/teacher/homework/submissions/${item.id}`}>
|
||||||
|
Grade
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ 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)
|
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
|
||||||
const topClassesByStudents = [...classes].sort((a, b) => (b.studentCount ?? 0) - (a.studentCount ?? 0)).slice(0, 8)
|
|
||||||
const maxStudentCount = Math.max(1, ...topClassesByStudents.map((c) => c.studentCount ?? 0))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -33,52 +31,40 @@ export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
|||||||
className="border-none h-72"
|
className="border-none h-72"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-1">
|
||||||
{topClassesByStudents.length > 0 ? (
|
|
||||||
<div className="rounded-md border bg-card p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-sm font-medium">Students by class</div>
|
|
||||||
<div className="text-xs text-muted-foreground tabular-nums">Total {totalStudents}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-2">
|
|
||||||
{topClassesByStudents.map((c) => {
|
|
||||||
const count = c.studentCount ?? 0
|
|
||||||
const pct = Math.max(0, Math.min(100, (count / maxStudentCount) * 100))
|
|
||||||
return (
|
|
||||||
<div key={c.id} className="grid grid-cols-[minmax(0,1fr)_120px_52px] items-center gap-3">
|
|
||||||
<div className="truncate text-sm">{c.name}</div>
|
|
||||||
<div className="h-2 rounded-full bg-muted">
|
|
||||||
<div className="h-2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-xs tabular-nums text-muted-foreground">{count}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{classes.slice(0, 6).map((c) => (
|
{classes.slice(0, 6).map((c) => (
|
||||||
<Link
|
<Link
|
||||||
key={c.id}
|
key={c.id}
|
||||||
href={`/teacher/classes/my/${encodeURIComponent(c.id)}`}
|
href={`/teacher/classes/my/${encodeURIComponent(c.id)}`}
|
||||||
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
|
className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1 mr-3">
|
||||||
<div className="font-medium truncate">{c.name}</div>
|
<div className="font-medium truncate group-hover:text-primary transition-colors">{c.name}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground truncate flex items-center gap-1.5">
|
||||||
{c.grade}
|
<span className="inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
{c.homeroom ? ` · ${c.homeroom}` : ""}
|
{c.grade}
|
||||||
{c.room ? ` · ${c.room}` : ""}
|
</span>
|
||||||
|
{c.homeroom && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Homeroom: {c.homeroom}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{c.room && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Room {c.room}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="flex items-center gap-1">
|
<div className="flex items-center text-xs text-muted-foreground tabular-nums">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="mr-1.5 h-3 w-3 opacity-70" />
|
||||||
{c.studentCount} students
|
{c.studentCount}
|
||||||
</Badge>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||||
|
|
||||||
export function TeacherDashboardHeader() {
|
interface TeacherDashboardHeaderProps {
|
||||||
|
teacherName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
||||||
|
const today = new Date().toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Teacher</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Good morning, {teacherName}</h2>
|
||||||
<p className="text-muted-foreground">Overview of today's work and your classes.</p>
|
<p className="text-muted-foreground">It's {today}. Here's your daily overview.</p>
|
||||||
</div>
|
</div>
|
||||||
<TeacherQuickActions />
|
<TeacherQuickActions />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TeacherHomeworkCard } from "./teacher-homework-card"
|
|||||||
import { RecentSubmissions } from "./recent-submissions"
|
import { RecentSubmissions } from "./recent-submissions"
|
||||||
import { TeacherSchedule } from "./teacher-schedule"
|
import { TeacherSchedule } from "./teacher-schedule"
|
||||||
import { TeacherStats } from "./teacher-stats"
|
import { TeacherStats } from "./teacher-stats"
|
||||||
|
import { TeacherGradeTrends } from "./teacher-grade-trends"
|
||||||
|
|
||||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||||
const day = d.getDay()
|
const day = d.getDay()
|
||||||
@@ -32,27 +33,52 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
|||||||
|
|
||||||
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
|
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
|
||||||
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
||||||
const recentSubmissions = submittedSubmissions.slice(0, 6)
|
|
||||||
|
// Filter for submissions that actually need grading (status === "submitted")
|
||||||
|
// If we have less than 5 to grade, maybe also show some recently graded ones?
|
||||||
|
// For now, let's stick to "Needs Grading" as it's more useful.
|
||||||
|
const submissionsToGrade = submittedSubmissions
|
||||||
|
.filter(s => s.status === "submitted")
|
||||||
|
.sort((a, b) => new Date(a.submittedAt!).getTime() - new Date(b.submittedAt!).getTime()) // Oldest first? Or Newest? Usually oldest first for queue.
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
// Calculate stats for the dashboard
|
||||||
|
const activeAssignmentsCount = data.assignments.filter(a => a.status === "published").length
|
||||||
|
|
||||||
|
const totalTrendScore = data.gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0)
|
||||||
|
const averageScore = data.gradeTrends.length > 0 ? totalTrendScore / data.gradeTrends.length : 0
|
||||||
|
|
||||||
|
const totalSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.submissionCount, 0)
|
||||||
|
const totalPotentialSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.totalStudents, 0)
|
||||||
|
const submissionRate = totalPotentialSubmissions > 0 ? (totalSubmissions / totalPotentialSubmissions) * 100 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-6 p-8">
|
||||||
<TeacherDashboardHeader />
|
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||||
|
|
||||||
<TeacherStats
|
<TeacherStats
|
||||||
totalStudents={totalStudents}
|
|
||||||
classCount={data.classes.length}
|
|
||||||
toGradeCount={toGradeCount}
|
toGradeCount={toGradeCount}
|
||||||
todayScheduleCount={todayScheduleItems.length}
|
activeAssignmentsCount={activeAssignmentsCount}
|
||||||
|
averageScore={averageScore}
|
||||||
|
submissionRate={submissionRate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
<TeacherSchedule items={todayScheduleItems} />
|
<div className="flex flex-col gap-6 lg:col-span-8">
|
||||||
<RecentSubmissions submissions={recentSubmissions} />
|
<TeacherGradeTrends trends={data.gradeTrends} />
|
||||||
</div>
|
<RecentSubmissions
|
||||||
|
submissions={submissionsToGrade}
|
||||||
|
title="Needs Grading"
|
||||||
|
emptyTitle="All caught up!"
|
||||||
|
emptyDescription="You have no pending submissions to grade."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="flex flex-col gap-6 lg:col-span-4">
|
||||||
<TeacherClassesCard classes={data.classes} />
|
<TeacherSchedule items={todayScheduleItems} />
|
||||||
<TeacherHomeworkCard assignments={data.assignments} />
|
<TeacherHomeworkCard assignments={data.assignments} />
|
||||||
|
<TeacherClassesCard classes={data.classes} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
|
import { TrendingUp } from "lucide-react"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import type { TeacherGradeTrendItem } from "@/modules/homework/types"
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||||
|
|
||||||
|
export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }) {
|
||||||
|
const hasTrends = trends.length > 0
|
||||||
|
|
||||||
|
// Calculate percentages for the chart
|
||||||
|
const chartData = trends.map((item) => {
|
||||||
|
const percentage = item.maxScore > 0 ? (item.averageScore / item.maxScore) * 100 : 0
|
||||||
|
return {
|
||||||
|
title: item.title,
|
||||||
|
score: Math.round(percentage),
|
||||||
|
fullTitle: item.title, // For tooltip
|
||||||
|
submissionCount: item.submissionCount,
|
||||||
|
totalStudents: item.totalStudents,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
score: {
|
||||||
|
label: "Average Score (%)",
|
||||||
|
color: "hsl(var(--primary))",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
Class Performance
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Average scores for the last {trends.length} assignments
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!hasTrends ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={TrendingUp}
|
||||||
|
title="No data available"
|
||||||
|
description="Publish assignments to see class performance trends."
|
||||||
|
className="border-none h-[200px] p-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ChartContainer config={chartConfig} className="h-[200px] w-full">
|
||||||
|
<LineChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
top: 12,
|
||||||
|
bottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="title"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={{
|
||||||
|
stroke: "hsl(var(--muted-foreground))",
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: "4 4",
|
||||||
|
}}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
indicator="line"
|
||||||
|
labelKey="fullTitle"
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="score"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--color-score)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{
|
||||||
|
fill: "var(--color-score)",
|
||||||
|
r: 4,
|
||||||
|
strokeWidth: 2,
|
||||||
|
stroke: "hsl(var(--background))"
|
||||||
|
}}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
strokeWidth: 2,
|
||||||
|
stroke: "hsl(var(--background))"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
{/* Metric Summary */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{chartData.slice().reverse().slice(0, 3).map((item, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
|
||||||
|
<div className="text-xs text-muted-foreground truncate" title={item.fullTitle}>
|
||||||
|
{item.fullTitle}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-xl font-bold tabular-nums">
|
||||||
|
{item.score}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{item.submissionCount}/{item.totalStudents} submitted
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,54 +1,93 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PenTool } from "lucide-react"
|
import { PenTool, Calendar, Plus } from "lucide-react"
|
||||||
|
|
||||||
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 { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||||
Homework
|
Homework
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<Button asChild size="icon" variant="ghost" className="h-8 w-8">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Link href="/teacher/homework/assignments/create" title="Create new assignment">
|
||||||
<Link href="/teacher/homework/assignments">Open list</Link>
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Link>
|
||||||
<Button asChild size="sm">
|
</Button>
|
||||||
<Link href="/teacher/homework/assignments/create">New</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3">
|
<CardContent>
|
||||||
{assignments.length === 0 ? (
|
{assignments.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
title="No homework assignments yet"
|
title="No assignments"
|
||||||
description="Create an assignment from an exam and publish it to students."
|
description="Create an assignment to get started."
|
||||||
action={{ label: "Create assignment", href: "/teacher/homework/assignments/create" }}
|
action={{ label: "Create", href: "/teacher/homework/assignments/create" }}
|
||||||
className="border-none h-72"
|
className="border-none h-48"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
assignments.slice(0, 6).map((a) => (
|
<div className="space-y-1">
|
||||||
<Link
|
{assignments.slice(0, 6).map((a) => {
|
||||||
key={a.id}
|
const isPublished = a.status === "published"
|
||||||
href={`/teacher/homework/assignments/${encodeURIComponent(a.id)}`}
|
const isDraft = a.status === "draft"
|
||||||
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
|
|
||||||
>
|
return (
|
||||||
<div className="min-w-0">
|
<Link
|
||||||
<div className="font-medium truncate">{a.title}</div>
|
key={a.id}
|
||||||
<div className="text-sm text-muted-foreground truncate">{a.sourceExamTitle}</div>
|
href={`/teacher/homework/assignments/${encodeURIComponent(a.id)}`}
|
||||||
</div>
|
className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
|
||||||
<Badge variant="outline" className="capitalize">
|
>
|
||||||
{a.status}
|
<div className="min-w-0 flex-1 mr-3">
|
||||||
</Badge>
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
</Link>
|
<div className={cn(
|
||||||
))
|
"h-2 w-2 rounded-full",
|
||||||
|
isPublished ? "bg-emerald-500" :
|
||||||
|
isDraft ? "bg-amber-400" : "bg-muted-foreground"
|
||||||
|
)} />
|
||||||
|
<div className="font-medium truncate text-sm group-hover:text-primary transition-colors">
|
||||||
|
{a.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate pl-4">
|
||||||
|
{a.sourceExamTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
{a.dueAt ? (
|
||||||
|
<div className="flex items-center text-xs text-muted-foreground tabular-nums">
|
||||||
|
<Calendar className="mr-1 h-3 w-3 opacity-70" />
|
||||||
|
{formatDate(a.dueAt)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-muted-foreground italic">No due date</span>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] h-4 px-1.5 capitalize font-normal border-transparent bg-muted/50",
|
||||||
|
isPublished && "text-emerald-600 bg-emerald-500/10",
|
||||||
|
isDraft && "text-amber-600 bg-amber-500/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button asChild variant="link" size="sm" className="w-full text-muted-foreground h-auto py-1 text-xs">
|
||||||
|
<Link href="/teacher/homework/assignments">View all assignments</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Clock, MapPin, CalendarDays, CalendarX } from "lucide-react";
|
import { CalendarDays, CalendarX, MapPin } from "lucide-react";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||||||
|
|
||||||
type TeacherTodayScheduleItem = {
|
type TeacherTodayScheduleItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,54 +19,130 @@ type TeacherTodayScheduleItem = {
|
|||||||
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||||
const hasSchedule = items.length > 0;
|
const hasSchedule = items.length > 0;
|
||||||
|
|
||||||
|
const getStatus = (start: string, end: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
|
const [startH, startM] = start.split(":").map(Number);
|
||||||
|
const [endH, endM] = end.split(":").map(Number);
|
||||||
|
const startTime = startH * 60 + startM;
|
||||||
|
const endTime = endH * 60 + endM;
|
||||||
|
|
||||||
|
if (currentTime >= startTime && currentTime <= endTime) return "live";
|
||||||
|
if (currentTime < startTime) return "upcoming";
|
||||||
|
return "past";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="col-span-3">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||||
Today's Schedule
|
Today's Schedule
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
{!hasSchedule ? (
|
{!hasSchedule ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={CalendarX}
|
icon={CalendarX}
|
||||||
title="No Classes Today"
|
title="No Classes Today"
|
||||||
description="No timetable entries for today."
|
description="No timetable entries."
|
||||||
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
|
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
|
||||||
className="border-none h-[300px]"
|
className="border-none h-[200px]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<ScrollArea className="h-[240px] px-6 py-2">
|
||||||
{items.map((item) => (
|
<div className="relative space-y-0 ml-1">
|
||||||
<div
|
{/* Vertical Timeline Line */}
|
||||||
key={item.id}
|
<div className="absolute left-[11px] -top-2 -bottom-2 w-px bg-border/50" />
|
||||||
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
|
||||||
>
|
{/* Top Fade Hint */}
|
||||||
<div className="space-y-1">
|
<div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
|
||||||
<Link
|
|
||||||
href={`/teacher/classes/schedule?classId=${encodeURIComponent(item.classId)}`}
|
{items.map((item, index) => {
|
||||||
className="font-medium leading-none hover:underline"
|
const status = getStatus(item.startTime, item.endTime);
|
||||||
>
|
const isLive = status === "live";
|
||||||
{item.course}
|
const isPast = status === "past";
|
||||||
</Link>
|
const isLast = index === items.length - 1;
|
||||||
<div className="flex items-center text-sm text-muted-foreground">
|
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
return (
|
||||||
<span className="mr-3">{item.startTime}–{item.endTime}</span>
|
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
|
||||||
{item.location ? (
|
{/* Timeline Dot */}
|
||||||
<>
|
<div className={cn(
|
||||||
<MapPin className="mr-1 h-3 w-3" />
|
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
|
||||||
<span>{item.location}</span>
|
isLive ? "bg-primary border-primary" :
|
||||||
</>
|
isPast ? "bg-muted border-muted-foreground/30" :
|
||||||
) : null}
|
"bg-background border-primary"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
|
||||||
|
className={cn(
|
||||||
|
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
|
||||||
|
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
|
||||||
|
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
|
||||||
|
"bg-card"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-0.5 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn(
|
||||||
|
"font-medium text-sm truncate",
|
||||||
|
isLive ? "text-primary" : "text-foreground"
|
||||||
|
)}>
|
||||||
|
{item.course}
|
||||||
|
</span>
|
||||||
|
{isLive && (
|
||||||
|
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
|
||||||
|
LIVE
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||||
|
<span>{item.className}</span>
|
||||||
|
{item.location && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<MapPin className="mr-0.5 h-2.5 w-2.5" />
|
||||||
|
{item.location}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
|
||||||
|
isLive ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{item.startTime}
|
||||||
|
<span className="text-[10px] opacity-70 ml-0.5">– {item.endTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Connection Line to Next (if not last) */}
|
||||||
|
{!isLast && (
|
||||||
|
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Bottom Hint */}
|
||||||
|
{items.length > 3 ? (
|
||||||
|
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
|
||||||
|
Scroll for more
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<Badge variant="secondary">
|
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
|
||||||
{item.className}
|
No more classes today
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||||
import { Users, BookOpen, FileCheck, Calendar } from "lucide-react";
|
import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react";
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
interface TeacherStatsProps {
|
interface TeacherStatsProps {
|
||||||
totalStudents: number;
|
|
||||||
classCount: number;
|
|
||||||
toGradeCount: number;
|
toGradeCount: number;
|
||||||
todayScheduleCount: number;
|
activeAssignmentsCount: number;
|
||||||
|
averageScore: number;
|
||||||
|
submissionRate: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherStats({
|
export function TeacherStats({
|
||||||
totalStudents,
|
|
||||||
classCount,
|
|
||||||
toGradeCount,
|
toGradeCount,
|
||||||
todayScheduleCount,
|
activeAssignmentsCount,
|
||||||
|
averageScore,
|
||||||
|
submissionRate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: TeacherStatsProps) {
|
}: TeacherStatsProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -38,48 +40,59 @@ export function TeacherStats({
|
|||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
title: "Total Students",
|
title: "Needs Grading",
|
||||||
value: String(totalStudents),
|
|
||||||
description: "Across all your classes",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "My Classes",
|
|
||||||
value: String(classCount),
|
|
||||||
description: "Active classes you manage",
|
|
||||||
icon: BookOpen,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "To Grade",
|
|
||||||
value: String(toGradeCount),
|
value: String(toGradeCount),
|
||||||
description: "Submitted homework waiting for grading",
|
description: "Submissions pending review",
|
||||||
icon: FileCheck,
|
icon: FileCheck,
|
||||||
|
href: "/teacher/homework/submissions?status=submitted",
|
||||||
|
highlight: toGradeCount > 0,
|
||||||
|
color: "text-amber-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Today",
|
title: "Active Assignments",
|
||||||
value: String(todayScheduleCount),
|
value: String(activeAssignmentsCount),
|
||||||
description: "Scheduled items today",
|
description: "Published and ongoing",
|
||||||
icon: Calendar,
|
icon: PenTool,
|
||||||
|
href: "/teacher/homework/assignments?status=published",
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Average Score",
|
||||||
|
value: `${Math.round(averageScore)}%`,
|
||||||
|
description: "Across recent assignments",
|
||||||
|
icon: TrendingUp,
|
||||||
|
href: "#grade-trends",
|
||||||
|
color: "text-emerald-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Submission Rate",
|
||||||
|
value: `${Math.round(submissionRate)}%`,
|
||||||
|
description: "Overall completion rate",
|
||||||
|
icon: BarChart,
|
||||||
|
href: "#grade-trends",
|
||||||
|
color: "text-purple-500",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{stats.map((stat, i) => (
|
{stats.map((stat, i) => (
|
||||||
<Card key={i}>
|
<Link key={i} href={stat.href} className="block transition-transform hover:-translate-y-1">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Card className={cn(stat.highlight && "border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20")}>
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
{stat.title}
|
<CardTitle className="text-sm font-medium">
|
||||||
</CardTitle>
|
{stat.title}
|
||||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
</CardTitle>
|
||||||
</CardHeader>
|
<stat.icon className={cn("h-4 w-4", stat.color)} />
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="text-2xl font-bold">{stat.value}</div>
|
<CardContent>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="text-2xl font-bold">{stat.value}</div>
|
||||||
{stat.description}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{stat.description}
|
||||||
</CardContent>
|
</p>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
import type { TeacherClass, ClassScheduleItem } from "@/modules/classes/types"
|
import type { TeacherClass, ClassScheduleItem } from "@/modules/classes/types"
|
||||||
import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem } from "@/modules/homework/types"
|
import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem, TeacherGradeTrendItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
export type AdminDashboardUserRoleCount = {
|
export type AdminDashboardUserRoleCount = {
|
||||||
role: string
|
role: string
|
||||||
@@ -67,4 +67,6 @@ export type TeacherDashboardData = {
|
|||||||
schedule: ClassScheduleItem[]
|
schedule: ClassScheduleItem[]
|
||||||
assignments: HomeworkAssignmentListItem[]
|
assignments: HomeworkAssignmentListItem[]
|
||||||
submissions: HomeworkSubmissionListItem[]
|
submissions: HomeworkSubmissionListItem[]
|
||||||
|
teacherName: string
|
||||||
|
gradeTrends: TeacherGradeTrendItem[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,69 @@ import type {
|
|||||||
StudentDashboardGradeProps,
|
StudentDashboardGradeProps,
|
||||||
StudentHomeworkScoreAnalytics,
|
StudentHomeworkScoreAnalytics,
|
||||||
StudentRanking,
|
StudentRanking,
|
||||||
|
TeacherGradeTrendItem,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
|
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
|
||||||
|
const recentAssignments = await db.query.homeworkAssignments.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(homeworkAssignments.creatorId, teacherId),
|
||||||
|
or(eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "archived"))
|
||||||
|
),
|
||||||
|
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||||
|
limit: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (recentAssignments.length === 0) return []
|
||||||
|
|
||||||
|
const assignmentIds = recentAssignments.map((a) => a.id)
|
||||||
|
|
||||||
|
const [maxScoreMap, targetCountRows, submissionStats] = await Promise.all([
|
||||||
|
getAssignmentMaxScoreById(assignmentIds),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||||
|
count: count(homeworkAssignmentTargets.studentId),
|
||||||
|
})
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
||||||
|
.groupBy(homeworkAssignmentTargets.assignmentId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
assignmentId: homeworkSubmissions.assignmentId,
|
||||||
|
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
|
||||||
|
count: count(homeworkSubmissions.id),
|
||||||
|
})
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||||
|
eq(homeworkSubmissions.status, "graded")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(homeworkSubmissions.assignmentId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const targetCountMap = new Map<string, number>()
|
||||||
|
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
|
||||||
|
|
||||||
|
const statsMap = new Map<string, { avg: number; count: number }>()
|
||||||
|
for (const r of submissionStats) statsMap.set(r.assignmentId, { avg: Number(r.avgScore), count: Number(r.count) })
|
||||||
|
|
||||||
|
return recentAssignments.map((a) => {
|
||||||
|
const stats = statsMap.get(a.id) ?? { avg: 0, count: 0 }
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
averageScore: stats.avg,
|
||||||
|
maxScore: maxScoreMap.get(a.id) ?? 0,
|
||||||
|
submissionCount: stats.count,
|
||||||
|
totalStudents: targetCountMap.get(a.id) ?? 0,
|
||||||
|
createdAt: a.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
}).reverse() // Reverse to show trend from left (older) to right (newer)
|
||||||
|
})
|
||||||
|
|
||||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||||
|
|
||||||
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ export type HomeworkAssignmentStatus = "draft" | "published" | "archived"
|
|||||||
|
|
||||||
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
|
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
|
||||||
|
|
||||||
|
export interface TeacherGradeTrendItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
averageScore: number
|
||||||
|
maxScore: number
|
||||||
|
submissionCount: number
|
||||||
|
totalStudents: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface HomeworkAssignmentListItem {
|
export interface HomeworkAssignmentListItem {
|
||||||
id: string
|
id: string
|
||||||
sourceExamId: string
|
sourceExamId: string
|
||||||
|
|||||||
356
src/shared/components/ui/chart.tsx
Normal file
356
src/shared/components/ui/chart.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
|
||||||
|
}
|
||||||
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-chart={chartId}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ChartContainer.displayName = "ChartContainer"
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ? (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
) : null
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ChartTooltipContent.displayName = "ChartTooltipContent"
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
const ChartLegendContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}
|
||||||
|
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ChartLegendContent.displayName = "ChartLegendContent"
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user