Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb4555f611 | ||
|
|
9bfc621d3f | ||
|
|
ade8d4346c |
@@ -91,6 +91,9 @@
|
|||||||
- 若现有基础组件无法满足需求:
|
- 若现有基础组件无法满足需求:
|
||||||
1. 优先通过 Composition 在业务模块里封装“业务组件”
|
1. 优先通过 Composition 在业务模块里封装“业务组件”
|
||||||
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
|
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
|
||||||
|
- **图表库**:统一使用 `Recharts`,禁止引入其他图表库(Chart.js / ECharts 等)。
|
||||||
|
- 使用 `src/shared/components/ui/chart.tsx` 进行封装。
|
||||||
|
- 遵循 Shadcn/UI Chart 规范。
|
||||||
|
|
||||||
### 2.4 Client Component 引用边界(强制)
|
### 2.4 Client Component 引用边界(强制)
|
||||||
|
|
||||||
@@ -419,6 +422,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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
|
|||||||
- 运行命令:`npm run db:seed`
|
- 运行命令:`npm run db:seed`
|
||||||
|
|
||||||
### 6.5 开发过程中的问题与处理
|
### 6.5 开发过程中的问题与处理
|
||||||
|
|
||||||
- 端口占用(EADDRINUSE):开发服务器端口被占用时,通过更换端口启动规避(例如 `next dev -p <port>`)。
|
- 端口占用(EADDRINUSE):开发服务器端口被占用时,通过更换端口启动规避(例如 `next dev -p <port>`)。
|
||||||
- Next dev 锁文件:出现 `.next/dev/lock` 无法获取锁时,需要确保只有一个 dev 实例在运行,并清理残留 lock。
|
- Next dev 锁文件:出现 `.next/dev/lock` 无法获取锁时,需要确保只有一个 dev 实例在运行,并清理残留 lock。
|
||||||
- 头像资源 404:移除 Header 中硬编码的本地头像资源引用,避免 `public/avatars/...` 不存在导致的 404 噪音(见 `src/modules/layout/components/site-header.tsx`)。
|
- 头像资源 404:移除 Header 中硬编码的本地头像资源引用,避免 `public/avatars/...` 不存在导致的 404 噪音(见 `src/modules/layout/components/site-header.tsx`)。
|
||||||
@@ -199,33 +199,44 @@ 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-14)
|
||||||
- 在「我的班级」卡片中展示邀请码。
|
|
||||||
- 提供“刷新邀请码”操作:生成新的 6 位码并写入数据库(确保唯一性)。
|
|
||||||
|
|
||||||
#### 6.7.3 学生端能力
|
**日期**: 2026-01-14
|
||||||
- 提供“通过邀请码加入班级”的入口,输入 6 位码后完成报名。
|
**范围**: 班级创建权限收归管理端,教师端仅保留查看与加入
|
||||||
- 写库操作设计为幂等:重复提交同一个邀请码不会生成重复报名记录,已有记录会被更新为有效状态。
|
|
||||||
|
|
||||||
#### 6.7.4 Seed 支持
|
### 7.1 职责分离 (Role Separation)
|
||||||
- `scripts/seed.ts` 为示例班级补充 `invitationCode`,便于在开发环境直接验证加入流程。
|
|
||||||
|
|
||||||
### 6.8 更新记录(2026-01-09)
|
- **管理端 (Management)**:
|
||||||
|
- 新增 `src/app/(dashboard)/management/grade/classes/page.tsx`
|
||||||
|
- 供年级组长 (Grade Head) 与管理员创建、编辑、删除班级
|
||||||
|
- 引入 `GradeClassesView` 组件,支持按年级管理班级
|
||||||
|
- **教师端 (Teacher)**:
|
||||||
|
- 移除创建班级入口
|
||||||
|
- 新增「通过邀请码加入班级」功能 (`JoinClassDialog`)
|
||||||
|
- `MyClassesGrid` 样式优化,移除硬编码渐变,使用标准 `bg-card`
|
||||||
|
|
||||||
#### 6.8.1 班级创建权限收紧
|
### 7.2 数据访问与权限
|
||||||
- 目标:仅允许年级组长与 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`)。
|
|
||||||
|
|
||||||
#### 6.8.2 注册页面从演示提交改为真实注册
|
- 新增 `getGradeManagedClasses`: 仅返回用户作为 Grade Head 或 Teaching Head 管理的年级下的班级
|
||||||
- `/register` 增加服务端注册动作:校验输入、邮箱查重、插入 `users` 表,默认 `role=student`(`src/app/(auth)/register/page.tsx`)。
|
- Server Actions (`createGradeClassAction` 等) 增加严格的 RBAC 校验,确保操作者对目标年级有管理权限
|
||||||
- 注册表单接入注册动作并展示成功/失败提示,成功后跳转至 `/login`(`src/modules/auth/components/register-form.tsx`)。
|
|
||||||
|
|
||||||
#### 6.8.3 生产环境登录 UntrustedHost 修复
|
## 8. 课表模块视觉升级与架构优化 (2026-01-15)
|
||||||
- 问题:服务器上访问 `/api/auth/session` 报 `[auth][error] UntrustedHost`。
|
|
||||||
- 修复:Auth.js 配置开启 `trustHost: true` 并显式设置 `secret`(`src/auth.ts`)。
|
**日期**: 2026-01-15
|
||||||
|
**范围**: 课表视图 (Schedule View) 视觉重构、Insights 模块移除
|
||||||
|
|
||||||
|
### 8.1 课表视图重构 (Schedule Optimization)
|
||||||
|
|
||||||
|
- **视觉对齐**: 重构 `ScheduleView` (`src/modules/classes/components/schedule-view.tsx`) 以完全匹配 `ClassScheduleGrid` 组件的视觉风格。
|
||||||
|
- **无边框设计**: 移除网格线与外边框,采用更现代的洁净布局。
|
||||||
|
- **时间轴定位**: 废弃 Grid 布局,改用基于时间的绝对定位 (`top`, `height` 百分比计算),支持 8:00 - 18:00 时间段。
|
||||||
|
- **语义化配色**: 新增 `getSubjectColor` 工具函数,根据课程名称 (Math, Physics, etc.) 自动映射语义化背景色与边框色。
|
||||||
|
- **过滤器优化**: `ScheduleFilters` 移除边框与阴影,居中显示当前选中的班级名称 (`{Class Name} Schedule`),移除冗余的 Reset 按钮。
|
||||||
|
|
||||||
|
### 8.2 架构精简 (Insights Removal)
|
||||||
|
|
||||||
|
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
|
||||||
|
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
|
||||||
|
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Textbooks Module Implementation Details
|
# Textbooks Module Implementation Details
|
||||||
|
|
||||||
**Date**: 2025-12-23
|
**Date**: 2025-12-23
|
||||||
**Updated**: 2025-12-31
|
**Updated**: 2026-01-13
|
||||||
**Author**: DevOps Architect
|
**Author**: DevOps Architect
|
||||||
**Module**: Textbooks (`src/modules/textbooks`)
|
**Module**: Textbooks (`src/modules/textbooks`)
|
||||||
|
|
||||||
@@ -143,6 +143,51 @@ src/
|
|||||||
* 通过 `npm run lint / typecheck / build`。
|
* 通过 `npm run lint / typecheck / build`。
|
||||||
|
|
||||||
## 8. 后续计划 (Next Steps)
|
## 8. 后续计划 (Next Steps)
|
||||||
* [ ] **富文本编辑器**: 集成编辑器替换当前 Markdown Textarea,提升编辑体验。
|
* [x] **富文本编辑器**: 已集成 Tiptap 富文本编辑器,支持 Markdown 读写、即时预览与工具栏操作。
|
||||||
* [ ] **拖拽排序**: 实现章节树拖拽排序与持久化。
|
* [x] **拖拽排序**: 实现章节树拖拽排序与持久化。
|
||||||
* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。
|
* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 界面与交互优化 (2026-01-12)
|
||||||
|
|
||||||
|
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
|
||||||
|
|
||||||
|
### 9.1 卡片与列表 (Textbook Card & Filters)
|
||||||
|
* **Dynamic Covers**: 卡片封面采用动态渐变色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),提升识别度。
|
||||||
|
* **Information Density**: 增加元数据展示(Grade, Publisher, Chapter Count),并优化排版层级。
|
||||||
|
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
|
||||||
|
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
|
||||||
|
|
||||||
|
### 9.2 详情页工作台 (Detail Workbench)
|
||||||
|
* **Immersive Layout**:
|
||||||
|
* **Full Height**: 采用 `h-[calc(100vh-8rem)]` 撑满剩余空间,移除多余滚动条。
|
||||||
|
* **Sticky Header**: 章节标题与操作栏吸顶,内容区独立滚动。
|
||||||
|
* **Typography**: 引入 `prose-zinc` 与优化的字体排版,提升阅读舒适度。
|
||||||
|
* **Sidebar Refinement**:
|
||||||
|
* **Chapter Tree**: 增加左侧边框线与层级缩进,选中态更明显;操作按钮(添加/删除)仅在 Hover 时显示,减少视觉干扰。
|
||||||
|
* **Knowledge Points**: 改为卡片式列表,Hover 显示删除按钮;增加空状态引导。
|
||||||
|
* **Drag & Drop**: 集成 `@dnd-kit` 实现章节拖拽排序,支持同级拖动并实时持久化到数据库。
|
||||||
|
|
||||||
|
### 9.3 富文本编辑器 (Rich Text Editor)
|
||||||
|
* **Tiptap Integration**: 引入 `@tiptap/react` 替换原有的 Textarea。
|
||||||
|
* **Markdown Support**: 支持 Markdown 源码读写,保持数据格式兼容性。
|
||||||
|
* **Toolbar**: 实现悬浮工具栏,支持 Bold, Italic, Headings, Lists, Blockquote 等常用格式。
|
||||||
|
* **SSR Fix**: 解决 Tiptap 在 Next.js 中的 Hydration Mismatch 问题 (`immediatelyRender: false`)。
|
||||||
|
|
||||||
|
### 9.4 系统组件优化 (UI Components)
|
||||||
|
* **Dialog**:
|
||||||
|
* 优化遮罩层 (`backdrop-blur`) 与弹窗阴影,提升通透感。
|
||||||
|
* 调整动画时长 (`duration-200`) 与缓动,移除位移动画,改为纯净的 Fade + Zoom 效果。
|
||||||
|
* 增加内部间距 (`gap-6`) 与圆角 (`rounded-xl`),使排版更现代。
|
||||||
|
* **Create Chapter Dialog**: 优化触发按钮样式,增加 `sr-only` 辅助文本,修复点击区域过小的问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 近期改进 (2026-01-13)
|
||||||
|
|
||||||
|
### 10.1 导航体验 (Navigation)
|
||||||
|
* **Dynamic Breadcrumbs**: 接入全局动态面包屑系统。
|
||||||
|
* 支持从路由路径(e.g., `/teacher/textbooks/123`)自动生成层级导航。
|
||||||
|
* 解决了深层嵌套页面(如教材详情页)缺乏上下文回退路径的问题。
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,37 @@ type ExamNode = {
|
|||||||
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
|
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
|
||||||
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
|
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
|
||||||
|
|
||||||
## 7. 变更记录(合并 Homework)
|
### 6.5 `getExamPreviewAction` (新增)
|
||||||
|
- **入参**: `examId` (string)
|
||||||
|
- **行为**:
|
||||||
|
- 查询指定 exam 及其关联的 questions (通过 `exam_questions` 关系)。
|
||||||
|
- 返回完整的 `structure` (JSON 树) 和扁平化的 `questions` 列表。
|
||||||
|
- 用于预览弹窗的数据加载。
|
||||||
|
|
||||||
|
## 7. 变更记录
|
||||||
|
|
||||||
|
**日期**:2026-01-12 (当前)
|
||||||
|
|
||||||
|
- **列表页优化 (`/teacher/exams/all`)**:
|
||||||
|
- 移除了冗余的 "All Exams" 页面标题和描述。
|
||||||
|
- 重构了表格列 (`ExamColumns`):
|
||||||
|
- 合并标题、标签、科目、年级为 "Exam Info" 列。
|
||||||
|
- 合并题目数、总分、时长为 "Stats" 列。
|
||||||
|
- 合并创建时间和预定时间为 "Date" 列。
|
||||||
|
- 优化了状态 (Status) 和难度 (Difficulty) 的视觉样式 (Badge, Progress bar)。
|
||||||
|
- 优化了表格分页和布局 (`ExamDataTable`)。
|
||||||
|
|
||||||
|
- **预览功能增强**:
|
||||||
|
- 新增直接预览功能:在操作列添加了 "View" (眼睛图标) 按钮。
|
||||||
|
- 点击 "View" 触发 `getExamPreviewAction` 获取完整试卷数据。
|
||||||
|
- 弹窗 (`Dialog`) 直接展示试卷内容 (`ExamPaperPreview`),移除了冗余的头部描述,优化了滚动体验。
|
||||||
|
- 修复了可访问性问题 (DialogTitle)。
|
||||||
|
|
||||||
|
- **组卷页面升级 (`/teacher/exams/[id]/build`)**:
|
||||||
|
- **布局重构**: 扩展工作区高度,调整左右面板比例 (2:1),优化头部信息展示和进度可视化。
|
||||||
|
- **题库增强**: 实现了基于 Server Action (`getQuestionsAction`) 的分页加载和服务器端筛选,提升大数据量下的性能;优化了搜索和筛选器 UI。
|
||||||
|
- **预览优化**: 移除了内联预览,改为通过 "Preview" 按钮触发弹窗预览,避免干扰编辑流。
|
||||||
|
- **视觉降噪**: 移除了页面顶部冗余的标题和描述。
|
||||||
|
|
||||||
**日期**:2025-12-31
|
**日期**:2025-12-31
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,39 @@
|
|||||||
- `npm run lint`: 通过
|
- `npm run lint`: 通过
|
||||||
- `npm run typecheck`: 通过
|
- `npm run typecheck`: 通过
|
||||||
- `npm run build`: 通过
|
- `npm run build`: 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. UI/UX 优化更新(2026-01-12)
|
||||||
|
|
||||||
|
### 12.1 教师端作业列表 (`/teacher/homework/assignments`)
|
||||||
|
|
||||||
|
- **表格重构**: 从简单的卡片列表升级为功能丰富的数据表格(Table)。
|
||||||
|
- **信息增强**: 合并展示标题/时间,使用 Badge 区分状态,清晰展示截止日期(含 Late 标记),可视化提交进度。
|
||||||
|
- **操作便捷**: 每行增加操作菜单(Actions),支持快速跳转详情或提交列表。
|
||||||
|
|
||||||
|
### 12.2 作业详情页 (`/teacher/homework/assignments/[id]`)
|
||||||
|
|
||||||
|
- **布局重构**:
|
||||||
|
- **Sticky Header**: 头部信息栏(标题、状态、面包屑)随滚动吸顶,但后续优化为随页面滚动(移除 Sticky)以节省空间。
|
||||||
|
- **关键指标**: 将截止日期、目标数、提交数、已批改数整合到头部下方,使用图标增强可读性。
|
||||||
|
- **双栏布局**: 主体内容分为“Performance Analytics”(分析)和“Assignment Content”(内容)两部分。
|
||||||
|
- **图表升级**:
|
||||||
|
- 重构 `HomeworkAssignmentQuestionErrorOverviewCard`,废弃 SVG,改用 **Recharts** 实现柱状图(BarChart)。
|
||||||
|
- 增强交互:支持 Tooltip 悬停查看具体题目错误率和人数。
|
||||||
|
- **详情面板优化**:
|
||||||
|
- 移除了冗余的 `HomeworkAssignmentQuestionErrorDetailsCard`。
|
||||||
|
- 深度优化 `HomeworkAssignmentQuestionErrorDetailPanel`:
|
||||||
|
- 增加饼图展示单题错误率。
|
||||||
|
- 错误答案列表卡片化,清晰展示每个错误答案的内容及选择人数。
|
||||||
|
- 整合预览面板与详情面板,提供更连贯的“左侧选题-右侧分析”体验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Bug 修复与完善 (2026-01-13)
|
||||||
|
|
||||||
|
### 13.1 批改视图 (Grading View)
|
||||||
|
- **Type Safety Fix**: 修复了 `HomeworkGradingView` 组件中的 TypeScript 类型错误。
|
||||||
|
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
|
||||||
|
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,10 @@ Next_Edu 旨在对抗教育系统常见的信息过载。我们的设计风格
|
|||||||
* **Height**: `64px` (h-16).
|
* **Height**: `64px` (h-16).
|
||||||
* **Layout**: `flex items-center justify-between px-6 border-b`.
|
* **Layout**: `flex items-center justify-between px-6 border-b`.
|
||||||
* **Components**:
|
* **Components**:
|
||||||
1. **Breadcrumb**: 显示当前路径,层级清晰。
|
1. **Breadcrumb**: 动态路径导航 (Dynamic Breadcrumb).
|
||||||
|
* **Implementation**: 基于 `usePathname()` 自动解析路由段。
|
||||||
|
* **Mapping**: 通过 `NAV_CONFIG` 或 `BREADCRUMB_MAP` 映射路径到友好标题 (e.g., `/teacher/textbooks` -> "Textbooks").
|
||||||
|
* **Filtering**: 自动过滤根角色路径 (e.g., `/teacher`) 以保持简洁。
|
||||||
2. **Global Search**: `Cmd+K` 触发,居中或靠右。
|
2. **Global Search**: `Cmd+K` 触发,居中或靠右。
|
||||||
3. **User Nav**: 头像 + 下拉菜单。
|
3. **User Nav**: 头像 + 下拉菜单。
|
||||||
|
|
||||||
|
|||||||
107
docs/work_log.md
Normal file
107
docs/work_log.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Work Log
|
||||||
|
|
||||||
|
## 2026-01-15
|
||||||
|
|
||||||
|
### 1. Schedule Module Optimization
|
||||||
|
* **Visual Overhaul (`schedule-view.tsx`)**:
|
||||||
|
* Refactored the schedule grid to match the exact design of the `ClassScheduleGrid` widget.
|
||||||
|
* Implemented a clean, borderless layout with no grid lines for a modern look.
|
||||||
|
* **Time-Based Positioning**: Replaced grid-row logic with absolute positioning based on time (8:00 - 18:00 range) using percentage calculations (`getPositionStyle`).
|
||||||
|
* **Color Coding**: Added `getSubjectColor` to auto-assign thematic colors (blue for Math, purple for Physics, etc.) based on course names.
|
||||||
|
* **Card Design**: Refined course cards with vertical centering, better spacing, and removed unnecessary UI elements (like the "+" button in headers).
|
||||||
|
|
||||||
|
* **Filter Bar Refinement (`schedule-filters.tsx`)**:
|
||||||
|
* **Minimalist Design**: Removed borders and shadows from the class selector and buttons to integrate seamlessly with the background.
|
||||||
|
* **Center Label**: Added a dynamic, absolute-centered text label that updates based on selection:
|
||||||
|
* Shows "All Classes" when no filter is active.
|
||||||
|
* Shows "{Class Name}" when a specific class is selected.
|
||||||
|
* **Simplified Controls**: Removed the "Reset" button (X icon) entirely for a cleaner interface.
|
||||||
|
* **Ghost Buttons**: Styled the "Add Event" button as a ghost variant with muted colors.
|
||||||
|
|
||||||
|
### 2. Architecture & Cleanup
|
||||||
|
* **Insights Module Removal**:
|
||||||
|
* Deleted the entire `src/app/(dashboard)/teacher/classes/insights` directory as the feature was deemed redundant.
|
||||||
|
* Removed `insights-filters.tsx` component.
|
||||||
|
* Updated `navigation.ts` to remove the "Insights" link from the sidebar.
|
||||||
|
* *Note*: Preserved `getClassHomeworkInsights` in `data-access.ts` as it's still used by the Class Detail dashboard widgets.
|
||||||
|
|
||||||
|
### 3. Verification
|
||||||
|
* **Type Safety**: Ran `npm run typecheck` multiple times during refactoring to ensure no regressions (Passed).
|
||||||
|
* **Build**: Attempted to clear build cache to resolve a chunk loading error (Windows permission issue encountered but workaround applied).
|
||||||
|
|
||||||
|
## 2026-01-14
|
||||||
|
|
||||||
|
### 1. Class Management Refactoring (Role Separation)
|
||||||
|
* **Separation of Duties**:
|
||||||
|
* Moved class creation and management responsibilities from the generic Teacher view to a dedicated Management view.
|
||||||
|
* Created **Grade Management Page** at `src/app/(dashboard)/management/grade/classes/page.tsx` for Grade Directors and Admins.
|
||||||
|
* Teachers can now only **Join Classes** (via code) or view their assigned classes in "My Classes".
|
||||||
|
|
||||||
|
* **New Components & Pages**:
|
||||||
|
* `GradeClassesView` (`src/modules/classes/components/grade-classes-view.tsx`): A comprehensive table view for managing classes within specific grades, supporting creation, editing, and deletion.
|
||||||
|
* `GradeClassesPage`: Server Component that fetches managed grades and classes using strict RBAC (Role-Based Access Control).
|
||||||
|
|
||||||
|
* **Teacher "My Classes" Update (`my-classes-grid.tsx`)**:
|
||||||
|
* Removed the "Create Class" button/dialog.
|
||||||
|
* Added a **"Join Class"** dialog that accepts a 6-digit invitation code.
|
||||||
|
* Updated styling to use standard design system colors (`bg-card`, `border-border`) instead of hardcoded gradients.
|
||||||
|
|
||||||
|
### 2. Backend & Logic Updates
|
||||||
|
* **Data Access (`data-access.ts`)**:
|
||||||
|
* Implemented `getGradeManagedClasses`: Fetches classes for grades where the user is either a Grade Head or Teaching Head.
|
||||||
|
* Implemented `getManagedGrades`: Fetches the list of grades managed by the user for the creation dropdown.
|
||||||
|
* Updated `getTeacherClasses`: Now returns both **owned classes** (assigned by admin) and **enrolled classes** (joined via code).
|
||||||
|
* Fixed a SQL syntax error in `getGradeManagedClasses` (unescaped backticks in template literal).
|
||||||
|
|
||||||
|
* **Server Actions (`actions.ts`)**:
|
||||||
|
* Added `createGradeClassAction`, `updateGradeClassAction`, `deleteGradeClassAction`: These actions enforce that the user manages the target grade before performing operations.
|
||||||
|
* Updated `joinClassByInvitationCodeAction`: Expanded to allow Teachers (role `teacher`) to join classes, not just Students.
|
||||||
|
|
||||||
|
### 3. Verification
|
||||||
|
* **RBAC**: Verified that users can only manage classes for grades they are assigned to.
|
||||||
|
* **Flow**: Verified Teacher "Join Class" flow correctly redirects and updates the list.
|
||||||
|
* **Syntax**: Fixed TypeScript/SQL syntax errors in the new data access functions.
|
||||||
|
|
||||||
|
### 4. Class UI/UX Optimization
|
||||||
|
* **Students Management Interface (`students-table.tsx`, `students-filters.tsx`)**:
|
||||||
|
* **Enhanced Table**: Added student avatars, gender display, and join date.
|
||||||
|
* **Pagination**: Implemented client-side pagination (10 items per page) to handle larger classes gracefully.
|
||||||
|
* **Status Filtering**: Added "Active/Inactive" filter with visual status badges (Emerald for active, muted for inactive).
|
||||||
|
* **Data Access**: Updated `getClassStudents` to fetch extended user profile data and support server-side status filtering.
|
||||||
|
|
||||||
|
* **Class Detail Dashboard (`/teacher/classes/my/[id]/page.tsx`)**:
|
||||||
|
* **Dashboard Layout**: Refactored into a responsive two-column layout (Main Content + Sidebar).
|
||||||
|
* **Key Metrics**: Added a 4-card stats grid at the top displaying critical insights:
|
||||||
|
* Total Students (Active/Inactive breakdown)
|
||||||
|
* Schedule Items (Weekly sessions)
|
||||||
|
* Active Assignments (Overdue count)
|
||||||
|
* Class Average (Based on graded submissions)
|
||||||
|
* **Sidebar Widgets**: Added "Class Schedule" and "Homework History" widgets for quick access to temporal data.
|
||||||
|
* **Visual Polish**: Integrated `lucide-react` icons throughout for better information scanning.
|
||||||
|
|
||||||
|
## 2026-01-13
|
||||||
|
|
||||||
|
### 1. Navigation & Layout Improvements
|
||||||
|
* **Dynamic Breadcrumbs (`site-header.tsx`)**:
|
||||||
|
* Replaced hardcoded "Dashboard > Overview" breadcrumbs with a dynamic system.
|
||||||
|
* Implemented a path-to-title lookup using `NAV_CONFIG` from `src/modules/layout/config/navigation.ts`.
|
||||||
|
* Added logic to filter out root role segments (admin/teacher/student/parent) for cleaner paths.
|
||||||
|
* Added fallback capitalization for segments not found in the config.
|
||||||
|
* Refactored `SiteHeader` to use `usePathname` for real-time route updates.
|
||||||
|
|
||||||
|
### 2. Code Quality & Bug Fixes
|
||||||
|
* **Type Safety (`homework-grading-view.tsx`)**:
|
||||||
|
* Fixed a TypeScript error where a boolean expression was returning `boolean | undefined` which is not a valid React node (implicit `true` check added).
|
||||||
|
* Resolved "Calling setState synchronously within an effect" React warning by initializing state lazily instead of using `useEffect`.
|
||||||
|
* Fixed implicit `any` type errors in map functions.
|
||||||
|
* **Linting**:
|
||||||
|
* Cleaned up unused imports across multiple files (`exam-actions.tsx`, `exam-assembly.tsx`, `textbook-reader.tsx`, etc.).
|
||||||
|
* Fixed unescaped HTML entities in `student-dashboard-header.tsx` and others.
|
||||||
|
* Removed unused variables to clear ESLint warnings.
|
||||||
|
* **Refactoring**:
|
||||||
|
* Updated `TextbookCard` to support `hideActions` prop for better reuse in student views.
|
||||||
|
* Added missing `Progress` component to `src/shared/components/ui/progress.tsx`.
|
||||||
|
|
||||||
|
### 3. Verification
|
||||||
|
* Ran `npm run typecheck`: **Passed** (0 errors).
|
||||||
|
* Ran `npm run lint`: **Passed** (0 errors, 28 warnings remaining for unused vars/components that may be needed later).
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE `classes` ADD `invitation_code` varchar(6);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `classes` ADD CONSTRAINT `classes_invitation_code_unique` UNIQUE(`invitation_code`);
|
|
||||||
6
drizzle/0007_talented_bromley.sql
Normal file
6
drizzle/0007_talented_bromley.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE `exams` ADD `subject_id` varchar(128);--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD `grade_id` varchar(128);--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD CONSTRAINT `exams_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD CONSTRAINT `exams_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `exams_subject_idx` ON `exams` (`subject_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `exams_grade_idx` ON `exams` (`grade_id`);
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
ALTER TABLE `users` ADD `phone` varchar(30);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `users` ADD `address` varchar(255);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `users` ADD `gender` varchar(20);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `users` ADD `age` int;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `users` ADD `grade_id` varchar(128);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `users` ADD `department_id` varchar(128);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `users` ADD `onboarded_at` timestamp;
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `users_grade_id_idx` ON `users` (`grade_id`);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `users_department_id_idx` ON `users` (`department_id`);
|
|
||||||
1
drizzle/0008_thin_madrox.sql
Normal file
1
drizzle/0008_thin_madrox.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);
|
||||||
3064
drizzle/meta/0007_snapshot.json
Normal file
3064
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3071
drizzle/meta/0008_snapshot.json
Normal file
3071
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3009
drizzle/meta/0009_snapshot.json
Normal file
3009
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -54,16 +54,16 @@
|
|||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1767782500000,
|
"when": 1768205524480,
|
||||||
"tag": "0007_add_class_invitation_code",
|
"tag": "0007_talented_bromley",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 8,
|
"idx": 8,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1767941300000,
|
"when": 1768470966367,
|
||||||
"tag": "0008_add_user_profile_fields",
|
"tag": "0008_thin_madrox",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
1480
package-lock.json
generated
1480
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -22,8 +22,12 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
@@ -33,6 +37,10 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-placeholder": "^3.15.3",
|
||||||
|
"@tiptap/pm": "^3.15.3",
|
||||||
|
"@tiptap/react": "^3.15.3",
|
||||||
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -46,18 +54,20 @@
|
|||||||
"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",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
"zod": "^4.2.1",
|
"zod": "^4.2.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
textbooks, chapters,
|
textbooks, chapters,
|
||||||
schools,
|
schools,
|
||||||
grades,
|
grades,
|
||||||
classes, classEnrollments, classSchedule
|
classes, classEnrollments, classSchedule,
|
||||||
|
subjects
|
||||||
} from "../src/shared/db/schema";
|
} from "../src/shared/db/schema";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
@@ -43,7 +44,7 @@ async function seed() {
|
|||||||
"submission_answers", "exam_submissions", "exam_questions", "exams",
|
"submission_answers", "exam_submissions", "exam_questions", "exams",
|
||||||
"questions_to_knowledge_points", "questions", "knowledge_points",
|
"questions_to_knowledge_points", "questions", "knowledge_points",
|
||||||
"chapters", "textbooks",
|
"chapters", "textbooks",
|
||||||
"grades", "schools",
|
"grades", "schools", "subjects",
|
||||||
"users_to_roles", "roles", "users", "accounts", "sessions"
|
"users_to_roles", "roles", "users", "accounts", "sessions"
|
||||||
];
|
];
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
@@ -133,6 +134,17 @@ async function seed() {
|
|||||||
{ id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" },
|
{ id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// --- Seeding Subjects ---
|
||||||
|
await db.insert(subjects).values([
|
||||||
|
{ id: createId(), name: "Mathematics", code: "MATH", order: 1 },
|
||||||
|
{ id: createId(), name: "Physics", code: "PHYS", order: 2 },
|
||||||
|
{ id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
|
||||||
|
{ id: createId(), name: "English", code: "ENG", order: 4 },
|
||||||
|
{ id: createId(), name: "History", code: "HIST", order: 5 },
|
||||||
|
{ id: createId(), name: "Geography", code: "GEO", order: 6 },
|
||||||
|
{ id: createId(), name: "Biology", code: "BIO", order: 7 },
|
||||||
|
])
|
||||||
|
|
||||||
await db.insert(grades).values([
|
await db.insert(grades).values([
|
||||||
{
|
{
|
||||||
id: grade10Id,
|
id: grade10Id,
|
||||||
|
|||||||
31
src/app/(dashboard)/management/grade/classes/page.tsx
Normal file
31
src/app/(dashboard)/management/grade/classes/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { getGradeManagedClasses, getManagedGrades, getTeacherOptions } from "@/modules/classes/data-access"
|
||||||
|
import { GradeClassesClient } from "@/modules/classes/components/grade-classes-view"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function GradeClassesPage() {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id ?? ""
|
||||||
|
|
||||||
|
const [classes, teachers, managedGrades] = await Promise.all([
|
||||||
|
getGradeManagedClasses(userId),
|
||||||
|
getTeacherOptions(),
|
||||||
|
getManagedGrades(userId),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Class Management</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage classes for your grades.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GradeClassesClient classes={classes} teachers={teachers} managedGrades={managedGrades} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
|||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form action="/teacher/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
<form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
<label className="text-sm font-medium">Grade</label>
|
<label className="text-sm font-medium">Grade</label>
|
||||||
<select
|
<select
|
||||||
name="gradeId"
|
name="gradeId"
|
||||||
@@ -4,14 +4,16 @@ import { redirect } from "next/navigation"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||||
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
||||||
import { StudentRankingCard } from "@/modules/dashboard/components/student-dashboard/student-ranking-card"
|
|
||||||
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
||||||
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
||||||
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
||||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||||
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -20,17 +22,31 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
|||||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date | null) => {
|
||||||
|
if (!date) return "-"
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user) redirect("/login")
|
if (!session?.user) redirect("/login")
|
||||||
|
|
||||||
const name = session.user.name ?? "User"
|
|
||||||
const email = session.user.email ?? "-"
|
|
||||||
const role = String(session.user.role ?? "teacher")
|
|
||||||
const userId = String(session.user.id ?? "").trim()
|
const userId = String(session.user.id ?? "").trim()
|
||||||
|
const userProfile = await getUserProfile(userId)
|
||||||
|
|
||||||
|
if (!userProfile) {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = userProfile.role || "student"
|
||||||
|
const isStudent = role === "student"
|
||||||
|
|
||||||
const studentData =
|
const studentData =
|
||||||
role === "student" && userId
|
isStudent
|
||||||
? await (async () => {
|
? await (async () => {
|
||||||
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
||||||
getStudentClasses(userId),
|
getStudentClasses(userId),
|
||||||
@@ -96,36 +112,104 @@ export default async function ProfilePage() {
|
|||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
|
||||||
<div className="text-sm text-muted-foreground">Your account information.</div>
|
<div className="text-sm text-muted-foreground">Manage your personal and account information.</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/settings">Open settings</Link>
|
<Link href="/settings">Edit Profile</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Account</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Signed-in user details from session.</CardDescription>
|
<CardTitle className="flex items-center gap-2">
|
||||||
</CardHeader>
|
<User className="h-5 w-5" />
|
||||||
<CardContent className="space-y-3">
|
Personal Information
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</CardTitle>
|
||||||
<div className="text-sm font-medium">{name}</div>
|
<CardDescription>Basic personal details.</CardDescription>
|
||||||
<Badge variant="secondary" className="capitalize">
|
</CardHeader>
|
||||||
{role}
|
<CardContent className="space-y-4">
|
||||||
</Badge>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
<div className="text-sm text-muted-foreground">{email}</div>
|
<div className="text-sm font-medium text-muted-foreground">Full Name</div>
|
||||||
</CardContent>
|
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
|
||||||
</Card>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Gender</div>
|
||||||
|
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Age</div>
|
||||||
|
<div className="text-sm">{userProfile.age ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Phone</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
|
||||||
|
{userProfile.phone ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 sm:col-span-2 space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Address</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
|
||||||
|
{userProfile.address ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Account Information
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>System account details.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="col-span-1 sm:col-span-2 space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Email</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{userProfile.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Role</div>
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{userProfile.role}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Member Since</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{formatDate(userProfile.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{formatDate(userProfile.onboardedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{studentData ? (
|
{studentData ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Separator />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-semibold tracking-tight">Student</h2>
|
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
|
||||||
<div className="text-sm text-muted-foreground">Your learning overview.</div>
|
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StudentStatsGrid
|
<StudentStatsGrid
|
||||||
@@ -133,16 +217,17 @@ export default async function ProfilePage() {
|
|||||||
dueSoonCount={studentData.dueSoonCount}
|
dueSoonCount={studentData.dueSoonCount}
|
||||||
overdueCount={studentData.overdueCount}
|
overdueCount={studentData.overdueCount}
|
||||||
gradedCount={studentData.gradedCount}
|
gradedCount={studentData.gradedCount}
|
||||||
|
ranking={studentData.grades.ranking}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<StudentGradesCard grades={studentData.grades} />
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<StudentRankingCard ranking={studentData.grades.ranking} />
|
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
|
||||||
</div>
|
<StudentGradesCard grades={studentData.grades} />
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="space-y-6">
|
||||||
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
||||||
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { auth } from "@/auth"
|
|||||||
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
|
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
|
||||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||||
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -11,11 +12,16 @@ export default async function SettingsPage() {
|
|||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user) redirect("/login")
|
if (!session?.user) redirect("/login")
|
||||||
|
|
||||||
const role = String(session.user.role ?? "teacher")
|
const userId = String(session.user.id ?? "").trim()
|
||||||
|
const userProfile = await getUserProfile(userId)
|
||||||
|
|
||||||
if (role === "admin") return <AdminSettingsView />
|
if (!userProfile) redirect("/login")
|
||||||
if (role === "student") return <StudentSettingsView user={session.user} />
|
|
||||||
if (role === "teacher") return <TeacherSettingsView user={session.user} />
|
const role = userProfile.role || "student"
|
||||||
|
|
||||||
|
if (role === "admin") return <AdminSettingsView user={userProfile} />
|
||||||
|
if (role === "student") return <StudentSettingsView user={userProfile} />
|
||||||
|
if (role === "teacher") return <TeacherSettingsView user={userProfile} />
|
||||||
|
|
||||||
redirect("/dashboard")
|
redirect("/dashboard")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation"
|
|||||||
|
|
||||||
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||||
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
|
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
|
||||||
|
import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -18,6 +19,23 @@ export default async function StudentAssignmentTakePage({
|
|||||||
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
||||||
if (!data) return notFound()
|
if (!data) return notFound()
|
||||||
|
|
||||||
|
// If status is graded or submitted, use the review view
|
||||||
|
const status = data.submission?.status
|
||||||
|
if (status === "graded" || status === "submitted") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-4 p-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HomeworkReviewView initialData={data} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-4 p-6">
|
<div className="flex h-full flex-col space-y-4 p-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ const getStatusLabel = (status: string) => {
|
|||||||
return "Not started"
|
return "Not started"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getActionLabel = (status: string) => {
|
||||||
|
if (status === "graded") return "Review"
|
||||||
|
if (status === "submitted") return "View"
|
||||||
|
if (status === "in_progress") return "Continue"
|
||||||
|
return "Start"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||||
|
if (status === "graded" || status === "submitted") return "outline"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
export default async function StudentAssignmentsPage() {
|
export default async function StudentAssignmentsPage() {
|
||||||
const student = await getDemoStudentUser()
|
const student = await getDemoStudentUser()
|
||||||
|
|
||||||
@@ -75,6 +87,7 @@ export default async function StudentAssignmentsPage() {
|
|||||||
<TableHead>Due</TableHead>
|
<TableHead>Due</TableHead>
|
||||||
<TableHead>Attempts</TableHead>
|
<TableHead>Attempts</TableHead>
|
||||||
<TableHead>Score</TableHead>
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead className="text-right">Action</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -95,6 +108,13 @@ export default async function StudentAssignmentsPage() {
|
|||||||
{a.attemptsUsed}/{a.maxAttempts}
|
{a.attemptsUsed}/{a.maxAttempts}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
||||||
|
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||||
|
{getActionLabel(a.progressStatus)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
import { ArrowLeft, BookOpen, Inbox } from "lucide-react"
|
import { BookOpen, Inbox } from "lucide-react"
|
||||||
|
|
||||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
||||||
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
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 { getDemoStudentUser } from "@/modules/homework/data-access"
|
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||||
|
|
||||||
@@ -34,42 +33,42 @@ export default async function StudentTextbookDetailPage({
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)])
|
const [textbook, chapters, knowledgePoints] = await Promise.all([
|
||||||
|
getTextbookById(id),
|
||||||
|
getChaptersByTextbookId(id),
|
||||||
|
getKnowledgePointsByTextbookId(id)
|
||||||
|
])
|
||||||
|
|
||||||
if (!textbook) notFound()
|
if (!textbook) notFound()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
|
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10">
|
<div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Link href="/student/learning/textbooks">
|
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
</Link>
|
<span className="hidden sm:inline-block w-px h-4 bg-border" />
|
||||||
</Button>
|
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
|
||||||
<div className="flex-1 min-w-0">
|
{textbook.grade && (
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
|
||||||
<Badge variant="outline">{textbook.subject}</Badge>
|
)}
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
|
||||||
{textbook.grade ?? "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden pt-6">
|
<div className="flex-1 overflow-hidden p-6">
|
||||||
{chapters.length === 0 ? (
|
{chapters.length === 0 ? (
|
||||||
<div className="px-8">
|
<div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title="No chapters"
|
title="No chapters"
|
||||||
description="This textbook has no chapters yet."
|
description="This textbook has no chapters yet."
|
||||||
className="bg-card"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[calc(100vh-140px)] px-8 min-h-0">
|
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
||||||
<TextbookReader chapters={chapters} />
|
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default async function StudentTextbooksPage({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{textbooks.map((textbook) => (
|
{textbooks.map((textbook) => (
|
||||||
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" />
|
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" hideActions />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import { Suspense } from "react"
|
|
||||||
import { BarChart3 } from "lucide-react"
|
|
||||||
|
|
||||||
import { getClassHomeworkInsights, getTeacherClasses } from "@/modules/classes/data-access"
|
|
||||||
import { InsightsFilters } from "@/modules/classes/components/insights-filters"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string) => {
|
|
||||||
const v = params[key]
|
|
||||||
return Array.isArray(v) ? v[0] : v
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (v: number | null, digits = 1) => {
|
|
||||||
if (typeof v !== "number" || Number.isNaN(v)) return "-"
|
|
||||||
return v.toFixed(digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InsightsResultsFallback() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
|
||||||
<div key={idx} className="rounded-lg border bg-card">
|
|
||||||
<div className="p-6">
|
|
||||||
<Skeleton className="h-5 w-28" />
|
|
||||||
<Skeleton className="mt-3 h-8 w-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border bg-card">
|
|
||||||
<div className="p-4">
|
|
||||||
<Skeleton className="h-8 w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 p-4 pt-0">
|
|
||||||
{Array.from({ length: 8 }).map((_, idx) => (
|
|
||||||
<Skeleton key={idx} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function InsightsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
|
||||||
const params = await searchParams
|
|
||||||
const classId = getParam(params, "classId")
|
|
||||||
|
|
||||||
if (!classId || classId === "all") {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
title="Select a class to view insights"
|
|
||||||
description="Pick a class to see latest homework and historical score statistics."
|
|
||||||
className="h-[360px] bg-card"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const insights = await getClassHomeworkInsights({ classId, limit: 50 })
|
|
||||||
if (!insights) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
title="Class not found"
|
|
||||||
description="This class may not exist or is not accessible."
|
|
||||||
className="h-[360px] bg-card"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAssignments = insights.assignments.length > 0
|
|
||||||
|
|
||||||
if (!hasAssignments) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
title="No homework data for this class"
|
|
||||||
description="No homework assignments were targeted to students in this class yet."
|
|
||||||
className="h-[360px] bg-card"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const latest = insights.latest
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Students</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{insights.assignments.length}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Latest: {latest ? formatDate(latest.createdAt) : "-"}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Overall scores</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{latest && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">Latest assignment</CardTitle>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">{latest.title}</span>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{latest.status}
|
|
||||||
</Badge>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{formatDate(latest.createdAt)}</span>
|
|
||||||
{latest.dueAt ? (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span>Due {formatDate(latest.dueAt)}</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 md:grid-cols-5">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Targeted</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.targetCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Submitted</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.submittedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Graded</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.gradedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Average</div>
|
|
||||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Median</div>
|
|
||||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="rounded-md border bg-card">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Assignment</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Due</TableHead>
|
|
||||||
<TableHead className="text-right">Targeted</TableHead>
|
|
||||||
<TableHead className="text-right">Submitted</TableHead>
|
|
||||||
<TableHead className="text-right">Graded</TableHead>
|
|
||||||
<TableHead className="text-right">Avg</TableHead>
|
|
||||||
<TableHead className="text-right">Median</TableHead>
|
|
||||||
<TableHead className="text-right">Min</TableHead>
|
|
||||||
<TableHead className="text-right">Max</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{insights.assignments.map((a) => (
|
|
||||||
<TableRow key={a.assignmentId}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
|
|
||||||
{a.title}
|
|
||||||
</Link>
|
|
||||||
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{a.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.targetCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.submittedCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.gradedCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.min, 0)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.max, 0)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
|
||||||
const classes = await getTeacherClasses()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Class Insights</h2>
|
|
||||||
<p className="text-muted-foreground">Latest homework and historical score statistics for a class.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
|
||||||
<InsightsFilters classes={classes} />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Suspense fallback={<InsightsResultsFallback />}>
|
|
||||||
<InsightsResults searchParams={searchParams} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,28 +1,18 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
|
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
|
||||||
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
|
||||||
|
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string) => {
|
|
||||||
const v = params[key]
|
|
||||||
return Array.isArray(v) ? v[0] : v
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (v: number | null, digits = 1) => {
|
|
||||||
if (typeof v !== "number" || Number.isNaN(v)) return "-"
|
|
||||||
return v.toFixed(digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ClassDetailPage({
|
export default async function ClassDetailPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -31,285 +21,97 @@ export default async function ClassDetailPage({
|
|||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const sp = await searchParams
|
|
||||||
const hw = getParam(sp, "hw")
|
|
||||||
const hwFilter = hw === "active" || hw === "overdue" ? hw : "all"
|
|
||||||
|
|
||||||
|
// Parallel data fetching
|
||||||
const [insights, students, schedule] = await Promise.all([
|
const [insights, students, schedule] = await Promise.all([
|
||||||
getClassHomeworkInsights({ classId: id, limit: 50 }),
|
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
|
||||||
getClassStudents({ classId: id }),
|
getClassStudents({ classId: id }),
|
||||||
getClassSchedule({ classId: id }),
|
getClassSchedule({ classId: id }),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!insights) return notFound()
|
if (!insights) return notFound()
|
||||||
|
|
||||||
const latest = insights.latest
|
// Fetch subject scores
|
||||||
const filteredAssignments = insights.assignments.filter((a) => {
|
const studentScores = await getClassStudentSubjectScoresV2(id)
|
||||||
if (hwFilter === "all") return true
|
|
||||||
if (hwFilter === "overdue") return a.isOverdue
|
// Data mapping for widgets
|
||||||
if (hwFilter === "active") return a.isActive
|
const assignmentSummaries = insights.assignments.map(a => ({
|
||||||
return true
|
id: a.assignmentId,
|
||||||
})
|
title: a.title,
|
||||||
const hasAssignments = filteredAssignments.length > 0
|
status: a.status,
|
||||||
const scheduleBuilderClasses = [
|
subject: a.subject,
|
||||||
{
|
isActive: a.isActive,
|
||||||
id: insights.class.id,
|
isOverdue: a.isOverdue,
|
||||||
name: insights.class.name,
|
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
||||||
grade: insights.class.grade,
|
submittedCount: a.submittedCount,
|
||||||
homeroom: insights.class.homeroom ?? null,
|
targetCount: a.targetCount,
|
||||||
room: insights.class.room ?? null,
|
avgScore: a.scoreStats.avg,
|
||||||
studentCount: insights.studentCounts.total,
|
medianScore: a.scoreStats.median
|
||||||
},
|
}))
|
||||||
]
|
|
||||||
|
const studentSummaries = students.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
email: s.email,
|
||||||
|
image: s.image,
|
||||||
|
status: s.status,
|
||||||
|
subjectScores: studentScores.get(s.id) ?? {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Calculate advanced stats
|
||||||
|
const activeAssignments = insights.assignments.filter(a => a.isActive)
|
||||||
|
const papersToGrade = activeAssignments.reduce((acc, a) => acc + (a.submittedCount - a.gradedCount), 0)
|
||||||
|
const overdueCount = activeAssignments.filter(a => a.isOverdue).length
|
||||||
|
|
||||||
|
const totalSubmissionRate = activeAssignments.length > 0
|
||||||
|
? activeAssignments.reduce((acc, a) => acc + (a.targetCount > 0 ? a.submittedCount / a.targetCount : 0), 0) / activeAssignments.length
|
||||||
|
: 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex min-h-screen flex-col bg-muted/10">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
<ClassHeader
|
||||||
<div className="space-y-2">
|
classId={insights.class.id}
|
||||||
<div className="flex items-center gap-2">
|
name={insights.class.name}
|
||||||
<Button asChild variant="outline" size="sm">
|
grade={insights.class.grade}
|
||||||
<Link href="/teacher/classes/my">Back</Link>
|
homeroom={insights.class.homeroom}
|
||||||
</Button>
|
room={insights.class.room}
|
||||||
<Badge variant="secondary">{insights.class.grade}</Badge>
|
schoolName={insights.class.schoolName}
|
||||||
<Badge variant="outline">{insights.studentCounts.total} students</Badge>
|
studentCount={insights.studentCounts.total}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-6 p-6">
|
||||||
|
{/* Key Metrics */}
|
||||||
|
<ClassOverviewStats
|
||||||
|
averageScore={insights.overallScores.avg}
|
||||||
|
submissionRate={totalSubmissionRate * 100}
|
||||||
|
papersToGrade={papersToGrade}
|
||||||
|
overdueCount={overdueCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Main Content Area (Left 2/3) */}
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<ClassTrendsWidget
|
||||||
|
classId={insights.class.id}
|
||||||
|
assignments={assignmentSummaries}
|
||||||
|
/>
|
||||||
|
<ClassStudentsWidget
|
||||||
|
classId={insights.class.id}
|
||||||
|
students={studentSummaries}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
{/* Sidebar Area (Right 1/3) */}
|
||||||
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"}
|
<div className="space-y-6">
|
||||||
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null}
|
{/* <ClassQuickActions classId={insights.class.id} /> */}
|
||||||
|
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
|
||||||
|
<ClassAssignmentsWidget
|
||||||
|
classId={insights.class.id}
|
||||||
|
assignments={assignmentSummaries}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>Students</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>Schedule</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(insights.class.id)}`}>Insights</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Students</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Schedule items</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{schedule.length}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Weekly timetable entries</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{insights.assignments.length}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{latest ? `Latest ${formatDate(latest.createdAt)}` : "No homework yet"}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Overall avg</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{latest ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">Latest homework</CardTitle>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">{latest.title}</span>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{latest.status}
|
|
||||||
</Badge>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{formatDate(latest.createdAt)}</span>
|
|
||||||
{latest.dueAt ? (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span>Due {formatDate(latest.dueAt)}</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 md:grid-cols-5">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Targeted</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.targetCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Submitted</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.submittedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Graded</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.gradedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Average</div>
|
|
||||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Median</div>
|
|
||||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-base">Students (preview)</CardTitle>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{students.length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground">No students enrolled.</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{students.slice(0, 8).map((s) => (
|
|
||||||
<TableRow key={s.id}>
|
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{s.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-base">Schedule</CardTitle>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<CardTitle className="text-base">Homework history</CardTitle>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button asChild size="sm" variant={hwFilter === "all" ? "secondary" : "outline"}>
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild size="sm" variant={hwFilter === "active" ? "secondary" : "outline"}>
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild size="sm" variant={hwFilter === "overdue" ? "secondary" : "outline"}>
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>Open list</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild size="sm">
|
|
||||||
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>New homework</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!hasAssignments ? (
|
|
||||||
<div className="text-sm text-muted-foreground">No homework assignments yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Assignment</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Due</TableHead>
|
|
||||||
<TableHead className="text-right">Targeted</TableHead>
|
|
||||||
<TableHead className="text-right">Submitted</TableHead>
|
|
||||||
<TableHead className="text-right">Graded</TableHead>
|
|
||||||
<TableHead className="text-right">Avg</TableHead>
|
|
||||||
<TableHead className="text-right">Median</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredAssignments.map((a) => (
|
|
||||||
<TableRow key={a.assignmentId}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
|
|
||||||
{a.title}
|
|
||||||
</Link>
|
|
||||||
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{a.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.targetCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.submittedCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{a.gradedCount}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,29 +13,10 @@ export default function MyClassesPage() {
|
|||||||
|
|
||||||
async function MyClassesPageImpl() {
|
async function MyClassesPageImpl() {
|
||||||
const classes = await getTeacherClasses()
|
const classes = await getTeacherClasses()
|
||||||
const session = await auth()
|
|
||||||
const role = String(session?.user?.role ?? "")
|
|
||||||
const userId = String(session?.user?.id ?? "").trim()
|
|
||||||
|
|
||||||
const canCreateClass = await (async () => {
|
|
||||||
if (role === "admin") return true
|
|
||||||
if (!userId) return false
|
|
||||||
const [row] = await db.select({ id: grades.id }).from(grades).where(eq(grades.gradeHeadId, userId)).limit(1)
|
|
||||||
return Boolean(row)
|
|
||||||
})()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-4 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
<MyClassesGrid classes={classes} canCreateClass={false} />
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">My Classes</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Overview of your classes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MyClassesGrid classes={classes} canCreateClass={canCreateClass} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,16 +67,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
<div className="space-y-6">
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
View class schedule.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||||
<ScheduleFilters classes={classes} />
|
<ScheduleFilters classes={classes} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { User } from "lucide-react"
|
import { User } from "lucide-react"
|
||||||
|
|
||||||
import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access"
|
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
|
||||||
import { StudentsFilters } from "@/modules/classes/components/students-filters"
|
import { StudentsFilters } from "@/modules/classes/components/students-filters"
|
||||||
import { StudentsTable } from "@/modules/classes/components/students-table"
|
import { StudentsTable } from "@/modules/classes/components/students-table"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
@@ -16,18 +16,35 @@ const getParam = (params: SearchParams, key: string) => {
|
|||||||
return Array.isArray(v) ? v[0] : v
|
return Array.isArray(v) ? v[0] : v
|
||||||
}
|
}
|
||||||
|
|
||||||
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }) {
|
||||||
const params = await searchParams
|
const params = await searchParams
|
||||||
|
|
||||||
const q = getParam(params, "q") || undefined
|
const q = getParam(params, "q") || undefined
|
||||||
const classId = getParam(params, "classId")
|
const classId = getParam(params, "classId")
|
||||||
|
const status = getParam(params, "status")
|
||||||
|
|
||||||
|
// If classId is explicit in URL, use it (unless "all"). If not, use defaultClassId.
|
||||||
|
// If user explicitly selects "all", classId will be "all".
|
||||||
|
// However, the requirement is "Default to showing the first class".
|
||||||
|
// If classId param is missing, we use defaultClassId.
|
||||||
|
const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId
|
||||||
|
|
||||||
const filteredStudents = await getClassStudents({
|
const filteredStudents = await getClassStudents({
|
||||||
q,
|
q,
|
||||||
classId: classId && classId !== "all" ? classId : undefined,
|
classId: targetClassId,
|
||||||
|
status: status && status !== "all" ? status : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasFilters = Boolean(q || (classId && classId !== "all"))
|
// Fetch subject scores for all filtered students
|
||||||
|
if (filteredStudents.length > 0) {
|
||||||
|
const studentIds = filteredStudents.map(s => s.id)
|
||||||
|
const scores = await getStudentsSubjectScores(studentIds)
|
||||||
|
for (const student of filteredStudents) {
|
||||||
|
student.subjectScores = scores.get(student.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = Boolean(q || (classId && classId !== "all") || (status && status !== "all"))
|
||||||
|
|
||||||
if (filteredStudents.length === 0) {
|
if (filteredStudents.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -65,25 +82,20 @@ function StudentsResultsFallback() {
|
|||||||
|
|
||||||
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const classes = await getTeacherClasses()
|
const classes = await getTeacherClasses()
|
||||||
|
const params = await searchParams
|
||||||
|
|
||||||
|
// Logic to determine default class (first one available)
|
||||||
|
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-4 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Students</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage student list.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||||
<StudentsFilters classes={classes} />
|
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Suspense fallback={<StudentsResultsFallback />}>
|
<Suspense fallback={<StudentsResultsFallback />}>
|
||||||
<StudentsResults searchParams={searchParams} />
|
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
|||||||
const exam = await getExamById(id)
|
const exam = await getExamById(id)
|
||||||
if (!exam) return notFound()
|
if (!exam) return notFound()
|
||||||
|
|
||||||
// Fetch all available questions (for selection pool)
|
// Fetch initial questions for the bank (pagination handled by client)
|
||||||
// In a real app, this might be paginated or filtered by exam subject/grade
|
const { data: questionsData } = await getQuestions({ pageSize: 20 })
|
||||||
const { data: questionsData } = await getQuestions({ pageSize: 100 })
|
|
||||||
|
|
||||||
const initialSelected = (exam.questions || []).map(q => ({
|
const initialSelected = (exam.questions || []).map(q => ({
|
||||||
id: q.id,
|
id: q.id,
|
||||||
@@ -103,13 +102,7 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-4 p-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Build Exam</h2>
|
|
||||||
<p className="text-muted-foreground">Add questions and adjust scores.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ExamAssembly
|
<ExamAssembly
|
||||||
examId={exam.id}
|
examId={exam.id}
|
||||||
title={exam.title}
|
title={exam.title}
|
||||||
|
|||||||
@@ -131,13 +131,6 @@ export default async function AllExamsPage({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
|
|
||||||
<p className="text-muted-foreground">View and manage all your exams.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||||
<ExamFilters />
|
<ExamFilters />
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/shared/components/ui/breadcrumb"
|
||||||
|
|
||||||
export default function CreateExamPage() {
|
export default function CreateExamPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>Create</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Create Exam</h2>
|
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
|
||||||
<p className="text-muted-foreground">Design a new exam for your students.</p>
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Set up a new exam draft and choose your assembly method.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ExamForm />
|
<ExamForm />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import Link from "next/link"
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
|
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
|
||||||
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
|
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
|
||||||
import { HomeworkAssignmentQuestionErrorDetailsCard } from "@/modules/homework/components/homework-assignment-question-error-details-card"
|
|
||||||
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -20,82 +20,82 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
|
|||||||
const { assignment, questions, gradedSampleCount } = analytics
|
const { assignment, questions, gradedSampleCount } = analytics
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex flex-col min-h-full">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
{/* Header */}
|
||||||
<div>
|
<div className="border-b bg-background px-8 py-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2>
|
<div className="flex flex-col gap-2">
|
||||||
<Badge variant="outline" className="capitalize">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
{assignment.status}
|
<Link href="/teacher/homework/assignments" className="flex items-center hover:text-foreground transition-colors">
|
||||||
</Badge>
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
</div>
|
Assignments
|
||||||
<p className="text-muted-foreground mt-1">{assignment.description || "—"}</p>
|
</Link>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
<span>/</span>
|
||||||
<span>Source Exam: {assignment.sourceExamTitle}</span>
|
<span>Details</span>
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<span>Created: {formatDate(assignment.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/teacher/homework/assignments">Back</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>View Submissions</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Targets</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{assignment.targetCount}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Submissions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{assignment.submissionCount}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Due</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Late:{" "}
|
|
||||||
{assignment.allowLate
|
|
||||||
? assignment.lateDueAt
|
|
||||||
? formatDate(assignment.lateDueAt)
|
|
||||||
: "Allowed"
|
|
||||||
: "Not allowed"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex items-center gap-3">
|
||||||
</Card>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{assignment.title}</h1>
|
||||||
|
<Badge variant={assignment.status === "published" ? "default" : "secondary"} className="capitalize">
|
||||||
|
{assignment.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm max-w-2xl">{assignment.description || "No description provided."}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-2 md:mt-0">
|
||||||
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
|
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>
|
||||||
|
<Users className="h-4 w-4 mr-2" />
|
||||||
|
View Submissions
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats Row */}
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>Due: <span className="font-medium text-foreground">{assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>Targets: <span className="font-medium text-foreground">{assignment.targetCount}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<span>Submissions: <span className="font-medium text-foreground">{assignment.submissionCount}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
<span>Graded: <span className="font-medium text-foreground">{gradedSampleCount}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="flex-1 p-8 space-y-8 bg-muted/5">
|
||||||
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
{/* Analytics Section */}
|
||||||
<HomeworkAssignmentQuestionErrorDetailsCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
<section className="space-y-4">
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Performance Analytics</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-1">
|
||||||
|
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<HomeworkAssignmentExamContentCard
|
{/* Content Section */}
|
||||||
structure={assignment.structure}
|
<section className="space-y-4">
|
||||||
questions={questions}
|
<div className="flex items-center justify-between">
|
||||||
gradedSampleCount={gradedSampleCount}
|
<h2 className="text-lg font-semibold tracking-tight">Assignment Content</h2>
|
||||||
/>
|
</div>
|
||||||
|
<HomeworkAssignmentExamContentCard
|
||||||
|
structure={assignment.structure}
|
||||||
|
questions={questions}
|
||||||
|
gradedSampleCount={gradedSampleCount}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
|
|||||||
status={submission.status}
|
status={submission.status}
|
||||||
totalScore={submission.totalScore}
|
totalScore={submission.totalScore}
|
||||||
answers={submission.answers}
|
answers={submission.answers}
|
||||||
|
prevSubmissionId={submission.prevSubmissionId}
|
||||||
|
nextSubmissionId={submission.nextSubmissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
|
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
|
||||||
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout";
|
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader";
|
||||||
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
|
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -51,10 +51,11 @@ export default async function TextbookDetailPage({
|
|||||||
|
|
||||||
{/* Main Content Layout (Flex grow) */}
|
{/* Main Content Layout (Flex grow) */}
|
||||||
<div className="flex-1 overflow-hidden pt-6">
|
<div className="flex-1 overflow-hidden pt-6">
|
||||||
<TextbookContentLayout
|
<TextbookReader
|
||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
knowledgePoints={knowledgePoints}
|
knowledgePoints={knowledgePoints}
|
||||||
textbookId={id}
|
textbookId={id}
|
||||||
|
canEdit={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
|||||||
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
|
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
|
||||||
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
|
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
|
||||||
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
|
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
|
||||||
className="bg-card"
|
className="min-h-[400px] border-muted-foreground/10"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
|||||||
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { and, eq, sql } from "drizzle-orm"
|
import { and, eq, sql, or, inArray } from "drizzle-orm"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { grades } from "@/shared/db/schema"
|
import { grades, classes } from "@/shared/db/schema"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import {
|
import {
|
||||||
createAdminClass,
|
createAdminClass,
|
||||||
@@ -138,6 +138,201 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createGradeClassAction(
|
||||||
|
prevState: ActionState<string> | undefined,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
if (!userId) return { success: false, message: "Unauthorized" }
|
||||||
|
|
||||||
|
const schoolName = formData.get("schoolName")
|
||||||
|
const schoolId = formData.get("schoolId")
|
||||||
|
const name = formData.get("name")
|
||||||
|
const grade = formData.get("grade")
|
||||||
|
const gradeId = formData.get("gradeId")
|
||||||
|
const teacherId = formData.get("teacherId")
|
||||||
|
const homeroom = formData.get("homeroom")
|
||||||
|
const room = formData.get("room")
|
||||||
|
|
||||||
|
if (typeof name !== "string" || name.trim().length === 0) {
|
||||||
|
return { success: false, message: "Class name is required" }
|
||||||
|
}
|
||||||
|
if (typeof gradeId !== "string" || gradeId.trim().length === 0) {
|
||||||
|
return { success: false, message: "Grade selection is required" }
|
||||||
|
}
|
||||||
|
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
|
||||||
|
return { success: false, message: "Teacher is required" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
const [managedGrade] = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!managedGrade) {
|
||||||
|
return { success: false, message: "You do not have permission to create classes for this grade" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = await createAdminClass({
|
||||||
|
schoolName: typeof schoolName === "string" ? schoolName : null,
|
||||||
|
schoolId: typeof schoolId === "string" ? schoolId : null,
|
||||||
|
name,
|
||||||
|
grade: typeof grade === "string" ? grade : "", // Should be passed from UI based on selected grade
|
||||||
|
gradeId,
|
||||||
|
teacherId,
|
||||||
|
homeroom: typeof homeroom === "string" ? homeroom : null,
|
||||||
|
room: typeof room === "string" ? room : null,
|
||||||
|
})
|
||||||
|
revalidatePath("/management/grade/classes")
|
||||||
|
return { success: true, message: "Class created successfully", data: id }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGradeClassAction(
|
||||||
|
classId: string,
|
||||||
|
prevState: ActionState | undefined,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState> {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
if (!userId) return { success: false, message: "Unauthorized" }
|
||||||
|
|
||||||
|
const schoolName = formData.get("schoolName")
|
||||||
|
const schoolId = formData.get("schoolId")
|
||||||
|
const name = formData.get("name")
|
||||||
|
const grade = formData.get("grade")
|
||||||
|
const gradeId = formData.get("gradeId")
|
||||||
|
const teacherId = formData.get("teacherId")
|
||||||
|
const homeroom = formData.get("homeroom")
|
||||||
|
const room = formData.get("room")
|
||||||
|
const subjectTeachers = formData.get("subjectTeachers")
|
||||||
|
|
||||||
|
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||||
|
return { success: false, message: "Missing class id" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access: Check if the class belongs to a managed grade
|
||||||
|
const [cls] = await db
|
||||||
|
.select({ gradeId: classes.gradeId })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, classId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!cls || !cls.gradeId) {
|
||||||
|
return { success: false, message: "Class not found or not linked to a grade" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [managedGrade] = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!managedGrade) {
|
||||||
|
return { success: false, message: "You do not have permission to update this class" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If changing gradeId, verify target grade too
|
||||||
|
if (typeof gradeId === "string" && gradeId !== cls.gradeId) {
|
||||||
|
const [targetGrade] = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!targetGrade) {
|
||||||
|
return { success: false, message: "You do not have permission to move class to this grade" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateAdminClass(classId, {
|
||||||
|
schoolName: typeof schoolName === "string" ? schoolName : undefined,
|
||||||
|
schoolId: typeof schoolId === "string" ? schoolId : undefined,
|
||||||
|
name: typeof name === "string" ? name : undefined,
|
||||||
|
grade: typeof grade === "string" ? grade : undefined,
|
||||||
|
gradeId: typeof gradeId === "string" ? gradeId : undefined,
|
||||||
|
teacherId: typeof teacherId === "string" ? teacherId : undefined,
|
||||||
|
homeroom: typeof homeroom === "string" ? homeroom : undefined,
|
||||||
|
room: typeof room === "string" ? room : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
|
||||||
|
const parsed = JSON.parse(subjectTeachers) as unknown
|
||||||
|
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
|
||||||
|
|
||||||
|
await setClassSubjectTeachers({
|
||||||
|
classId,
|
||||||
|
assignments: parsed.flatMap((item) => {
|
||||||
|
if (!item || typeof item !== "object") return []
|
||||||
|
const subject = (item as { subject?: unknown }).subject
|
||||||
|
const teacherId = (item as { teacherId?: unknown }).teacherId
|
||||||
|
|
||||||
|
if (typeof subject !== "string" || !isClassSubject(subject)) return []
|
||||||
|
|
||||||
|
if (teacherId === null || typeof teacherId === "undefined") {
|
||||||
|
return [{ subject, teacherId: null }]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof teacherId !== "string") return []
|
||||||
|
const trimmed = teacherId.trim()
|
||||||
|
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/management/grade/classes")
|
||||||
|
return { success: true, message: "Class updated successfully" }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
if (!userId) return { success: false, message: "Unauthorized" }
|
||||||
|
|
||||||
|
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||||
|
return { success: false, message: "Missing class id" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
const [cls] = await db
|
||||||
|
.select({ gradeId: classes.gradeId })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, classId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!cls || !cls.gradeId) {
|
||||||
|
return { success: false, message: "Class not found or not linked to a grade" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [managedGrade] = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!managedGrade) {
|
||||||
|
return { success: false, message: "You do not have permission to delete this class" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAdminClass(classId)
|
||||||
|
revalidatePath("/management/grade/classes")
|
||||||
|
return { success: true, message: "Class deleted successfully" }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function enrollStudentByEmailAction(
|
export async function enrollStudentByEmailAction(
|
||||||
classId: string,
|
classId: string,
|
||||||
prevState: ActionState | null,
|
prevState: ActionState | null,
|
||||||
@@ -171,14 +366,19 @@ export async function joinClassByInvitationCodeAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id || String(session.user.role ?? "") !== "student") {
|
const role = String(session?.user?.role ?? "")
|
||||||
|
if (!session?.user?.id || (role !== "student" && role !== "teacher")) {
|
||||||
return { success: false, message: "Unauthorized" }
|
return { success: false, message: "Unauthorized" }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const classId = await enrollStudentByInvitationCode(session.user.id, code)
|
const classId = await enrollStudentByInvitationCode(session.user.id, code)
|
||||||
revalidatePath("/student/learning/courses")
|
if (role === "student") {
|
||||||
revalidatePath("/student/schedule")
|
revalidatePath("/student/learning/courses")
|
||||||
|
revalidatePath("/student/schedule")
|
||||||
|
} else {
|
||||||
|
revalidatePath("/teacher/classes/my")
|
||||||
|
}
|
||||||
revalidatePath("/profile")
|
revalidatePath("/profile")
|
||||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
return { success: true, message: "Joined class successfully", data: { classId } }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronRight, FileText } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
interface AssignmentSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
isActive: boolean
|
||||||
|
isOverdue: boolean
|
||||||
|
dueAt: Date | null
|
||||||
|
submittedCount: number
|
||||||
|
targetCount: number
|
||||||
|
avgScore: number | null
|
||||||
|
medianScore: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassAssignmentsWidgetProps {
|
||||||
|
classId: string
|
||||||
|
assignments: AssignmentSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
|
||||||
|
const activeAssignments = assignments.filter((a) => a.isActive)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-semibold">Recent Homework</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{activeAssignments.length} active assignments
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
View All
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{assignments.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3">
|
||||||
|
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">No homework yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Create an assignment to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
Create Homework
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{assignments.slice(0, 5).map((assignment) => (
|
||||||
|
<div
|
||||||
|
key={assignment.id}
|
||||||
|
className="flex items-start justify-between space-x-4 rounded-md border p-3 transition-all hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Link
|
||||||
|
href={`/teacher/homework/assignments/${assignment.id}`}
|
||||||
|
className="block font-medium hover:underline line-clamp-1"
|
||||||
|
>
|
||||||
|
{assignment.title}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
|
||||||
|
Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{assignment.submittedCount}/{assignment.targetCount} Submitted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={assignment.isActive ? "default" : "secondary"}
|
||||||
|
className="rounded-sm px-1.5 py-0.5 text-[10px] uppercase"
|
||||||
|
>
|
||||||
|
{assignment.status}
|
||||||
|
</Badge>
|
||||||
|
{typeof assignment.avgScore === "number" && (
|
||||||
|
<span className="text-xs font-medium tabular-nums">
|
||||||
|
Avg: {assignment.avgScore.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
src/modules/classes/components/class-detail/class-header.tsx
Normal file
119
src/modules/classes/components/class-detail/class-header.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import { EditClassDialog } from "./edit-class-dialog"
|
||||||
|
|
||||||
|
interface ClassHeaderProps {
|
||||||
|
classId: string
|
||||||
|
name: string
|
||||||
|
grade: string
|
||||||
|
homeroom?: string | null
|
||||||
|
room?: string | null
|
||||||
|
schoolName?: string | null
|
||||||
|
studentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassHeader({
|
||||||
|
classId,
|
||||||
|
name,
|
||||||
|
grade,
|
||||||
|
homeroom,
|
||||||
|
room,
|
||||||
|
schoolName,
|
||||||
|
studentCount,
|
||||||
|
}: ClassHeaderProps) {
|
||||||
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4 border-b bg-background px-6 py-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||||
|
{name}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{schoolName && (
|
||||||
|
<>
|
||||||
|
<span>{schoolName}</span>
|
||||||
|
<span className="text-muted-foreground/40">•</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="font-medium">
|
||||||
|
{grade}
|
||||||
|
</Badge>
|
||||||
|
{homeroom && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40">•</span>
|
||||||
|
<span>Homeroom {homeroom}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{room && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40">•</span>
|
||||||
|
<span>Room {room}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground/40">•</span>
|
||||||
|
<span>{studentCount} Students</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="hidden sm:flex">
|
||||||
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
|
Invite students
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Class settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditClassDialog
|
||||||
|
open={showEdit}
|
||||||
|
onOpenChange={setShowEdit}
|
||||||
|
classId={classId}
|
||||||
|
initialData={{
|
||||||
|
name,
|
||||||
|
grade,
|
||||||
|
homeroom,
|
||||||
|
room,
|
||||||
|
schoolName
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
interface ClassOverviewStatsProps {
|
||||||
|
averageScore: number | null
|
||||||
|
submissionRate: number
|
||||||
|
papersToGrade: number
|
||||||
|
overdueCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassOverviewStats({
|
||||||
|
averageScore,
|
||||||
|
submissionRate,
|
||||||
|
papersToGrade,
|
||||||
|
overdueCount,
|
||||||
|
}: ClassOverviewStatsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<StatsCard
|
||||||
|
title="Class Average"
|
||||||
|
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
|
||||||
|
subValue="Overall performance"
|
||||||
|
icon={BarChart3}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Submission Rate"
|
||||||
|
value={`${submissionRate.toFixed(0)}%`}
|
||||||
|
subValue="Average turn-in rate"
|
||||||
|
icon={CheckCircle2}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="To Grade"
|
||||||
|
value={papersToGrade.toString()}
|
||||||
|
subValue="Pending reviews"
|
||||||
|
icon={PenTool}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Missed Deadlines"
|
||||||
|
value={overdueCount.toString()}
|
||||||
|
subValue="Active assignments past due"
|
||||||
|
icon={AlertCircle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subValue,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
subValue: string
|
||||||
|
icon: React.ElementType
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between space-y-0 pb-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{subValue}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
interface ClassQuickActionsProps {
|
||||||
|
classId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
<Button asChild className="w-full justify-start" size="sm">
|
||||||
|
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
<FilePlus className="mr-2 h-4 w-4" />
|
||||||
|
Create Homework
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" className="w-full justify-start" size="sm">
|
||||||
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Manage Schedule
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
||||||
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
|
Message Class (Coming soon)
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Class Settings
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/shared/components/ui/hover-card"
|
||||||
|
import type { ClassScheduleItem } from "@/modules/classes/types"
|
||||||
|
|
||||||
|
interface ClassScheduleWidgetProps {
|
||||||
|
classId: string
|
||||||
|
schedule: ClassScheduleItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
|
||||||
|
|
||||||
|
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
|
||||||
|
// Group by weekday
|
||||||
|
const groupedSchedule = schedule.reduce((acc, item) => {
|
||||||
|
const day = item.weekday
|
||||||
|
if (!acc[day]) acc[day] = []
|
||||||
|
acc[day].push(item)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<number, ClassScheduleItem[]>)
|
||||||
|
|
||||||
|
// Sort items within each day by start time
|
||||||
|
Object.keys(groupedSchedule).forEach(key => {
|
||||||
|
groupedSchedule[Number(key)].sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (schedule.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3 py-6 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3">
|
||||||
|
<Calendar className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">No sessions scheduled.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
||||||
|
{WEEKDAYS.slice(0, 5).map((day, i) => (
|
||||||
|
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{WEEKDAY_INDICES.slice(0, 5).map((dayNum) => {
|
||||||
|
const items = groupedSchedule[dayNum] || []
|
||||||
|
return (
|
||||||
|
<div key={dayNum} className={`flex flex-col gap-1 py-1 border-r last:border-r-0 border-muted/30 ${compact ? 'max-h-[140px]' : 'min-h-[100px]'}`}>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex-1" />
|
||||||
|
) : (
|
||||||
|
items.map(item => (
|
||||||
|
<HoverCard key={item.id}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<div className="bg-primary/5 text-primary rounded-[2px] p-1 text-[10px] text-left relative hover:bg-primary/10 transition-colors cursor-default leading-tight shrink-0">
|
||||||
|
<div className="font-semibold truncate">{item.course}</div>
|
||||||
|
<div className="opacity-70 scale-90 origin-left mt-0.5 whitespace-nowrap">{item.startTime}-{item.endTime}</div>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-48 p-3" align="start" side="top">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="font-semibold text-sm border-b pb-1 mb-1">{item.course}</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||||
|
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{item.startTime} - {item.endTime}</span>
|
||||||
|
</div>
|
||||||
|
{item.location && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||||
|
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{item.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
|
||||||
|
return (
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-semibold">Weekly Schedule</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
Manage
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<ClassScheduleGrid schedule={schedule} />
|
||||||
|
<div className="mt-2 text-[10px] text-muted-foreground text-center">
|
||||||
|
* Showing Mon-Fri schedule
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronRight, Users } from "lucide-react"
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
interface StudentSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
image?: string | null
|
||||||
|
status: string
|
||||||
|
subjectScores?: Record<string, number | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassStudentsWidgetProps {
|
||||||
|
classId: string
|
||||||
|
students: StudentSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
|
||||||
|
const activeCount = students.filter(s => s.status === "active").length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-semibold">Students</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{activeCount} active students
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
|
||||||
|
View All
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{students.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3">
|
||||||
|
<Users className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">No students enrolled yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{students.slice(0, 6).map((student) => (
|
||||||
|
<div key={student.id} className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={student.image || undefined} alt={student.name} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{student.name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium leading-none">{student.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground line-clamp-1">{student.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={student.status === "active" ? "outline" : "secondary"}
|
||||||
|
className="text-[10px] capitalize"
|
||||||
|
>
|
||||||
|
{student.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subject Scores */}
|
||||||
|
{student.subjectScores && Object.keys(student.subjectScores).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{Object.entries(student.subjectScores).map(([subject, score]) => (
|
||||||
|
<div key={subject} className="flex items-center gap-1.5 rounded bg-muted/50 px-2 py-1 text-[10px]">
|
||||||
|
<span className="font-medium text-muted-foreground">{subject}</span>
|
||||||
|
{score !== null ? (
|
||||||
|
<span className={score >= 60 ? "font-semibold text-primary" : "font-semibold text-destructive"}>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
interface AssignmentSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
subject?: string | null
|
||||||
|
isActive: boolean
|
||||||
|
isOverdue: boolean
|
||||||
|
dueAt: Date | null
|
||||||
|
submittedCount: number
|
||||||
|
targetCount: number
|
||||||
|
avgScore: number | null
|
||||||
|
medianScore: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassTrendsWidgetProps {
|
||||||
|
classId: string
|
||||||
|
assignments: AssignmentSummary[]
|
||||||
|
compact?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
submitted: {
|
||||||
|
label: "Submitted",
|
||||||
|
color: "hsl(var(--primary))",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
label: "Total Students",
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
avg: {
|
||||||
|
label: "Average Score",
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
median: {
|
||||||
|
label: "Median Score",
|
||||||
|
color: "hsl(var(--chart-4))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
|
||||||
|
const data = [...assignments].reverse().map(a => ({
|
||||||
|
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
|
||||||
|
fullTitle: a.title,
|
||||||
|
submitted: a.submittedCount,
|
||||||
|
target: a.targetCount,
|
||||||
|
avg: a.avgScore ? Math.round(a.avgScore) : null,
|
||||||
|
median: a.medianScore ? Math.round(a.medianScore) : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
return data.slice(-limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassSubmissionTrendChart({
|
||||||
|
data,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
data: any[]
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ChartContainer config={chartConfig} className={className}>
|
||||||
|
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="title"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={10}
|
||||||
|
hide
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={10}
|
||||||
|
domain={[0, 'auto']}
|
||||||
|
tickFormatter={(value) => `${value}`}
|
||||||
|
hide
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="target"
|
||||||
|
stroke="var(--color-target)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="submitted"
|
||||||
|
stroke="var(--color-submitted)"
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
|
||||||
|
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
|
||||||
|
const [selectedSubject, setSelectedSubject] = useState<string>("all")
|
||||||
|
|
||||||
|
// Extract unique subjects
|
||||||
|
const subjects = Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]
|
||||||
|
|
||||||
|
const activeAssignments = assignments.filter((a) => {
|
||||||
|
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
|
||||||
|
return a.isActive || a.status === "published" // Include published even if not "active" in terms of due date
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartData = transformAssignmentsToChartData(activeAssignments, 7)
|
||||||
|
|
||||||
|
if (chartData.length === 0 && selectedSubject === "all") return null
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
// Calculate simple stats for compact view
|
||||||
|
const lastAssignment = chartData[chartData.length - 1]
|
||||||
|
|
||||||
|
let metricValue = "0%"
|
||||||
|
let metricLabel = "Latest"
|
||||||
|
|
||||||
|
if (lastAssignment) {
|
||||||
|
if (chartTab === "submission") {
|
||||||
|
metricValue = lastAssignment.target > 0
|
||||||
|
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
|
||||||
|
: "0%"
|
||||||
|
} else {
|
||||||
|
metricValue = lastAssignment.avg ? `${lastAssignment.avg}` : "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full ${className || ""}`}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
|
||||||
|
{chartTab === "submission" ? "Submission" : "Score"}
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
|
||||||
|
Submission Trends
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
|
||||||
|
Score Trends
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{subjects.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
{selectedSubject === "all" ? "All Subjects" : selectedSubject}
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
|
||||||
|
All Subjects
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{subjects.map(s => (
|
||||||
|
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
|
||||||
|
{s}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
{metricLabel}: <span className="text-foreground">{metricValue}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact Sparkline Chart */}
|
||||||
|
<div className="flex-1 w-full min-h-0">
|
||||||
|
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||||
|
{chartTab === "submission" ? (
|
||||||
|
<AreaChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillSubmitted" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-submitted)" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="var(--color-submitted)" stopOpacity={0.05}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis dataKey="title" hide />
|
||||||
|
<YAxis hide domain={[0, 'auto']} />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent indicator="dot" hideLabel />}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="submitted"
|
||||||
|
stroke="var(--color-submitted)"
|
||||||
|
fill="url(#fillSubmitted)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="target"
|
||||||
|
stroke="var(--color-target)"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="2 2"
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
) : (
|
||||||
|
<LineChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis dataKey="title" hide />
|
||||||
|
<YAxis hide domain={[0, 100]} />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent indicator="dot" />}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avg"
|
||||||
|
stroke="var(--color-avg)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="median"
|
||||||
|
stroke="var(--color-median)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 h-8">
|
||||||
|
<TabsTrigger value="submission" className="text-xs">Submission</TabsTrigger>
|
||||||
|
<TabsTrigger value="score" className="text-xs">Score</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subjects.length > 0 && (
|
||||||
|
<Tabs value={selectedSubject} onValueChange={setSelectedSubject} className="w-full">
|
||||||
|
<TabsList className="h-8 w-auto flex-wrap justify-start bg-transparent p-0">
|
||||||
|
<TabsTrigger
|
||||||
|
value="all"
|
||||||
|
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
All Subjects
|
||||||
|
</TabsTrigger>
|
||||||
|
{subjects.map(s => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={s}
|
||||||
|
value={s}
|
||||||
|
className="ml-2 h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ChartContainer config={chartConfig} className="h-[250px] w-full">
|
||||||
|
{chartTab === "submission" ? (
|
||||||
|
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="title"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
domain={[0, 'auto']}
|
||||||
|
tickFormatter={(value) => `${value}`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="target"
|
||||||
|
stroke="var(--color-target)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="submitted"
|
||||||
|
stroke="var(--color-submitted)"
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
) : (
|
||||||
|
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="title"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
fontSize={12}
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avg"
|
||||||
|
stroke="var(--color-avg)"
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="median"
|
||||||
|
stroke="var(--color-median)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</ChartContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No data for this subject
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { updateTeacherClassAction } from "../../actions"
|
||||||
|
|
||||||
|
interface EditClassDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
classId: string
|
||||||
|
initialData: {
|
||||||
|
name: string
|
||||||
|
grade: string
|
||||||
|
homeroom?: string | null
|
||||||
|
room?: string | null
|
||||||
|
schoolName?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditClassDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
classId,
|
||||||
|
initialData,
|
||||||
|
}: EditClassDialogProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
|
const handleEdit = async (formData: FormData) => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await updateTeacherClassAction(classId, null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
onOpenChange(false)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to update class")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update class")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
if (isWorking) return
|
||||||
|
onOpenChange(val)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit class</DialogTitle>
|
||||||
|
<DialogDescription>Update basic class information.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleEdit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="schoolName" className="text-right">
|
||||||
|
School
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="schoolName"
|
||||||
|
name="schoolName"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.schoolName ?? ""}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="grade" className="text-right">
|
||||||
|
Grade
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="grade"
|
||||||
|
name="grade"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.grade}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="homeroom" className="text-right">
|
||||||
|
Homeroom
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="homeroom"
|
||||||
|
name="homeroom"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.homeroom ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="room" className="text-right">
|
||||||
|
Room
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="room"
|
||||||
|
name="room"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={initialData.room ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isWorking}>
|
||||||
|
{isWorking ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
455
src/modules/classes/components/grade-classes-view.tsx
Normal file
455
src/modules/classes/components/grade-classes-view.tsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
|
||||||
|
import { DEFAULT_CLASS_SUBJECTS } from "../types"
|
||||||
|
import { createGradeClassAction, deleteGradeClassAction, updateGradeClassAction } from "../actions"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
export function GradeClassesClient({
|
||||||
|
classes,
|
||||||
|
teachers,
|
||||||
|
managedGrades,
|
||||||
|
}: {
|
||||||
|
classes: AdminClassListItem[]
|
||||||
|
teachers: TeacherOption[]
|
||||||
|
managedGrades: { id: string; name: string; schoolId: string; schoolName: string | null }[]
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
|
||||||
|
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
|
||||||
|
|
||||||
|
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
|
||||||
|
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
|
||||||
|
const [createGradeId, setCreateGradeId] = useState(managedGrades[0]?.id ?? "")
|
||||||
|
|
||||||
|
const [editTeacherId, setEditTeacherId] = useState("")
|
||||||
|
const [editGradeId, setEditGradeId] = useState("")
|
||||||
|
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createOpen) return
|
||||||
|
setCreateTeacherId(defaultTeacherId)
|
||||||
|
setCreateGradeId(managedGrades[0]?.id ?? "")
|
||||||
|
}, [createOpen, defaultTeacherId, managedGrades])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editItem) return
|
||||||
|
setEditTeacherId(editItem.teacher.id)
|
||||||
|
setEditGradeId(editItem.gradeId ?? managedGrades[0]?.id ?? "")
|
||||||
|
setEditSubjectTeachers(
|
||||||
|
DEFAULT_CLASS_SUBJECTS.map((s) => ({
|
||||||
|
subject: s,
|
||||||
|
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}, [editItem, managedGrades])
|
||||||
|
|
||||||
|
const handleCreate = async (formData: FormData) => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await createGradeClassAction(undefined, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
setCreateOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to create class")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create class")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async (formData: FormData) => {
|
||||||
|
if (!editItem) return
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await updateGradeClassAction(editItem.id, undefined, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
setEditItem(null)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to update class")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update class")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteItem) return
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
const res = await deleteGradeClassAction(deleteItem.id)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
setDeleteItem(null)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to delete class")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete class")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
|
||||||
|
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
|
||||||
|
const pairs = list
|
||||||
|
.filter((x) => x.teacher)
|
||||||
|
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
|
||||||
|
.filter((x) => x.length > 0)
|
||||||
|
return pairs.length > 0 ? pairs.join(",") : "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCreateGrade = managedGrades.find(g => g.id === createGradeId)
|
||||||
|
const selectedEditGrade = managedGrades.find(g => g.id === editGradeId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setCreateOpen(true)} disabled={isWorking || managedGrades.length === 0}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New class
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">All classes</CardTitle>
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{classes.length}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{classes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No classes"
|
||||||
|
description={managedGrades.length === 0 ? "You are not managing any grades yet." : "Create classes to manage students and schedules."}
|
||||||
|
className="h-auto border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>School</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Grade</TableHead>
|
||||||
|
<TableHead>Homeroom</TableHead>
|
||||||
|
<TableHead>Room</TableHead>
|
||||||
|
<TableHead>班主任</TableHead>
|
||||||
|
<TableHead>任课老师</TableHead>
|
||||||
|
<TableHead className="text-right">Students</TableHead>
|
||||||
|
<TableHead>Updated</TableHead>
|
||||||
|
<TableHead className="w-[60px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setEditItem(c)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setDeleteItem(c)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New class</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleCreate} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Grade</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={createGradeId} onValueChange={setCreateGradeId} disabled={managedGrades.length === 0}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={managedGrades.length === 0 ? "No managed grades" : "Select a grade"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{managedGrades.map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>
|
||||||
|
{g.name} ({g.schoolName})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="gradeId" value={createGradeId} />
|
||||||
|
<input type="hidden" name="grade" value={selectedCreateGrade?.name ?? ""} />
|
||||||
|
<input type="hidden" name="schoolId" value={selectedCreateGrade?.schoolId ?? ""} />
|
||||||
|
<input type="hidden" name="schoolName" value={selectedCreateGrade?.schoolName ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="create-name" className="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 3" autoFocus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="create-homeroom" className="text-right">
|
||||||
|
Homeroom
|
||||||
|
</Label>
|
||||||
|
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="create-room" className="text-right">
|
||||||
|
Room
|
||||||
|
</Label>
|
||||||
|
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">班主任</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{teachers.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name} ({t.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="teacherId" value={createTeacherId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId || !createGradeId}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(editItem)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (isWorking) return
|
||||||
|
if (!open) setEditItem(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit class</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editItem ? (
|
||||||
|
<form action={handleUpdate} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Grade</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={editGradeId} onValueChange={setEditGradeId} disabled={managedGrades.length === 0}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a grade" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{managedGrades.map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>
|
||||||
|
{g.name} ({g.schoolName})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="gradeId" value={editGradeId} />
|
||||||
|
<input type="hidden" name="grade" value={selectedEditGrade?.name ?? ""} />
|
||||||
|
<input type="hidden" name="schoolId" value={selectedEditGrade?.schoolId ?? ""} />
|
||||||
|
<input type="hidden" name="schoolName" value={selectedEditGrade?.schoolName ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-name" className="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-homeroom" className="text-right">
|
||||||
|
Homeroom
|
||||||
|
</Label>
|
||||||
|
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-room" className="text-right">
|
||||||
|
Room
|
||||||
|
</Label>
|
||||||
|
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">班主任</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{teachers.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name} ({t.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="teacherId" value={editTeacherId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border p-4">
|
||||||
|
<div className="text-sm font-medium">任课老师</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
|
||||||
|
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
|
||||||
|
return (
|
||||||
|
<div key={subject} className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">{subject}</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Select
|
||||||
|
value={selected ?? ""}
|
||||||
|
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
|
||||||
|
disabled={teachers.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{teachers.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name} ({t.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking || !editTeacherId}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(deleteItem)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleteItem(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete class</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
|
||||||
import type { TeacherClass } from "../types"
|
|
||||||
|
|
||||||
export function InsightsFilters({ classes }: { classes: TeacherClass[] }) {
|
|
||||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
|
||||||
<SelectTrigger className="w-[240px]">
|
|
||||||
<SelectValue placeholder="Class" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Select a class</SelectItem>
|
|
||||||
{classes.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{classId !== "all" && (
|
|
||||||
<Button variant="ghost" onClick={() => setClassId(null)} className="h-8 px-2 lg:px-3">
|
|
||||||
Reset
|
|
||||||
<X className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -3,32 +3,22 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Calendar, Copy, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Users } from "lucide-react"
|
import {
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
Users,
|
||||||
|
MapPin,
|
||||||
|
GraduationCap,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { parseAsString, useQueryState } from "nuqs"
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -40,198 +30,144 @@ import {
|
|||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||||
import type { TeacherClass } from "../types"
|
import type { TeacherClass, ClassScheduleItem } from "../types"
|
||||||
import {
|
import {
|
||||||
createTeacherClassAction,
|
|
||||||
deleteTeacherClassAction,
|
|
||||||
ensureClassInvitationCodeAction,
|
ensureClassInvitationCodeAction,
|
||||||
regenerateClassInvitationCodeAction,
|
regenerateClassInvitationCodeAction,
|
||||||
updateTeacherClassAction,
|
joinClassByInvitationCodeAction,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
|
|
||||||
|
const GRADIENTS = [
|
||||||
|
"bg-card border-border",
|
||||||
|
"bg-card border-border",
|
||||||
|
"bg-card border-border",
|
||||||
|
"bg-card border-border",
|
||||||
|
"bg-card border-border",
|
||||||
|
]
|
||||||
|
|
||||||
|
function getClassGradient(id: string) {
|
||||||
|
return "bg-card border-border shadow-sm hover:shadow-md"
|
||||||
|
}
|
||||||
|
|
||||||
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
|
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [joinOpen, setJoinOpen] = useState(false)
|
||||||
|
|
||||||
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
|
const handleJoin = async (formData: FormData) => {
|
||||||
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
|
|
||||||
|
|
||||||
const gradeOptions = useMemo(() => {
|
|
||||||
const set = new Set<string>()
|
|
||||||
for (const c of classes) set.add(c.grade)
|
|
||||||
return Array.from(set).sort((a, b) => a.localeCompare(b))
|
|
||||||
}, [classes])
|
|
||||||
|
|
||||||
const filteredClasses = useMemo(() => {
|
|
||||||
const needle = q.trim().toLowerCase()
|
|
||||||
return classes.filter((c) => {
|
|
||||||
const gradeOk = grade === "all" ? true : c.grade === grade
|
|
||||||
const qOk = needle.length === 0 ? true : c.name.toLowerCase().includes(needle)
|
|
||||||
return gradeOk && qOk
|
|
||||||
})
|
|
||||||
}, [classes, grade, q])
|
|
||||||
|
|
||||||
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
|
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await createTeacherClassAction(null, formData)
|
const res = await joinClassByInvitationCodeAction(null, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message || "Joined class successfully")
|
||||||
setCreateOpen(false)
|
setJoinOpen(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to create class")
|
toast.error(res.message || "Failed to join class")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to create class")
|
toast.error("Failed to join class")
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
{/* Filter Bar */}
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
|
||||||
<div className="relative flex-1 md:max-w-sm">
|
<div className="relative">
|
||||||
<Input
|
<Dialog
|
||||||
placeholder="Search classes..."
|
open={joinOpen}
|
||||||
value={q}
|
onOpenChange={(open) => {
|
||||||
onChange={(e) => setQ(e.target.value || null)}
|
if (isWorking) return
|
||||||
/>
|
setJoinOpen(open)
|
||||||
</div>
|
}}
|
||||||
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
|
>
|
||||||
<SelectTrigger className="w-[200px]">
|
<DialogTrigger asChild>
|
||||||
<SelectValue placeholder="Grade" />
|
<div className="group relative">
|
||||||
</SelectTrigger>
|
{/* Decorative Ticket Stub Effect */}
|
||||||
<SelectContent>
|
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-lg blur opacity-30 group-hover:opacity-60 transition duration-500"></div>
|
||||||
<SelectItem value="all">All grades</SelectItem>
|
<Button className="relative gap-2 h-10 px-5 shadow-sm border border-primary/10 hover:shadow-md transition-all bg-background text-foreground hover:bg-muted/50" disabled={isWorking} variant="outline">
|
||||||
{gradeOptions.map((g) => (
|
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
|
||||||
<SelectItem key={g} value={g}>
|
<Plus className="size-3.5" strokeWidth={3} />
|
||||||
{g}
|
</div>
|
||||||
</SelectItem>
|
<span className="font-semibold tracking-tight">Join New Class</span>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{(q || grade !== "all") && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-9"
|
|
||||||
onClick={() => {
|
|
||||||
setQ(null)
|
|
||||||
setGrade(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!canCreateClass) return
|
|
||||||
if (isWorking) return
|
|
||||||
setCreateOpen(open)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="gap-2" disabled={isWorking || !canCreateClass}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
New class
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create class</DialogTitle>
|
|
||||||
<DialogDescription>Add a new class to start managing students.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form action={handleCreate}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-school-name" className="text-right">
|
|
||||||
School
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="create-school-name"
|
|
||||||
name="schoolName"
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-name" className="text-right">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 1A" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-grade" className="text-right">
|
|
||||||
Grade
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="create-grade"
|
|
||||||
name="grade"
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="e.g. Grade 7"
|
|
||||||
defaultValue={defaultGrade}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-homeroom" className="text-right">
|
|
||||||
Homeroom
|
|
||||||
</Label>
|
|
||||||
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-room" className="text-right">
|
|
||||||
Room
|
|
||||||
</Label>
|
|
||||||
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isWorking}>
|
|
||||||
{isWorking ? "Creating..." : "Create"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</form>
|
</DialogTrigger>
|
||||||
</DialogContent>
|
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden gap-0 border-none shadow-2xl">
|
||||||
</Dialog>
|
{/* Header with Pattern */}
|
||||||
|
<div className="relative bg-primary/5 p-6 border-b border-border/50">
|
||||||
|
<div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '12px 12px' }}></div>
|
||||||
|
<DialogHeader className="relative z-10">
|
||||||
|
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||||
|
<Plus className="size-5" />
|
||||||
|
</div>
|
||||||
|
Join a Class
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground mt-1.5">
|
||||||
|
Enter the 6-digit invitation code provided by your administrator.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={handleJoin} className="bg-card">
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="join-code" className="text-sm font-medium">
|
||||||
|
Invitation Code
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="join-code"
|
||||||
|
name="code"
|
||||||
|
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
|
||||||
|
placeholder="e.g. 123456"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
pattern="\d{6}"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/30 pointer-events-none">
|
||||||
|
<Users className="size-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ask your administrator for the code if you don't have one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
|
||||||
|
{isWorking ? "Joining..." : "Join Class"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
{/* List */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
{classes.length === 0 ? (
|
{classes.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No classes yet"
|
title="No classes yet"
|
||||||
description="Create your first class to start managing students and schedules."
|
description="Join a class to start managing students and schedules."
|
||||||
icon={Users}
|
icon={Users}
|
||||||
action={canCreateClass ? { label: "Create class", onClick: () => setCreateOpen(true) } : undefined}
|
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
||||||
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
|
className="h-[360px] bg-card border-dashed"
|
||||||
/>
|
|
||||||
) : filteredClasses.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="No classes match your filters"
|
|
||||||
description="Try clearing filters or adjusting keywords."
|
|
||||||
icon={Users}
|
|
||||||
action={{ label: "Clear filters", onClick: () => {
|
|
||||||
setQ(null)
|
|
||||||
setGrade(null)
|
|
||||||
}}}
|
|
||||||
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filteredClasses.map((c) => (
|
classes.map((c) => (
|
||||||
<ClassCard
|
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
||||||
key={c.id}
|
|
||||||
c={c}
|
|
||||||
onWorkingChange={setIsWorking}
|
|
||||||
isWorking={isWorking}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +175,12 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClassCard({
|
import { ClassScheduleGrid } from "./class-detail/class-schedule-widget"
|
||||||
|
import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
|
||||||
|
|
||||||
|
// Removed MiniSchedule since we're using ClassScheduleGrid now
|
||||||
|
|
||||||
|
function ClassTicket({
|
||||||
c,
|
c,
|
||||||
isWorking,
|
isWorking,
|
||||||
onWorkingChange,
|
onWorkingChange,
|
||||||
@@ -249,8 +190,6 @@ function ClassCard({
|
|||||||
onWorkingChange: (v: boolean) => void
|
onWorkingChange: (v: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [showEdit, setShowEdit] = useState(false)
|
|
||||||
const [showDelete, setShowDelete] = useState(false)
|
|
||||||
|
|
||||||
const handleEnsureCode = async () => {
|
const handleEnsureCode = async () => {
|
||||||
onWorkingChange(true)
|
onWorkingChange(true)
|
||||||
@@ -297,238 +236,160 @@ function ClassCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = async (formData: FormData) => {
|
// Real data for chart
|
||||||
onWorkingChange(true)
|
const recentAssignments = c.recentAssignments ?? []
|
||||||
try {
|
|
||||||
const res = await updateTeacherClassAction(c.id, null, formData)
|
// Calculate performance change for indicator (still needed for the top indicator)
|
||||||
if (res.success) {
|
// We can't reuse chart data easily here without recalculating, but ClassTrendsWidget handles its own data now
|
||||||
toast.success(res.message)
|
const lastTwoAssignments = [...recentAssignments].reverse().slice(-2)
|
||||||
setShowEdit(false)
|
const performanceChange = lastTwoAssignments.length === 2 && lastTwoAssignments[0].submittedCount > 0
|
||||||
router.refresh()
|
? ((lastTwoAssignments[1].submittedCount - lastTwoAssignments[0].submittedCount) / lastTwoAssignments[0].submittedCount) * 100
|
||||||
} else {
|
: 0
|
||||||
toast.error(res.message || "Failed to update class")
|
const isPositive = performanceChange >= 0
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to update class")
|
|
||||||
} finally {
|
|
||||||
onWorkingChange(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
onWorkingChange(true)
|
|
||||||
try {
|
|
||||||
const res = await deleteTeacherClassAction(c.id)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message)
|
|
||||||
setShowDelete(false)
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || "Failed to delete class")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to delete class")
|
|
||||||
} finally {
|
|
||||||
onWorkingChange(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-none">
|
<div className="group relative flex w-full overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md">
|
||||||
<CardHeader className="space-y-2">
|
{/* Realistic Paper Texture & Noise */}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="absolute inset-0 pointer-events-none opacity-[0.02]" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\'/%3E%3C/svg%3E")' }}></div>
|
||||||
<div className="min-w-0">
|
<div className="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
|
||||||
<CardTitle className="text-base truncate">
|
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
|
{/* Decorative Barcode Strip */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
|
||||||
|
{Array.from({ length: 20 }).map((_, i) => (
|
||||||
|
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left Section: Basic Info (Narrower) */}
|
||||||
|
<div className="flex w-full flex-col justify-between p-5 pl-7 sm:w-[320px] sm:flex-shrink-0 relative z-10 border-r border-dashed border-muted-foreground/20">
|
||||||
|
{/* Punch Hole Effect Top-Left */}
|
||||||
|
<div className="absolute -left-2 -top-2 h-6 w-6 rounded-full bg-background border border-border shadow-[inset_1px_1px_2px_rgba(0,0,0,0.1)] z-20"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/5 text-xl font-bold text-primary shadow-sm border border-primary/10">
|
||||||
|
{c.grade.replace(/[^0-9]/g, '')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="text-lg font-bold hover:underline tracking-tight line-clamp-1">
|
||||||
{c.name}
|
{c.name}
|
||||||
</Link>
|
</Link>
|
||||||
</CardTitle>
|
<Badge variant="secondary" className="w-fit font-normal text-xs bg-muted/50 font-mono tracking-tight">
|
||||||
<div className="text-muted-foreground text-sm mt-1">
|
{c.grade} • {c.id.slice(-4).toUpperCase()}
|
||||||
{c.room ? `Room: ${c.room}` : "Room: Not set"}
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
<Badge variant="secondary">{c.grade}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenu>
|
<Users className="size-4 text-muted-foreground/70" />
|
||||||
<DropdownMenuTrigger asChild>
|
<span className="font-medium text-foreground/80">{c.studentCount}</span> Students
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
</div>
|
||||||
<MoreHorizontal className="size-4" />
|
<div className="flex items-center gap-2">
|
||||||
</Button>
|
<MapPin className="size-4 text-muted-foreground/70" />
|
||||||
</DropdownMenuTrigger>
|
<span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
|
||||||
<DropdownMenuContent align="end">
|
</div>
|
||||||
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
{c.schoolName && (
|
||||||
<Pencil className="mr-2 size-4" />
|
<div className="flex items-center gap-2">
|
||||||
Edit
|
<GraduationCap className="size-4 text-muted-foreground/70" />
|
||||||
</DropdownMenuItem>
|
<span className="line-clamp-1">{c.schoolName}</span>
|
||||||
<DropdownMenuSeparator />
|
</div>
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => setShowDelete(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 size-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-sm font-medium tabular-nums">{c.studentCount} students</div>
|
|
||||||
{c.homeroom ? <Badge variant="outline">{c.homeroom}</Badge> : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-xs uppercase text-muted-foreground">Invitation code</div>
|
|
||||||
<div className="font-mono tabular-nums text-sm">{c.invitationCode ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{c.invitationCode ? (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2" onClick={handleCopyCode} disabled={isWorking}>
|
|
||||||
<Copy className="size-4" />
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2" onClick={handleRegenerateCode} disabled={isWorking}>
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
Regenerate
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" size="sm" onClick={handleEnsureCode} disabled={isWorking}>
|
|
||||||
Generate
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("grid gap-2", "grid-cols-2")}>
|
|
||||||
<Button asChild variant="outline" className="w-full justify-start gap-2">
|
{/* Invitation Code Section */}
|
||||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
|
<div className="mt-6 pt-4 border-t border-dashed border-border relative">
|
||||||
<Users className="size-4" />
|
{/* Tiny Cut marks */}
|
||||||
Students
|
<div className="absolute -left-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||||
</Link>
|
<div className="absolute -right-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="w-full justify-start gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
|
<div className="flex items-center justify-between">
|
||||||
<Calendar className="size-4" />
|
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
|
||||||
Schedule
|
<div className="flex gap-0.5">
|
||||||
</Link>
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
</Button>
|
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-muted/30 px-3 py-1.5 rounded-sm border border-dashed border-muted-foreground/30 relative overflow-hidden">
|
||||||
|
<span className="font-mono text-lg font-bold tracking-widest text-foreground z-10">{c.invitationCode || "—"}</span>
|
||||||
|
|
||||||
|
{/* Faint QR Code Placeholder Background */}
|
||||||
|
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
|
||||||
|
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
|
||||||
|
{Array.from({ length: 16 }).map((_, i) => (
|
||||||
|
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.invitationCode ? (
|
||||||
|
<div className="flex gap-1 z-10">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title="Copy">
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
{/* Dashed Divider (Ticket perforation) */}
|
||||||
open={showEdit}
|
<div className="relative hidden w-4 flex-col items-center justify-center sm:flex -ml-2 z-20">
|
||||||
onOpenChange={(open) => {
|
<div className="absolute -top-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_-1px_1px_rgba(0,0,0,0.05)]" />
|
||||||
if (isWorking) return
|
<div className="h-full w-px border-l-2 border-dashed border-muted-foreground/20 relative">
|
||||||
setShowEdit(open)
|
{/* Scissor Icon */}
|
||||||
}}
|
<div className="absolute top-1/2 -left-[5px] -translate-y-1/2 text-muted-foreground/20 -rotate-90 text-[10px]">✂</div>
|
||||||
>
|
</div>
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
<div className="absolute -bottom-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_1px_1px_rgba(0,0,0,0.05)]" />
|
||||||
<DialogHeader>
|
</div>
|
||||||
<DialogTitle>Edit class</DialogTitle>
|
|
||||||
<DialogDescription>Update basic class information.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form action={handleEdit}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-school-name-${c.id}`} className="text-right">
|
|
||||||
School
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-school-name-${c.id}`}
|
|
||||||
name="schoolName"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.schoolName ?? ""}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-name-${c.id}`} className="text-right">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-name-${c.id}`}
|
|
||||||
name="name"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.name}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-grade-${c.id}`} className="text-right">
|
|
||||||
Grade
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-grade-${c.id}`}
|
|
||||||
name="grade"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.grade}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-homeroom-${c.id}`} className="text-right">
|
|
||||||
Homeroom
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-homeroom-${c.id}`}
|
|
||||||
name="homeroom"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.homeroom ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor={`edit-room-${c.id}`} className="text-right">
|
|
||||||
Room
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`edit-room-${c.id}`}
|
|
||||||
name="room"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={c.room ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isWorking}>
|
|
||||||
{isWorking ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
{/* Right Section: Stats & Actions (Wider) */}
|
||||||
open={showDelete}
|
<div className="flex flex-1 flex-col bg-muted/5 p-6 relative z-10">
|
||||||
onOpenChange={(open) => {
|
<div className="flex flex-1 gap-6">
|
||||||
if (isWorking) return
|
{/* Left: Submission Trends */}
|
||||||
setShowDelete(open)
|
<div className="flex-1 flex flex-col gap-4 min-w-0">
|
||||||
}}
|
<div className="flex items-center justify-between">
|
||||||
>
|
<h4 className="text-sm font-semibold text-foreground/80">Submission Trends</h4>
|
||||||
<AlertDialogContent>
|
<span className={cn(
|
||||||
<AlertDialogHeader>
|
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
|
||||||
<AlertDialogTitle>Delete class?</AlertDialogTitle>
|
isPositive
|
||||||
<AlertDialogDescription>
|
? "text-emerald-600 bg-emerald-50 border-emerald-100"
|
||||||
This will permanently delete <span className="font-medium text-foreground">{c.name}</span> and remove all
|
: "text-red-600 bg-red-50 border-red-100"
|
||||||
enrollments.
|
)}>
|
||||||
</AlertDialogDescription>
|
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
|
||||||
</AlertDialogHeader>
|
</span>
|
||||||
<AlertDialogFooter>
|
</div>
|
||||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
{/* Real Chart */}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
<div className="h-[140px] w-full">
|
||||||
onClick={handleDelete}
|
<ClassTrendsWidget
|
||||||
disabled={isWorking}
|
classId={c.id}
|
||||||
>
|
assignments={recentAssignments}
|
||||||
{isWorking ? "Deleting..." : "Delete"}
|
compact
|
||||||
</AlertDialogAction>
|
className="h-full w-full"
|
||||||
</AlertDialogFooter>
|
/>
|
||||||
</AlertDialogContent>
|
</div>
|
||||||
</AlertDialog>
|
</div>
|
||||||
</Card>
|
|
||||||
|
{/* Right: Weekly Schedule */}
|
||||||
|
<div className="flex-1 flex flex-col gap-4 border-l border-dashed border-muted-foreground/20 pl-6 min-w-0">
|
||||||
|
<div className="h-[170px] w-full overflow-y-auto pr-1">
|
||||||
|
<ClassScheduleGrid schedule={c.schedule ?? []} compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Plus, X } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -29,7 +29,7 @@ import type { TeacherClass } from "../types"
|
|||||||
import { createClassScheduleItemAction } from "../actions"
|
import { createClassScheduleItemAction } from "../actions"
|
||||||
|
|
||||||
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -64,33 +64,29 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedClass = classes.find((c) => c.id === classId)
|
||||||
|
const title = selectedClass ? selectedClass.name : "All Classes"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="relative flex items-center justify-between py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
|
||||||
<SelectTrigger className="w-[240px]">
|
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
|
||||||
<SelectValue placeholder="Class" />
|
<SelectValue placeholder="All Classes" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Classes</SelectItem>
|
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<SelectItem key={c.id} value={c.id}>
|
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||||
{c.name}
|
{c.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{classId !== "all" && (
|
<div className="absolute left-1/2 -translate-x-1/2 text-sm font-medium text-muted-foreground">
|
||||||
<Button
|
{title}
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setClassId(null)}
|
|
||||||
className="h-8 px-2 lg:px-3"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
<X className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -101,9 +97,13 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="gap-2" disabled={classes.length === 0}>
|
<Button
|
||||||
<Plus className="size-4" />
|
className="h-8 gap-1.5 text-xs px-3 shadow-none border-transparent bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
Add item
|
disabled={classes.length === 0}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add Event
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
|
|||||||
@@ -151,88 +151,145 @@ export function ScheduleView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPositionStyle = (startTime: string, endTime: string) => {
|
||||||
|
// Range 8:00 (480 min) -> 18:00 (1080 min)
|
||||||
|
// Total duration: 600 min
|
||||||
|
const startParts = startTime.split(':').map(Number)
|
||||||
|
const endParts = endTime.split(':').map(Number)
|
||||||
|
|
||||||
|
const startMinutes = startParts[0] * 60 + startParts[1]
|
||||||
|
const endMinutes = endParts[0] * 60 + endParts[1]
|
||||||
|
|
||||||
|
const minTime = 8 * 60
|
||||||
|
const maxTime = 18 * 60
|
||||||
|
const totalDuration = maxTime - minTime
|
||||||
|
|
||||||
|
// Calculate percentage positions
|
||||||
|
const top = Math.max(0, ((startMinutes - minTime) / totalDuration) * 100)
|
||||||
|
const height = Math.min(100 - top, ((endMinutes - startMinutes) / totalDuration) * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${top}%`,
|
||||||
|
height: `${height}%`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
|
||||||
|
|
||||||
|
// Predefined colors for different subjects to add visual variety
|
||||||
|
const getSubjectColor = (subject: string) => {
|
||||||
|
const s = subject.toLowerCase()
|
||||||
|
if (s.includes('math')) return 'bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20'
|
||||||
|
if (s.includes('physics') || s.includes('science')) return 'bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20'
|
||||||
|
if (s.includes('english') || s.includes('lit')) return 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20'
|
||||||
|
if (s.includes('history') || s.includes('geo')) return 'bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20'
|
||||||
|
if (s.includes('art') || s.includes('music')) return 'bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20'
|
||||||
|
if (s.includes('sport') || s.includes('pe')) return 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20'
|
||||||
|
return 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
<div className="h-[600px] flex flex-col">
|
||||||
{WEEKDAYS.map((d) => {
|
<div className="flex h-full">
|
||||||
const items = byDay.get(d.key) ?? []
|
{/* Time Axis */}
|
||||||
return (
|
<div className="w-14 flex-shrink-0 flex flex-col">
|
||||||
<Card key={d.key} className="shadow-none">
|
<div className="h-10" /> {/* Header spacer */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<div className="flex-1 relative">
|
||||||
<div className="flex items-center gap-2">
|
{HOURS.map((h, i) => (
|
||||||
<CardTitle className="text-base">{d.label}</CardTitle>
|
<div
|
||||||
<Badge variant="secondary" className={cn(items.length === 0 && "opacity-60")}>
|
key={h}
|
||||||
{items.length} items
|
className="absolute w-full text-right pr-3 text-[11px] text-muted-foreground/60 font-medium -translate-y-1/2 font-mono"
|
||||||
</Badge>
|
style={{ top: `${(i / 10) * 100}%` }}
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={classes.length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
setCreateWeekday(d.key)
|
|
||||||
setCreateOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
{h}:00
|
||||||
</Button>
|
</div>
|
||||||
</CardHeader>
|
))}
|
||||||
<CardContent>
|
</div>
|
||||||
{items.length === 0 ? (
|
</div>
|
||||||
<div className="text-muted-foreground text-sm">No classes scheduled.</div>
|
|
||||||
) : (
|
{/* Days Columns */}
|
||||||
<div className="space-y-4">
|
<div className="flex-1 grid grid-cols-5">
|
||||||
{items.map((item) => (
|
{WEEKDAYS.slice(0, 5).map((d) => (
|
||||||
<div key={item.id} className="space-y-1 border-b pb-4 last:border-0 last:pb-0">
|
<div key={d.key} className="flex flex-col h-full min-w-0">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-center justify-center py-2 h-10 group">
|
||||||
<div className="min-w-0 flex-1">
|
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
|
||||||
<div className="font-medium leading-none">{item.course}</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-full mx-1">
|
||||||
|
{/* Subtle vertical guideline */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-px bg-border/30" />
|
||||||
|
|
||||||
|
{(byDay.get(d.key) ?? []).map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="group absolute w-full px-1 z-10"
|
||||||
|
style={getPositionStyle(item.startTime, item.endTime)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"rounded-md p-2 text-xs text-left relative transition-all cursor-default leading-tight h-full border overflow-hidden shadow-sm hover:shadow-md flex flex-col justify-center",
|
||||||
|
getSubjectColor(item.course)
|
||||||
|
)}>
|
||||||
|
<div className="flex justify-between items-start gap-1">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||||
|
<div className="font-bold truncate text-[11px] leading-none tracking-tight">{item.course}</div>
|
||||||
|
<div className="opacity-80 scale-95 origin-left whitespace-nowrap tabular-nums text-[10px] font-medium leading-none font-mono">
|
||||||
|
{item.startTime} - {item.endTime}
|
||||||
|
</div>
|
||||||
|
<div className="opacity-70 scale-95 origin-left truncate text-[9px] leading-none mt-0.5 font-medium">
|
||||||
|
{classNameById.get(item.classId)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge>
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-32">
|
||||||
<DropdownMenuItem onClick={() => setEditItem(item)}>
|
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
|
||||||
<Pencil className="mr-2 size-4" />
|
<Pencil className="mr-2 h-3 w-3" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
onClick={() => setDeleteItem(item)}
|
onClick={() => setDeleteItem(item)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash2 className="mr-2 h-3 w-3" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex items-center gap-3 text-sm">
|
|
||||||
<span className="inline-flex items-center gap-1 tabular-nums">
|
|
||||||
<Clock className="h-3.5 w-3.5" />
|
|
||||||
{item.startTime}–{item.endTime}
|
|
||||||
</span>
|
|
||||||
{item.location ? (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<MapPin className="h-3.5 w-3.5" />
|
|
||||||
{item.location}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add Button Overlay - Only visible on hover of the column */}
|
||||||
|
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
|
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
|
||||||
|
disabled={classes.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setCreateWeekday(d.key)
|
||||||
|
setCreateOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
))}
|
||||||
)
|
</div>
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
@@ -311,7 +368,7 @@ export function ScheduleView({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(editItem)}
|
open={!!editItem}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => {
|
||||||
if (isWorking) return
|
if (isWorking) return
|
||||||
if (!v) setEditItem(null)
|
if (!v) setEditItem(null)
|
||||||
@@ -320,116 +377,118 @@ export function ScheduleView({
|
|||||||
<DialogContent className="sm:max-w-[560px]">
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit schedule item</DialogTitle>
|
<DialogTitle>Edit schedule item</DialogTitle>
|
||||||
<DialogDescription>Update this schedule entry.</DialogDescription>
|
<DialogDescription>Update class schedule entry.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{editItem ? (
|
<form action={handleUpdate}>
|
||||||
<form action={handleUpdate}>
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<Label className="text-right">Class</Label>
|
||||||
<Label className="text-right">Class</Label>
|
<div className="col-span-3">
|
||||||
<div className="col-span-3">
|
<Select value={editClassId} onValueChange={setEditClassId}>
|
||||||
<Select value={editClassId} onValueChange={setEditClassId}>
|
<SelectTrigger>
|
||||||
<SelectTrigger>
|
<SelectValue placeholder="Select a class" />
|
||||||
<SelectValue placeholder="Select a class" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
{classes.map((c) => (
|
||||||
{classes.map((c) => (
|
<SelectItem key={c.id} value={c.id}>
|
||||||
<SelectItem key={c.id} value={c.id}>
|
{c.name}
|
||||||
{c.name}
|
</SelectItem>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<input type="hidden" name="classId" value={editClassId} />
|
||||||
<input type="hidden" name="classId" value={editClassId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Weekday</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select weekday" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">Mon</SelectItem>
|
|
||||||
<SelectItem value="2">Tue</SelectItem>
|
|
||||||
<SelectItem value="3">Wed</SelectItem>
|
|
||||||
<SelectItem value="4">Thu</SelectItem>
|
|
||||||
<SelectItem value="5">Fri</SelectItem>
|
|
||||||
<SelectItem value="6">Sat</SelectItem>
|
|
||||||
<SelectItem value="7">Sun</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<input type="hidden" name="weekday" value={editWeekday} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-startTime" className="text-right">
|
|
||||||
Start
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-startTime"
|
|
||||||
name="startTime"
|
|
||||||
type="time"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem.startTime}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-endTime" className="text-right">
|
|
||||||
End
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-endTime"
|
|
||||||
name="endTime"
|
|
||||||
type="time"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem.endTime}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-course" className="text-right">
|
|
||||||
Course
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-course"
|
|
||||||
name="course"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem.course}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="edit-location" className="text-right">
|
|
||||||
Location
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-location"
|
|
||||||
name="location"
|
|
||||||
className="col-span-3"
|
|
||||||
defaultValue={editItem.location ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isWorking}>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
{isWorking ? "Saving..." : "Save"}
|
<Label htmlFor="edit-weekday" className="text-right">
|
||||||
</Button>
|
Weekday
|
||||||
</DialogFooter>
|
</Label>
|
||||||
</form>
|
<div className="col-span-3">
|
||||||
) : null}
|
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select weekday" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">Mon</SelectItem>
|
||||||
|
<SelectItem value="2">Tue</SelectItem>
|
||||||
|
<SelectItem value="3">Wed</SelectItem>
|
||||||
|
<SelectItem value="4">Thu</SelectItem>
|
||||||
|
<SelectItem value="5">Fri</SelectItem>
|
||||||
|
<SelectItem value="6">Sat</SelectItem>
|
||||||
|
<SelectItem value="7">Sun</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="weekday" value={editWeekday} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-startTime" className="text-right">
|
||||||
|
Start
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-startTime"
|
||||||
|
name="startTime"
|
||||||
|
type="time"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.startTime}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-endTime" className="text-right">
|
||||||
|
End
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-endTime"
|
||||||
|
name="endTime"
|
||||||
|
type="time"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.endTime}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-course" className="text-right">
|
||||||
|
Course
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-course"
|
||||||
|
name="course"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.course}
|
||||||
|
placeholder="e.g. Math"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-location" className="text-right">
|
||||||
|
Location
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-location"
|
||||||
|
name="location"
|
||||||
|
className="col-span-3"
|
||||||
|
defaultValue={editItem?.location ?? ""}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isWorking || !editClassId}>
|
||||||
|
{isWorking ? "Saving..." : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={Boolean(deleteItem)}
|
open={!!deleteItem}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => {
|
||||||
if (isWorking) return
|
if (isWorking) return
|
||||||
if (!v) setDeleteItem(null)
|
if (!v) setDeleteItem(null)
|
||||||
@@ -437,22 +496,20 @@ export function ScheduleView({
|
|||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle>
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{deleteItem ? (
|
This will permanently delete this schedule item.
|
||||||
<>
|
|
||||||
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
|
|
||||||
({deleteItem.startTime}–{deleteItem.endTime}).
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
onClick={(e) => {
|
||||||
onClick={handleDelete}
|
e.preventDefault()
|
||||||
|
handleDelete()
|
||||||
|
}}
|
||||||
disabled={isWorking}
|
disabled={isWorking}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{isWorking ? "Deleting..." : "Delete"}
|
{isWorking ? "Deleting..." : "Delete"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
@@ -461,5 +518,4 @@ export function ScheduleView({
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,18 +3,19 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Search, UserPlus, X } from "lucide-react"
|
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Select,
|
DropdownMenu,
|
||||||
SelectContent,
|
DropdownMenuContent,
|
||||||
SelectItem,
|
DropdownMenuItem,
|
||||||
SelectTrigger,
|
DropdownMenuTrigger,
|
||||||
SelectValue,
|
DropdownMenuSeparator,
|
||||||
} from "@/shared/components/ui/select"
|
DropdownMenuLabel,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -24,25 +25,35 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
import type { TeacherClass } from "../types"
|
import type { TeacherClass } from "../types"
|
||||||
import { enrollStudentByEmailAction } from "../actions"
|
import { enrollStudentByEmailAction } from "../actions"
|
||||||
|
|
||||||
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) {
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 }))
|
||||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault(defaultClassId || "all").withOptions({ shallow: false }))
|
||||||
|
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
|
const effectiveClassId = classId === "all" && defaultClassId ? defaultClassId : classId
|
||||||
const [enrollClassId, setEnrollClassId] = useState(defaultClassId)
|
|
||||||
|
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setEnrollClassId(defaultClassId)
|
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||||
}, [open, defaultClassId])
|
}, [open, effectiveClassId, classes])
|
||||||
|
|
||||||
const handleEnroll = async (formData: FormData) => {
|
const handleEnroll = async (formData: FormData) => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
@@ -62,46 +73,84 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedClass = classes.find(c => c.id === classId)
|
||||||
|
const classLabel = classId === "all" ? "All Classes" : (selectedClass?.name || "Unknown Class")
|
||||||
|
|
||||||
|
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
|
||||||
|
|
||||||
|
const hasFilters = search || classId !== "all" || status !== "all"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1 md:max-w-sm">
|
{/* Search - Minimal */}
|
||||||
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
|
<div className="relative group">
|
||||||
|
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search students..."
|
placeholder="Search students..."
|
||||||
className="pl-8"
|
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value || null)}
|
onChange={(e) => setSearch(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
<SelectValue placeholder="Class" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Classes</SelectItem>
|
|
||||||
{classes.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{(search || classId !== "all") && (
|
{/* Class Filter - Compact */}
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => {
|
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||||
setSearch(null)
|
<span className="truncate max-w-[120px]">{classLabel}</span>
|
||||||
setClassId(null)
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
}}
|
</Button>
|
||||||
className="h-8 px-2 lg:px-3"
|
</DropdownMenuTrigger>
|
||||||
>
|
<DropdownMenuContent align="start" className="w-[200px]">
|
||||||
Reset
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Class</DropdownMenuLabel>
|
||||||
<X className="ml-2 h-4 w-4" />
|
<DropdownMenuItem
|
||||||
</Button>
|
onClick={() => setClassId("all")}
|
||||||
)}
|
className="text-xs flex items-center justify-between"
|
||||||
|
>
|
||||||
|
All Classes
|
||||||
|
{classId === "all" && <Check className="h-3 w-3" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{classes.map((c) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setClassId(c.id)}
|
||||||
|
className="text-xs flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
{classId === c.id && <Check className="h-3 w-3" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Status Filter - Compact */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||||
|
{statusLabel}
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
|
||||||
|
All Status
|
||||||
|
{status === "all" && <Check className="h-3 w-3" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
|
||||||
|
Active
|
||||||
|
{status === "active" && <Check className="h-3 w-3" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
|
||||||
|
Inactive
|
||||||
|
{status === "inactive" && <Check className="h-3 w-3" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -112,8 +161,8 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="gap-2" disabled={classes.length === 0}>
|
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
|
||||||
<UserPlus className="size-4" />
|
<UserPlus className="size-3.5" />
|
||||||
Add student
|
Add student
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
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 { cn } from "@/shared/lib/utils"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -25,14 +27,6 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/shared/components/ui/table"
|
|
||||||
import type { ClassStudent } from "../types"
|
import type { ClassStudent } from "../types"
|
||||||
import { setStudentEnrollmentStatusAction } from "../actions"
|
import { setStudentEnrollmentStatusAction } from "../actions"
|
||||||
|
|
||||||
@@ -42,7 +36,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
||||||
|
|
||||||
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
|
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
|
||||||
const key = `${student.classId}:${student.id}:${status}`
|
const key = `${student.classId}:${student.id}`
|
||||||
setWorkingKey(key)
|
setWorkingKey(key)
|
||||||
try {
|
try {
|
||||||
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
|
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
|
||||||
@@ -50,73 +44,132 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
toast.success(res.message)
|
toast.success(res.message)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to update student")
|
toast.error(res.message)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to update student")
|
toast.error("Failed to update status")
|
||||||
} finally {
|
} finally {
|
||||||
setWorkingKey(null)
|
setWorkingKey(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table>
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<TableHeader>
|
{students.map((s) => (
|
||||||
<TableRow className="bg-muted/50">
|
<Card key={`${s.classId}:${s.id}`} className="overflow-hidden">
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
|
<CardHeader className="flex flex-row items-center gap-4 space-y-0 p-4 pb-2">
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Email</TableHead>
|
<div className="relative">
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
|
<Avatar className="h-10 w-10 border">
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
<AvatarImage src={s.image || undefined} alt={s.name} />
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Actions</TableHead>
|
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
||||||
</TableRow>
|
</Avatar>
|
||||||
</TableHeader>
|
<span className={cn(
|
||||||
<TableBody>
|
"absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
|
||||||
{students.map((s) => (
|
s.status === "active" ? "bg-emerald-500" : "bg-muted-foreground"
|
||||||
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-12", s.status !== "active" && "opacity-70")}>
|
)} />
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
</div>
|
||||||
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
<TableCell>{s.className}</TableCell>
|
<div className="flex items-start justify-between">
|
||||||
<TableCell>
|
<div className="flex flex-col overflow-hidden mr-2">
|
||||||
<Badge variant={s.status === "active" ? "secondary" : "outline"}>
|
<span className="truncate font-semibold text-sm">{s.name}</span>
|
||||||
{s.status === "active" ? "Active" : "Inactive"}
|
<span className="truncate text-xs text-muted-foreground">{s.email}</span>
|
||||||
</Badge>
|
</div>
|
||||||
</TableCell>
|
<div className="flex flex-col items-end gap-0.5 text-xs text-muted-foreground shrink-0">
|
||||||
<TableCell className="text-right">
|
<span className="text-[10px] font-medium text-foreground/80">
|
||||||
<DropdownMenu>
|
{s.className}
|
||||||
<DropdownMenuTrigger asChild>
|
</span>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
|
<span className="text-[10px]">
|
||||||
<MoreHorizontal className="size-4" />
|
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
|
||||||
</Button>
|
day: "2-digit",
|
||||||
</DropdownMenuTrigger>
|
month: "2-digit",
|
||||||
<DropdownMenuContent align="end">
|
year: "2-digit"
|
||||||
{s.status !== "active" ? (
|
})}
|
||||||
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
</span>
|
||||||
<UserCheck className="mr-2 size-4" />
|
</div>
|
||||||
Set active
|
</div>
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
) : (
|
</CardHeader>
|
||||||
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
<CardContent className="p-4 pt-0">
|
||||||
<UserX className="mr-2 size-4" />
|
{s.subjectScores && Object.keys(s.subjectScores).length > 0 ? (
|
||||||
Set inactive
|
<div className="flex flex-col gap-2">
|
||||||
</DropdownMenuItem>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Object.entries(s.subjectScores).slice(0, 4).map(([subject, score]) => (
|
||||||
|
<div key={subject} className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs border border-muted/50">
|
||||||
|
<span className="font-medium text-muted-foreground/80">{subject}</span>
|
||||||
|
{score !== null ? (
|
||||||
|
<span className={cn(
|
||||||
|
"font-bold",
|
||||||
|
score >= 90 ? "text-emerald-600" :
|
||||||
|
score >= 80 ? "text-primary" :
|
||||||
|
score >= 60 ? "text-yellow-600" : "text-destructive"
|
||||||
|
)}>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(s.subjectScores).length > 4 && (
|
||||||
|
<div className="flex items-center justify-center rounded-md bg-muted/50 px-2 py-1 text-xs text-muted-foreground font-medium border border-muted/50">
|
||||||
|
+{Object.keys(s.subjectScores).length - 4}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
</div>
|
||||||
<DropdownMenuItem
|
</div>
|
||||||
onClick={() => setRemoveTarget(s)}
|
) : (
|
||||||
className="text-destructive focus:text-destructive"
|
<div className="flex items-center justify-center h-[32px] rounded-md bg-muted/20 border border-dashed border-muted">
|
||||||
disabled={s.status === "inactive" || workingKey !== null}
|
<span className="text-xs text-muted-foreground/50 italic">No recent scores</span>
|
||||||
>
|
</div>
|
||||||
<UserX className="mr-2 size-4" />
|
)}
|
||||||
Remove from class
|
</CardContent>
|
||||||
|
<CardFooter className="flex items-center justify-between border-t bg-muted/50 p-2">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" asChild>
|
||||||
|
<a href={`mailto:${s.email}`}>Email</a>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={workingKey !== null}>
|
||||||
|
<MoreHorizontal className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{s.status !== "active" ? (
|
||||||
|
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
||||||
|
<UserCheck className="mr-2 size-4" />
|
||||||
|
Set active
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
) : (
|
||||||
</DropdownMenu>
|
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
||||||
</TableCell>
|
<UserX className="mr-2 size-4" />
|
||||||
</TableRow>
|
Set inactive
|
||||||
))}
|
</DropdownMenuItem>
|
||||||
</TableBody>
|
)}
|
||||||
</Table>
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setRemoveTarget(s)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
disabled={s.status === "inactive" || workingKey !== null}
|
||||||
|
>
|
||||||
|
<UserX className="mr-2 size-4" />
|
||||||
|
Remove from class
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={Boolean(removeTarget)}
|
open={Boolean(removeTarget)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { randomInt } from "node:crypto"
|
import { randomInt } from "node:crypto"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
homeworkAssignments,
|
homeworkAssignments,
|
||||||
homeworkSubmissions,
|
homeworkSubmissions,
|
||||||
schools,
|
schools,
|
||||||
|
subjects,
|
||||||
|
exams,
|
||||||
users,
|
users,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||||
@@ -122,6 +124,20 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
|||||||
|
|
||||||
const rows = await (async () => {
|
const rows = await (async () => {
|
||||||
try {
|
try {
|
||||||
|
const ownedIds = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.teacherId, teacherId))
|
||||||
|
|
||||||
|
const enrolledIds = await db
|
||||||
|
.select({ id: classEnrollments.classId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
|
||||||
|
|
||||||
|
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
|
||||||
|
|
||||||
|
if (allIds.length === 0) return []
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select({
|
||||||
id: classes.id,
|
id: classes.id,
|
||||||
@@ -135,26 +151,11 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
|||||||
})
|
})
|
||||||
.from(classes)
|
.from(classes)
|
||||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||||
.where(eq(classes.teacherId, teacherId))
|
.where(inArray(classes.id, allIds))
|
||||||
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
||||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
} catch {
|
} catch {
|
||||||
return await db
|
return []
|
||||||
.select({
|
|
||||||
id: classes.id,
|
|
||||||
schoolName: sql<string | null>`NULL`.as("schoolName"),
|
|
||||||
name: classes.name,
|
|
||||||
grade: classes.grade,
|
|
||||||
homeroom: classes.homeroom,
|
|
||||||
room: classes.room,
|
|
||||||
invitationCode: sql<string | null>`NULL`.as("invitationCode"),
|
|
||||||
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
|
||||||
})
|
|
||||||
.from(classes)
|
|
||||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
|
||||||
.where(eq(classes.teacherId, teacherId))
|
|
||||||
.groupBy(classes.id, classes.name, classes.grade, classes.homeroom, classes.room)
|
|
||||||
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -170,7 +171,35 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
list.sort(compareClassLike)
|
list.sort(compareClassLike)
|
||||||
return list
|
|
||||||
|
// Fetch recent assignments for trends and schedule
|
||||||
|
const listWithTrends = await Promise.all(
|
||||||
|
list.map(async (c) => {
|
||||||
|
const [insights, schedule] = await Promise.all([
|
||||||
|
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
|
||||||
|
getClassSchedule({ classId: c.id, teacherId }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const recentAssignments = insights
|
||||||
|
? insights.assignments.map((a) => ({
|
||||||
|
id: a.assignmentId,
|
||||||
|
title: a.title,
|
||||||
|
status: a.status,
|
||||||
|
subject: a.subject,
|
||||||
|
isActive: a.isActive,
|
||||||
|
isOverdue: a.isOverdue,
|
||||||
|
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
||||||
|
submittedCount: a.submittedCount,
|
||||||
|
targetCount: a.targetCount,
|
||||||
|
avgScore: a.scoreStats.avg,
|
||||||
|
medianScore: a.scoreStats.median,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
return { ...c, recentAssignments, schedule }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return listWithTrends
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
||||||
@@ -331,6 +360,143 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
|
|||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getGradeManagedClasses = cache(async (userId: string): Promise<AdminClassListItem[]> => {
|
||||||
|
const managedGradeIds = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||||
|
|
||||||
|
if (managedGradeIds.length === 0) return []
|
||||||
|
|
||||||
|
const gradeIds = managedGradeIds.map((g) => g.id)
|
||||||
|
|
||||||
|
const [rows, subjectRows] = await Promise.all([
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
schoolName: classes.schoolName,
|
||||||
|
schoolId: classes.schoolId,
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
gradeId: classes.gradeId,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
invitationCode: classes.invitationCode,
|
||||||
|
teacherId: users.id,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||||
|
createdAt: classes.createdAt,
|
||||||
|
updatedAt: classes.updatedAt,
|
||||||
|
})
|
||||||
|
.from(classes)
|
||||||
|
.innerJoin(users, eq(users.id, classes.teacherId))
|
||||||
|
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||||
|
.where(inArray(classes.gradeId, gradeIds))
|
||||||
|
.groupBy(
|
||||||
|
classes.id,
|
||||||
|
classes.schoolName,
|
||||||
|
classes.schoolId,
|
||||||
|
classes.name,
|
||||||
|
classes.grade,
|
||||||
|
classes.gradeId,
|
||||||
|
classes.homeroom,
|
||||||
|
classes.room,
|
||||||
|
classes.invitationCode,
|
||||||
|
users.id,
|
||||||
|
users.name,
|
||||||
|
users.email,
|
||||||
|
classes.createdAt,
|
||||||
|
classes.updatedAt
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(classes.schoolName),
|
||||||
|
asc(classes.grade),
|
||||||
|
asc(classes.name),
|
||||||
|
asc(classes.homeroom),
|
||||||
|
asc(classes.room)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
classId: classSubjectTeachers.classId,
|
||||||
|
subject: classSubjectTeachers.subject,
|
||||||
|
teacherId: users.id,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||||
|
.where(inArray(classes.gradeId, gradeIds))
|
||||||
|
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||||
|
for (const r of subjectRows) {
|
||||||
|
const subject = r.subject as ClassSubject
|
||||||
|
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
|
||||||
|
const teacher =
|
||||||
|
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||||
|
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||||
|
: null
|
||||||
|
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
|
||||||
|
bySubject.set(subject, teacher)
|
||||||
|
subjectsByClassId.set(r.classId, bySubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = rows.map((r) => {
|
||||||
|
const bySubject = subjectsByClassId.get(r.id)
|
||||||
|
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||||
|
subject,
|
||||||
|
teacher: bySubject?.get(subject) ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
schoolName: r.schoolName,
|
||||||
|
schoolId: r.schoolId,
|
||||||
|
name: r.name,
|
||||||
|
grade: r.grade,
|
||||||
|
gradeId: r.gradeId,
|
||||||
|
homeroom: r.homeroom,
|
||||||
|
room: r.room,
|
||||||
|
invitationCode: r.invitationCode ?? null,
|
||||||
|
teacher: {
|
||||||
|
id: r.teacherId,
|
||||||
|
name: r.teacherName ?? "Unnamed",
|
||||||
|
email: r.teacherEmail,
|
||||||
|
},
|
||||||
|
subjectTeachers,
|
||||||
|
studentCount: Number(r.studentCount ?? 0),
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
list.sort(compareClassLike)
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getManagedGrades = cache(async (userId: string) => {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: grades.id,
|
||||||
|
name: grades.name,
|
||||||
|
schoolId: grades.schoolId,
|
||||||
|
schoolName: schools.name,
|
||||||
|
})
|
||||||
|
.from(grades)
|
||||||
|
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||||||
|
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||||
|
.orderBy(asc(schools.name), asc(grades.name))
|
||||||
|
})
|
||||||
|
|
||||||
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
|
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
|
||||||
const id = studentId.trim()
|
const id = studentId.trim()
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
@@ -345,9 +511,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
|||||||
grade: classes.grade,
|
grade: classes.grade,
|
||||||
homeroom: classes.homeroom,
|
homeroom: classes.homeroom,
|
||||||
room: classes.room,
|
room: classes.room,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
})
|
})
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -359,9 +528,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
|||||||
grade: classes.grade,
|
grade: classes.grade,
|
||||||
homeroom: classes.homeroom,
|
homeroom: classes.homeroom,
|
||||||
room: classes.room,
|
room: classes.room,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
})
|
})
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||||
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
}
|
}
|
||||||
@@ -374,6 +546,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
|||||||
grade: r.grade,
|
grade: r.grade,
|
||||||
homeroom: r.homeroom,
|
homeroom: r.homeroom,
|
||||||
room: r.room,
|
room: r.room,
|
||||||
|
teacherName: r.teacherName,
|
||||||
|
teacherEmail: r.teacherEmail,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
list.sort(compareClassLike)
|
list.sort(compareClassLike)
|
||||||
@@ -414,12 +588,13 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const getClassStudents = cache(
|
export const getClassStudents = cache(
|
||||||
async (params?: { classId?: string; q?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
||||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
||||||
if (!teacherId) return []
|
if (!teacherId) return []
|
||||||
|
|
||||||
const classId = params?.classId?.trim()
|
const classId = params?.classId?.trim()
|
||||||
const q = params?.q?.trim().toLowerCase()
|
const q = params?.q?.trim().toLowerCase()
|
||||||
|
const status = params?.status?.trim().toLowerCase()
|
||||||
|
|
||||||
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
||||||
|
|
||||||
@@ -427,6 +602,10 @@ export const getClassStudents = cache(
|
|||||||
conditions.push(eq(classes.id, classId))
|
conditions.push(eq(classes.id, classId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "active" || status === "inactive") {
|
||||||
|
conditions.push(eq(classEnrollments.status, status))
|
||||||
|
}
|
||||||
|
|
||||||
if (q && q.length > 0) {
|
if (q && q.length > 0) {
|
||||||
const needle = `%${q}%`
|
const needle = `%${q}%`
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -439,9 +618,12 @@ export const getClassStudents = cache(
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
name: users.name,
|
name: users.name,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
|
image: users.image,
|
||||||
|
gender: users.gender,
|
||||||
classId: classes.id,
|
classId: classes.id,
|
||||||
className: classes.name,
|
className: classes.name,
|
||||||
status: classEnrollments.status,
|
status: classEnrollments.status,
|
||||||
|
joinedAt: classEnrollments.createdAt,
|
||||||
})
|
})
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
@@ -453,9 +635,12 @@ export const getClassStudents = cache(
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name ?? "Unnamed",
|
name: r.name ?? "Unnamed",
|
||||||
email: r.email,
|
email: r.email,
|
||||||
|
image: r.image,
|
||||||
|
gender: r.gender,
|
||||||
classId: r.classId,
|
classId: r.classId,
|
||||||
className: r.className,
|
className: r.className,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
|
joinedAt: r.joinedAt,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -597,11 +782,22 @@ export const getClassHomeworkInsights = cache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||||
const assignments = await db.query.homeworkAssignments.findMany({
|
const assignments = await db
|
||||||
where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)),
|
.select({
|
||||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
id: homeworkAssignments.id,
|
||||||
limit,
|
title: homeworkAssignments.title,
|
||||||
})
|
status: homeworkAssignments.status,
|
||||||
|
createdAt: homeworkAssignments.createdAt,
|
||||||
|
dueAt: homeworkAssignments.dueAt,
|
||||||
|
subjectId: exams.subjectId,
|
||||||
|
subjectName: subjects.name
|
||||||
|
})
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||||
|
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||||
|
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
|
||||||
|
.orderBy(desc(homeworkAssignments.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
const usedAssignmentIds = assignments.map((a) => a.id)
|
const usedAssignmentIds = assignments.map((a) => a.id)
|
||||||
if (usedAssignmentIds.length === 0) {
|
if (usedAssignmentIds.length === 0) {
|
||||||
@@ -690,6 +886,7 @@ export const getClassHomeworkInsights = cache(
|
|||||||
assignmentId: a.id,
|
assignmentId: a.id,
|
||||||
title: a.title,
|
title: a.title,
|
||||||
status: (a.status as string) ?? "draft",
|
status: (a.status as string) ?? "draft",
|
||||||
|
subject: a.subjectName,
|
||||||
createdAt: a.createdAt.toISOString(),
|
createdAt: a.createdAt.toISOString(),
|
||||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||||
isActive: dueMs === null || dueMs >= nowMs,
|
isActive: dueMs === null || dueMs >= nowMs,
|
||||||
@@ -1539,3 +1736,104 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise<void>
|
|||||||
|
|
||||||
await db.delete(classSchedule).where(eq(classSchedule.id, id))
|
await db.delete(classSchedule).where(eq(classSchedule.id, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getStudentsSubjectScores = cache(
|
||||||
|
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
|
||||||
|
if (studentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 1. Find assignments targeted at these students
|
||||||
|
const assignmentTargets = await db
|
||||||
|
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||||
|
.from(homeworkAssignmentTargets)
|
||||||
|
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||||
|
|
||||||
|
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
|
||||||
|
if (assignmentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 2. Get assignment details including subject from linked exam
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
id: homeworkAssignments.id,
|
||||||
|
createdAt: homeworkAssignments.createdAt,
|
||||||
|
subjectId: exams.subjectId,
|
||||||
|
subjectName: subjects.name
|
||||||
|
})
|
||||||
|
.from(homeworkAssignments)
|
||||||
|
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||||
|
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||||
|
.where(and(
|
||||||
|
inArray(homeworkAssignments.id, assignmentIds),
|
||||||
|
eq(homeworkAssignments.status, "published")
|
||||||
|
))
|
||||||
|
.orderBy(desc(homeworkAssignments.createdAt))
|
||||||
|
|
||||||
|
// 3. Filter subjects (exclude PE, Music, Art)
|
||||||
|
const excludeSubjects = ["体育", "音乐", "美术"]
|
||||||
|
const subjectAssignments = new Map<string, string>() // subject -> assignmentId (latest)
|
||||||
|
|
||||||
|
for (const a of assignments) {
|
||||||
|
if (!a.subjectName) continue
|
||||||
|
if (excludeSubjects.includes(a.subjectName)) continue
|
||||||
|
if (!subjectAssignments.has(a.subjectName)) {
|
||||||
|
subjectAssignments.set(a.subjectName, a.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAssignmentIds = Array.from(subjectAssignments.values())
|
||||||
|
if (targetAssignmentIds.length === 0) return new Map()
|
||||||
|
|
||||||
|
// 4. Get submissions for these assignments
|
||||||
|
const submissions = await db
|
||||||
|
.select({
|
||||||
|
studentId: homeworkSubmissions.studentId,
|
||||||
|
assignmentId: homeworkSubmissions.assignmentId,
|
||||||
|
score: homeworkSubmissions.score,
|
||||||
|
createdAt: homeworkSubmissions.createdAt,
|
||||||
|
})
|
||||||
|
.from(homeworkSubmissions)
|
||||||
|
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
|
||||||
|
.orderBy(desc(homeworkSubmissions.createdAt))
|
||||||
|
|
||||||
|
// 5. Map back to subject scores per student
|
||||||
|
const studentScores = new Map<string, Record<string, number | null>>()
|
||||||
|
|
||||||
|
// Create reverse map for assignment -> subject
|
||||||
|
const assignmentSubjectMap = new Map<string, string>()
|
||||||
|
for (const [subject, id] of subjectAssignments.entries()) {
|
||||||
|
assignmentSubjectMap.set(id, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of submissions) {
|
||||||
|
const subject = assignmentSubjectMap.get(s.assignmentId)
|
||||||
|
if (!subject) continue
|
||||||
|
|
||||||
|
if (!studentScores.has(s.studentId)) {
|
||||||
|
studentScores.set(s.studentId, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores = studentScores.get(s.studentId)!
|
||||||
|
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
|
||||||
|
if (scores[subject] === undefined) {
|
||||||
|
scores[subject] = s.score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return studentScores
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getClassStudentSubjectScoresV2 = cache(
|
||||||
|
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
|
||||||
|
// 1. Get student IDs in the class
|
||||||
|
const enrollments = await db
|
||||||
|
.select({ studentId: classEnrollments.studentId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.where(and(
|
||||||
|
eq(classEnrollments.classId, classId),
|
||||||
|
eq(classEnrollments.status, "active")
|
||||||
|
))
|
||||||
|
|
||||||
|
const studentIds = enrollments.map(e => e.studentId)
|
||||||
|
return getStudentsSubjectScores(studentIds)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ export type TeacherClass = {
|
|||||||
room?: string | null
|
room?: string | null
|
||||||
invitationCode?: string | null
|
invitationCode?: string | null
|
||||||
studentCount: number
|
studentCount: number
|
||||||
|
recentAssignments?: AssignmentSummary[]
|
||||||
|
schedule?: ClassScheduleItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignmentSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
subject?: string | null
|
||||||
|
isActive: boolean
|
||||||
|
isOverdue: boolean
|
||||||
|
dueAt: Date | null
|
||||||
|
submittedCount: number
|
||||||
|
targetCount: number
|
||||||
|
avgScore: number | null
|
||||||
|
medianScore: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TeacherOption = {
|
export type TeacherOption = {
|
||||||
@@ -65,9 +81,13 @@ export type ClassStudent = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
|
image?: string | null
|
||||||
|
gender?: string | null
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
status: "active" | "inactive"
|
status: "active" | "inactive"
|
||||||
|
joinedAt: Date
|
||||||
|
subjectScores?: Record<string, number | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassScheduleItem = {
|
export type ClassScheduleItem = {
|
||||||
@@ -80,26 +100,6 @@ export type ClassScheduleItem = {
|
|||||||
location?: string | null
|
location?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudentEnrolledClass = {
|
|
||||||
id: string
|
|
||||||
schoolName?: string | null
|
|
||||||
name: string
|
|
||||||
grade: string
|
|
||||||
homeroom?: string | null
|
|
||||||
room?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StudentScheduleItem = {
|
|
||||||
id: string
|
|
||||||
classId: string
|
|
||||||
className: string
|
|
||||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
|
||||||
startTime: string
|
|
||||||
endTime: string
|
|
||||||
course: string
|
|
||||||
location?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateClassScheduleItemInput = {
|
export type CreateClassScheduleItemInput = {
|
||||||
classId: string
|
classId: string
|
||||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
@@ -118,13 +118,26 @@ export type UpdateClassScheduleItemInput = {
|
|||||||
location?: string | null
|
location?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassBasicInfo = {
|
export type StudentEnrolledClass = {
|
||||||
id: string
|
id: string
|
||||||
|
schoolName?: string | null
|
||||||
name: string
|
name: string
|
||||||
grade: string
|
grade: string
|
||||||
homeroom?: string | null
|
homeroom?: string | null
|
||||||
room?: string | null
|
room?: string | null
|
||||||
invitationCode?: string | null
|
teacherName?: string | null
|
||||||
|
teacherEmail?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudentScheduleItem = {
|
||||||
|
id: string
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
course: string
|
||||||
|
location?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScoreStats = {
|
export type ScoreStats = {
|
||||||
@@ -139,6 +152,7 @@ export type ClassHomeworkAssignmentStats = {
|
|||||||
assignmentId: string
|
assignmentId: string
|
||||||
title: string
|
title: string
|
||||||
status: string
|
status: string
|
||||||
|
subject?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
dueAt: string | null
|
dueAt: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
@@ -151,24 +165,25 @@ export type ClassHomeworkAssignmentStats = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ClassHomeworkInsights = {
|
export type ClassHomeworkInsights = {
|
||||||
class: ClassBasicInfo
|
class: {
|
||||||
studentCounts: {
|
id: string
|
||||||
total: number
|
schoolName?: string | null
|
||||||
active: number
|
schoolId?: string | null
|
||||||
inactive: number
|
name: string
|
||||||
|
grade: string
|
||||||
|
homeroom?: string | null
|
||||||
|
room?: string | null
|
||||||
|
invitationCode?: string | null
|
||||||
}
|
}
|
||||||
|
studentCounts: { total: number; active: number; inactive: number }
|
||||||
assignments: ClassHomeworkAssignmentStats[]
|
assignments: ClassHomeworkAssignmentStats[]
|
||||||
latest: ClassHomeworkAssignmentStats | null
|
latest: ClassHomeworkAssignmentStats | null
|
||||||
overallScores: ScoreStats
|
overallScores: ScoreStats
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GradeHomeworkClassSummary = {
|
export type GradeHomeworkClassSummary = {
|
||||||
class: ClassBasicInfo
|
class: { id: string; name: string; grade: string; homeroom?: string | null; room?: string | null }
|
||||||
studentCounts: {
|
studentCounts: { total: number; active: number; inactive: number }
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
inactive: number
|
|
||||||
}
|
|
||||||
latestAvg: number | null
|
latestAvg: number | null
|
||||||
prevAvg: number | null
|
prevAvg: number | null
|
||||||
deltaAvg: number | null
|
deltaAvg: number | null
|
||||||
@@ -176,17 +191,9 @@ export type GradeHomeworkClassSummary = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GradeHomeworkInsights = {
|
export type GradeHomeworkInsights = {
|
||||||
grade: {
|
grade: { id: string; name: string; school: { id: string; name: string } }
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
school: { id: string; name: string }
|
|
||||||
}
|
|
||||||
classCount: number
|
classCount: number
|
||||||
studentCounts: {
|
studentCounts: { total: number; active: number; inactive: number }
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
inactive: number
|
|
||||||
}
|
|
||||||
assignments: ClassHomeworkAssignmentStats[]
|
assignments: ClassHomeworkAssignmentStats[]
|
||||||
latest: ClassHomeworkAssignmentStats | null
|
latest: ClassHomeworkAssignmentStats | null
|
||||||
overallScores: ScoreStats
|
overallScores: ScoreStats
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { CalendarDays, BookOpen, PenTool } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
|
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
let greeting = "Welcome back"
|
||||||
|
if (hour < 12) greeting = "Good morning"
|
||||||
|
else if (hour < 18) greeting = "Good afternoon"
|
||||||
|
else greeting = "Good evening"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||||
<div className="text-sm text-muted-foreground">Welcome back, {studentName}.</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{greeting}, {studentName}. Here's what's happening today.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||||
|
<Link href="/student/schedule">
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
Schedule
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||||
|
<Link href="/student/learning/textbooks">
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Textbooks
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" className="gap-2">
|
||||||
|
<Link href="/student/learning/assignments">
|
||||||
|
<PenTool className="h-4 w-4" />
|
||||||
|
Assignments
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/student/learning/assignments">View assignments</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { StudentDashboardProps } from "@/modules/dashboard/types"
|
|||||||
|
|
||||||
import { StudentDashboardHeader } from "./student-dashboard-header"
|
import { StudentDashboardHeader } from "./student-dashboard-header"
|
||||||
import { StudentGradesCard } from "./student-grades-card"
|
import { StudentGradesCard } from "./student-grades-card"
|
||||||
import { StudentRankingCard } from "./student-ranking-card"
|
|
||||||
import { StudentStatsGrid } from "./student-stats-grid"
|
import { StudentStatsGrid } from "./student-stats-grid"
|
||||||
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
||||||
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
||||||
@@ -26,16 +25,17 @@ export function StudentDashboard({
|
|||||||
dueSoonCount={dueSoonCount}
|
dueSoonCount={dueSoonCount}
|
||||||
overdueCount={overdueCount}
|
overdueCount={overdueCount}
|
||||||
gradedCount={gradedCount}
|
gradedCount={gradedCount}
|
||||||
|
ranking={grades.ranking}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<StudentGradesCard grades={grades} />
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<StudentRankingCard ranking={grades.ranking} />
|
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
||||||
</div>
|
<StudentGradesCard grades={grades} />
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="space-y-6">
|
||||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||||
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BarChart3 } from "lucide-react"
|
import { BarChart3 } from "lucide-react"
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||||
|
|
||||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||||
|
|
||||||
@@ -11,6 +15,24 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
const hasGradeTrend = grades.trend.length > 0
|
const hasGradeTrend = grades.trend.length > 0
|
||||||
const hasRecentGrades = grades.recent.length > 0
|
const hasRecentGrades = grades.recent.length > 0
|
||||||
|
|
||||||
|
const chartData = grades.trend.map((item) => ({
|
||||||
|
title: item.assignmentTitle,
|
||||||
|
score: Math.round(item.percentage),
|
||||||
|
fullTitle: item.assignmentTitle,
|
||||||
|
submittedAt: formatDate(item.submittedAt),
|
||||||
|
rawScore: item.score,
|
||||||
|
maxScore: item.maxScore,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
score: {
|
||||||
|
label: "Score (%)",
|
||||||
|
color: "hsl(var(--primary))",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestGrade = grades.trend[grades.trend.length - 1]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -30,37 +52,79 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border bg-card p-4">
|
<div className="rounded-md border bg-card p-4">
|
||||||
<svg viewBox="0 0 100 40" className="h-24 w-full">
|
<ChartContainer config={chartConfig} className="h-[200px] w-full">
|
||||||
<polyline
|
<LineChart
|
||||||
fill="none"
|
data={chartData}
|
||||||
stroke="currentColor"
|
margin={{
|
||||||
strokeWidth="2"
|
left: 12,
|
||||||
points={grades.trend
|
right: 12,
|
||||||
.map((p, i) => {
|
top: 12,
|
||||||
const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0
|
bottom: 12,
|
||||||
const x = t * 100
|
}}
|
||||||
const v = Number.isFinite(p.percentage) ? Math.max(0, Math.min(100, p.percentage)) : 0
|
>
|
||||||
const y = 40 - (v / 100) * 40
|
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||||
return `${x},${y}`
|
<XAxis
|
||||||
})
|
dataKey="title"
|
||||||
.join(" ")}
|
tickLine={false}
|
||||||
className="text-primary"
|
axisLine={false}
|
||||||
/>
|
tickMargin={8}
|
||||||
</svg>
|
tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
|
||||||
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
/>
|
||||||
<div>
|
<YAxis
|
||||||
Latest:{" "}
|
domain={[0, 100]}
|
||||||
<span className="font-medium text-foreground tabular-nums">
|
tickLine={false}
|
||||||
{Math.round(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}%
|
axisLine={false}
|
||||||
</span>
|
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,
|
||||||
|
}}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
strokeWidth: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
{latestGrade && (
|
||||||
|
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
Latest:{" "}
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{Math.round(latestGrade.percentage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Points:{" "}
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{latestGrade.score}/{latestGrade.maxScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
Points:{" "}
|
|
||||||
<span className="font-medium text-foreground tabular-nums">
|
|
||||||
{grades.trend[grades.trend.length - 1]?.score ?? 0}/{grades.trend[grades.trend.length - 1]?.maxScore ?? 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasRecentGrades ? null : (
|
{!hasRecentGrades ? null : (
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Trophy } from "lucide-react"
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
||||||
import type { StudentRanking } from "@/modules/homework/types"
|
|
||||||
|
|
||||||
export function StudentRankingCard({ ranking }: { ranking: StudentRanking | null }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Trophy className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Ranking
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!ranking ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Trophy}
|
|
||||||
title="No ranking available"
|
|
||||||
description="Join a class and complete graded work to see your rank."
|
|
||||||
className="border-none h-72"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="rounded-md border bg-card p-4">
|
|
||||||
<div className="text-sm text-muted-foreground">Class Rank</div>
|
|
||||||
<div className="mt-1 text-3xl font-bold tabular-nums">
|
|
||||||
{ranking.rank}/{ranking.classSize}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border bg-card p-4">
|
|
||||||
<div className="text-sm text-muted-foreground">Overall</div>
|
|
||||||
<div className="mt-1 text-3xl font-bold tabular-nums">{Math.round(ranking.percentage)}%</div>
|
|
||||||
<div className="text-xs text-muted-foreground tabular-nums">
|
|
||||||
{ranking.totalScore}/{ranking.totalMaxScore} pts
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">Based on latest graded submissions per assignment for your class.</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { BookOpen, CheckCircle2, PenTool, TriangleAlert } from "lucide-react"
|
import Link from "next/link"
|
||||||
|
import { BookOpen, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import type { StudentRanking } from "@/modules/homework/types"
|
||||||
|
|
||||||
type Stat = {
|
type Stat = {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
description: string
|
description: string
|
||||||
icon: typeof BookOpen
|
icon: typeof BookOpen
|
||||||
|
href: string
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudentStatsGrid({
|
export function StudentStatsGrid({
|
||||||
@@ -14,52 +19,64 @@ export function StudentStatsGrid({
|
|||||||
dueSoonCount,
|
dueSoonCount,
|
||||||
overdueCount,
|
overdueCount,
|
||||||
gradedCount,
|
gradedCount,
|
||||||
|
ranking,
|
||||||
}: {
|
}: {
|
||||||
enrolledClassCount: number
|
enrolledClassCount: number
|
||||||
dueSoonCount: number
|
dueSoonCount: number
|
||||||
overdueCount: number
|
overdueCount: number
|
||||||
gradedCount: number
|
gradedCount: number
|
||||||
|
ranking: StudentRanking | null
|
||||||
}) {
|
}) {
|
||||||
const stats: readonly Stat[] = [
|
const stats: Stat[] = [
|
||||||
{
|
{
|
||||||
title: "My Classes",
|
title: "Average Score",
|
||||||
value: String(enrolledClassCount),
|
value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
|
||||||
description: "Enrolled classes",
|
description: ranking ? "Overall performance" : "No grades yet",
|
||||||
icon: BookOpen,
|
icon: TrendingUp,
|
||||||
|
href: "/student/learning/assignments",
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Class Rank",
|
||||||
|
value: ranking ? `${ranking.rank}/${ranking.classSize}` : "-",
|
||||||
|
description: ranking ? "Current position" : "No ranking yet",
|
||||||
|
icon: Trophy,
|
||||||
|
href: "/student/learning/assignments",
|
||||||
|
color: "text-purple-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Due Soon",
|
title: "Due Soon",
|
||||||
value: String(dueSoonCount),
|
value: String(dueSoonCount),
|
||||||
description: "Next 7 days",
|
description: "Next 7 days",
|
||||||
icon: PenTool,
|
icon: PenTool,
|
||||||
|
href: "/student/learning/assignments",
|
||||||
|
color: dueSoonCount > 0 ? "text-orange-500" : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Overdue",
|
title: "Overdue",
|
||||||
value: String(overdueCount),
|
value: String(overdueCount),
|
||||||
description: "Needs attention",
|
description: "Needs attention",
|
||||||
icon: TriangleAlert,
|
icon: TriangleAlert,
|
||||||
},
|
href: "/student/learning/assignments",
|
||||||
{
|
color: overdueCount > 0 ? "text-red-500" : undefined,
|
||||||
title: "Graded",
|
|
||||||
value: String(gradedCount),
|
|
||||||
description: "With score",
|
|
||||||
icon: CheckCircle2,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
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) => (
|
{stats.map((stat) => (
|
||||||
<Card key={stat.title}>
|
<Link key={stat.title} href={stat.href}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">
|
||||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||||
</CardHeader>
|
<stat.icon className={cn("h-4 w-4 text-muted-foreground", stat.color)} />
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="text-2xl font-bold tabular-nums">{stat.value}</div>
|
<CardContent>
|
||||||
<div className="text-xs text-muted-foreground">{stat.description}</div>
|
<div className={cn("text-2xl font-bold tabular-nums", stat.color)}>{stat.value}</div>
|
||||||
</CardContent>
|
<div className="text-xs text-muted-foreground">{stat.description}</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate, cn } from "@/shared/lib/utils"
|
||||||
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||||
@@ -23,6 +23,30 @@ const getStatusLabel = (status: string) => {
|
|||||||
return "Not started"
|
return "Not started"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getActionLabel = (status: string) => {
|
||||||
|
if (status === "graded") return "Review"
|
||||||
|
if (status === "submitted") return "View"
|
||||||
|
if (status === "in_progress") return "Continue"
|
||||||
|
return "Start"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||||
|
if (status === "graded" || status === "submitted") return "outline"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDueUrgency = (dueAt: string | null) => {
|
||||||
|
if (!dueAt) return null
|
||||||
|
const now = new Date()
|
||||||
|
const due = new Date(dueAt)
|
||||||
|
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||||
|
|
||||||
|
if (diffHours < 0) return "overdue"
|
||||||
|
if (diffHours < 48) return "urgent" // 2 days
|
||||||
|
if (diffHours < 120) return "warning" // 5 days
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
|
||||||
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||||
const hasAssignments = upcomingAssignments.length > 0
|
const hasAssignments = upcomingAssignments.length > 0
|
||||||
|
|
||||||
@@ -54,25 +78,49 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
|||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
||||||
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Action</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{upcomingAssignments.map((a) => (
|
{upcomingAssignments.map((a) => {
|
||||||
<TableRow key={a.id} className="h-12">
|
const urgency = getDueUrgency(a.dueAt)
|
||||||
<TableCell className="font-medium">
|
const isGraded = a.progressStatus === "graded"
|
||||||
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
|
|
||||||
{a.title}
|
return (
|
||||||
</Link>
|
<TableRow key={a.id} className="h-12">
|
||||||
</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell>
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
|
||||||
{getStatusLabel(a.progressStatus)}
|
{a.title}
|
||||||
</Badge>
|
</Link>
|
||||||
</TableCell>
|
{!isGraded && urgency === "overdue" && (
|
||||||
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">Late</Badge>
|
||||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
)}
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||||
|
{getStatusLabel(a.progressStatus)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={cn(
|
||||||
|
"text-muted-foreground",
|
||||||
|
!isGraded && urgency === "overdue" && "text-destructive font-medium",
|
||||||
|
!isGraded && urgency === "urgent" && "text-orange-500 font-medium"
|
||||||
|
)}>
|
||||||
|
{a.dueAt ? formatDate(a.dueAt) : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)} className="h-7 text-xs">
|
||||||
|
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||||
|
{getActionLabel(a.progressStatus)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">
|
||||||
<Link
|
<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
|
||||||
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>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Users } from "lucide-react"
|
import { Users } from "lucide-react"
|
||||||
|
|
||||||
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"
|
||||||
@@ -9,8 +8,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 +30,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}
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
title="Needs Grading"
|
||||||
<TeacherClassesCard classes={data.classes} />
|
emptyTitle="All caught up!"
|
||||||
<TeacherHomeworkCard assignments={data.assignments} />
|
emptyDescription="You have no pending submissions to grade."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 lg:col-span-4">
|
||||||
|
<TeacherSchedule items={todayScheduleItems} />
|
||||||
|
<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;
|
||||||
@@ -17,55 +18,131 @@ 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,62 @@ 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",
|
||||||
|
highlight: false,
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Average Score",
|
||||||
|
value: `${Math.round(averageScore)}%`,
|
||||||
|
description: "Across recent assignments",
|
||||||
|
icon: TrendingUp,
|
||||||
|
href: "#grade-trends",
|
||||||
|
highlight: false,
|
||||||
|
color: "text-emerald-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Submission Rate",
|
||||||
|
value: `${Math.round(submissionRate)}%`,
|
||||||
|
description: "Overall completion rate",
|
||||||
|
icon: BarChart,
|
||||||
|
href: "#grade-trends",
|
||||||
|
highlight: false,
|
||||||
|
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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { ActionState } from "@/shared/types/action-state"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { exams, examQuestions } from "@/shared/db/schema"
|
import { exams, examQuestions, subjects, grades } from "@/shared/db/schema"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
import { omitScheduledAtFromDescription } from "./data-access"
|
||||||
|
|
||||||
const ExamCreateSchema = z.object({
|
const ExamCreateSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
@@ -56,9 +57,17 @@ export async function createExamAction(
|
|||||||
const examId = createId()
|
const examId = createId()
|
||||||
const scheduled = input.scheduledAt || undefined
|
const scheduled = input.scheduledAt || undefined
|
||||||
|
|
||||||
|
// Retrieve names for JSON description (to maintain compatibility)
|
||||||
|
const subjectRecord = await db.query.subjects.findFirst({
|
||||||
|
where: eq(subjects.id, input.subject),
|
||||||
|
})
|
||||||
|
const gradeRecord = await db.query.grades.findFirst({
|
||||||
|
where: eq(grades.id, input.grade),
|
||||||
|
})
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
subject: input.subject,
|
subject: subjectRecord?.name ?? input.subject,
|
||||||
grade: input.grade,
|
grade: gradeRecord?.name ?? input.grade,
|
||||||
difficulty: input.difficulty,
|
difficulty: input.difficulty,
|
||||||
totalScore: input.totalScore,
|
totalScore: input.totalScore,
|
||||||
durationMin: input.durationMin,
|
durationMin: input.durationMin,
|
||||||
@@ -71,11 +80,14 @@ export async function createExamAction(
|
|||||||
id: examId,
|
id: examId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: JSON.stringify(meta),
|
description: JSON.stringify(meta),
|
||||||
creatorId: user?.id ?? "user_teacher_123",
|
creatorId: user?.id ?? "user_teacher_math",
|
||||||
|
subjectId: input.subject,
|
||||||
|
gradeId: input.grade,
|
||||||
startTime: scheduled ? new Date(scheduled) : null,
|
startTime: scheduled ? new Date(scheduled) : null,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("Failed to create exam:", error)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Database error: Failed to create exam",
|
message: "Database error: Failed to create exam",
|
||||||
@@ -215,19 +227,6 @@ const ExamDuplicateSchema = z.object({
|
|||||||
examId: z.string().min(1),
|
examId: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
const omitScheduledAtFromDescription = (description: string | null) => {
|
|
||||||
if (!description) return null
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(description)
|
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description
|
|
||||||
const meta = parsed as Record<string, unknown>
|
|
||||||
if ("scheduledAt" in meta) delete meta.scheduledAt
|
|
||||||
return JSON.stringify(meta)
|
|
||||||
} catch {
|
|
||||||
return description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function duplicateExamAction(
|
export async function duplicateExamAction(
|
||||||
prevState: ActionState<string> | null,
|
prevState: ActionState<string> | null,
|
||||||
formData: FormData
|
formData: FormData
|
||||||
@@ -271,7 +270,7 @@ export async function duplicateExamAction(
|
|||||||
id: newExamId,
|
id: newExamId,
|
||||||
title: `${source.title} (Copy)`,
|
title: `${source.title} (Copy)`,
|
||||||
description: omitScheduledAtFromDescription(source.description),
|
description: omitScheduledAtFromDescription(source.description),
|
||||||
creatorId: user?.id ?? "user_teacher_123",
|
creatorId: user?.id ?? "user_teacher_math",
|
||||||
startTime: null,
|
startTime: null,
|
||||||
endTime: null,
|
endTime: null,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
@@ -305,6 +304,78 @@ export async function duplicateExamAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentUser() {
|
export async function getExamPreviewAction(examId: string) {
|
||||||
return { id: "user_teacher_123", role: "teacher" }
|
try {
|
||||||
|
const exam = await db.query.exams.findFirst({
|
||||||
|
where: eq(exams.id, examId),
|
||||||
|
with: {
|
||||||
|
questions: {
|
||||||
|
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||||
|
with: {
|
||||||
|
question: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!exam) {
|
||||||
|
return { success: false, message: "Exam not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract questions from the relation
|
||||||
|
const questions = exam.questions.map(eq => eq.question)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
structure: exam.structure,
|
||||||
|
questions: questions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return { success: false, message: "Failed to load exam preview" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||||
|
try {
|
||||||
|
const allSubjects = await db.query.subjects.findMany({
|
||||||
|
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: allSubjects.map((s) => ({ id: s.id, name: s.name })),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch subjects:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to load subjects",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||||
|
try {
|
||||||
|
const allGrades = await db.query.grades.findMany({
|
||||||
|
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: allGrades.map((g) => ({ id: g.id, name: g.name })),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch grades:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to load grades",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentUser() {
|
||||||
|
return { id: "user_teacher_math", role: "teacher" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Eye, Printer } from "lucide-react"
|
|
||||||
import type { ExamNode } from "./selected-question-list"
|
import type { ExamNode } from "./selected-question-list"
|
||||||
|
|
||||||
type ChoiceOption = {
|
type ChoiceOption = {
|
||||||
@@ -86,55 +82,33 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<div className="bg-card shadow-sm border p-12 print:shadow-none print:border-none">
|
||||||
<DialogTrigger asChild>
|
{/* Header */}
|
||||||
<Button variant="secondary" size="sm" className="gap-2">
|
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
|
||||||
<Eye className="h-4 w-4" />
|
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
|
||||||
Preview Exam
|
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
|
||||||
</Button>
|
<span>Subject: {subject}</span>
|
||||||
</DialogTrigger>
|
<span>Grade: {grade}</span>
|
||||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
<span>Time: {durationMin} mins</span>
|
||||||
<DialogHeader className="p-6 pb-2 border-b shrink-0">
|
<span>Total: {totalScore} pts</span>
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<DialogTitle>Exam Preview</DialogTitle>
|
<div className="flex justify-center gap-12 text-sm pt-4">
|
||||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
|
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
|
||||||
<Printer className="h-4 w-4 mr-2" />
|
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
|
||||||
Print
|
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
|
|
||||||
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
|
|
||||||
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
|
|
||||||
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
|
|
||||||
<span>Subject: {subject}</span>
|
|
||||||
<span>Grade: {grade}</span>
|
|
||||||
<span>Time: {durationMin} mins</span>
|
|
||||||
<span>Total: {totalScore} pts</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center gap-12 text-sm pt-4">
|
|
||||||
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
|
|
||||||
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
|
|
||||||
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{nodes.length === 0 ? (
|
{nodes.length === 0 ? (
|
||||||
<div className="text-center py-20 text-muted-foreground">
|
<div className="text-center py-20 text-muted-foreground">
|
||||||
Empty Exam Paper
|
Empty Exam Paper
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
nodes.map(node => renderNode(node))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
) : (
|
||||||
</DialogContent>
|
nodes.map(node => renderNode(node))
|
||||||
</Dialog>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ type QuestionBankListProps = {
|
|||||||
questions: Question[]
|
questions: Question[]
|
||||||
onAdd: (question: Question) => void
|
onAdd: (question: Question) => void
|
||||||
isAdded: (id: string) => boolean
|
isAdded: (id: string) => boolean
|
||||||
|
onLoadMore?: () => void
|
||||||
|
hasMore?: boolean
|
||||||
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) {
|
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0 && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
No questions found matching your filters.
|
No questions found matching your filters.
|
||||||
@@ -22,7 +25,7 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 pb-4">
|
||||||
{questions.map((q) => {
|
{questions.map((q) => {
|
||||||
const added = isAdded(q.id)
|
const added = isAdded(q.id)
|
||||||
const content = q.content as { text?: string }
|
const content = q.content as { text?: string }
|
||||||
@@ -60,6 +63,28 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="pt-2 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onLoadMore}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
{isLoading ? "Loading..." : "Load More"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && questions.length === 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1,2,3].map(i => (
|
||||||
|
<div key={i} className="h-20 bg-muted/20 rounded-lg animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,6 +550,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
|
id="structure-editor-dnd"
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={customCollisionDetection}
|
collisionDetection={customCollisionDetection}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy }
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -27,13 +28,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
|
|
||||||
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
|
import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreviewAction } from "../actions"
|
||||||
import { Exam } from "../types"
|
import { Exam } from "../types"
|
||||||
|
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
||||||
|
import type { ExamNode } from "./assembly/selected-question-list"
|
||||||
|
import type { Question } from "@/modules/questions/types"
|
||||||
|
|
||||||
interface ExamActionsProps {
|
interface ExamActionsProps {
|
||||||
exam: Exam
|
exam: Exam
|
||||||
@@ -44,6 +46,46 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
const [previewNodes, setPreviewNodes] = useState<ExamNode[] | null>(null)
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState(false)
|
||||||
|
|
||||||
|
const handleView = async () => {
|
||||||
|
setLoadingPreview(true)
|
||||||
|
setShowViewDialog(true)
|
||||||
|
try {
|
||||||
|
const result = await getExamPreviewAction(exam.id)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const { structure, questions } = result.data
|
||||||
|
const questionById = new Map<string, Question>()
|
||||||
|
for (const q of questions) questionById.set(q.id, q as unknown as Question)
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const hydrate = (nodes: any[]): ExamNode[] => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.type === "question") {
|
||||||
|
const q = node.questionId ? questionById.get(node.questionId) : undefined
|
||||||
|
return { ...node, question: q }
|
||||||
|
}
|
||||||
|
if (node.type === "group") {
|
||||||
|
return { ...node, children: hydrate(node.children || []) }
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = Array.isArray(structure) ? hydrate(structure) : []
|
||||||
|
setPreviewNodes(nodes)
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to load exam preview")
|
||||||
|
setShowViewDialog(false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to load exam preview")
|
||||||
|
setShowViewDialog(false)
|
||||||
|
} finally {
|
||||||
|
setLoadingPreview(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyId = () => {
|
const copyId = () => {
|
||||||
navigator.clipboard.writeText(exam.id)
|
navigator.clipboard.writeText(exam.id)
|
||||||
@@ -112,25 +154,35 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-1">
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
variant="ghost"
|
||||||
<span className="sr-only">Open menu</span>
|
size="icon"
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||||
</Button>
|
onClick={(e) => {
|
||||||
</DropdownMenuTrigger>
|
e.stopPropagation()
|
||||||
<DropdownMenuContent align="end">
|
handleView()
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
}}
|
||||||
<DropdownMenuItem onClick={copyId}>
|
title="Preview Exam"
|
||||||
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
>
|
||||||
</DropdownMenuItem>
|
<Eye className="h-4 w-4" />
|
||||||
<DropdownMenuSeparator />
|
</Button>
|
||||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
<DropdownMenu>
|
||||||
<Eye className="mr-2 h-4 w-4" /> View
|
<DropdownMenuTrigger asChild>
|
||||||
</DropdownMenuItem>
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
<span className="sr-only">Open menu</span>
|
||||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={copyId}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||||
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
|
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -166,49 +218,21 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Exam Details</DialogTitle>
|
|
||||||
<DialogDescription>ID: {exam.id}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Title:</span>
|
|
||||||
<span className="col-span-3">{exam.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Subject:</span>
|
|
||||||
<span className="col-span-3">{exam.subject}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Grade:</span>
|
|
||||||
<span className="col-span-3">{exam.grade}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Total Score:</span>
|
|
||||||
<span className="col-span-3">{exam.totalScore}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Duration:</span>
|
|
||||||
<span className="col-span-3">{exam.durationMin} min</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete exam?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the exam.
|
This action cannot be undone. This will permanently delete the exam
|
||||||
|
"{exam.title}" and remove all associated data.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleDelete()
|
handleDelete()
|
||||||
@@ -220,6 +244,34 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||||
|
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
||||||
|
<DialogTitle className="text-lg font-semibold tracking-tight">{exam.title}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{loadingPreview ? (
|
||||||
|
<div className="py-20 text-center text-muted-foreground">Loading preview...</div>
|
||||||
|
) : previewNodes && previewNodes.length > 0 ? (
|
||||||
|
<div className="max-w-3xl mx-auto py-8 px-6">
|
||||||
|
<ExamPaperPreview
|
||||||
|
title={exam.title}
|
||||||
|
subject={exam.subject}
|
||||||
|
grade={exam.grade}
|
||||||
|
durationMin={exam.durationMin}
|
||||||
|
totalScore={exam.totalScore}
|
||||||
|
nodes={previewNodes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-20 text-center text-muted-foreground">
|
||||||
|
No questions in this exam.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useDeferredValue, useMemo, useState } from "react"
|
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
import { useFormStatus } from "react-dom"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Search } from "lucide-react"
|
import { Search, Eye } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
||||||
import type { Question } from "@/modules/questions/types"
|
import type { Question } from "@/modules/questions/types"
|
||||||
import { updateExamAction } from "@/modules/exams/actions"
|
import { updateExamAction } from "@/modules/exams/actions"
|
||||||
|
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||||
import { StructureEditor } from "./assembly/structure-editor"
|
import { StructureEditor } from "./assembly/structure-editor"
|
||||||
import { QuestionBankList } from "./assembly/question-bank-list"
|
import { QuestionBankList } from "./assembly/question-bank-list"
|
||||||
import type { ExamNode } from "./assembly/selected-question-list"
|
import type { ExamNode } from "./assembly/selected-question-list"
|
||||||
@@ -49,6 +50,12 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||||
const deferredSearch = useDeferredValue(search)
|
const deferredSearch = useDeferredValue(search)
|
||||||
|
|
||||||
|
// Bank state
|
||||||
|
const [bankQuestions, setBankQuestions] = useState<Question[]>(props.questionOptions)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(props.questionOptions.length >= 20)
|
||||||
|
const [isBankLoading, startBankTransition] = useTransition()
|
||||||
|
|
||||||
// Initialize structure state
|
// Initialize structure state
|
||||||
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
||||||
const questionById = new Map<string, Question>()
|
const questionById = new Map<string, Question>()
|
||||||
@@ -76,26 +83,47 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredQuestions = useMemo(() => {
|
const fetchQuestions = (reset: boolean = false) => {
|
||||||
let list: Question[] = [...props.questionOptions]
|
startBankTransition(async () => {
|
||||||
|
const nextPage = reset ? 1 : page + 1
|
||||||
if (deferredSearch) {
|
try {
|
||||||
const lower = deferredSearch.toLowerCase()
|
const result = await getQuestionsAction({
|
||||||
list = list.filter(q => {
|
q: deferredSearch,
|
||||||
const content = q.content as { text?: string }
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return content.text?.toLowerCase().includes(lower)
|
type: typeFilter === 'all' ? undefined : typeFilter as any,
|
||||||
})
|
difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
|
||||||
}
|
page: nextPage,
|
||||||
|
pageSize: 20
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result && result.data) {
|
||||||
|
setBankQuestions(prev => {
|
||||||
|
if (reset) return result.data
|
||||||
|
// Deduplicate just in case
|
||||||
|
const existingIds = new Set(prev.map(q => q.id))
|
||||||
|
const newQuestions = result.data.filter(q => !existingIds.has(q.id))
|
||||||
|
return [...prev, ...newQuestions]
|
||||||
|
})
|
||||||
|
setHasMore(result.data.length === 20)
|
||||||
|
setPage(nextPage)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to load questions")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (typeFilter !== "all") {
|
const isFirstRender = useRef(true)
|
||||||
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
isFirstRender.current = false
|
||||||
|
if (deferredSearch === "" && typeFilter === "all" && difficultyFilter === "all") {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (difficultyFilter !== "all") {
|
fetchQuestions(true)
|
||||||
const d = parseInt(difficultyFilter)
|
}, [deferredSearch, typeFilter, difficultyFilter])
|
||||||
list = list.filter((q) => q.difficulty === d)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
|
|
||||||
|
|
||||||
// Recursively calculate total score
|
// Recursively calculate total score
|
||||||
const assignedTotal = useMemo(() => {
|
const assignedTotal = useMemo(() => {
|
||||||
@@ -231,6 +259,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
return clean(structure)
|
return clean(structure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
|
|
||||||
const handleSave = async (formData: FormData) => {
|
const handleSave = async (formData: FormData) => {
|
||||||
formData.set("examId", props.examId)
|
formData.set("examId", props.examId)
|
||||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
||||||
@@ -238,7 +268,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
|
|
||||||
const result = await updateExamAction(null, formData)
|
const result = await updateExamAction(null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Saved draft")
|
toast.success("Exam draft saved")
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Save failed")
|
toast.error(result.message || "Save failed")
|
||||||
}
|
}
|
||||||
@@ -260,47 +290,76 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-12rem)] gap-6 lg:grid-cols-5">
|
<div className="grid h-[calc(100vh-8rem)] gap-4 lg:grid-cols-12">
|
||||||
{/* Left: Preview (3 cols) */}
|
{/* Left: Preview (8 cols) */}
|
||||||
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
|
<Card className="lg:col-span-8 flex flex-col overflow-hidden border-2 border-primary/10 shadow-sm">
|
||||||
<CardHeader className="bg-muted/30 pb-4">
|
<CardHeader className="bg-muted/30 pb-4 border-b">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CardTitle>Exam Structure</CardTitle>
|
<CardTitle className="text-lg">Exam Structure</CardTitle>
|
||||||
<ExamPaperPreview
|
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||||
title={props.title}
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
subject={props.subject}
|
<span className="font-medium text-foreground">{props.subject}</span>
|
||||||
grade={props.grade}
|
<span>•</span>
|
||||||
durationMin={props.durationMin}
|
<span>{props.grade}</span>
|
||||||
totalScore={props.totalScore}
|
<span>•</span>
|
||||||
nodes={structure}
|
<span>{props.durationMin} min</span>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Total Score</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-24 rounded-full bg-secondary">
|
</div>
|
||||||
<div
|
<div className="flex items-center gap-6">
|
||||||
className={`h-full rounded-full transition-all ${
|
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||||
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
<DialogTrigger asChild>
|
||||||
}`}
|
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
|
||||||
style={{ width: `${progress}%` }}
|
<Eye className="h-4 w-4" />
|
||||||
/>
|
Preview
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
||||||
|
<DialogTitle className="text-lg font-semibold tracking-tight">{props.title}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="max-w-3xl mx-auto py-8 px-6">
|
||||||
|
<ExamPaperPreview
|
||||||
|
title={props.title}
|
||||||
|
subject={props.subject}
|
||||||
|
grade={props.grade}
|
||||||
|
durationMin={props.durationMin}
|
||||||
|
totalScore={props.totalScore}
|
||||||
|
nodes={structure}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-lg font-bold ${assignedTotal > props.totalScore ? "text-destructive" : "text-primary"}`}>
|
||||||
|
{assignedTotal}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">/ {props.totalScore}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Total Score</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-2 rounded-full bg-secondary overflow-hidden flex flex-col-reverse">
|
||||||
|
<div
|
||||||
|
className={`w-full transition-all ${
|
||||||
|
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
||||||
|
}`}
|
||||||
|
style={{ height: `${Math.min(progress, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
<ScrollArea className="flex-1 bg-muted/5">
|
||||||
<div className="space-y-6">
|
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||||
<div className="grid grid-cols-3 gap-4 text-sm text-muted-foreground bg-muted/20 p-3 rounded-md">
|
|
||||||
<div><span className="font-medium text-foreground">{props.subject}</span></div>
|
|
||||||
<div><span className="font-medium text-foreground">{props.grade}</span></div>
|
|
||||||
<div>Duration: <span className="font-medium text-foreground">{props.durationMin} min</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StructureEditor
|
<StructureEditor
|
||||||
items={structure}
|
items={structure}
|
||||||
onChange={setStructure}
|
onChange={setStructure}
|
||||||
@@ -312,32 +371,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="border-t p-4 bg-muted/30 flex gap-3 justify-end">
|
<div className="border-t p-4 bg-background flex gap-3 justify-end items-center shadow-[0_-1px_2px_rgba(0,0,0,0.03)]">
|
||||||
<form action={handleSave} className="flex-1">
|
<div className="mr-auto text-xs text-muted-foreground">
|
||||||
<SubmitButton label="Save Draft" />
|
{structure.length === 0 ? "Start by adding questions from the right panel" : `${structure.length} items in structure`}
|
||||||
|
</div>
|
||||||
|
<form action={handleSave}>
|
||||||
|
<Button variant="outline" size="sm" type="submit" className="w-24">Save Draft</Button>
|
||||||
</form>
|
</form>
|
||||||
<form action={handlePublish} className="flex-1">
|
<form action={handlePublish}>
|
||||||
<SubmitButton label="Publish Exam" />
|
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">Publish</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Right: Question Bank (2 cols) */}
|
{/* Right: Question Bank (4 cols) */}
|
||||||
<Card className="lg:col-span-2 flex flex-col overflow-hidden">
|
<Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
|
||||||
<CardHeader className="pb-3 space-y-3">
|
<CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
|
||||||
<CardTitle className="text-base">Question Bank</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold">Question Bank</CardTitle>
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||||
|
{bankQuestions.length}{hasMore ? "+" : ""} loaded
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search questions..."
|
placeholder="Search by content..."
|
||||||
className="pl-8"
|
className="pl-9 h-9 text-sm"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="flex-1 h-8 text-xs"><SelectValue placeholder="Type" /></SelectTrigger>
|
<SelectTrigger className="flex-1 h-8 text-xs bg-background"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||||
@@ -347,7 +414,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
||||||
<SelectTrigger className="w-[80px] h-8 text-xs"><SelectValue placeholder="Diff" /></SelectTrigger>
|
<SelectTrigger className="w-[80px] h-8 text-xs bg-background"><SelectValue placeholder="Diff" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<SelectItem value="1">Lvl 1</SelectItem>
|
<SelectItem value="1">Lvl 1</SelectItem>
|
||||||
@@ -360,14 +427,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<Separator />
|
<ScrollArea className="flex-1 p-0 bg-muted/5">
|
||||||
|
<div className="p-3">
|
||||||
<ScrollArea className="flex-1 p-4 bg-muted/10">
|
<QuestionBankList
|
||||||
<QuestionBankList
|
questions={bankQuestions}
|
||||||
questions={filteredQuestions}
|
onAdd={handleAdd}
|
||||||
onAdd={handleAdd}
|
isAdded={(id) => addedQuestionIds.has(id)}
|
||||||
isAdded={(id) => addedQuestionIds.has(id)}
|
onLoadMore={() => fetchQuestions(false)}
|
||||||
/>
|
hasMore={hasMore}
|
||||||
|
isLoading={isBankLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
src/modules/exams/components/exam-card.tsx
Normal file
103
src/modules/exams/components/exam-card.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Book, Clock, GraduationCap, Trophy, HelpCircle } from "lucide-react"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import { Badge, BadgeProps } from "@/shared/components/ui/badge"
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
|
import { Exam } from "../types"
|
||||||
|
import { ExamActions } from "./exam-actions"
|
||||||
|
|
||||||
|
interface ExamCardProps {
|
||||||
|
exam: Exam
|
||||||
|
hrefBase?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectColorMap: Record<string, string> = {
|
||||||
|
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
|
||||||
|
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
|
||||||
|
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
|
||||||
|
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
|
||||||
|
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
|
||||||
|
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
|
||||||
|
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamCard({ exam, hrefBase }: ExamCardProps) {
|
||||||
|
const base = hrefBase || "/teacher/exams"
|
||||||
|
const colorClass = subjectColorMap[exam.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800"
|
||||||
|
|
||||||
|
const statusVariant: BadgeProps["variant"] =
|
||||||
|
exam.status === "published"
|
||||||
|
? "secondary"
|
||||||
|
: exam.status === "archived"
|
||||||
|
? "destructive"
|
||||||
|
: "outline"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
||||||
|
<Link href={`${base}/${exam.id}/build`} className="flex-1">
|
||||||
|
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
|
||||||
|
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
|
||||||
|
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<Badge variant={statusVariant} className="bg-background/50 backdrop-blur-sm shadow-none border-transparent">
|
||||||
|
{exam.status}
|
||||||
|
</Badge>
|
||||||
|
{exam.difficulty && (
|
||||||
|
<Badge variant="outline" className="bg-background/50 backdrop-blur-sm border-transparent shadow-none">
|
||||||
|
Lvl {exam.difficulty}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Book className="h-8 w-8 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-lg leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{exam.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-4 pt-1 pb-2">
|
||||||
|
<div className="flex flex-wrap gap-y-2 gap-x-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<GraduationCap className="h-3.5 w-3.5" />
|
||||||
|
<span>{exam.grade}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
<span>{exam.durationMin} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Trophy className="h-3.5 w-3.5" />
|
||||||
|
<span>{exam.totalScore} pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||||
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
|
<span>{exam.questionCount || 0} Questions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 mr-2">
|
||||||
|
{formatDate(exam.updatedAt || exam.createdAt)}
|
||||||
|
</span>
|
||||||
|
<ExamActions exam={exam} />
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,109 +30,126 @@ export const examColumns: ColumnDef<Exam>[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "title",
|
accessorKey: "title",
|
||||||
header: "Title",
|
header: "Exam Info",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium">{row.original.title}</span>
|
<div className="flex items-center gap-2">
|
||||||
{row.original.tags && row.original.tags.length > 0 && (
|
<span className="font-semibold text-base">{row.original.title}</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
{row.original.tags && row.original.tags.length > 0 && (
|
||||||
{row.original.tags.slice(0, 2).map((t, idx) => (
|
<div className="flex flex-wrap gap-1">
|
||||||
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
|
{row.original.tags.slice(0, 2).map((t, idx) => (
|
||||||
{t}
|
<Badge key={`${t}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||||
</Badge>
|
{t}
|
||||||
))}
|
</Badge>
|
||||||
{row.original.tags.length > 2 && (
|
))}
|
||||||
<Badge variant="outline" className="text-xs">+{row.original.tags.length - 2}</Badge>
|
{row.original.tags.length > 2 && (
|
||||||
)}
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">+{row.original.tags.length - 2}</Badge>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground/80">{row.original.subject}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{row.original.grade}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "subject",
|
|
||||||
header: "Subject",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "grade",
|
|
||||||
header: "Grade",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-muted-foreground text-xs">{row.original.grade}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.original.status
|
const status = row.original.status
|
||||||
|
// Use 'default' as base for published/success to ensure type safety,
|
||||||
|
// but override with className below
|
||||||
const variant: BadgeProps["variant"] =
|
const variant: BadgeProps["variant"] =
|
||||||
status === "published"
|
status === "published"
|
||||||
? "secondary"
|
? "default"
|
||||||
: status === "archived"
|
: status === "archived"
|
||||||
? "destructive"
|
? "secondary"
|
||||||
: "outline"
|
: "outline"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={variant} className="capitalize">
|
<Badge
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"capitalize",
|
||||||
|
status === "published" && "bg-green-600 hover:bg-green-700 border-transparent",
|
||||||
|
status === "draft" && "bg-amber-100 text-amber-800 hover:bg-amber-200 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "stats",
|
||||||
|
header: "Stats",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">{row.original.questionCount} Qs</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{row.original.totalScore} Pts</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>{row.original.durationMin} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "difficulty",
|
accessorKey: "difficulty",
|
||||||
header: "Difficulty",
|
header: "Difficulty",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const diff = row.original.difficulty
|
const diff = row.original.difficulty
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex flex-col gap-1">
|
||||||
<span
|
<div className="flex gap-0.5">
|
||||||
className={cn(
|
{[1, 2, 3, 4, 5].map((level) => (
|
||||||
"font-medium",
|
<div
|
||||||
diff <= 2 ? "text-green-600" : diff === 3 ? "text-yellow-600" : "text-red-600"
|
key={level}
|
||||||
)}
|
className={cn(
|
||||||
>
|
"h-1.5 w-3 rounded-full",
|
||||||
{diff === 1
|
level <= diff
|
||||||
? "Easy"
|
? diff <= 2 ? "bg-green-500" : diff === 3 ? "bg-yellow-500" : "bg-red-500"
|
||||||
: diff === 2
|
: "bg-muted"
|
||||||
? "Easy-Med"
|
)}
|
||||||
: diff === 3
|
/>
|
||||||
? "Medium"
|
))}
|
||||||
: diff === 4
|
</div>
|
||||||
? "Med-Hard"
|
<span className="text-[10px] text-muted-foreground font-medium">
|
||||||
: "Hard"}
|
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "durationMin",
|
id: "dates",
|
||||||
header: "Duration",
|
header: "Date",
|
||||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.durationMin} min</span>,
|
cell: ({ row }) => {
|
||||||
},
|
const scheduled = row.original.scheduledAt
|
||||||
{
|
const created = row.original.createdAt
|
||||||
accessorKey: "totalScore",
|
|
||||||
header: "Total",
|
return (
|
||||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.totalScore}</span>,
|
<div className="flex flex-col gap-0.5 text-xs">
|
||||||
},
|
{scheduled ? (
|
||||||
{
|
<>
|
||||||
accessorKey: "scheduledAt",
|
<span className="font-medium text-blue-600 dark:text-blue-400">Scheduled</span>
|
||||||
header: "Scheduled",
|
<span className="text-muted-foreground">{formatDate(scheduled)}</span>
|
||||||
cell: ({ row }) => (
|
</>
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
) : (
|
||||||
{row.original.scheduledAt ? formatDate(row.original.scheduledAt) : "-"}
|
<>
|
||||||
</span>
|
<span className="text-muted-foreground">Created</span>
|
||||||
),
|
<span>{formatDate(created)}</span>
|
||||||
},
|
</>
|
||||||
{
|
)}
|
||||||
accessorKey: "createdAt",
|
</div>
|
||||||
header: "Created",
|
)
|
||||||
cell: ({ row }) => (
|
},
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
|
||||||
{formatDate(row.original.createdAt)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-muted/40">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
@@ -88,20 +88,38 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
<div className="flex items-center justify-between px-2 py-4">
|
||||||
<div className="flex-1 text-sm text-muted-foreground">
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||||
selected.
|
selected.
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-2">
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
<div className="flex items-center space-x-2">
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<p className="text-sm font-medium">Page</p>
|
||||||
Previous
|
<span className="text-sm font-medium">
|
||||||
</Button>
|
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
</span>
|
||||||
Next
|
</div>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<div className="flex items-center space-x-2">
|
||||||
</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,57 +19,59 @@ export function ExamFilters() {
|
|||||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
<div className="relative w-full md:w-[260px]">
|
<div className="relative w-full md:w-80">
|
||||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search exams..."
|
placeholder="Search exams..."
|
||||||
className="pl-7"
|
className="pl-9 bg-background border-muted-foreground/20"
|
||||||
value={search || ""}
|
value={search || ""}
|
||||||
onChange={(e) => setSearch(e.target.value || null)}
|
onChange={(e) => setSearch(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||||
<SelectTrigger className="w-[160px]">
|
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||||
<SelectValue placeholder="Status" />
|
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Status" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="all">Any Status</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
<SelectItem value="all">Any Status</SelectItem>
|
||||||
<SelectItem value="published">Published</SelectItem>
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
<SelectItem value="archived">Archived</SelectItem>
|
<SelectItem value="published">Published</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="archived">Archived</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||||
<SelectValue placeholder="Difficulty" />
|
<SelectValue placeholder="Difficulty" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Any Difficulty</SelectItem>
|
<SelectItem value="all">Any Difficulty</SelectItem>
|
||||||
<SelectItem value="1">Easy (1)</SelectItem>
|
<SelectItem value="1">Easy (1)</SelectItem>
|
||||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
||||||
<SelectItem value="3">Medium (3)</SelectItem>
|
<SelectItem value="3">Medium (3)</SelectItem>
|
||||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
||||||
<SelectItem value="5">Hard (5)</SelectItem>
|
<SelectItem value="5">Hard (5)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
|
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearch(null)
|
setSearch(null)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
setDifficulty(null)
|
setDifficulty(null)
|
||||||
}}
|
}}
|
||||||
className="h-8 px-2 lg:px-3"
|
className="h-10 px-3"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
<X className="ml-2 h-4 w-4" />
|
<X className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,357 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useTransition, useEffect, useState } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Loader2, Sparkles, BookOpen } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/components/ui/form"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import {
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
Select,
|
||||||
import { createExamAction } from "../actions"
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import { createExamAction, getSubjectsAction, getGradesAction } from "../actions"
|
||||||
|
|
||||||
function SubmitButton() {
|
export const formSchema = z.object({
|
||||||
const { pending } = useFormStatus()
|
title: z.string().min(2, "Title must be at least 2 characters."),
|
||||||
return (
|
subject: z.string().min(1, "Subject is required."),
|
||||||
<Button type="submit" disabled={pending}>
|
grade: z.string().min(1, "Grade is required."),
|
||||||
{pending ? "Creating..." : "Create Exam"}
|
difficulty: z.string(),
|
||||||
</Button>
|
totalScore: z.coerce.number().min(1, "Total score must be at least 1."),
|
||||||
)
|
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes."),
|
||||||
|
scheduledAt: z.string().optional(),
|
||||||
|
mode: z.enum(["manual", "ai"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ExamFormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
const defaultValues: Partial<ExamFormValues> = {
|
||||||
|
title: "",
|
||||||
|
subject: "",
|
||||||
|
grade: "",
|
||||||
|
difficulty: "3",
|
||||||
|
totalScore: 100,
|
||||||
|
durationMin: 90,
|
||||||
|
mode: "manual",
|
||||||
|
scheduledAt: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExamForm() {
|
export function ExamForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [difficulty, setDifficulty] = useState<string>("3")
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
||||||
|
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
||||||
|
const [grades, setGrades] = useState<{ id: string; name: string }[]>([])
|
||||||
|
const [loadingGrades, setLoadingGrades] = useState(true)
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const form = useForm<ExamFormValues>({
|
||||||
const result = await createExamAction(null, formData)
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (result.success) {
|
resolver: zodResolver(formSchema) as any,
|
||||||
toast.success(result.message)
|
defaultValues: defaultValues as unknown as ExamFormValues,
|
||||||
if (result.data) {
|
})
|
||||||
router.push(`/teacher/exams/${result.data}/build`)
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMetadata = async () => {
|
||||||
|
try {
|
||||||
|
const [subjectsResult, gradesResult] = await Promise.all([
|
||||||
|
getSubjectsAction(),
|
||||||
|
getGradesAction()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (subjectsResult.success && subjectsResult.data) {
|
||||||
|
setSubjects(subjectsResult.data)
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to load subjects")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gradesResult.success && gradesResult.data) {
|
||||||
|
setGrades(gradesResult.data)
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to load grades")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Failed to load form data")
|
||||||
|
} finally {
|
||||||
|
setLoadingSubjects(false)
|
||||||
|
setLoadingGrades(false)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
toast.error(result.message)
|
|
||||||
}
|
}
|
||||||
|
fetchMetadata()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function onSubmit(data: ExamFormValues) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("title", data.title)
|
||||||
|
formData.append("subject", data.subject)
|
||||||
|
formData.append("grade", data.grade)
|
||||||
|
formData.append("difficulty", data.difficulty)
|
||||||
|
formData.append("totalScore", data.totalScore.toString())
|
||||||
|
formData.append("durationMin", data.durationMin.toString())
|
||||||
|
if (data.scheduledAt) {
|
||||||
|
formData.append("scheduledAt", data.scheduledAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createExamAction(null, formData)
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
toast.success("Exam draft created", {
|
||||||
|
description: "Redirecting to exam builder...",
|
||||||
|
})
|
||||||
|
router.push(`/teacher/exams/${result.data}/build`)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to create exam")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const handleSubmit = (e: any) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
form.handleSubmit(onSubmit as any)(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Form {...form}>
|
||||||
<CardHeader>
|
<form onSubmit={handleSubmit} className="grid gap-8 lg:grid-cols-3">
|
||||||
<CardTitle>Exam Creator</CardTitle>
|
{/* Left Column: Exam Details */}
|
||||||
</CardHeader>
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<CardContent>
|
<Card>
|
||||||
<form action={handleSubmit} className="space-y-6">
|
<CardHeader>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardTitle>Exam Details</CardTitle>
|
||||||
<div className="grid gap-2">
|
<CardDescription>
|
||||||
<Label htmlFor="title">Title</Label>
|
Define the core information for your exam.
|
||||||
<Input id="title" name="title" placeholder="e.g. Algebra Midterm" required />
|
</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="grid gap-2">
|
<CardContent className="grid gap-6">
|
||||||
<Label htmlFor="subject">Subject</Label>
|
<FormField
|
||||||
<Input id="subject" name="subject" placeholder="e.g. Mathematics" required />
|
control={form.control}
|
||||||
</div>
|
name="title"
|
||||||
<div className="grid gap-2">
|
render={({ field }) => (
|
||||||
<Label htmlFor="grade">Grade</Label>
|
<FormItem>
|
||||||
<Input id="grade" name="grade" placeholder="e.g. Grade 10" required />
|
<FormLabel>Title</FormLabel>
|
||||||
</div>
|
<FormControl>
|
||||||
<div className="grid gap-2">
|
<Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
|
||||||
<Label>Difficulty</Label>
|
</FormControl>
|
||||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val)} name="difficulty">
|
<FormMessage />
|
||||||
<SelectTrigger>
|
</FormItem>
|
||||||
<SelectValue placeholder="Select difficulty" />
|
)}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">Easy (1)</SelectItem>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
<FormField
|
||||||
<SelectItem value="3">Medium (3)</SelectItem>
|
control={form.control}
|
||||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
name="subject"
|
||||||
<SelectItem value="5">Hard (5)</SelectItem>
|
render={({ field }) => (
|
||||||
</SelectContent>
|
<FormItem>
|
||||||
</Select>
|
<FormLabel>Subject</FormLabel>
|
||||||
<input type="hidden" name="difficulty" value={difficulty} />
|
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
|
||||||
</div>
|
<FormControl>
|
||||||
<div className="grid gap-2">
|
<SelectTrigger>
|
||||||
<Label htmlFor="totalScore">Total Score</Label>
|
<SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
|
||||||
<Input id="totalScore" name="totalScore" type="number" min={1} placeholder="e.g. 100" required />
|
</SelectTrigger>
|
||||||
</div>
|
</FormControl>
|
||||||
<div className="grid gap-2">
|
<SelectContent>
|
||||||
<Label htmlFor="durationMin">Duration (min)</Label>
|
{subjects.map((subject) => (
|
||||||
<Input id="durationMin" name="durationMin" type="number" min={10} placeholder="e.g. 90" required />
|
<SelectItem key={subject.id} value={subject.id}>
|
||||||
</div>
|
{subject.name}
|
||||||
<div className="grid gap-2 md:col-span-2">
|
</SelectItem>
|
||||||
<Label htmlFor="scheduledAt">Scheduled At (optional)</Label>
|
))}
|
||||||
<Input id="scheduledAt" name="scheduledAt" type="datetime-local" />
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
</div>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="grade"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Grade Level</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingGrades}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{grades.map((grade) => (
|
||||||
|
<SelectItem key={grade.id} value={grade.id}>
|
||||||
|
{grade.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="difficulty"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Difficulty</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select level" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">Level 1 (Easy)</SelectItem>
|
||||||
|
<SelectItem value="2">Level 2</SelectItem>
|
||||||
|
<SelectItem value="3">Level 3 (Medium)</SelectItem>
|
||||||
|
<SelectItem value="4">Level 4</SelectItem>
|
||||||
|
<SelectItem value="5">Level 5 (Hard)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="totalScore"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Total Score</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="durationMin"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Duration (min)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardFooter className="justify-end">
|
<FormField
|
||||||
<SubmitButton />
|
control={form.control}
|
||||||
</CardFooter>
|
name="scheduledAt"
|
||||||
</form>
|
render={({ field }) => (
|
||||||
</CardContent>
|
<FormItem>
|
||||||
</Card>
|
<FormLabel>Schedule Start Time (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="datetime-local" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
If set, this exam will be scheduled for a specific time.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Mode & Actions */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Assembly Mode</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose how to build the exam structure.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-3">
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
{/* Manual Mode */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground",
|
||||||
|
field.value === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
|
||||||
|
)}
|
||||||
|
onClick={() => field.onChange("manual")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-medium">Manual Assembly</span>
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Manually select questions from the bank and organize structure.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Mode (Disabled) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-not-allowed flex-col rounded-lg border p-4 shadow-sm outline-none opacity-50 bg-muted/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||||
|
<span className="font-medium">AI Generation</span>
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Automatically generate exam structure based on topics. (Coming Soon)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isPending ? "Creating Draft..." : "Create & Start Building"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/modules/exams/components/exam-grid.tsx
Normal file
16
src/modules/exams/components/exam-grid.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Exam } from "../types"
|
||||||
|
import { ExamCard } from "./exam-card"
|
||||||
|
|
||||||
|
interface ExamGridProps {
|
||||||
|
exams: Exam[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamGrid({ exams }: ExamGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{exams.map((exam) => (
|
||||||
|
<ExamCard key={exam.id} exam={exam} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -68,6 +68,10 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
|||||||
const data = await db.query.exams.findMany({
|
const data = await db.query.exams.findMany({
|
||||||
where: conditions.length ? and(...conditions) : undefined,
|
where: conditions.length ? and(...conditions) : undefined,
|
||||||
orderBy: [desc(exams.createdAt)],
|
orderBy: [desc(exams.createdAt)],
|
||||||
|
with: {
|
||||||
|
subject: true,
|
||||||
|
gradeEntity: true,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Transform and Filter (especially for JSON fields)
|
// Transform and Filter (especially for JSON fields)
|
||||||
@@ -78,8 +82,8 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
|||||||
id: exam.id,
|
id: exam.id,
|
||||||
title: exam.title,
|
title: exam.title,
|
||||||
status: (exam.status as ExamStatus) || "draft",
|
status: (exam.status as ExamStatus) || "draft",
|
||||||
subject: getString(meta, "subject") || "General",
|
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||||
grade: getString(meta, "grade") || "General",
|
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||||
totalScore: getNumber(meta, "totalScore") || 100,
|
totalScore: getNumber(meta, "totalScore") || 100,
|
||||||
durationMin: getNumber(meta, "durationMin") || 60,
|
durationMin: getNumber(meta, "durationMin") || 60,
|
||||||
@@ -103,6 +107,8 @@ export const getExamById = cache(async (id: string) => {
|
|||||||
const exam = await db.query.exams.findFirst({
|
const exam = await db.query.exams.findFirst({
|
||||||
where: eq(exams.id, id),
|
where: eq(exams.id, id),
|
||||||
with: {
|
with: {
|
||||||
|
subject: true,
|
||||||
|
gradeEntity: true,
|
||||||
questions: {
|
questions: {
|
||||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||||
with: {
|
with: {
|
||||||
@@ -120,8 +126,8 @@ export const getExamById = cache(async (id: string) => {
|
|||||||
id: exam.id,
|
id: exam.id,
|
||||||
title: exam.title,
|
title: exam.title,
|
||||||
status: (exam.status as ExamStatus) || "draft",
|
status: (exam.status as ExamStatus) || "draft",
|
||||||
subject: getString(meta, "subject") || "General",
|
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||||
grade: getString(meta, "grade") || "General",
|
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||||
totalScore: getNumber(meta, "totalScore") || 100,
|
totalScore: getNumber(meta, "totalScore") || 100,
|
||||||
durationMin: getNumber(meta, "durationMin") || 60,
|
durationMin: getNumber(meta, "durationMin") || 60,
|
||||||
@@ -137,3 +143,18 @@ export const getExamById = cache(async (id: string) => {
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const omitScheduledAtFromDescription = (description: string | null): string => {
|
||||||
|
if (!description) return "{}"
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(description)
|
||||||
|
if (typeof meta === "object" && meta !== null) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { scheduledAt, ...rest } = meta as any
|
||||||
|
return JSON.stringify(rest)
|
||||||
|
}
|
||||||
|
return description
|
||||||
|
} catch {
|
||||||
|
return description || "{}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async function getCurrentUser() {
|
|||||||
|
|
||||||
if (anyUser) return { id: anyUser.id, role: roleHint }
|
if (anyUser) return { id: anyUser.id, role: roleHint }
|
||||||
|
|
||||||
return { id: "user_teacher_123", role: roleHint }
|
return { id: "user_teacher_math", role: roleHint }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTeacher() {
|
async function ensureTeacher() {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function HomeworkAssignmentExamContentCard({
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<HomeworkAssignmentExamErrorExplorerLazy
|
<HomeworkAssignmentExamErrorExplorerLazy
|
||||||
structure={structure}
|
structure={structure}
|
||||||
questions={questions}
|
questions={questions}
|
||||||
|
|||||||
@@ -7,44 +7,47 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
|
|||||||
|
|
||||||
function ExamErrorExplorerFallback() {
|
function ExamErrorExplorerFallback() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]">
|
<div className="grid grid-cols-1 gap-0 md:grid-cols-3 h-[600px] divide-y md:divide-y-0 md:divide-x">
|
||||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||||
<div className="border-b px-4 py-3 text-sm font-medium">题目</div>
|
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||||
<div className="flex-1 p-4 space-y-3">
|
<span className="text-sm font-medium">Question Preview</span>
|
||||||
<Skeleton className="h-10 w-[40%]" />
|
</div>
|
||||||
<Skeleton className="h-10 w-[60%]" />
|
<div className="flex-1 p-6 space-y-6">
|
||||||
<Skeleton className="h-10 w-[75%]" />
|
<Skeleton className="h-8 w-[60%]" />
|
||||||
<Skeleton className="h-10 w-[55%]" />
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-10 w-[68%]" />
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-[90%]" />
|
||||||
|
<Skeleton className="h-4 w-[80%]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 pt-4">
|
||||||
|
<Skeleton className="h-12 w-full rounded-md" />
|
||||||
|
<Skeleton className="h-12 w-full rounded-md" />
|
||||||
|
<Skeleton className="h-12 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="border-b px-4 py-3">
|
<div className="border-b px-6 py-4">
|
||||||
<div className="text-sm font-medium">错题详情</div>
|
<div className="text-sm font-medium">Error Analysis</div>
|
||||||
<div className="mt-2 flex items-center gap-3">
|
</div>
|
||||||
<Skeleton className="size-12 rounded-full" />
|
<div className="flex-1 p-6 space-y-6">
|
||||||
<div className="min-w-0 flex-1 grid gap-1">
|
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<Skeleton className="size-16 rounded-full shrink-0" />
|
||||||
<Skeleton className="h-3 w-16" />
|
<div className="space-y-2 flex-1">
|
||||||
<Skeleton className="h-3 w-10" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
<Skeleton className="h-3 w-32" />
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<Skeleton className="h-3 w-16" />
|
</div>
|
||||||
<Skeleton className="h-3 w-12" />
|
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers</div>
|
||||||
<Skeleton className="h-3 w-16" />
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-3 w-10" />
|
<Skeleton className="h-14 w-full rounded-md" />
|
||||||
</div>
|
<Skeleton className="h-14 w-full rounded-md" />
|
||||||
|
<Skeleton className="h-14 w-full rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4 space-y-3">
|
|
||||||
<Skeleton className="h-4 w-[45%]" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function HomeworkAssignmentExamErrorExplorer({
|
|||||||
}, [questions, selectedQuestionId])
|
}, [questions, selectedQuestionId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-1 gap-4 md:grid-cols-3 ${heightClassName}`}>
|
<div className={`grid grid-cols-1 gap-0 md:grid-cols-3 ${heightClassName} divide-y md:divide-y-0 md:divide-x border rounded-md bg-background overflow-hidden`}>
|
||||||
<HomeworkAssignmentExamPreviewPane
|
<HomeworkAssignmentExamPreviewPane
|
||||||
structure={structure}
|
structure={structure}
|
||||||
questions={questions.map((q) => ({
|
questions={questions.map((q) => ({
|
||||||
|
|||||||
@@ -20,15 +20,19 @@ export function HomeworkAssignmentExamPreviewPane({
|
|||||||
onQuestionSelect: (questionId: string) => void
|
onQuestionSelect: (questionId: string) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||||
<div className="border-b px-4 py-3 text-sm font-medium">题目</div>
|
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||||
<ScrollArea className="flex-1 p-4">
|
<span className="text-sm font-medium">Question Preview</span>
|
||||||
<ExamViewer
|
</div>
|
||||||
structure={structure}
|
<ScrollArea className="flex-1 bg-background">
|
||||||
questions={questions}
|
<div className="p-6">
|
||||||
selectedQuestionId={selectedQuestionId}
|
<ExamViewer
|
||||||
onQuestionSelect={onQuestionSelect}
|
structure={structure}
|
||||||
/>
|
questions={questions}
|
||||||
|
selectedQuestionId={selectedQuestionId}
|
||||||
|
onQuestionSelect={onQuestionSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,59 +92,63 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
|||||||
const errorRate = selected?.errorRate ?? 0
|
const errorRate = selected?.errorRate ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="border-b px-4 py-3">
|
<div className="border-b px-6 py-4 bg-muted/5">
|
||||||
<div className="text-sm font-medium">错题详情</div>
|
<div className="text-sm font-medium">Error Analysis</div>
|
||||||
{selected ? (
|
|
||||||
<div className="mt-2 flex items-center gap-3">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<ErrorRatePieChart errorRate={errorRate} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 grid gap-1 text-xs text-muted-foreground">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>错误人数</span>
|
|
||||||
<span className="tabular-nums text-foreground">{errorCount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>错误率</span>
|
|
||||||
<span className="tabular-nums text-foreground">{(errorRate * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>统计样本</span>
|
|
||||||
<span className="tabular-nums text-foreground">{gradedSampleCount}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">请选择左侧题目</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="p-6 space-y-6">
|
||||||
<ScrollArea className="h-full p-4">
|
{selected ? (
|
||||||
{!selected ? (
|
<>
|
||||||
<div className="text-sm text-muted-foreground">暂无数据</div>
|
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||||
) : wrongAnswers.length === 0 ? (
|
<div className="shrink-0">
|
||||||
<div className="text-sm text-muted-foreground">暂无错误答案</div>
|
<ErrorRatePieChart errorRate={errorRate} />
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 flex-1 grid gap-1">
|
||||||
<div className="text-xs text-muted-foreground">错误答案列表(可滚动)</div>
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="space-y-2">
|
<span className="text-muted-foreground">Question</span>
|
||||||
{wrongAnswers.map((item, idx) => (
|
<span className="font-medium">Q{selected.questionId.slice(-4)}</span>
|
||||||
<div key={`${selected.questionId}-${idx}`} className="rounded-md border bg-muted/20 px-3 py-2">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-24 shrink-0 text-xs text-muted-foreground truncate">{item.studentName}</div>
|
|
||||||
<div className="min-w-0 flex-1 text-sm wrap-break-word whitespace-pre-wrap">
|
|
||||||
{formatAnswer(item.answerContent, selected)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Errors</span>
|
||||||
|
<span className="font-medium text-destructive">
|
||||||
|
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers ({wrongAnswers.length})</div>
|
||||||
|
{wrongAnswers.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
|
||||||
|
No wrong answers recorded.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{wrongAnswers.map((wa, i) => (
|
||||||
|
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-destructive break-words">
|
||||||
|
{formatAnswer(wa.answerContent, selected)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
|
||||||
|
<p>Select a question from the left</p>
|
||||||
|
<p className="text-xs mt-1">to view error analysis</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
|
||||||
|
|
||||||
export function HomeworkAssignmentQuestionErrorDetailsCard({
|
|
||||||
questions,
|
|
||||||
gradedSampleCount,
|
|
||||||
}: {
|
|
||||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
|
||||||
gradedSampleCount: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="md:col-span-1">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Details</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{questions.length === 0 || gradedSampleCount === 0 ? (
|
|
||||||
<div className="p-4 text-sm text-muted-foreground">No data available.</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="h-72">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[70px]">Question</TableHead>
|
|
||||||
<TableHead className="text-right">Error Count</TableHead>
|
|
||||||
<TableHead className="text-right">Error Rate</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{questions.map((q, index) => (
|
|
||||||
<TableRow key={q.questionId}>
|
|
||||||
<TableCell className="text-sm">
|
|
||||||
<div className="font-medium">Q{index + 1}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-sm tabular-nums">{q.errorCount}</TableCell>
|
|
||||||
<TableCell className="text-right text-sm tabular-nums">{(q.errorRate * 100).toFixed(1)}%</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,103 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||||
function ErrorRateChart({
|
|
||||||
questions,
|
|
||||||
gradedSampleCount,
|
|
||||||
}: {
|
|
||||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
|
||||||
gradedSampleCount: number
|
|
||||||
}) {
|
|
||||||
const w = 100
|
|
||||||
const h = 60
|
|
||||||
const padL = 10
|
|
||||||
const padR = 3
|
|
||||||
const padT = 4
|
|
||||||
const padB = 10
|
|
||||||
const plotW = w - padL - padR
|
|
||||||
const plotH = h - padT - padB
|
|
||||||
const n = questions.length
|
|
||||||
|
|
||||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
|
||||||
const xFor = (i: number) => padL + (n <= 1 ? 0 : (i / (n - 1)) * plotW)
|
|
||||||
const yFor = (rate: number) => padT + (1 - clamp01(rate)) * plotH
|
|
||||||
|
|
||||||
const points = questions.map((q, i) => `${xFor(i)},${yFor(q.errorRate)}`).join(" ")
|
|
||||||
const areaD =
|
|
||||||
n === 0
|
|
||||||
? ""
|
|
||||||
: `M ${padL} ${padT + plotH} L ${points.split(" ").join(" L ")} L ${padL + plotW} ${padT + plotH} Z`
|
|
||||||
|
|
||||||
const gridYs = [
|
|
||||||
{ v: 1, label: "100%" },
|
|
||||||
{ v: 0.5, label: "50%" },
|
|
||||||
{ v: 0, label: "0%" },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
|
|
||||||
{gridYs.map((g) => {
|
|
||||||
const y = yFor(g.v)
|
|
||||||
return (
|
|
||||||
<g key={g.label}>
|
|
||||||
<line x1={padL} y1={y} x2={padL + plotW} y2={y} className="stroke-border" strokeWidth={0.5} />
|
|
||||||
<text x={2} y={y + 1.2} className="fill-muted-foreground text-[3px]">
|
|
||||||
{g.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} className="stroke-border" strokeWidth={0.7} />
|
|
||||||
<line
|
|
||||||
x1={padL}
|
|
||||||
y1={padT + plotH}
|
|
||||||
x2={padL + plotW}
|
|
||||||
y2={padT + plotH}
|
|
||||||
className="stroke-border"
|
|
||||||
strokeWidth={0.7}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{n >= 2 ? <path d={areaD} className="fill-primary/10" /> : null}
|
|
||||||
<polyline
|
|
||||||
points={points}
|
|
||||||
fill="none"
|
|
||||||
className="stroke-primary"
|
|
||||||
strokeWidth={1.2}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{questions.map((q, i) => {
|
|
||||||
const cx = xFor(i)
|
|
||||||
const cy = yFor(q.errorRate)
|
|
||||||
const label = `Q${i + 1}`
|
|
||||||
return (
|
|
||||||
<g key={q.questionId}>
|
|
||||||
<circle cx={cx} cy={cy} r={1.2} className="fill-primary" />
|
|
||||||
<title>{`${label}: ${(q.errorRate * 100).toFixed(1)}% (${q.errorCount} / ${gradedSampleCount})`}</title>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{questions.map((q, i) => {
|
|
||||||
if (n > 12 && i % 2 === 1) return null
|
|
||||||
const x = xFor(i)
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={`x-${q.questionId}`}
|
|
||||||
x={x}
|
|
||||||
y={h - 2}
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-muted-foreground text-[3px]"
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeworkAssignmentQuestionErrorOverviewCard({
|
export function HomeworkAssignmentQuestionErrorOverviewCard({
|
||||||
questions,
|
questions,
|
||||||
@@ -106,26 +11,78 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
|
|||||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
questions: HomeworkAssignmentQuestionAnalytics[]
|
||||||
gradedSampleCount: number
|
gradedSampleCount: number
|
||||||
}) {
|
}) {
|
||||||
|
const data = questions.map((q, index) => ({
|
||||||
|
name: `Q${index + 1}`,
|
||||||
|
errorRate: q.errorRate * 100,
|
||||||
|
errorCount: q.errorCount,
|
||||||
|
total: gradedSampleCount,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="md:col-span-1">
|
<Card className="md:col-span-1">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Overview</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Error Rate Overview</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="h-72">
|
||||||
{questions.length === 0 || gradedSampleCount === 0 ? (
|
{questions.length === 0 || gradedSampleCount === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
No graded submissions yet. Error analytics will appear here after grading.
|
No graded submissions yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
<span>Graded students</span>
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
<span className="font-medium text-foreground">{gradedSampleCount}</span>
|
<XAxis
|
||||||
</div>
|
dataKey="name"
|
||||||
<div className="h-56 rounded-md border bg-muted/40 px-3 py-2">
|
tickLine={false}
|
||||||
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} />
|
axisLine={false}
|
||||||
</div>
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
</div>
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "hsl(var(--muted)/0.2)" }}
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const d = payload[0].payload
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[0.70rem] uppercase text-muted-foreground">Question</span>
|
||||||
|
<span className="font-bold text-muted-foreground">{d.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[0.70rem] uppercase text-muted-foreground">Error Rate</span>
|
||||||
|
<span className="font-bold">{d.errorRate.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[0.70rem] uppercase text-muted-foreground">Errors</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{d.errorCount} / {d.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="errorRate"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={40}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,10 +3,20 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Check, MessageSquarePlus, X } from "lucide-react"
|
import {
|
||||||
|
Check,
|
||||||
|
MessageSquarePlus,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Save,
|
||||||
|
User,
|
||||||
|
AlertCircle,
|
||||||
|
Clock
|
||||||
|
} 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, CardDescription, CardFooter } from "@/shared/components/ui/card"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
@@ -39,20 +49,48 @@ type HomeworkGradingViewProps = {
|
|||||||
status: string
|
status: string
|
||||||
totalScore: number | null
|
totalScore: number | null
|
||||||
answers: Answer[]
|
answers: Answer[]
|
||||||
|
prevSubmissionId?: string | null
|
||||||
|
nextSubmissionId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeworkGradingView({
|
export function HomeworkGradingView({
|
||||||
submissionId,
|
submissionId,
|
||||||
answers: initialAnswers,
|
answers: initialAnswers,
|
||||||
|
prevSubmissionId,
|
||||||
|
nextSubmissionId,
|
||||||
|
studentName,
|
||||||
|
assignmentTitle,
|
||||||
|
submittedAt,
|
||||||
}: HomeworkGradingViewProps) {
|
}: HomeworkGradingViewProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>({})
|
|
||||||
|
// Initialize feedback visibility for answers that already have feedback
|
||||||
|
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>(() => {
|
||||||
|
const initialVisibility: Record<string, boolean> = {}
|
||||||
|
if (initialAnswers) {
|
||||||
|
initialAnswers.forEach(a => {
|
||||||
|
if (a.feedback && a.feedback.trim().length > 0) {
|
||||||
|
initialVisibility[a.id] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return initialVisibility
|
||||||
|
})
|
||||||
|
|
||||||
const handleManualScoreChange = (id: string, val: string) => {
|
const handleManualScoreChange = (id: string, val: string) => {
|
||||||
const parsed = val === "" ? 0 : Number(val)
|
const parsed = val === "" ? 0 : Number(val)
|
||||||
const nextScore = Number.isFinite(parsed) ? parsed : 0
|
// Clamp score between 0 and maxScore? Or allow extra credit?
|
||||||
|
// Usually maxScore is the limit, but let's just ensure it's a number.
|
||||||
|
// Ideally we should clamp it to [0, maxScore] to avoid errors, but sometimes teachers want to give 0 for invalid input.
|
||||||
|
const targetAnswer = answers.find(a => a.id === id)
|
||||||
|
const max = targetAnswer?.maxScore ?? 100
|
||||||
|
|
||||||
|
let nextScore = Number.isFinite(parsed) ? parsed : 0
|
||||||
|
if (nextScore > max) nextScore = max
|
||||||
|
if (nextScore < 0) nextScore = 0
|
||||||
|
|
||||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +107,12 @@ export function HomeworkGradingView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
||||||
const binaryAnswers = answers.filter(shouldUseBinaryGrading)
|
const maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
|
||||||
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 0
|
||||||
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
|
||||||
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount
|
const correctCount = answers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
||||||
|
const incorrectCount = answers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
||||||
|
const partialCount = answers.reduce((sum, a) => sum + (a.score !== null && a.score > 0 && a.score < a.maxScore ? 1 : 0), 0)
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
@@ -89,177 +129,357 @@ export function HomeworkGradingView({
|
|||||||
const result = await gradeHomeworkSubmissionAction(null, formData)
|
const result = await gradeHomeworkSubmissionAction(null, formData)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Grading saved")
|
toast.success("Grading saved successfully")
|
||||||
router.push("/teacher/homework/submissions")
|
// Optionally redirect or stay
|
||||||
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to save")
|
toast.error(result.message || "Failed to save grading")
|
||||||
}
|
}
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScrollToQuestion = (id: string) => {
|
||||||
|
const el = document.getElementById(`question-card-${id}`)
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
{/* Main Content: Questions List */}
|
||||||
<div className="border-b p-4">
|
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
|
||||||
<h3 className="font-semibold">Student Response</h3>
|
<ScrollArea className="flex-1 p-4 lg:p-8">
|
||||||
</div>
|
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
||||||
<ScrollArea className="flex-1 p-4">
|
|
||||||
<div className="space-y-8">
|
|
||||||
{answers.map((ans, index) => (
|
{answers.map((ans, index) => (
|
||||||
<div key={ans.id} className="space-y-4">
|
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${
|
||||||
<div className="flex items-start justify-between">
|
ans.score === ans.maxScore ? "border-l-4 border-l-emerald-500" :
|
||||||
<div className="space-y-1">
|
ans.score === 0 && ans.maxScore > 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted"
|
||||||
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span>
|
}`}>
|
||||||
<div className="text-sm">{ans.questionContent?.text}</div>
|
<CardHeader className="bg-card pb-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="h-6 w-6 shrink-0 justify-center rounded-full p-0">
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{ans.questionType.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
{isAutoGradable(ans) && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">Auto-graded</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-medium leading-relaxed pt-2">
|
||||||
|
{ans.questionContent?.text || "No question text"}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="outline" className="whitespace-nowrap">
|
||||||
|
{ans.score ?? 0} / {ans.maxScore} pts
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">Max: {ans.maxScore}</Badge>
|
</CardHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md bg-muted/50 p-4">
|
|
||||||
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{formatStudentAnswer(ans.studentAnswer)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
|
||||||
|
<CardContent className="bg-card/50 p-6 space-y-6">
|
||||||
|
{/* Student Answer Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<User className="h-3 w-3" /> Student Answer
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-background p-4 shadow-sm">
|
||||||
|
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
|
||||||
|
Array.isArray(ans.questionContent?.options) ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(ans.questionContent.options as ChoiceOption[]).map((opt: ChoiceOption) => {
|
||||||
|
const isSelected = Array.isArray(extractAnswerValue(ans.studentAnswer))
|
||||||
|
? (extractAnswerValue(ans.studentAnswer) as string[]).includes(opt.id as string)
|
||||||
|
: extractAnswerValue(ans.studentAnswer) === opt.id
|
||||||
|
|
||||||
|
const isCorrect = opt.isCorrect === true
|
||||||
|
|
||||||
|
// Visual logic:
|
||||||
|
// If selected and correct -> Green + Check
|
||||||
|
// If selected and wrong -> Red + X
|
||||||
|
// If not selected but correct -> Green outline (show missed correct answer)
|
||||||
|
|
||||||
|
let containerClass = "border-transparent hover:bg-muted/50"
|
||||||
|
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
if (isCorrect) {
|
||||||
|
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
|
||||||
|
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
|
||||||
|
} else {
|
||||||
|
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
|
||||||
|
indicatorClass = "border-red-500 bg-red-500 text-white"
|
||||||
|
}
|
||||||
|
} else if (isCorrect) {
|
||||||
|
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
|
||||||
|
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.id as string}
|
||||||
|
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
|
||||||
|
>
|
||||||
|
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
|
||||||
|
{opt.id as string}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1">{opt.text}</span>
|
||||||
|
{isCorrect && <Check className="h-4 w-4 text-emerald-600" />}
|
||||||
|
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{formatStudentAnswer(ans.studentAnswer)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference Answer (for text/non-choice questions) */}
|
||||||
|
{ans.questionType === "text" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<Check className="h-3 w-3" /> Reference Answer
|
||||||
|
</Label>
|
||||||
|
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
|
||||||
|
{getTextCorrectAnswers(ans.questionContent).join(" / ") || "No reference answer provided."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between w-full gap-4">
|
||||||
|
{/* Grading Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={getCorrectnessState(ans) === "correct" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={getCorrectnessState(ans) === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
|
||||||
|
onClick={() => handleMarkCorrect(ans.id)}
|
||||||
|
>
|
||||||
|
<Check className="mr-1 h-4 w-4" /> Correct
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={getCorrectnessState(ans) === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
|
||||||
|
onClick={() => handleMarkIncorrect(ans.id)}
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-4 w-4" /> Incorrect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">Score:</Label>
|
||||||
|
<Input
|
||||||
|
id={`score-${ans.id}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={ans.maxScore}
|
||||||
|
className="w-20 h-8"
|
||||||
|
value={ans.score ?? ""}
|
||||||
|
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
|
||||||
|
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||||
|
{showFeedbackByAnswerId[ans.id] ? "Hide Feedback" : "Add Feedback"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Textarea */}
|
||||||
|
{showFeedbackByAnswerId[ans.id] && (
|
||||||
|
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<Textarea
|
||||||
|
placeholder={`Provide feedback for ${studentName}...`}
|
||||||
|
value={ans.feedback ?? ""}
|
||||||
|
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
||||||
|
className="min-h-[80px] bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
{/* Sidebar: Summary & Actions */}
|
||||||
<div className="border-b p-4">
|
<div className="lg:col-span-3 h-full flex flex-col gap-6">
|
||||||
<h3 className="font-semibold">Grading</h3>
|
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
|
||||||
<div className="mt-2 flex items-center justify-between text-sm">
|
<CardHeader className="pb-2">
|
||||||
<span className="text-muted-foreground">Total Score</span>
|
<CardTitle className="text-lg">Grading Summary</CardTitle>
|
||||||
<span className="font-bold text-lg text-primary">{currentTotal}</span>
|
<CardDescription>{assignmentTitle}</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
{binaryAnswers.length > 0 ? (
|
<CardContent className="space-y-6">
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="space-y-1">
|
||||||
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700">
|
<div className="flex items-center justify-between text-sm">
|
||||||
Correct {correctCount}
|
<span className="text-muted-foreground">Total Score</span>
|
||||||
</Badge>
|
<span className="font-bold">{currentTotal} / {maxTotal}</span>
|
||||||
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700">
|
</div>
|
||||||
Incorrect {incorrectCount}
|
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
</Badge>
|
<div
|
||||||
{ungradedCount > 0 ? (
|
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||||
<Badge variant="outline" className="text-muted-foreground">
|
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||||
Ungraded {ungradedCount}
|
/>
|
||||||
</Badge>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
<div className="space-y-3 pt-2">
|
||||||
<div className="space-y-6">
|
<div className="flex items-center justify-between text-sm">
|
||||||
{answers.map((ans, index) => (
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Card key={ans.id} className="border-l-4 border-l-primary/20">
|
<User className="h-4 w-4" /> Student
|
||||||
<CardHeader className="py-3 px-4">
|
</span>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<span className="font-medium">{studentName}</span>
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
</div>
|
||||||
<span>Q{index + 1}</span>
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span>
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
{shouldUseBinaryGrading(ans) ? (
|
<Clock className="h-4 w-4" /> Submitted
|
||||||
<Badge
|
</span>
|
||||||
variant="outline"
|
<span className="font-medium">
|
||||||
className={getCorrectnessBadgeClassName(ans)}
|
{submittedAt ? new Date(submittedAt).toLocaleDateString() : "N/A"}
|
||||||
>
|
</span>
|
||||||
{getCorrectnessLabel(ans)}
|
</div>
|
||||||
</Badge>
|
</div>
|
||||||
) : null}
|
|
||||||
</CardTitle>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{answers.length > 0 && (
|
||||||
{shouldUseBinaryGrading(ans) ? (
|
<div className="space-y-4 pt-2">
|
||||||
<>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Button
|
<div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
|
||||||
|
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Correct</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
|
||||||
|
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Incorrect</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
|
||||||
|
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Partial</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
|
||||||
|
Question Status
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{answers.map((ans, i) => {
|
||||||
|
const state = getCorrectnessState(ans)
|
||||||
|
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
|
||||||
|
|
||||||
|
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
|
||||||
|
else if (state === "incorrect") badgeClass = "border-red-200 bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:border-red-800 dark:text-red-400"
|
||||||
|
else if (state === "partial") badgeClass = "border-amber-200 bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-400"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ans.id}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
onClick={() => handleScrollToQuestion(ans.id)}
|
||||||
size="icon"
|
className={`flex h-8 items-center justify-center rounded border text-xs font-medium transition-colors cursor-pointer hover:ring-2 hover:ring-ring hover:ring-offset-2 ${badgeClass}`}
|
||||||
aria-label="mark correct"
|
title={`Q${i + 1}: ${state}`}
|
||||||
className={getMarkCorrectButtonClassName(ans)}
|
|
||||||
onClick={() => handleMarkCorrect(ans.id)}
|
|
||||||
>
|
>
|
||||||
<Check />
|
{i + 1}
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
)
|
||||||
type="button"
|
})}
|
||||||
variant="outline"
|
</div>
|
||||||
size="icon"
|
</div>
|
||||||
aria-label="mark incorrect"
|
</div>
|
||||||
className={getMarkIncorrectButtonClassName(ans)}
|
)}
|
||||||
onClick={() => handleMarkIncorrect(ans.id)}
|
</CardContent>
|
||||||
>
|
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||||
<X />
|
<Button
|
||||||
</Button>
|
className="w-full"
|
||||||
</>
|
size="lg"
|
||||||
) : null}
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>Saving...</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" /> Submit Grades
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Tooltip>
|
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
aria-label="add feedback"
|
className="flex-1"
|
||||||
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)}
|
disabled={!prevSubmissionId}
|
||||||
onClick={() =>
|
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
|
||||||
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) }))
|
>
|
||||||
}
|
<ChevronLeft className="mr-1 h-4 w-4" /> Prev
|
||||||
>
|
</Button>
|
||||||
<MessageSquarePlus />
|
</TooltipTrigger>
|
||||||
</Button>
|
<TooltipContent>Previous Student</TooltipContent>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
<TooltipContent>add feedback</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="py-3 px-4 space-y-3">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{!shouldUseBinaryGrading(ans) ? (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`score-${ans.id}`}>Score</Label>
|
|
||||||
<Input
|
|
||||||
id={`score-${ans.id}`}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={ans.maxScore}
|
|
||||||
value={ans.score ?? ""}
|
|
||||||
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
|
|
||||||
<Textarea
|
|
||||||
id={`fb-${ans.id}`}
|
|
||||||
placeholder="Optional feedback..."
|
|
||||||
className="min-h-[60px] resize-none"
|
|
||||||
value={ans.feedback ?? ""}
|
|
||||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="border-t p-4 bg-muted/20">
|
<Tooltip>
|
||||||
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitting}>
|
<TooltipTrigger asChild>
|
||||||
{isSubmitting ? "Saving..." : "Submit Grades"}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!nextSubmissionId}
|
||||||
|
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
|
||||||
|
>
|
||||||
|
Next <ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Next Student</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-950/30 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChoiceOption = { id?: unknown; isCorrect?: unknown }
|
type ChoiceOption = { id?: unknown; isCorrect?: unknown; text?: string }
|
||||||
|
|
||||||
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
|
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
|
||||||
|
|
||||||
@@ -295,14 +515,6 @@ const getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | nu
|
|||||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldUseBinaryGrading = (ans: Answer): boolean => {
|
|
||||||
if (ans.questionType === "single_choice") return true
|
|
||||||
if (ans.questionType === "multiple_choice") return true
|
|
||||||
if (ans.questionType === "judgment") return true
|
|
||||||
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAutoGradable = (ans: Answer): boolean => {
|
const isAutoGradable = (ans: Answer): boolean => {
|
||||||
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
|
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
|
||||||
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
|
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
|
||||||
@@ -370,39 +582,6 @@ const getCorrectnessState = (ans: Answer): CorrectnessState => {
|
|||||||
return "partial"
|
return "partial"
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCorrectnessLabel = (ans: Answer): string => {
|
|
||||||
const s = getCorrectnessState(ans)
|
|
||||||
if (s === "correct") return "Correct"
|
|
||||||
if (s === "incorrect") return "Incorrect"
|
|
||||||
if (s === "partial") return "Partial"
|
|
||||||
return "Ungraded"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCorrectnessBadgeClassName = (ans: Answer): string => {
|
|
||||||
const s = getCorrectnessState(ans)
|
|
||||||
if (s === "correct") return "border-emerald-200 bg-emerald-50 text-emerald-700"
|
|
||||||
if (s === "incorrect") return "border-red-200 bg-red-50 text-red-700"
|
|
||||||
if (s === "partial") return "border-amber-200 bg-amber-50 text-amber-800"
|
|
||||||
return "text-muted-foreground"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMarkCorrectButtonClassName = (ans: Answer): string => {
|
|
||||||
const active = getCorrectnessState(ans) === "correct"
|
|
||||||
return active ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "text-muted-foreground"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMarkIncorrectButtonClassName = (ans: Answer): string => {
|
|
||||||
const active = getCorrectnessState(ans) === "incorrect"
|
|
||||||
return active ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-100" : "text-muted-foreground"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFeedbackIconButtonClassName = (ans: Answer, isOpen: boolean): string => {
|
|
||||||
const hasFeedback = typeof ans.feedback === "string" && ans.feedback.trim().length > 0
|
|
||||||
if (isOpen) return "text-primary"
|
|
||||||
if (hasFeedback) return "text-primary/80"
|
|
||||||
return "text-muted-foreground"
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatStudentAnswer = (studentAnswer: unknown): string => {
|
const formatStudentAnswer = (studentAnswer: unknown): string => {
|
||||||
const v = extractAnswerValue(studentAnswer)
|
const v = extractAnswerValue(studentAnswer)
|
||||||
if (typeof v === "string") return v
|
if (typeof v === "string") return v
|
||||||
|
|||||||
@@ -3,22 +3,17 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||||
|
|
||||||
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, CardDescription } from "@/shared/components/ui/card"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
import {
|
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select"
|
|
||||||
|
|
||||||
import type { StudentHomeworkTakeData } from "../types"
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
||||||
@@ -87,6 +82,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
|
|
||||||
const isStarted = submissionStatus === "started"
|
const isStarted = submissionStatus === "started"
|
||||||
const canEdit = isStarted && Boolean(submissionId)
|
const canEdit = isStarted && Boolean(submissionId)
|
||||||
|
const showQuestions = submissionStatus !== "not_started"
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
@@ -106,7 +102,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
|
|
||||||
const handleSaveQuestion = async (questionId: string) => {
|
const handleSaveQuestion = async (questionId: string) => {
|
||||||
if (!submissionId) return
|
if (!submissionId) return
|
||||||
setIsBusy(true)
|
// setIsBusy(true) // Don't block UI for individual saves
|
||||||
const payload = answersByQuestionId[questionId]?.answer ?? null
|
const payload = answersByQuestionId[questionId]?.answer ?? null
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("submissionId", submissionId)
|
fd.set("submissionId", submissionId)
|
||||||
@@ -115,12 +111,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
const res = await saveHomeworkAnswerAction(null, fd)
|
const res = await saveHomeworkAnswerAction(null, fd)
|
||||||
if (res.success) toast.success("Saved")
|
if (res.success) toast.success("Saved")
|
||||||
else toast.error(res.message || "Failed to save")
|
else toast.error(res.message || "Failed to save")
|
||||||
setIsBusy(false)
|
// setIsBusy(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!submissionId) return
|
if (!submissionId) return
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
|
// Save all first
|
||||||
for (const q of initialData.questions) {
|
for (const q of initialData.questions) {
|
||||||
const payload = answersByQuestionId[q.questionId]?.answer ?? null
|
const payload = answersByQuestionId[q.questionId]?.answer ?? null
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
@@ -149,50 +146,86 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
<div className="border-b p-4 flex items-center justify-between">
|
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="font-semibold">Questions</h3>
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||||
<Badge variant="outline" className="capitalize">
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
{submissionStatus === "not_started" ? "not started" : submissionStatus}
|
</div>
|
||||||
</Badge>
|
<div>
|
||||||
|
<h3 className="font-semibold leading-none">Questions</h3>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
|
||||||
|
{submissionStatus === "not_started" ? "Not Started" : submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{initialData.questions.length} Questions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!canEdit ? (
|
{!canEdit ? (
|
||||||
<Button onClick={handleStart} disabled={isBusy}>
|
<Button onClick={handleStart} disabled={isBusy} size="sm">
|
||||||
{isBusy ? "Starting..." : "Start"}
|
{isBusy ? "Starting..." : "Start Assignment"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={handleSubmit} disabled={isBusy}>
|
<div className="flex items-center gap-2">
|
||||||
{isBusy ? "Submitting..." : "Submit"}
|
<span className="text-xs text-muted-foreground hidden sm:inline-block">
|
||||||
</Button>
|
Auto-saving enabled
|
||||||
|
</span>
|
||||||
|
<Button onClick={handleSubmit} disabled={isBusy} size="sm">
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
{isBusy ? "Submitting..." : "Submit Assignment"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
<ScrollArea className="flex-1 bg-muted/10">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||||
{initialData.questions.map((q, idx) => {
|
{!isStarted && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||||
|
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium">Ready to start?</h3>
|
||||||
|
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
|
||||||
|
Click the "Start Assignment" button above to begin. The timer will start once you confirm.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleStart} disabled={isBusy}>
|
||||||
|
Start Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showQuestions && initialData.questions.map((q, idx) => {
|
||||||
const text = getQuestionText(q.questionContent)
|
const text = getQuestionText(q.questionContent)
|
||||||
const options = getOptions(q.questionContent)
|
const options = getOptions(q.questionContent)
|
||||||
const value = answersByQuestionId[q.questionId]?.answer
|
const value = answersByQuestionId[q.questionId]?.answer
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={q.questionId} className="border-l-4 border-l-primary/20">
|
<Card key={q.questionId} className="border-l-4 border-l-primary shadow-sm">
|
||||||
<CardHeader className="py-3 px-4">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<span>
|
<div className="space-y-1">
|
||||||
Q{idx + 1} <span className="text-muted-foreground font-normal">({q.questionType})</span>
|
<CardTitle className="text-base font-medium">
|
||||||
</span>
|
Question {idx + 1}
|
||||||
<span className="text-xs text-muted-foreground">Max: {q.maxScore}</span>
|
</CardTitle>
|
||||||
</CardTitle>
|
<CardDescription className="text-xs">
|
||||||
|
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="py-3 px-4 space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="text-sm">{text || "—"}</div>
|
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||||
|
|
||||||
{q.questionType === "text" ? (
|
{q.questionType === "text" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Your answer</Label>
|
<Label className="sr-only">Your answer</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
placeholder="Type your answer here..."
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setAnswersByQuestionId((prev) => ({
|
setAnswersByQuestionId((prev) => ({
|
||||||
@@ -200,14 +233,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
[q.questionId]: { answer: e.target.value },
|
[q.questionId]: { answer: e.target.value },
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="min-h-[100px]"
|
className="min-h-[120px] resize-y"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : q.questionType === "judgment" ? (
|
) : q.questionType === "judgment" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Your answer</Label>
|
<RadioGroup
|
||||||
<Select
|
|
||||||
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setAnswersByQuestionId((prev) => ({
|
setAnswersByQuestionId((prev) => ({
|
||||||
@@ -216,20 +248,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||||
<SelectValue placeholder="Select" />
|
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||||
</SelectTrigger>
|
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">True</Label>
|
||||||
<SelectContent>
|
</div>
|
||||||
<SelectItem value="true">True</SelectItem>
|
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||||
<SelectItem value="false">False</SelectItem>
|
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||||
</SelectContent>
|
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">False</Label>
|
||||||
</Select>
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
) : q.questionType === "single_choice" ? (
|
) : q.questionType === "single_choice" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Your answer</Label>
|
<RadioGroup
|
||||||
<Select
|
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setAnswersByQuestionId((prev) => ({
|
setAnswersByQuestionId((prev) => ({
|
||||||
@@ -238,28 +271,27 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
{options.map((o) => (
|
||||||
<SelectValue placeholder="Select an option" />
|
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||||
</SelectTrigger>
|
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||||
<SelectContent>
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
|
||||||
{options.map((o) => (
|
|
||||||
<SelectItem key={o.id} value={o.id}>
|
|
||||||
{o.text}
|
{o.text}
|
||||||
</SelectItem>
|
</Label>
|
||||||
))}
|
</div>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
) : q.questionType === "multiple_choice" ? (
|
) : q.questionType === "multiple_choice" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Your answer</Label>
|
<div className="flex flex-col gap-2">
|
||||||
<div className="space-y-2">
|
|
||||||
{options.map((o) => {
|
{options.map((o) => {
|
||||||
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||||
return (
|
return (
|
||||||
<label key={o.id} className="flex items-start gap-3 rounded-md border p-3">
|
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
id={`${q.questionId}-${o.id}`}
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
const isChecked = checked === true
|
const isChecked = checked === true
|
||||||
@@ -275,30 +307,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
}}
|
}}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">{o.text}</span>
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
|
||||||
</label>
|
{o.text}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">Unsupported question type</div>
|
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submissionStatus === "graded" && (
|
||||||
|
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||||
|
{q.feedback ? (
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<div className="font-medium text-foreground">Teacher Feedback</div>
|
||||||
|
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||||
|
{q.feedback}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<>
|
<div className="flex justify-end pt-2">
|
||||||
<Separator />
|
<Button
|
||||||
<div className="flex justify-end">
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
onClick={() => handleSaveQuestion(q.questionId)}
|
||||||
size="sm"
|
disabled={isBusy}
|
||||||
onClick={() => handleSaveQuestion(q.questionId)}
|
className="text-muted-foreground hover:text-foreground"
|
||||||
disabled={isBusy}
|
>
|
||||||
>
|
<Save className="mr-2 h-3 w-3" />
|
||||||
Save
|
Save Answer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -308,38 +356,66 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
<div className="border-b p-4">
|
<div className="border-b p-4 bg-muted/30">
|
||||||
<h3 className="font-semibold">Info</h3>
|
<h3 className="font-semibold">Assignment Info</h3>
|
||||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex-1 p-4 overflow-y-auto">
|
||||||
<span>Status</span>
|
<div className="space-y-6">
|
||||||
<span className="capitalize text-foreground">{submissionStatus === "not_started" ? "not started" : submissionStatus}</span>
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
|
||||||
|
{submissionStatus === "not_started" ? "not started" : submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Questions</span>
|
<div>
|
||||||
<span className="text-foreground tabular-nums">{initialData.questions.length}</span>
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{initialData.assignment.description || "No description provided."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showQuestions && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Progress</Label>
|
||||||
|
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||||
|
{initialData.questions.map((q, i) => {
|
||||||
|
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||||
|
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||||
|
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.questionId}
|
||||||
|
className={`
|
||||||
|
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
|
||||||
|
${hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="space-y-3 text-sm">
|
{canEdit && (
|
||||||
<div className="text-muted-foreground">{initialData.assignment.description || "—"}</div>
|
<div className="border-t p-4 bg-muted/20">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t p-4 bg-muted/20">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
||||||
{isBusy ? "Submitting..." : "Submit"}
|
{isBusy ? "Submitting..." : "Submit All"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
<p className="mt-2 text-xs text-center text-muted-foreground">
|
||||||
<Button className="w-full" onClick={handleStart} disabled={isBusy}>
|
Make sure you have answered all questions.
|
||||||
{isBusy ? "Starting..." : "Start"}
|
</p>
|
||||||
</Button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
320
src/modules/homework/components/student-homework-review-view.tsx
Normal file
320
src/modules/homework/components/student-homework-review-view.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
import { CheckCircle2, FileText, ChevronLeft } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||||
|
|
||||||
|
type Option = { id: string; text: string }
|
||||||
|
|
||||||
|
const getQuestionText = (content: unknown): string => {
|
||||||
|
if (!isRecord(content)) return ""
|
||||||
|
return typeof content.text === "string" ? content.text : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOptions = (content: unknown): Option[] => {
|
||||||
|
if (!isRecord(content)) return []
|
||||||
|
const raw = content.options
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const out: Option[] = []
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!isRecord(item)) continue
|
||||||
|
const id = typeof item.id === "string" ? item.id : ""
|
||||||
|
const text = typeof item.text === "string" ? item.text : ""
|
||||||
|
if (!id || !text) continue
|
||||||
|
out.push({ id, text })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const toAnswerShape = (questionType: string, v: unknown) => {
|
||||||
|
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
||||||
|
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
||||||
|
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
||||||
|
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
|
||||||
|
return { answer: v }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSavedAnswer = (saved: unknown, questionType: string) => {
|
||||||
|
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
|
||||||
|
return toAnswerShape(questionType, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeworkReviewViewProps = {
|
||||||
|
initialData: StudentHomeworkTakeData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||||
|
const submissionStatus = initialData.submission?.status ?? "not_started"
|
||||||
|
const isGraded = submissionStatus === "graded"
|
||||||
|
const isSubmitted = submissionStatus === "submitted"
|
||||||
|
|
||||||
|
const answersByQuestionId = useMemo(() => {
|
||||||
|
const map = new Map<string, { answer: unknown }>()
|
||||||
|
for (const q of initialData.questions) {
|
||||||
|
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
|
||||||
|
}
|
||||||
|
const obj: Record<string, { answer: unknown }> = {}
|
||||||
|
for (const [k, v] of map.entries()) obj[k] = v
|
||||||
|
return obj
|
||||||
|
}, [initialData.questions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
|
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold leading-none">
|
||||||
|
{isGraded ? "Graded Report" : "Submission Details"}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] capitalize">
|
||||||
|
{submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{initialData.questions.length} Questions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/student/learning/assignments">
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to List
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 bg-muted/10">
|
||||||
|
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||||
|
{initialData.questions.map((q, idx) => {
|
||||||
|
const text = getQuestionText(q.questionContent)
|
||||||
|
const options = getOptions(q.questionContent)
|
||||||
|
const value = answersByQuestionId[q.questionId]?.answer
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={q.questionId} className={`shadow-sm ${isGraded ? 'border-l-4' : 'border-l-4 border-l-primary'}`}
|
||||||
|
style={isGraded ? { borderLeftColor: q.score === q.maxScore && q.maxScore > 0 ? '#10b981' : q.score && q.score > 0 ? '#eab308' : '#ef4444' } : undefined}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
Question {idx + 1}
|
||||||
|
{isGraded && (
|
||||||
|
<Badge variant="outline" className={`ml-2 ${q.score === q.maxScore ? "text-emerald-600 border-emerald-200 bg-emerald-50" : "text-red-600 border-red-200 bg-red-50"}`}>
|
||||||
|
{q.score} / {q.maxScore}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs flex flex-col gap-1.5">
|
||||||
|
<span>{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points</span>
|
||||||
|
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{q.knowledgePoints.map((kp) => (
|
||||||
|
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
|
||||||
|
{kp.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||||
|
|
||||||
|
{q.questionType === "text" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="sr-only">Your answer</Label>
|
||||||
|
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
|
||||||
|
{typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : q.questionType === "judgment" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<RadioGroup
|
||||||
|
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||||
|
disabled
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||||
|
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||||
|
<Label htmlFor={`${q.questionId}-true`} className="flex-1 font-normal">True</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||||
|
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||||
|
<Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
) : q.questionType === "single_choice" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<RadioGroup
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
disabled
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||||
|
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||||
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
|
||||||
|
{o.text}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
) : q.questionType === "multiple_choice" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{options.map((o) => {
|
||||||
|
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||||
|
return (
|
||||||
|
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 bg-muted/20">
|
||||||
|
<Checkbox
|
||||||
|
id={`${q.questionId}-${o.id}`}
|
||||||
|
checked={selected}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal">
|
||||||
|
{o.text}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGraded && (
|
||||||
|
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||||
|
{q.feedback ? (
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<div className="font-medium text-foreground">Teacher Feedback</div>
|
||||||
|
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||||
|
{q.feedback}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
|
<div className="border-b p-4 bg-muted/30">
|
||||||
|
<h3 className="font-semibold">Assignment Info</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4 overflow-y-auto">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{initialData.assignment.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGraded && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Total Score</Label>
|
||||||
|
<div className="mt-2 flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold text-primary">
|
||||||
|
{initialData.submission?.score ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
/ {initialData.questions.reduce((acc, q) => acc + q.maxScore, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-emerald-600"></div>
|
||||||
|
<span>Correct</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
|
||||||
|
<span>Partial</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||||
|
<span>Incorrect</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
|
{isGraded ? "Question Breakdown" : "Response Summary"}
|
||||||
|
</Label>
|
||||||
|
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||||
|
{initialData.questions.map((q, i) => {
|
||||||
|
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||||
|
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||||
|
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||||
|
|
||||||
|
const score = q.score ?? 0
|
||||||
|
const max = q.maxScore
|
||||||
|
let statusClass = "bg-background text-muted-foreground border-input"
|
||||||
|
|
||||||
|
if (isGraded) {
|
||||||
|
if (score === max && max > 0) statusClass = "bg-emerald-600 text-white border-emerald-600"
|
||||||
|
else if (score > 0) statusClass = "bg-yellow-500 text-white border-yellow-500"
|
||||||
|
else statusClass = "bg-red-500 text-white border-red-500"
|
||||||
|
} else if (hasAnswer) {
|
||||||
|
statusClass = "bg-primary text-primary-foreground border-primary"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.questionId}
|
||||||
|
className={`
|
||||||
|
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
|
||||||
|
${statusClass}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 => {
|
||||||
@@ -463,6 +524,17 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
|||||||
})
|
})
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
// Fetch adjacent submissions for navigation
|
||||||
|
const allSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||||
|
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||||
|
columns: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId)
|
||||||
|
const prevSubmissionId = currentIndex > 0 ? allSubmissions[currentIndex - 1].id : null
|
||||||
|
const nextSubmissionId = currentIndex >= 0 && currentIndex < allSubmissions.length - 1 ? allSubmissions[currentIndex + 1].id : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: submission.id,
|
id: submission.id,
|
||||||
assignmentId: submission.assignmentId,
|
assignmentId: submission.assignmentId,
|
||||||
@@ -472,6 +544,8 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
|||||||
status: submission.status as HomeworkSubmissionDetails["status"],
|
status: submission.status as HomeworkSubmissionDetails["status"],
|
||||||
totalScore: submission.score,
|
totalScore: submission.score,
|
||||||
answers: answersWithDetails,
|
answers: answersWithDetails,
|
||||||
|
prevSubmissionId,
|
||||||
|
nextSubmissionId,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -582,16 +656,32 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
|||||||
|
|
||||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||||
with: { question: true },
|
with: {
|
||||||
|
question: {
|
||||||
|
with: {
|
||||||
|
knowledgePoints: {
|
||||||
|
with: {
|
||||||
|
knowledgePoint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: (q, { asc }) => [asc(q.order)],
|
orderBy: (q, { asc }) => [asc(q.order)],
|
||||||
})
|
})
|
||||||
|
|
||||||
const savedByQuestionId = new Map<string, unknown>()
|
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
|
||||||
if (latestSubmission) {
|
if (latestSubmission) {
|
||||||
const answers = await db.query.homeworkAnswers.findMany({
|
const answers = await db.query.homeworkAnswers.findMany({
|
||||||
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
|
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
|
||||||
})
|
})
|
||||||
for (const ans of answers) savedByQuestionId.set(ans.questionId, ans.answerContent)
|
for (const ans of answers) {
|
||||||
|
answersByQuestionId.set(ans.questionId, {
|
||||||
|
answer: ans.answerContent,
|
||||||
|
score: ans.score,
|
||||||
|
feedback: ans.feedback,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -614,14 +704,25 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
|||||||
score: latestSubmission.score ?? null,
|
score: latestSubmission.score ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
questions: assignmentQuestions.map((aq) => ({
|
questions: assignmentQuestions.map((aq) => {
|
||||||
questionId: aq.questionId,
|
const saved = answersByQuestionId.get(aq.questionId)
|
||||||
questionType: aq.question.type,
|
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
|
||||||
questionContent: toQuestionContent(aq.question.content),
|
const kps = aq.question.knowledgePoints ?? []
|
||||||
maxScore: aq.score ?? 0,
|
return {
|
||||||
order: aq.order ?? 0,
|
questionId: aq.questionId,
|
||||||
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null,
|
questionType: aq.question.type,
|
||||||
})),
|
questionContent: toQuestionContent(aq.question.content),
|
||||||
|
maxScore: aq.score ?? 0,
|
||||||
|
order: aq.order ?? 0,
|
||||||
|
savedAnswer: saved?.answer ?? null,
|
||||||
|
score: saved?.score ?? null,
|
||||||
|
feedback: saved?.feedback ?? null,
|
||||||
|
knowledgePoints: kps.map((kp) => ({
|
||||||
|
id: kp.knowledgePoint.id,
|
||||||
|
name: kp.knowledgePoint.name,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -63,6 +73,8 @@ export type HomeworkSubmissionDetails = {
|
|||||||
status: HomeworkSubmissionStatus
|
status: HomeworkSubmissionStatus
|
||||||
totalScore: number | null
|
totalScore: number | null
|
||||||
answers: HomeworkSubmissionAnswerDetails[]
|
answers: HomeworkSubmissionAnswerDetails[]
|
||||||
|
prevSubmissionId?: string | null
|
||||||
|
nextSubmissionId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
|
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
|
||||||
@@ -104,6 +116,9 @@ export type StudentHomeworkTakeQuestion = {
|
|||||||
maxScore: number
|
maxScore: number
|
||||||
order: number
|
order: number
|
||||||
savedAnswer: unknown
|
savedAnswer: unknown
|
||||||
|
score?: number | null
|
||||||
|
feedback?: string | null
|
||||||
|
knowledgePoints?: Array<{ id: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudentHomeworkTakeData = {
|
export type StudentHomeworkTakeData = {
|
||||||
@@ -135,7 +150,7 @@ export type HomeworkAssignmentQuestionAnalytics = {
|
|||||||
order: number
|
order: number
|
||||||
errorCount: number
|
errorCount: number
|
||||||
errorRate: number
|
errorRate: number
|
||||||
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }>
|
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown; count?: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HomeworkAssignmentAnalytics = {
|
export type HomeworkAssignmentAnalytics = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
import { Bell, Menu, Search } from "lucide-react"
|
import { Bell, Menu, Search } from "lucide-react"
|
||||||
import { signOut, useSession } from "next-auth/react"
|
import { signOut, useSession } from "next-auth/react"
|
||||||
|
|
||||||
@@ -27,8 +28,21 @@ import {
|
|||||||
} from "@/shared/components/ui/dropdown-menu"
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
|
||||||
import { useSidebar } from "./sidebar-provider"
|
import { useSidebar } from "./sidebar-provider"
|
||||||
|
import { NAV_CONFIG } from "../config/navigation"
|
||||||
|
|
||||||
|
// Build lookup map for breadcrumbs
|
||||||
|
const BREADCRUMB_MAP = new Map<string, string>()
|
||||||
|
Object.values(NAV_CONFIG).forEach((items) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
BREADCRUMB_MAP.set(item.href, item.title)
|
||||||
|
item.items?.forEach((subItem) => {
|
||||||
|
BREADCRUMB_MAP.set(subItem.href, subItem.title)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
|
const pathname = usePathname()
|
||||||
const { toggleSidebar, isMobile } = useSidebar()
|
const { toggleSidebar, isMobile } = useSidebar()
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
|
|
||||||
@@ -44,6 +58,16 @@ export function SiteHeader() {
|
|||||||
.map((p) => p[0]?.toUpperCase())
|
.map((p) => p[0]?.toUpperCase())
|
||||||
.join("")
|
.join("")
|
||||||
|
|
||||||
|
// Generate breadcrumbs
|
||||||
|
const segments = pathname.split("/").filter(Boolean)
|
||||||
|
const breadcrumbs = segments
|
||||||
|
.map((segment, index) => {
|
||||||
|
const href = `/${segments.slice(0, index + 1).join("/")}`
|
||||||
|
const title = BREADCRUMB_MAP.get(href) || segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||||
|
return { href, title, isLast: index === segments.length - 1 }
|
||||||
|
})
|
||||||
|
.filter((b) => !["admin", "teacher", "student", "parent"].includes(b.title.toLowerCase()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
|
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
|
||||||
<div className="flex flex-1 items-center gap-4">
|
<div className="flex flex-1 items-center gap-4">
|
||||||
@@ -60,13 +84,26 @@ export function SiteHeader() {
|
|||||||
{/* Breadcrumbs */}
|
{/* Breadcrumbs */}
|
||||||
<Breadcrumb className="hidden md:flex">
|
<Breadcrumb className="hidden md:flex">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
{breadcrumbs.length > 0 ? (
|
||||||
<BreadcrumbLink href="/dashboard">Dashboard</BreadcrumbLink>
|
breadcrumbs.map((crumb) => (
|
||||||
</BreadcrumbItem>
|
<React.Fragment key={crumb.href}>
|
||||||
<BreadcrumbSeparator />
|
<BreadcrumbItem>
|
||||||
<BreadcrumbItem>
|
{crumb.isLast ? (
|
||||||
<BreadcrumbPage>Overview</BreadcrumbPage>
|
<BreadcrumbPage>{crumb.title}</BreadcrumbPage>
|
||||||
</BreadcrumbItem>
|
) : (
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link href={crumb.href}>{crumb.title}</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{!crumb.isLast && <BreadcrumbSeparator />}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>Home</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
)}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
FileQuestion,
|
FileQuestion,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Library,
|
Library,
|
||||||
PenTool
|
PenTool,
|
||||||
|
Briefcase
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -123,8 +124,14 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
|||||||
{ title: "My Classes", href: "/teacher/classes/my" },
|
{ title: "My Classes", href: "/teacher/classes/my" },
|
||||||
{ title: "Students", href: "/teacher/classes/students" },
|
{ title: "Students", href: "/teacher/classes/students" },
|
||||||
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
||||||
{ title: "Insights", href: "/teacher/classes/insights" },
|
]
|
||||||
{ title: "Grade Insights", href: "/teacher/grades/insights" },
|
},
|
||||||
|
{
|
||||||
|
title: "Management",
|
||||||
|
icon: Briefcase,
|
||||||
|
href: "/management",
|
||||||
|
items: [
|
||||||
|
{ title: "Grade Insights", href: "/management/grade/insights" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user