diff --git a/docs/architecture/003_frontend_engineering_standards.md b/docs/architecture/003_frontend_engineering_standards.md index a633fae..288fbbe 100644 --- a/docs/architecture/003_frontend_engineering_standards.md +++ b/docs/architecture/003_frontend_engineering_standards.md @@ -419,6 +419,7 @@ export type ActionState = { - 禁止在 CSS 中 `@import` 外部字体 URL(避免 CLS 与阻塞渲染) - 依赖: - 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益 + - **图表**:标准图表库统一使用 `recharts`(通过 `src/shared/components/ui/chart.tsx` 封装),禁止引入其他图表库(如 Chart.js / Highcharts)。 - 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback --- diff --git a/docs/design/002_teacher_dashboard_implementation.md b/docs/design/002_teacher_dashboard_implementation.md index cd6b88f..3cc9393 100644 --- a/docs/design/002_teacher_dashboard_implementation.md +++ b/docs/design/002_teacher_dashboard_implementation.md @@ -165,7 +165,7 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面 - 运行命令:`npm run db:seed` ### 6.5 开发过程中的问题与处理 - + - 端口占用(EADDRINUSE):开发服务器端口被占用时,通过更换端口启动规避(例如 `next dev -p `)。 - Next dev 锁文件:出现 `.next/dev/lock` 无法获取锁时,需要确保只有一个 dev 实例在运行,并清理残留 lock。 - 头像资源 404:移除 Header 中硬编码的本地头像资源引用,避免 `public/avatars/...` 不存在导致的 404 噪音(见 `src/modules/layout/components/site-header.tsx`)。 @@ -199,33 +199,38 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面 **日期**: 2026-01-08 **范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码 -#### 6.7.1 数据结构 -- 表:`classes` -- 字段:`invitation_code`(varchar(6),unique,可为空) -- 迁移:`drizzle/0007_add_class_invitation_code.sql` +--- -#### 6.7.2 教师端能力 -- 在「我的班级」卡片中展示邀请码。 -- 提供“刷新邀请码”操作:生成新的 6 位码并写入数据库(确保唯一性)。 +## 7. 教师仪表盘体验优化 (2026-01-12) -#### 6.7.3 学生端能力 -- 提供“通过邀请码加入班级”的入口,输入 6 位码后完成报名。 -- 写库操作设计为幂等:重复提交同一个邀请码不会生成重复报名记录,已有记录会被更新为有效状态。 +**目标**: 提升教师仪表盘的信息密度与易用性,优化核心指标展示,调整布局以符合教师工作流。 -#### 6.7.4 Seed 支持 -- `scripts/seed.ts` 为示例班级补充 `invitationCode`,便于在开发环境直接验证加入流程。 +### 7.1 核心指标卡片重构 (TeacherStats) +- **原有问题**: 展示的总学生数、总课程数等静态指标对日常教学决策帮助有限。 +- **优化方案**: 替换为高频动态指标,并增强视觉提示。 + - **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 班级创建权限收紧 -- 目标:仅允许年级组长与 admin 创建班级。 -- 后端:`createTeacherClassAction` 增加权限校验,非 admin 必须是对应年级的 `gradeHead`;`createAdminClassAction` 强制仅 admin 可调用(`src/modules/classes/actions.ts`)。 -- 前端:教师端「My Classes」页基于当前用户是否为任一年级 `gradeHead` 计算 `canCreateClass`,并禁用创建入口(`src/app/(dashboard)/teacher/classes/my/page.tsx`、`src/modules/classes/components/my-classes-grid.tsx`)。 +### 7.3 组件功能增强 +- **RecentSubmissions (Needs Grading)**: + - 升级为 Table 视图,展示头像、作业名、提交时间。 + - 增加 "Grade" 快捷按钮,一键进入批改页面。 + - 增加 "Late" 状态标记。 +- **TeacherSchedule**: + - 采用垂直时间轴设计。 + - 增加滚动提示 (Scroll Hint) 与 "No more classes" 状态提示。 +- **TeacherHomeworkCard**: + - 优化为紧凑型列表,显示发布状态 (Published/Draft) 与截止日期。 -#### 6.8.2 注册页面从演示提交改为真实注册 -- `/register` 增加服务端注册动作:校验输入、邮箱查重、插入 `users` 表,默认 `role=student`(`src/app/(auth)/register/page.tsx`)。 -- 注册表单接入注册动作并展示成功/失败提示,成功后跳转至 `/login`(`src/modules/auth/components/register-form.tsx`)。 - -#### 6.8.3 生产环境登录 UntrustedHost 修复 -- 问题:服务器上访问 `/api/auth/session` 报 `[auth][error] UntrustedHost`。 -- 修复:Auth.js 配置开启 `trustHost: true` 并显式设置 `secret`(`src/auth.ts`)。 +### 7.4 技术细节 +- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。 +- 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。 diff --git a/package-lock.json b/package-lock.json index b205c79..cde4674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "react-dom": "19.2.1", "react-hook-form": "^7.69.0", "react-markdown": "^10.1.0", + "recharts": "^3.6.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", @@ -4178,6 +4179,42 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -4615,6 +4652,69 @@ "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": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4712,6 +4812,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "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": { "version": "8.49.0", "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==", "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": { "version": "1.0.8", "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": { "version": "1.2.0", "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" } }, + "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": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -7002,6 +7245,12 @@ "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": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -7548,6 +7797,16 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7596,6 +7855,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -10329,7 +10597,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-markdown": { @@ -10359,6 +10626,29 @@ "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": { "version": "2.7.2", "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10553,6 +10888,12 @@ "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": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11253,6 +11594,12 @@ "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": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -11784,6 +12131,28 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 6d7cfce..a9866db 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,9 @@ "react-dom": "19.2.1", "react-hook-form": "^7.69.0", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", + "recharts": "^3.6.0", "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -56,8 +57,8 @@ }, "devDependencies": { "@faker-js/faker": "^10.1.0", - "@tailwindcss/typography": "^0.5.16", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.16", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/(dashboard)/teacher/dashboard/page.tsx b/src/app/(dashboard)/teacher/dashboard/page.tsx index 977b902..d0f35e3 100644 --- a/src/app/(dashboard)/teacher/dashboard/page.tsx +++ b/src/app/(dashboard)/teacher/dashboard/page.tsx @@ -1,17 +1,25 @@ import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view" 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 default async function TeacherDashboardPage() { const teacherId = await getTeacherIdForMutations(); - const [classes, schedule, assignments, submissions] = await Promise.all([ + const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([ getTeacherClasses({ teacherId }), getClassSchedule({ teacherId }), getHomeworkAssignments({ creatorId: teacherId }), getHomeworkSubmissions({ creatorId: teacherId }), + db.query.users.findFirst({ + where: eq(users.id, teacherId), + columns: { name: true }, + }), + getTeacherGradeTrends(teacherId), ]); return ( @@ -21,6 +29,8 @@ export default async function TeacherDashboardPage() { schedule, assignments, submissions, + teacherName: teacherProfile?.name ?? "Teacher", + gradeTrends, }} /> ) diff --git a/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx b/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx index 874d780..481a59a 100644 --- a/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx @@ -1,67 +1,114 @@ import Link from "next/link"; +import { Inbox, ArrowRight } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; 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 { Inbox } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table"; import { formatDate } from "@/shared/lib/utils"; 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; return ( - - - - - Recent Submissions + + + + + {title} + - + {!hasSubmissions ? ( ) : ( -
- {submissions.map((item) => ( -
-
- - - {item.studentName.charAt(0)} - -
-

- {item.studentName} -

-

- + + + + Student + Assignment + Submitted + Action + + + + {submissions.map((item) => ( + + +
+ + + + {item.studentName.charAt(0)} + + + {item.studentName} +
+
+ + {item.assignmentTitle} -

- - -
-
- {item.submittedAt ? formatDate(item.submittedAt) : "-"} -
- {item.isLate && ( - - Late - - )} -
- - ))} +
+ +
+ + {item.submittedAt ? formatDate(item.submittedAt) : "-"} + + {item.isLate && ( + + Late + + )} +
+
+ + + +
+ ))} +
+

)} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx index e8b9e35..3c86998 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx @@ -9,8 +9,6 @@ import type { TeacherClass } from "@/modules/classes/types" export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) { 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 ( @@ -33,52 +31,40 @@ export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) { className="border-none h-72" /> ) : ( - <> - {topClassesByStudents.length > 0 ? ( -
-
-
Students by class
-
Total {totalStudents}
-
-
- {topClassesByStudents.map((c) => { - const count = c.studentCount ?? 0 - const pct = Math.max(0, Math.min(100, (count / maxStudentCount) * 100)) - return ( -
-
{c.name}
-
-
-
-
{count}
-
- ) - })} -
-
- ) : null} - +
{classes.slice(0, 6).map((c) => ( -
-
{c.name}
-
- {c.grade} - {c.homeroom ? ` · ${c.homeroom}` : ""} - {c.room ? ` · ${c.room}` : ""} +
+
{c.name}
+
+ + {c.grade} + + {c.homeroom && ( + <> + · + Homeroom: {c.homeroom} + + )} + {c.room && ( + <> + · + Room {c.room} + + )}
- - - {c.studentCount} students - +
+ + {c.studentCount} +
))} - +
)} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx index 053a9e1..0b20a40 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx @@ -1,11 +1,22 @@ 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 (
-

Teacher

-

Overview of today's work and your classes.

+

Good morning, {teacherName}

+

It's {today}. Here's your daily overview.

diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx index 1bdad80..6591d46 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx @@ -6,6 +6,7 @@ import { TeacherHomeworkCard } from "./teacher-homework-card" import { RecentSubmissions } from "./recent-submissions" import { TeacherSchedule } from "./teacher-schedule" import { TeacherStats } from "./teacher-stats" +import { TeacherGradeTrends } from "./teacher-grade-trends" const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { const day = d.getDay() @@ -32,27 +33,52 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) { const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt)) 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 ( -
- +
+ -
- - -
- -
- - +
+
+ + +
+ +
+ + + +
) diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx new file mode 100644 index 0000000..0eec475 --- /dev/null +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx @@ -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 ( + + + + + Class Performance + + + Average scores for the last {trends.length} assignments + + + + {!hasTrends ? ( + + ) : ( +
+ + + + value.slice(0, 10) + (value.length > 10 ? "..." : "")} + /> + `${value}%`} + width={30} + /> + + } + /> + + + + + {/* Metric Summary */} +
+ {chartData.slice().reverse().slice(0, 3).map((item, i) => ( +
+
+ {item.fullTitle} +
+
+ + {item.score}% + +
+
+ {item.submissionCount}/{item.totalStudents} submitted +
+
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx index ec627db..fcb68d8 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx @@ -1,54 +1,93 @@ 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 { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" +import { cn, formatDate } from "@/shared/lib/utils" import type { HomeworkAssignmentListItem } from "@/modules/homework/types" export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) { return ( - + Homework -
- - -
+
- + {assignments.length === 0 ? ( ) : ( - assignments.slice(0, 6).map((a) => ( - -
-
{a.title}
-
{a.sourceExamTitle}
-
- - {a.status} - - - )) +
+ {assignments.slice(0, 6).map((a) => { + const isPublished = a.status === "published" + const isDraft = a.status === "draft" + + return ( + +
+
+
+
+ {a.title} +
+
+
+ {a.sourceExamTitle} +
+
+ +
+ {a.dueAt ? ( +
+ + {formatDate(a.dueAt)} +
+ ) : ( + No due date + )} + + {a.status} + +
+ + ) + })} +
+ +
+
)} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx index f432e92..cd7b34e 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx @@ -1,9 +1,10 @@ import Link from "next/link"; - import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; 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 { cn } from "@/shared/lib/utils"; +import { ScrollArea } from "@/shared/components/ui/scroll-area"; type TeacherTodayScheduleItem = { id: string; @@ -17,55 +18,131 @@ type TeacherTodayScheduleItem = { export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) { 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 ( - - - + + + Today's Schedule - + {!hasSchedule ? ( ) : ( -
- {items.map((item) => ( -
-
- - {item.course} - -
- - {item.startTime}–{item.endTime} - {item.location ? ( - <> - - {item.location} - - ) : null} + +
+ {/* Vertical Timeline Line */} +
+ + {/* Top Fade Hint */} +
+ + {items.map((item, index) => { + const status = getStatus(item.startTime, item.endTime); + const isLive = status === "live"; + const isPast = status === "past"; + const isLast = index === items.length - 1; + + return ( +
+ {/* Timeline Dot */} +
+ + +
+
+
+ + {item.course} + + {isLive && ( + + LIVE + + )} +
+
+ {item.className} + {item.location && ( + <> + · + + + {item.location} + + + )} +
+
+ +
+ {item.startTime} + – {item.endTime} +
+
+ + + {/* Connection Line to Next (if not last) */} + {!isLast && ( +
+ )} +
+ ); + })} + + {/* Bottom Hint */} + {items.length > 3 ? ( +
+ Scroll for more
-
- - {item.className} - -
- ))} -
+ ) : ( +
+ No more classes today +
+ )} +
+ )} diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx index 844d59d..9c9a898 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx @@ -1,20 +1,22 @@ +import Link from "next/link"; 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 { cn } from "@/shared/lib/utils"; interface TeacherStatsProps { - totalStudents: number; - classCount: number; toGradeCount: number; - todayScheduleCount: number; + activeAssignmentsCount: number; + averageScore: number; + submissionRate: number; isLoading?: boolean; } export function TeacherStats({ - totalStudents, - classCount, toGradeCount, - todayScheduleCount, + activeAssignmentsCount, + averageScore, + submissionRate, isLoading = false, }: TeacherStatsProps) { if (isLoading) { @@ -38,48 +40,59 @@ export function TeacherStats({ const stats = [ { - title: "Total Students", - 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", + title: "Needs Grading", value: String(toGradeCount), - description: "Submitted homework waiting for grading", + description: "Submissions pending review", icon: FileCheck, + href: "/teacher/homework/submissions?status=submitted", + highlight: toGradeCount > 0, + color: "text-amber-500", }, { - title: "Today", - value: String(todayScheduleCount), - description: "Scheduled items today", - icon: Calendar, + title: "Active Assignments", + value: String(activeAssignmentsCount), + description: "Published and ongoing", + 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; return (
{stats.map((stat, i) => ( - - - - {stat.title} - - - - -
{stat.value}
-

- {stat.description} -

-
-
+ + + + + {stat.title} + + + + +
{stat.value}
+

+ {stat.description} +

+
+
+ ))}
); diff --git a/src/modules/dashboard/types.ts b/src/modules/dashboard/types.ts index d0538ed..b5775b1 100644 --- a/src/modules/dashboard/types.ts +++ b/src/modules/dashboard/types.ts @@ -1,6 +1,6 @@ import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/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 = { role: string @@ -67,4 +67,6 @@ export type TeacherDashboardData = { schedule: ClassScheduleItem[] assignments: HomeworkAssignmentListItem[] submissions: HomeworkSubmissionListItem[] + teacherName: string + gradeTrends: TeacherGradeTrendItem[] } diff --git a/src/modules/homework/data-access.ts b/src/modules/homework/data-access.ts index 7afbc5f..ec0b9c8 100644 --- a/src/modules/homework/data-access.ts +++ b/src/modules/homework/data-access.ts @@ -29,8 +29,69 @@ import type { StudentDashboardGradeProps, StudentHomeworkScoreAnalytics, StudentRanking, + TeacherGradeTrendItem, } from "./types" +export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise => { + 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`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() + for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count) + + const statsMap = new Map() + 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 => typeof v === "object" && v !== null const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => { diff --git a/src/modules/homework/types.ts b/src/modules/homework/types.ts index 81fde42..a9f1694 100644 --- a/src/modules/homework/types.ts +++ b/src/modules/homework/types.ts @@ -2,6 +2,16 @@ export type HomeworkAssignmentStatus = "draft" | "published" | "archived" 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 { id: string sourceExamId: string diff --git a/src/shared/components/ui/chart.tsx b/src/shared/components/ui/chart.tsx new file mode 100644 index 0000000..c3a4464 --- /dev/null +++ b/src/shared/components/ui/chart.tsx @@ -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 } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +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 ( +