Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb4555f611 | ||
|
|
9bfc621d3f | ||
|
|
ade8d4346c |
@@ -91,6 +91,9 @@
|
||||
- 若现有基础组件无法满足需求:
|
||||
1. 优先通过 Composition 在业务模块里封装“业务组件”
|
||||
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
|
||||
- **图表库**:统一使用 `Recharts`,禁止引入其他图表库(Chart.js / ECharts 等)。
|
||||
- 使用 `src/shared/components/ui/chart.tsx` 进行封装。
|
||||
- 遵循 Shadcn/UI Chart 规范。
|
||||
|
||||
### 2.4 Client Component 引用边界(强制)
|
||||
|
||||
@@ -419,6 +422,7 @@ export type ActionState<T = void> = {
|
||||
- 禁止在 CSS 中 `@import` 外部字体 URL(避免 CLS 与阻塞渲染)
|
||||
- 依赖:
|
||||
- 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益
|
||||
- **图表**:标准图表库统一使用 `recharts`(通过 `src/shared/components/ui/chart.tsx` 封装),禁止引入其他图表库(如 Chart.js / Highcharts)。
|
||||
- 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback
|
||||
|
||||
---
|
||||
|
||||
@@ -199,33 +199,44 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
|
||||
**日期**: 2026-01-08
|
||||
**范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码
|
||||
|
||||
#### 6.7.1 数据结构
|
||||
- 表:`classes`
|
||||
- 字段:`invitation_code`(varchar(6),unique,可为空)
|
||||
- 迁移:`drizzle/0007_add_class_invitation_code.sql`
|
||||
---
|
||||
|
||||
#### 6.7.2 教师端能力
|
||||
- 在「我的班级」卡片中展示邀请码。
|
||||
- 提供“刷新邀请码”操作:生成新的 6 位码并写入数据库(确保唯一性)。
|
||||
## 7. 班级管理重构与角色分离 (2026-01-14)
|
||||
|
||||
#### 6.7.3 学生端能力
|
||||
- 提供“通过邀请码加入班级”的入口,输入 6 位码后完成报名。
|
||||
- 写库操作设计为幂等:重复提交同一个邀请码不会生成重复报名记录,已有记录会被更新为有效状态。
|
||||
**日期**: 2026-01-14
|
||||
**范围**: 班级创建权限收归管理端,教师端仅保留查看与加入
|
||||
|
||||
#### 6.7.4 Seed 支持
|
||||
- `scripts/seed.ts` 为示例班级补充 `invitationCode`,便于在开发环境直接验证加入流程。
|
||||
### 7.1 职责分离 (Role Separation)
|
||||
|
||||
### 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 班级创建权限收紧
|
||||
- 目标:仅允许年级组长与 admin 创建班级。
|
||||
- 后端:`createTeacherClassAction` 增加权限校验,非 admin 必须是对应年级的 `gradeHead`;`createAdminClassAction` 强制仅 admin 可调用(`src/modules/classes/actions.ts`)。
|
||||
- 前端:教师端「My Classes」页基于当前用户是否为任一年级 `gradeHead` 计算 `canCreateClass`,并禁用创建入口(`src/app/(dashboard)/teacher/classes/my/page.tsx`、`src/modules/classes/components/my-classes-grid.tsx`)。
|
||||
### 7.2 数据访问与权限
|
||||
|
||||
#### 6.8.2 注册页面从演示提交改为真实注册
|
||||
- `/register` 增加服务端注册动作:校验输入、邮箱查重、插入 `users` 表,默认 `role=student`(`src/app/(auth)/register/page.tsx`)。
|
||||
- 注册表单接入注册动作并展示成功/失败提示,成功后跳转至 `/login`(`src/modules/auth/components/register-form.tsx`)。
|
||||
- 新增 `getGradeManagedClasses`: 仅返回用户作为 Grade Head 或 Teaching Head 管理的年级下的班级
|
||||
- Server Actions (`createGradeClassAction` 等) 增加严格的 RBAC 校验,确保操作者对目标年级有管理权限
|
||||
|
||||
#### 6.8.3 生产环境登录 UntrustedHost 修复
|
||||
- 问题:服务器上访问 `/api/auth/session` 报 `[auth][error] UntrustedHost`。
|
||||
- 修复:Auth.js 配置开启 `trustHost: true` 并显式设置 `secret`(`src/auth.ts`)。
|
||||
## 8. 课表模块视觉升级与架构优化 (2026-01-15)
|
||||
|
||||
**日期**: 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
|
||||
|
||||
**Date**: 2025-12-23
|
||||
**Updated**: 2025-12-31
|
||||
**Updated**: 2026-01-13
|
||||
**Author**: DevOps Architect
|
||||
**Module**: Textbooks (`src/modules/textbooks`)
|
||||
|
||||
@@ -143,6 +143,51 @@ src/
|
||||
* 通过 `npm run lint / typecheck / build`。
|
||||
|
||||
## 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 中,采用内存过滤
|
||||
- `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
|
||||
|
||||
|
||||
@@ -268,3 +268,39 @@
|
||||
- `npm run lint`: 通过
|
||||
- `npm run typecheck`: 通过
|
||||
- `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).
|
||||
* **Layout**: `flex items-center justify-between px-6 border-b`.
|
||||
* **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` 触发,居中或靠右。
|
||||
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,15 +54,15 @@
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1767782500000,
|
||||
"tag": "0007_add_class_invitation_code",
|
||||
"when": 1768205524480,
|
||||
"tag": "0007_talented_bromley",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1767941300000,
|
||||
"tag": "0008_add_user_profile_fields",
|
||||
"when": 1768470966367,
|
||||
"tag": "0008_thin_madrox",
|
||||
"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-checkbox": "^1.3.3",
|
||||
"@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-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-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -33,6 +37,10 @@
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -46,18 +54,20 @@
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"recharts": "^3.6.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
textbooks, chapters,
|
||||
schools,
|
||||
grades,
|
||||
classes, classEnrollments, classSchedule
|
||||
classes, classEnrollments, classSchedule,
|
||||
subjects
|
||||
} from "../src/shared/db/schema";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { faker } from "@faker-js/faker";
|
||||
@@ -43,7 +44,7 @@ async function seed() {
|
||||
"submission_answers", "exam_submissions", "exam_questions", "exams",
|
||||
"questions_to_knowledge_points", "questions", "knowledge_points",
|
||||
"chapters", "textbooks",
|
||||
"grades", "schools",
|
||||
"grades", "schools", "subjects",
|
||||
"users_to_roles", "roles", "users", "accounts", "sessions"
|
||||
];
|
||||
for (const table of tables) {
|
||||
@@ -133,6 +134,17 @@ async function seed() {
|
||||
{ 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([
|
||||
{
|
||||
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>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<select
|
||||
name="gradeId"
|
||||
@@ -4,14 +4,16 @@ import { redirect } from "next/navigation"
|
||||
import { auth } from "@/auth"
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
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 { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
||||
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
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 { Separator } from "@/shared/components/ui/separator"
|
||||
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
const session = await auth()
|
||||
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 userProfile = await getUserProfile(userId)
|
||||
|
||||
if (!userProfile) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const role = userProfile.role || "student"
|
||||
const isStudent = role === "student"
|
||||
|
||||
const studentData =
|
||||
role === "student" && userId
|
||||
isStudent
|
||||
? await (async () => {
|
||||
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
||||
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="space-y-1">
|
||||
<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 className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings">Open settings</Link>
|
||||
<Link href="/settings">Edit Profile</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
<CardDescription>Signed-in user details from session.</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Personal Information
|
||||
</CardTitle>
|
||||
<CardDescription>Basic personal details.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{role}
|
||||
</Badge>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Full Name</div>
|
||||
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
|
||||
</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>
|
||||
<div className="text-sm text-muted-foreground">{email}</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 ? (
|
||||
<div className="space-y-6">
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold tracking-tight">Student</h2>
|
||||
<div className="text-sm text-muted-foreground">Your learning overview.</div>
|
||||
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
|
||||
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
|
||||
</div>
|
||||
|
||||
<StudentStatsGrid
|
||||
@@ -133,16 +217,17 @@ export default async function ProfilePage() {
|
||||
dueSoonCount={studentData.dueSoonCount}
|
||||
overdueCount={studentData.overdueCount}
|
||||
gradedCount={studentData.gradedCount}
|
||||
ranking={studentData.grades.ranking}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StudentGradesCard grades={studentData.grades} />
|
||||
<StudentRankingCard ranking={studentData.grades.ranking} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
|
||||
<StudentGradesCard grades={studentData.grades} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { auth } from "@/auth"
|
||||
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
|
||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -11,11 +12,16 @@ export default async function SettingsPage() {
|
||||
const session = await auth()
|
||||
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 (role === "student") return <StudentSettingsView user={session.user} />
|
||||
if (role === "teacher") return <TeacherSettingsView user={session.user} />
|
||||
if (!userProfile) redirect("/login")
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation"
|
||||
|
||||
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||
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"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -18,6 +19,23 @@ export default async function StudentAssignmentTakePage({
|
||||
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
||||
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 (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -31,6 +31,18 @@ const getStatusLabel = (status: string) => {
|
||||
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() {
|
||||
const student = await getDemoStudentUser()
|
||||
|
||||
@@ -75,6 +87,7 @@ export default async function StudentAssignmentsPage() {
|
||||
<TableHead>Due</TableHead>
|
||||
<TableHead>Attempts</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -95,6 +108,13 @@ export default async function StudentAssignmentsPage() {
|
||||
{a.attemptsUsed}/{a.maxAttempts}
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Link from "next/link"
|
||||
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 { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||
|
||||
@@ -34,42 +33,42 @@ export default async function StudentTextbookDetailPage({
|
||||
|
||||
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()
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/student/learning/textbooks">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{textbook.subject}</Badge>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||
{textbook.grade ?? "-"}
|
||||
</span>
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
|
||||
<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">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="hidden sm:inline-block w-px h-4 bg-border" />
|
||||
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
|
||||
{textbook.grade && (
|
||||
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden pt-6">
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
{chapters.length === 0 ? (
|
||||
<div className="px-8">
|
||||
<div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title="No chapters"
|
||||
description="This textbook has no chapters yet."
|
||||
className="bg-card"
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[calc(100vh-140px)] px-8 min-h-0">
|
||||
<TextbookReader chapters={chapters} />
|
||||
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
||||
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
|
||||
</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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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 { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
|
||||
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
|
||||
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
|
||||
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
||||
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
|
||||
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
|
||||
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
|
||||
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
|
||||
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export default async function ClassDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -31,285 +21,97 @@ export default async function ClassDetailPage({
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
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([
|
||||
getClassHomeworkInsights({ classId: id, limit: 50 }),
|
||||
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
|
||||
getClassStudents({ classId: id }),
|
||||
getClassSchedule({ classId: id }),
|
||||
])
|
||||
|
||||
if (!insights) return notFound()
|
||||
|
||||
const latest = insights.latest
|
||||
const filteredAssignments = insights.assignments.filter((a) => {
|
||||
if (hwFilter === "all") return true
|
||||
if (hwFilter === "overdue") return a.isOverdue
|
||||
if (hwFilter === "active") return a.isActive
|
||||
return true
|
||||
})
|
||||
const hasAssignments = filteredAssignments.length > 0
|
||||
const scheduleBuilderClasses = [
|
||||
{
|
||||
id: insights.class.id,
|
||||
name: insights.class.name,
|
||||
grade: insights.class.grade,
|
||||
homeroom: insights.class.homeroom ?? null,
|
||||
room: insights.class.room ?? null,
|
||||
studentCount: insights.studentCounts.total,
|
||||
},
|
||||
]
|
||||
// Fetch subject scores
|
||||
const studentScores = await getClassStudentSubjectScoresV2(id)
|
||||
|
||||
// Data mapping for widgets
|
||||
const assignmentSummaries = 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
|
||||
}))
|
||||
|
||||
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 (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/classes/my">Back</Link>
|
||||
</Button>
|
||||
<Badge variant="secondary">{insights.class.grade}</Badge>
|
||||
<Badge variant="outline">{insights.studentCounts.total} students</Badge>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"}
|
||||
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null}
|
||||
</div>
|
||||
<div className="flex min-h-screen flex-col bg-muted/10">
|
||||
<ClassHeader
|
||||
classId={insights.class.id}
|
||||
name={insights.class.name}
|
||||
grade={insights.class.grade}
|
||||
homeroom={insights.class.homeroom}
|
||||
room={insights.class.room}
|
||||
schoolName={insights.class.schoolName}
|
||||
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 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>
|
||||
{/* Sidebar Area (Right 1/3) */}
|
||||
<div className="space-y-6">
|
||||
{/* <ClassQuickActions classId={insights.class.id} /> */}
|
||||
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
|
||||
<ClassAssignmentsWidget
|
||||
classId={insights.class.id}
|
||||
assignments={assignmentSummaries}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,29 +13,10 @@ export default function MyClassesPage() {
|
||||
|
||||
async function MyClassesPageImpl() {
|
||||
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 (
|
||||
<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">My Classes</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Overview of your classes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MyClassesGrid classes={classes} canCreateClass={canCreateClass} />
|
||||
<div className="flex h-full flex-col space-y-4 p-8">
|
||||
<MyClassesGrid classes={classes} canCreateClass={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,16 +67,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
|
||||
|
||||
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">Schedule</h2>
|
||||
<p className="text-muted-foreground">
|
||||
View class schedule.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ScheduleFilters classes={classes} />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Suspense } from "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 { StudentsTable } from "@/modules/classes/components/students-table"
|
||||
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
|
||||
}
|
||||
|
||||
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }) {
|
||||
const params = await searchParams
|
||||
|
||||
const q = getParam(params, "q") || undefined
|
||||
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({
|
||||
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) {
|
||||
return (
|
||||
@@ -65,25 +82,20 @@ function StudentsResultsFallback() {
|
||||
|
||||
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
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 (
|
||||
<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">Students</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage student list.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col space-y-4 p-8">
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<StudentsFilters classes={classes} />
|
||||
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<StudentsResultsFallback />}>
|
||||
<StudentsResults searchParams={searchParams} />
|
||||
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
|
||||
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
|
||||
import { getHomeworkAssignments, getHomeworkSubmissions } from "@/modules/homework/data-access";
|
||||
import { getHomeworkAssignments, getHomeworkSubmissions, getTeacherGradeTrends } from "@/modules/homework/data-access";
|
||||
import { db } from "@/shared/db";
|
||||
import { users } from "@/shared/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TeacherDashboardPage() {
|
||||
const teacherId = await getTeacherIdForMutations();
|
||||
|
||||
const [classes, schedule, assignments, submissions] = await Promise.all([
|
||||
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
|
||||
getTeacherClasses({ teacherId }),
|
||||
getClassSchedule({ teacherId }),
|
||||
getHomeworkAssignments({ creatorId: teacherId }),
|
||||
getHomeworkSubmissions({ creatorId: teacherId }),
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, teacherId),
|
||||
columns: { name: true },
|
||||
}),
|
||||
getTeacherGradeTrends(teacherId),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -21,6 +29,8 @@ export default async function TeacherDashboardPage() {
|
||||
schedule,
|
||||
assignments,
|
||||
submissions,
|
||||
teacherName: teacherProfile?.name ?? "Teacher",
|
||||
gradeTrends,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -12,9 +12,8 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
const exam = await getExamById(id)
|
||||
if (!exam) return notFound()
|
||||
|
||||
// Fetch all available questions (for selection pool)
|
||||
// In a real app, this might be paginated or filtered by exam subject/grade
|
||||
const { data: questionsData } = await getQuestions({ pageSize: 100 })
|
||||
// Fetch initial questions for the bank (pagination handled by client)
|
||||
const { data: questionsData } = await getQuestions({ pageSize: 20 })
|
||||
|
||||
const initialSelected = (exam.questions || []).map(q => ({
|
||||
id: q.id,
|
||||
@@ -103,13 +102,7 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<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>
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<ExamAssembly
|
||||
examId={exam.id}
|
||||
title={exam.title}
|
||||
|
||||
@@ -131,13 +131,6 @@ export default async function AllExamsPage({
|
||||
}) {
|
||||
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">All Exams</h2>
|
||||
<p className="text-muted-foreground">View and manage all your exams.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ExamFilters />
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/shared/components/ui/breadcrumb"
|
||||
|
||||
export default function CreateExamPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Create Exam</h2>
|
||||
<p className="text-muted-foreground">Design a new exam for your students.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Set up a new exam draft and choose your assembly method.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExamForm />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,12 +2,12 @@ import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
|
||||
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 { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -20,82 +20,82 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
|
||||
const { assignment, questions, gradedSampleCount } = analytics
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<div className="flex flex-col min-h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background px-8 py-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<Link href="/teacher/homework/assignments" className="flex items-center hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Assignments
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>Details</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
<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 mt-1">{assignment.description || "—"}</p>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<span>Source Exam: {assignment.sourceExamTitle}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Created: {formatDate(assignment.createdAt)}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm max-w-2xl">{assignment.description || "No description provided."}</p>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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"}
|
||||
{/* 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="flex-1 p-8 space-y-8 bg-muted/5">
|
||||
{/* Analytics Section */}
|
||||
<section className="space-y-4">
|
||||
<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} />
|
||||
<HomeworkAssignmentQuestionErrorDetailsCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content Section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Assignment Content</h2>
|
||||
</div>
|
||||
<HomeworkAssignmentExamContentCard
|
||||
structure={assignment.structure}
|
||||
questions={questions}
|
||||
gradedSampleCount={gradedSampleCount}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
|
||||
status={submission.status}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
prevSubmissionId={submission.prevSubmissionId}
|
||||
nextSubmissionId={submission.nextSubmissionId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -51,10 +51,11 @@ export default async function TextbookDetailPage({
|
||||
|
||||
{/* Main Content Layout (Flex grow) */}
|
||||
<div className="flex-1 overflow-hidden pt-6">
|
||||
<TextbookContentLayout
|
||||
<TextbookReader
|
||||
chapters={chapters}
|
||||
knowledgePoints={knowledgePoints}
|
||||
textbookId={id}
|
||||
canEdit={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
||||
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."}
|
||||
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> }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 p-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
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 { 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 {
|
||||
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(
|
||||
classId: string,
|
||||
prevState: ActionState | null,
|
||||
@@ -171,14 +366,19 @@ export async function joinClassByInvitationCodeAction(
|
||||
}
|
||||
|
||||
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" }
|
||||
}
|
||||
|
||||
try {
|
||||
const classId = await enrollStudentByInvitationCode(session.user.id, code)
|
||||
if (role === "student") {
|
||||
revalidatePath("/student/learning/courses")
|
||||
revalidatePath("/student/schedule")
|
||||
} else {
|
||||
revalidatePath("/teacher/classes/my")
|
||||
}
|
||||
revalidatePath("/profile")
|
||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
||||
} 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 { useMemo, useState } from "react"
|
||||
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 { 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 { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -40,198 +30,144 @@ import {
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import type { TeacherClass } from "../types"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import type { TeacherClass, ClassScheduleItem } from "../types"
|
||||
import {
|
||||
createTeacherClassAction,
|
||||
deleteTeacherClassAction,
|
||||
ensureClassInvitationCodeAction,
|
||||
regenerateClassInvitationCodeAction,
|
||||
updateTeacherClassAction,
|
||||
joinClassByInvitationCodeAction,
|
||||
} 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 }) {
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
|
||||
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
|
||||
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) => {
|
||||
const handleJoin = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await createTeacherClassAction(null, formData)
|
||||
const res = await joinClassByInvitationCodeAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setCreateOpen(false)
|
||||
toast.success(res.message || "Joined class successfully")
|
||||
setJoinOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create class")
|
||||
toast.error(res.message || "Failed to join class")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create class")
|
||||
toast.error("Failed to join class")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 md:max-w-sm">
|
||||
<Input
|
||||
placeholder="Search classes..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Grade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All grades</SelectItem>
|
||||
{gradeOptions.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{g}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(q || grade !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9"
|
||||
onClick={() => {
|
||||
setQ(null)
|
||||
setGrade(null)
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* Filter Bar */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div className="relative">
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
open={joinOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!canCreateClass) return
|
||||
if (isWorking) return
|
||||
setCreateOpen(open)
|
||||
setJoinOpen(open)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" disabled={isWorking || !canCreateClass}>
|
||||
<Plus className="size-4" />
|
||||
New class
|
||||
<div className="group relative">
|
||||
{/* Decorative Ticket Stub Effect */}
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<Plus className="size-3.5" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="font-semibold tracking-tight">Join New Class</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create class</DialogTitle>
|
||||
<DialogDescription>Add a new class to start managing students.</DialogDescription>
|
||||
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden gap-0 border-none shadow-2xl">
|
||||
{/* 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>
|
||||
<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
|
||||
|
||||
<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="create-grade"
|
||||
name="grade"
|
||||
className="col-span-3"
|
||||
placeholder="e.g. Grade 7"
|
||||
defaultValue={defaultGrade}
|
||||
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>
|
||||
<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 className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/30 pointer-events-none">
|
||||
<Users className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Creating..." : "Create"}
|
||||
<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 className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* List */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
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}
|
||||
action={canCreateClass ? { label: "Create class", onClick: () => setCreateOpen(true) } : undefined}
|
||||
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
|
||||
/>
|
||||
) : 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"
|
||||
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
||||
className="h-[360px] bg-card border-dashed"
|
||||
/>
|
||||
) : (
|
||||
filteredClasses.map((c) => (
|
||||
<ClassCard
|
||||
key={c.id}
|
||||
c={c}
|
||||
onWorkingChange={setIsWorking}
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
classes.map((c) => (
|
||||
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
||||
))
|
||||
)}
|
||||
</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,
|
||||
isWorking,
|
||||
onWorkingChange,
|
||||
@@ -249,8 +190,6 @@ function ClassCard({
|
||||
onWorkingChange: (v: boolean) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
|
||||
const handleEnsureCode = async () => {
|
||||
onWorkingChange(true)
|
||||
@@ -297,238 +236,160 @@ function ClassCard({
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (formData: FormData) => {
|
||||
onWorkingChange(true)
|
||||
try {
|
||||
const res = await updateTeacherClassAction(c.id, null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setShowEdit(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update class")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update class")
|
||||
} finally {
|
||||
onWorkingChange(false)
|
||||
}
|
||||
}
|
||||
// Real data for chart
|
||||
const recentAssignments = c.recentAssignments ?? []
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Calculate performance change for indicator (still needed for the top indicator)
|
||||
// We can't reuse chart data easily here without recalculating, but ClassTrendsWidget handles its own data now
|
||||
const lastTwoAssignments = [...recentAssignments].reverse().slice(-2)
|
||||
const performanceChange = lastTwoAssignments.length === 2 && lastTwoAssignments[0].submittedCount > 0
|
||||
? ((lastTwoAssignments[1].submittedCount - lastTwoAssignments[0].submittedCount) / lastTwoAssignments[0].submittedCount) * 100
|
||||
: 0
|
||||
const isPositive = performanceChange >= 0
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate">
|
||||
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
|
||||
<div className="group relative flex w-full overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md">
|
||||
{/* Realistic Paper Texture & Noise */}
|
||||
<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="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
|
||||
|
||||
{/* 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}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<div className="text-muted-foreground text-sm mt-1">
|
||||
{c.room ? `Room: ${c.room}` : "Room: Not set"}
|
||||
<Badge variant="secondary" className="w-fit font-normal text-xs bg-muted/50 font-mono tracking-tight">
|
||||
{c.grade} • {c.id.slice(-4).toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{c.grade}</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDelete(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Users className="size-4 text-muted-foreground/70" />
|
||||
<span className="font-medium text-foreground/80">{c.studentCount}</span> Students
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 text-muted-foreground/70" />
|
||||
<span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
|
||||
</div>
|
||||
{c.schoolName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="size-4 text-muted-foreground/70" />
|
||||
<span className="line-clamp-1">{c.schoolName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Invitation Code Section */}
|
||||
<div className="mt-6 pt-4 border-t border-dashed border-border relative">
|
||||
{/* Tiny Cut marks */}
|
||||
<div className="absolute -left-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||
<div className="absolute -right-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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}
|
||||
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
|
||||
))}
|
||||
</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">
|
||||
<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 ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={handleCopyCode} disabled={isWorking}>
|
||||
<Copy className="size-4" />
|
||||
Copy
|
||||
<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="outline" size="sm" className="gap-2" onClick={handleRegenerateCode} disabled={isWorking}>
|
||||
<RefreshCw className="size-4" />
|
||||
Regenerate
|
||||
<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" onClick={handleEnsureCode} disabled={isWorking}>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("grid gap-2", "grid-cols-2")}>
|
||||
<Button asChild variant="outline" className="w-full justify-start gap-2">
|
||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
|
||||
<Users className="size-4" />
|
||||
Students
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full justify-start gap-2">
|
||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
|
||||
<Calendar className="size-4" />
|
||||
Schedule
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={showEdit}
|
||||
onOpenChange={(open) => {
|
||||
if (isWorking) return
|
||||
setShowEdit(open)
|
||||
}}
|
||||
>
|
||||
<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={`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"
|
||||
/>
|
||||
{/* Dashed Divider (Ticket perforation) */}
|
||||
<div className="relative hidden w-4 flex-col items-center justify-center sm:flex -ml-2 z-20">
|
||||
<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)]" />
|
||||
<div className="h-full w-px border-l-2 border-dashed border-muted-foreground/20 relative">
|
||||
{/* Scissor Icon */}
|
||||
<div className="absolute top-1/2 -left-[5px] -translate-y-1/2 text-muted-foreground/20 -rotate-90 text-[10px]">✂</div>
|
||||
</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 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)]" />
|
||||
</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
|
||||
open={showDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (isWorking) return
|
||||
setShowDelete(open)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete class?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete <span className="font-medium text-foreground">{c.name}</span> and remove all
|
||||
enrollments.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
disabled={isWorking}
|
||||
>
|
||||
{isWorking ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
{/* Right Section: Stats & Actions (Wider) */}
|
||||
<div className="flex flex-1 flex-col bg-muted/5 p-6 relative z-10">
|
||||
<div className="flex flex-1 gap-6">
|
||||
{/* Left: Submission Trends */}
|
||||
<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>
|
||||
<span className={cn(
|
||||
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
|
||||
isPositive
|
||||
? "text-emerald-600 bg-emerald-50 border-emerald-100"
|
||||
: "text-red-600 bg-red-50 border-red-100"
|
||||
)}>
|
||||
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Real Chart */}
|
||||
<div className="h-[140px] w-full">
|
||||
<ClassTrendsWidget
|
||||
classId={c.id}
|
||||
assignments={recentAssignments}
|
||||
compact
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 { useRouter } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -29,7 +29,7 @@ import type { TeacherClass } from "../types"
|
||||
import { createClassScheduleItemAction } from "../actions"
|
||||
|
||||
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 [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 (
|
||||
<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">
|
||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder="Class" />
|
||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
|
||||
<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="All Classes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Classes</SelectItem>
|
||||
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{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 className="absolute left-1/2 -translate-x-1/2 text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
@@ -101,9 +97,13 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" disabled={classes.length === 0}>
|
||||
<Plus className="size-4" />
|
||||
Add item
|
||||
<Button
|
||||
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"
|
||||
disabled={classes.length === 0}
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add Event
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<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 (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{WEEKDAYS.map((d) => {
|
||||
const items = byDay.get(d.key) ?? []
|
||||
return (
|
||||
<Card key={d.key} className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{d.label}</CardTitle>
|
||||
<Badge variant="secondary" className={cn(items.length === 0 && "opacity-60")}>
|
||||
{items.length} items
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={classes.length === 0}
|
||||
onClick={() => {
|
||||
setCreateWeekday(d.key)
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
<div className="h-[600px] flex flex-col">
|
||||
<div className="flex h-full">
|
||||
{/* Time Axis */}
|
||||
<div className="w-14 flex-shrink-0 flex flex-col">
|
||||
<div className="h-10" /> {/* Header spacer */}
|
||||
<div className="flex-1 relative">
|
||||
{HOURS.map((h, i) => (
|
||||
<div
|
||||
key={h}
|
||||
className="absolute w-full text-right pr-3 text-[11px] text-muted-foreground/60 font-medium -translate-y-1/2 font-mono"
|
||||
style={{ top: `${(i / 10) * 100}%` }}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No classes scheduled.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="space-y-1 border-b pb-4 last:border-0 last:pb-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-none">{item.course}</div>
|
||||
{h}:00
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days Columns */}
|
||||
<div className="flex-1 grid grid-cols-5">
|
||||
{WEEKDAYS.slice(0, 5).map((d) => (
|
||||
<div key={d.key} className="flex flex-col h-full min-w-0">
|
||||
<div className="flex items-center justify-center py-2 h-10 group">
|
||||
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
|
||||
</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 className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditItem(item)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
|
||||
<Pencil className="mr-2 h-3 w-3" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteItem(item)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
@@ -311,7 +368,7 @@ export function ScheduleView({
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editItem)}
|
||||
open={!!editItem}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
if (!v) setEditItem(null)
|
||||
@@ -320,9 +377,8 @@ export function ScheduleView({
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit schedule item</DialogTitle>
|
||||
<DialogDescription>Update this schedule entry.</DialogDescription>
|
||||
<DialogDescription>Update class schedule entry.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editItem ? (
|
||||
<form action={handleUpdate}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
@@ -345,7 +401,9 @@ export function ScheduleView({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Weekday</Label>
|
||||
<Label htmlFor="edit-weekday" className="text-right">
|
||||
Weekday
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
||||
<SelectTrigger>
|
||||
@@ -374,7 +432,7 @@ export function ScheduleView({
|
||||
name="startTime"
|
||||
type="time"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem.startTime}
|
||||
defaultValue={editItem?.startTime}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -388,7 +446,7 @@ export function ScheduleView({
|
||||
name="endTime"
|
||||
type="time"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem.endTime}
|
||||
defaultValue={editItem?.endTime}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -401,7 +459,8 @@ export function ScheduleView({
|
||||
id="edit-course"
|
||||
name="course"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem.course}
|
||||
defaultValue={editItem?.course}
|
||||
placeholder="e.g. Math"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -414,22 +473,22 @@ export function ScheduleView({
|
||||
id="edit-location"
|
||||
name="location"
|
||||
className="col-span-3"
|
||||
defaultValue={editItem.location ?? ""}
|
||||
defaultValue={editItem?.location ?? ""}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : "Save"}
|
||||
<Button type="submit" disabled={isWorking || !editClassId}>
|
||||
{isWorking ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteItem)}
|
||||
open={!!deleteItem}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
if (!v) setDeleteItem(null)
|
||||
@@ -437,22 +496,20 @@ export function ScheduleView({
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteItem ? (
|
||||
<>
|
||||
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
|
||||
({deleteItem.startTime}–{deleteItem.endTime}).
|
||||
</>
|
||||
) : null}
|
||||
This will permanently delete this schedule item.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={isWorking}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isWorking ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
@@ -462,4 +519,3 @@ export function ScheduleView({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 { Input } from "@/shared/components/ui/input"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,25 +25,35 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { TeacherClass } from "../types"
|
||||
import { enrollStudentByEmailAction } from "../actions"
|
||||
|
||||
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
||||
export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 }))
|
||||
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 [open, setOpen] = useState(false)
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
|
||||
const [enrollClassId, setEnrollClassId] = useState(defaultClassId)
|
||||
const effectiveClassId = classId === "all" && defaultClassId ? defaultClassId : classId
|
||||
|
||||
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setEnrollClassId(defaultClassId)
|
||||
}, [open, defaultClassId])
|
||||
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
}, [open, effectiveClassId, classes])
|
||||
|
||||
const handleEnroll = async (formData: FormData) => {
|
||||
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 (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 md:max-w-sm">
|
||||
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search - Minimal */}
|
||||
<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
|
||||
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}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
||||
<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>
|
||||
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||
|
||||
{(search || classId !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setClassId(null)
|
||||
}}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
{/* Class 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">
|
||||
<span className="truncate max-w-[120px]">{classLabel}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[200px]">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Class</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
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>
|
||||
|
||||
<Dialog
|
||||
@@ -112,8 +161,8 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" disabled={classes.length === 0}>
|
||||
<UserPlus className="size-4" />
|
||||
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
|
||||
<UserPlus className="size-3.5" />
|
||||
Add student
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,14 +27,6 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import type { ClassStudent } from "../types"
|
||||
import { setStudentEnrollmentStatusAction } from "../actions"
|
||||
|
||||
@@ -42,7 +36,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
||||
|
||||
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
|
||||
const key = `${student.classId}:${student.id}:${status}`
|
||||
const key = `${student.classId}:${student.id}`
|
||||
setWorkingKey(key)
|
||||
try {
|
||||
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
|
||||
@@ -50,43 +44,103 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
toast.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update student")
|
||||
toast.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update student")
|
||||
toast.error("Failed to update status")
|
||||
} finally {
|
||||
setWorkingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Email</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{students.map((s) => (
|
||||
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-12", s.status !== "active" && "opacity-70")}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
||||
<TableCell>{s.className}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.status === "active" ? "secondary" : "outline"}>
|
||||
{s.status === "active" ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Card key={`${s.classId}:${s.id}`} className="overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-4 space-y-0 p-4 pb-2">
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 border">
|
||||
<AvatarImage src={s.image || undefined} alt={s.name} />
|
||||
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={cn(
|
||||
"absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
|
||||
s.status === "active" ? "bg-emerald-500" : "bg-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col overflow-hidden mr-2">
|
||||
<span className="truncate font-semibold text-sm">{s.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{s.email}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 text-xs text-muted-foreground shrink-0">
|
||||
<span className="text-[10px] font-medium text-foreground/80">
|
||||
{s.className}
|
||||
</span>
|
||||
<span className="text-[10px]">
|
||||
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit"
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
{s.subjectScores && Object.keys(s.subjectScores).length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[32px] rounded-md bg-muted/20 border border-dashed border-muted">
|
||||
<span className="text-xs text-muted-foreground/50 italic">No recent scores</span>
|
||||
</div>
|
||||
)}
|
||||
</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-8 w-8" disabled={workingKey !== null}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<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">
|
||||
@@ -112,11 +166,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(removeTarget)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
|
||||
import { randomInt } from "node:crypto"
|
||||
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 { db } from "@/shared/db"
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
schools,
|
||||
subjects,
|
||||
exams,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
@@ -122,6 +124,20 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
||||
|
||||
const rows = await (async () => {
|
||||
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
|
||||
.select({
|
||||
id: classes.id,
|
||||
@@ -135,26 +151,11 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
||||
})
|
||||
.from(classes)
|
||||
.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)
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch {
|
||||
return await db
|
||||
.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))
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -170,7 +171,35 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
||||
}))
|
||||
|
||||
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[]> => {
|
||||
@@ -331,6 +360,143 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
|
||||
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[]> => {
|
||||
const id = studentId.trim()
|
||||
if (!id) return []
|
||||
@@ -345,9 +511,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
||||
grade: classes.grade,
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
teacherName: users.name,
|
||||
teacherEmail: users.email,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||
.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))
|
||||
} catch {
|
||||
@@ -359,9 +528,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
||||
grade: classes.grade,
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
teacherName: users.name,
|
||||
teacherEmail: users.email,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||
.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,
|
||||
homeroom: r.homeroom,
|
||||
room: r.room,
|
||||
teacherName: r.teacherName,
|
||||
teacherEmail: r.teacherEmail,
|
||||
}))
|
||||
|
||||
list.sort(compareClassLike)
|
||||
@@ -414,12 +588,13 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
|
||||
})
|
||||
|
||||
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())
|
||||
if (!teacherId) return []
|
||||
|
||||
const classId = params?.classId?.trim()
|
||||
const q = params?.q?.trim().toLowerCase()
|
||||
const status = params?.status?.trim().toLowerCase()
|
||||
|
||||
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
||||
|
||||
@@ -427,6 +602,10 @@ export const getClassStudents = cache(
|
||||
conditions.push(eq(classes.id, classId))
|
||||
}
|
||||
|
||||
if (status === "active" || status === "inactive") {
|
||||
conditions.push(eq(classEnrollments.status, status))
|
||||
}
|
||||
|
||||
if (q && q.length > 0) {
|
||||
const needle = `%${q}%`
|
||||
conditions.push(
|
||||
@@ -439,9 +618,12 @@ export const getClassStudents = cache(
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
image: users.image,
|
||||
gender: users.gender,
|
||||
classId: classes.id,
|
||||
className: classes.name,
|
||||
status: classEnrollments.status,
|
||||
joinedAt: classEnrollments.createdAt,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
@@ -453,9 +635,12 @@ export const getClassStudents = cache(
|
||||
id: r.id,
|
||||
name: r.name ?? "Unnamed",
|
||||
email: r.email,
|
||||
image: r.image,
|
||||
gender: r.gender,
|
||||
classId: r.classId,
|
||||
className: r.className,
|
||||
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 assignments = await db.query.homeworkAssignments.findMany({
|
||||
where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)),
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
limit,
|
||||
const assignments = await db
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
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)
|
||||
if (usedAssignmentIds.length === 0) {
|
||||
@@ -690,6 +886,7 @@ export const getClassHomeworkInsights = cache(
|
||||
assignmentId: a.id,
|
||||
title: a.title,
|
||||
status: (a.status as string) ?? "draft",
|
||||
subject: a.subjectName,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
invitationCode?: string | null
|
||||
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 = {
|
||||
@@ -65,9 +81,13 @@ export type ClassStudent = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image?: string | null
|
||||
gender?: string | null
|
||||
classId: string
|
||||
className: string
|
||||
status: "active" | "inactive"
|
||||
joinedAt: Date
|
||||
subjectScores?: Record<string, number | null>
|
||||
}
|
||||
|
||||
export type ClassScheduleItem = {
|
||||
@@ -80,26 +100,6 @@ export type ClassScheduleItem = {
|
||||
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 = {
|
||||
classId: string
|
||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
@@ -118,13 +118,26 @@ export type UpdateClassScheduleItemInput = {
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type ClassBasicInfo = {
|
||||
export type StudentEnrolledClass = {
|
||||
id: string
|
||||
schoolName?: string | null
|
||||
name: string
|
||||
grade: string
|
||||
homeroom?: 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 = {
|
||||
@@ -139,6 +152,7 @@ export type ClassHomeworkAssignmentStats = {
|
||||
assignmentId: string
|
||||
title: string
|
||||
status: string
|
||||
subject?: string | null
|
||||
createdAt: string
|
||||
dueAt: string | null
|
||||
isActive: boolean
|
||||
@@ -151,24 +165,25 @@ export type ClassHomeworkAssignmentStats = {
|
||||
}
|
||||
|
||||
export type ClassHomeworkInsights = {
|
||||
class: ClassBasicInfo
|
||||
studentCounts: {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
class: {
|
||||
id: string
|
||||
schoolName?: string | null
|
||||
schoolId?: string | null
|
||||
name: string
|
||||
grade: string
|
||||
homeroom?: string | null
|
||||
room?: string | null
|
||||
invitationCode?: string | null
|
||||
}
|
||||
studentCounts: { total: number; active: number; inactive: number }
|
||||
assignments: ClassHomeworkAssignmentStats[]
|
||||
latest: ClassHomeworkAssignmentStats | null
|
||||
overallScores: ScoreStats
|
||||
}
|
||||
|
||||
export type GradeHomeworkClassSummary = {
|
||||
class: ClassBasicInfo
|
||||
studentCounts: {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
}
|
||||
class: { id: string; name: string; grade: string; homeroom?: string | null; room?: string | null }
|
||||
studentCounts: { total: number; active: number; inactive: number }
|
||||
latestAvg: number | null
|
||||
prevAvg: number | null
|
||||
deltaAvg: number | null
|
||||
@@ -176,17 +191,9 @@ export type GradeHomeworkClassSummary = {
|
||||
}
|
||||
|
||||
export type GradeHomeworkInsights = {
|
||||
grade: {
|
||||
id: string
|
||||
name: string
|
||||
school: { id: string; name: string }
|
||||
}
|
||||
grade: { id: string; name: string; school: { id: string; name: string } }
|
||||
classCount: number
|
||||
studentCounts: {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
}
|
||||
studentCounts: { total: number; active: number; inactive: number }
|
||||
assignments: ClassHomeworkAssignmentStats[]
|
||||
latest: ClassHomeworkAssignmentStats | null
|
||||
overallScores: ScoreStats
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { CalendarDays, BookOpen, PenTool } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/learning/assignments">View assignments</Link>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { StudentDashboardProps } from "@/modules/dashboard/types"
|
||||
|
||||
import { StudentDashboardHeader } from "./student-dashboard-header"
|
||||
import { StudentGradesCard } from "./student-grades-card"
|
||||
import { StudentRankingCard } from "./student-ranking-card"
|
||||
import { StudentStatsGrid } from "./student-stats-grid"
|
||||
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
||||
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
||||
@@ -26,16 +25,17 @@ export function StudentDashboard({
|
||||
dueSoonCount={dueSoonCount}
|
||||
overdueCount={overdueCount}
|
||||
gradedCount={gradedCount}
|
||||
ranking={grades.ranking}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StudentGradesCard grades={grades} />
|
||||
<StudentRankingCard ranking={grades.ranking} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
||||
<StudentGradesCard grades={grades} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
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 type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||
|
||||
@@ -11,6 +15,24 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
const hasGradeTrend = grades.trend.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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -30,37 +52,79 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<svg viewBox="0 0 100 40" className="h-24 w-full">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
points={grades.trend
|
||||
.map((p, i) => {
|
||||
const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0
|
||||
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
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(" ")}
|
||||
className="text-primary"
|
||||
<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 ? "..." : "")}
|
||||
/>
|
||||
</svg>
|
||||
<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,
|
||||
}}
|
||||
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(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}%
|
||||
{Math.round(latestGrade.percentage)}%
|
||||
</span>
|
||||
</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}
|
||||
{latestGrade.score}/{latestGrade.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!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 { cn } from "@/shared/lib/utils"
|
||||
import type { StudentRanking } from "@/modules/homework/types"
|
||||
|
||||
type Stat = {
|
||||
title: string
|
||||
value: string
|
||||
description: string
|
||||
icon: typeof BookOpen
|
||||
href: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function StudentStatsGrid({
|
||||
@@ -14,52 +19,64 @@ export function StudentStatsGrid({
|
||||
dueSoonCount,
|
||||
overdueCount,
|
||||
gradedCount,
|
||||
ranking,
|
||||
}: {
|
||||
enrolledClassCount: number
|
||||
dueSoonCount: number
|
||||
overdueCount: number
|
||||
gradedCount: number
|
||||
ranking: StudentRanking | null
|
||||
}) {
|
||||
const stats: readonly Stat[] = [
|
||||
const stats: Stat[] = [
|
||||
{
|
||||
title: "My Classes",
|
||||
value: String(enrolledClassCount),
|
||||
description: "Enrolled classes",
|
||||
icon: BookOpen,
|
||||
title: "Average Score",
|
||||
value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
|
||||
description: ranking ? "Overall performance" : "No grades yet",
|
||||
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",
|
||||
value: String(dueSoonCount),
|
||||
description: "Next 7 days",
|
||||
icon: PenTool,
|
||||
href: "/student/learning/assignments",
|
||||
color: dueSoonCount > 0 ? "text-orange-500" : undefined,
|
||||
},
|
||||
{
|
||||
title: "Overdue",
|
||||
value: String(overdueCount),
|
||||
description: "Needs attention",
|
||||
icon: TriangleAlert,
|
||||
},
|
||||
{
|
||||
title: "Graded",
|
||||
value: String(gradedCount),
|
||||
description: "With score",
|
||||
icon: CheckCircle2,
|
||||
href: "/student/learning/assignments",
|
||||
color: overdueCount > 0 ? "text-red-500" : undefined,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title}>
|
||||
<Link key={stat.title} href={stat.href}>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<stat.icon className={cn("h-4 w-4 text-muted-foreground", stat.color)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{stat.value}</div>
|
||||
<div className={cn("text-2xl font-bold tabular-nums", stat.color)}>{stat.value}</div>
|
||||
<div className="text-xs text-muted-foreground">{stat.description}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { 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"
|
||||
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
@@ -23,6 +23,30 @@ const getStatusLabel = (status: string) => {
|
||||
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[] }) {
|
||||
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">Due</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{upcomingAssignments.map((a) => (
|
||||
{upcomingAssignments.map((a) => {
|
||||
const urgency = getDueUrgency(a.dueAt)
|
||||
const isGraded = a.progressStatus === "graded"
|
||||
|
||||
return (
|
||||
<TableRow key={a.id} className="h-12">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
{!isGraded && urgency === "overdue" && (
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">Late</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</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>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -1,67 +1,114 @@
|
||||
import Link from "next/link";
|
||||
import { Inbox, ArrowRight } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Inbox } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table";
|
||||
import { formatDate } from "@/shared/lib/utils";
|
||||
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
|
||||
|
||||
export function RecentSubmissions({ submissions }: { submissions: HomeworkSubmissionListItem[] }) {
|
||||
export function RecentSubmissions({
|
||||
submissions,
|
||||
title = "Recent Submissions",
|
||||
emptyTitle = "No New Submissions",
|
||||
emptyDescription = "All caught up! There are no new submissions to review."
|
||||
}: {
|
||||
submissions: HomeworkSubmissionListItem[],
|
||||
title?: string,
|
||||
emptyTitle?: string,
|
||||
emptyDescription?: string
|
||||
}) {
|
||||
const hasSubmissions = submissions.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="col-span-4 lg:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4 text-muted-foreground" />
|
||||
Recent Submissions
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
{title}
|
||||
</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>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1">
|
||||
{!hasSubmissions ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No New Submissions"
|
||||
description="All caught up! There are no new submissions to review."
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[200px]">Student</TableHead>
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead className="w-[140px]">Submitted</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{submissions.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between group">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-9 w-9">
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8 border">
|
||||
<AvatarImage src={undefined} alt={item.studentName} />
|
||||
<AvatarFallback>{item.studentName.charAt(0)}</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xs">
|
||||
{item.studentName.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{item.studentName}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-sm">{item.studentName}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
|
||||
</div>
|
||||
{item.isLate && (
|
||||
<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
|
||||
</span>
|
||||
{item.isLate && (
|
||||
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
||||
Late
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { Users } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -9,8 +8,6 @@ import type { TeacherClass } from "@/modules/classes/types"
|
||||
|
||||
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
|
||||
const topClassesByStudents = [...classes].sort((a, b) => (b.studentCount ?? 0) - (a.studentCount ?? 0)).slice(0, 8)
|
||||
const maxStudentCount = Math.max(1, ...topClassesByStudents.map((c) => c.studentCount ?? 0))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -33,52 +30,40 @@ export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{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}
|
||||
|
||||
<div className="space-y-1">
|
||||
{classes.slice(0, 6).map((c) => (
|
||||
<Link
|
||||
key={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="font-medium truncate">{c.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<div className="font-medium truncate group-hover:text-primary transition-colors">{c.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate flex items-center gap-1.5">
|
||||
<span className="inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{c.grade}
|
||||
{c.homeroom ? ` · ${c.homeroom}` : ""}
|
||||
{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>
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{c.studentCount} students
|
||||
</Badge>
|
||||
<div className="flex items-center text-xs text-muted-foreground tabular-nums">
|
||||
<Users className="mr-1.5 h-3 w-3 opacity-70" />
|
||||
{c.studentCount}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||
|
||||
export function TeacherDashboardHeader() {
|
||||
interface TeacherDashboardHeaderProps {
|
||||
teacherName: string
|
||||
}
|
||||
|
||||
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
||||
const today = new Date().toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<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">Teacher</h2>
|
||||
<p className="text-muted-foreground">Overview of today's work and your classes.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Good morning, {teacherName}</h2>
|
||||
<p className="text-muted-foreground">It's {today}. Here's your daily overview.</p>
|
||||
</div>
|
||||
<TeacherQuickActions />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TeacherHomeworkCard } from "./teacher-homework-card"
|
||||
import { RecentSubmissions } from "./recent-submissions"
|
||||
import { TeacherSchedule } from "./teacher-schedule"
|
||||
import { TeacherStats } from "./teacher-stats"
|
||||
import { TeacherGradeTrends } from "./teacher-grade-trends"
|
||||
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
const day = d.getDay()
|
||||
@@ -32,27 +33,52 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
||||
|
||||
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
|
||||
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
||||
const recentSubmissions = submittedSubmissions.slice(0, 6)
|
||||
|
||||
// Filter for submissions that actually need grading (status === "submitted")
|
||||
// If we have less than 5 to grade, maybe also show some recently graded ones?
|
||||
// For now, let's stick to "Needs Grading" as it's more useful.
|
||||
const submissionsToGrade = submittedSubmissions
|
||||
.filter(s => s.status === "submitted")
|
||||
.sort((a, b) => new Date(a.submittedAt!).getTime() - new Date(b.submittedAt!).getTime()) // Oldest first? Or Newest? Usually oldest first for queue.
|
||||
.slice(0, 6);
|
||||
|
||||
// Calculate stats for the dashboard
|
||||
const activeAssignmentsCount = data.assignments.filter(a => a.status === "published").length
|
||||
|
||||
const totalTrendScore = data.gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0)
|
||||
const averageScore = data.gradeTrends.length > 0 ? totalTrendScore / data.gradeTrends.length : 0
|
||||
|
||||
const totalSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.submissionCount, 0)
|
||||
const totalPotentialSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.totalStudents, 0)
|
||||
const submissionRate = totalPotentialSubmissions > 0 ? (totalSubmissions / totalPotentialSubmissions) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<TeacherDashboardHeader />
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
|
||||
<TeacherStats
|
||||
totalStudents={totalStudents}
|
||||
classCount={data.classes.length}
|
||||
toGradeCount={toGradeCount}
|
||||
todayScheduleCount={todayScheduleItems.length}
|
||||
activeAssignmentsCount={activeAssignmentsCount}
|
||||
averageScore={averageScore}
|
||||
submissionRate={submissionRate}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<TeacherSchedule items={todayScheduleItems} />
|
||||
<RecentSubmissions submissions={recentSubmissions} />
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<div className="flex flex-col gap-6 lg:col-span-8">
|
||||
<TeacherGradeTrends trends={data.gradeTrends} />
|
||||
<RecentSubmissions
|
||||
submissions={submissionsToGrade}
|
||||
title="Needs Grading"
|
||||
emptyTitle="All caught up!"
|
||||
emptyDescription="You have no pending submissions to grade."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -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 { PenTool } from "lucide-react"
|
||||
import { PenTool, Calendar, Plus } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
|
||||
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
||||
return (
|
||||
<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">
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||
Homework
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/homework/assignments">Open list</Link>
|
||||
<Button asChild size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Link href="/teacher/homework/assignments/create" title="Create new assignment">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/teacher/homework/assignments/create">New</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<CardContent>
|
||||
{assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PenTool}
|
||||
title="No homework assignments yet"
|
||||
description="Create an assignment from an exam and publish it to students."
|
||||
action={{ label: "Create assignment", href: "/teacher/homework/assignments/create" }}
|
||||
className="border-none h-72"
|
||||
title="No assignments"
|
||||
description="Create an assignment to get started."
|
||||
action={{ label: "Create", href: "/teacher/homework/assignments/create" }}
|
||||
className="border-none h-48"
|
||||
/>
|
||||
) : (
|
||||
assignments.slice(0, 6).map((a) => (
|
||||
<div className="space-y-1">
|
||||
{assignments.slice(0, 6).map((a) => {
|
||||
const isPublished = a.status === "published"
|
||||
const isDraft = a.status === "draft"
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/teacher/homework/assignments/${encodeURIComponent(a.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="font-medium truncate">{a.title}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">{a.sourceExamTitle}</div>
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<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>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Clock, MapPin, CalendarDays, CalendarX } from "lucide-react";
|
||||
import { CalendarDays, CalendarX, MapPin } from "lucide-react";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||||
|
||||
type TeacherTodayScheduleItem = {
|
||||
id: string;
|
||||
@@ -18,54 +19,130 @@ type TeacherTodayScheduleItem = {
|
||||
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||
const hasSchedule = items.length > 0;
|
||||
|
||||
const getStatus = (start: string, end: string) => {
|
||||
const now = new Date();
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const [startH, startM] = start.split(":").map(Number);
|
||||
const [endH, endM] = end.split(":").map(Number);
|
||||
const startTime = startH * 60 + startM;
|
||||
const endTime = endH * 60 + endM;
|
||||
|
||||
if (currentTime >= startTime && currentTime <= endTime) return "live";
|
||||
if (currentTime < startTime) return "upcoming";
|
||||
return "past";
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
Today's Schedule
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
{!hasSchedule ? (
|
||||
<EmptyState
|
||||
icon={CalendarX}
|
||||
title="No Classes Today"
|
||||
description="No timetable entries for today."
|
||||
description="No timetable entries."
|
||||
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
|
||||
className="border-none h-[300px]"
|
||||
className="border-none h-[200px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<ScrollArea className="h-[240px] px-6 py-2">
|
||||
<div className="relative space-y-0 ml-1">
|
||||
{/* Vertical Timeline Line */}
|
||||
<div className="absolute left-[11px] -top-2 -bottom-2 w-px bg-border/50" />
|
||||
|
||||
{/* Top Fade Hint */}
|
||||
<div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
|
||||
|
||||
{items.map((item, index) => {
|
||||
const status = getStatus(item.startTime, item.endTime);
|
||||
const isLive = status === "live";
|
||||
const isPast = status === "past";
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
|
||||
{/* Timeline Dot */}
|
||||
<div className={cn(
|
||||
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
|
||||
isLive ? "bg-primary border-primary" :
|
||||
isPast ? "bg-muted border-muted-foreground/30" :
|
||||
"bg-background border-primary"
|
||||
)} />
|
||||
|
||||
<Link
|
||||
href={`/teacher/classes/schedule?classId=${encodeURIComponent(item.classId)}`}
|
||||
className="font-medium leading-none hover:underline"
|
||||
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}
|
||||
</Link>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<span className="mr-3">{item.startTime}–{item.endTime}</span>
|
||||
{item.location ? (
|
||||
<>
|
||||
<MapPin className="mr-1 h-3 w-3" />
|
||||
<span>{item.location}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{item.className}
|
||||
</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 className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
|
||||
No more classes today
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Users, BookOpen, FileCheck, Calendar } from "lucide-react";
|
||||
import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
interface TeacherStatsProps {
|
||||
totalStudents: number;
|
||||
classCount: number;
|
||||
toGradeCount: number;
|
||||
todayScheduleCount: number;
|
||||
activeAssignmentsCount: number;
|
||||
averageScore: number;
|
||||
submissionRate: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function TeacherStats({
|
||||
totalStudents,
|
||||
classCount,
|
||||
toGradeCount,
|
||||
todayScheduleCount,
|
||||
activeAssignmentsCount,
|
||||
averageScore,
|
||||
submissionRate,
|
||||
isLoading = false,
|
||||
}: TeacherStatsProps) {
|
||||
if (isLoading) {
|
||||
@@ -38,40 +40,53 @@ export function TeacherStats({
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Total Students",
|
||||
value: String(totalStudents),
|
||||
description: "Across all your classes",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "My Classes",
|
||||
value: String(classCount),
|
||||
description: "Active classes you manage",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: "To Grade",
|
||||
title: "Needs Grading",
|
||||
value: String(toGradeCount),
|
||||
description: "Submitted homework waiting for grading",
|
||||
description: "Submissions pending review",
|
||||
icon: FileCheck,
|
||||
href: "/teacher/homework/submissions?status=submitted",
|
||||
highlight: toGradeCount > 0,
|
||||
color: "text-amber-500",
|
||||
},
|
||||
{
|
||||
title: "Today",
|
||||
value: String(todayScheduleCount),
|
||||
description: "Scheduled items today",
|
||||
icon: Calendar,
|
||||
title: "Active Assignments",
|
||||
value: String(activeAssignmentsCount),
|
||||
description: "Published and ongoing",
|
||||
icon: PenTool,
|
||||
href: "/teacher/homework/assignments?status=published",
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, i) => (
|
||||
<Card key={i}>
|
||||
<Link key={i} href={stat.href} className="block transition-transform hover:-translate-y-1">
|
||||
<Card className={cn(stat.highlight && "border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20")}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<stat.icon className={cn("h-4 w-4", stat.color)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
@@ -80,6 +95,7 @@ export function TeacherStats({
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
import type { TeacherClass, ClassScheduleItem } from "@/modules/classes/types"
|
||||
import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem } from "@/modules/homework/types"
|
||||
import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem, TeacherGradeTrendItem } from "@/modules/homework/types"
|
||||
|
||||
export type AdminDashboardUserRoleCount = {
|
||||
role: string
|
||||
@@ -67,4 +67,6 @@ export type TeacherDashboardData = {
|
||||
schedule: ClassScheduleItem[]
|
||||
assignments: HomeworkAssignmentListItem[]
|
||||
submissions: HomeworkSubmissionListItem[]
|
||||
teacherName: string
|
||||
gradeTrends: TeacherGradeTrendItem[]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { ActionState } from "@/shared/types/action-state"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
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 { omitScheduledAtFromDescription } from "./data-access"
|
||||
|
||||
const ExamCreateSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
@@ -56,9 +57,17 @@ export async function createExamAction(
|
||||
const examId = createId()
|
||||
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 = {
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
subject: subjectRecord?.name ?? input.subject,
|
||||
grade: gradeRecord?.name ?? input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
@@ -71,11 +80,14 @@ export async function createExamAction(
|
||||
id: examId,
|
||||
title: input.title,
|
||||
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,
|
||||
status: "draft",
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to create exam",
|
||||
@@ -215,19 +227,6 @@ const ExamDuplicateSchema = z.object({
|
||||
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(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -271,7 +270,7 @@ export async function duplicateExamAction(
|
||||
id: newExamId,
|
||||
title: `${source.title} (Copy)`,
|
||||
description: omitScheduledAtFromDescription(source.description),
|
||||
creatorId: user?.id ?? "user_teacher_123",
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
status: "draft",
|
||||
@@ -305,6 +304,78 @@ export async function duplicateExamAction(
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_123", role: "teacher" }
|
||||
export async function getExamPreviewAction(examId: string) {
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
type ChoiceOption = {
|
||||
@@ -86,26 +82,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" size="sm" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview Exam
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="p-6 pb-2 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Exam Preview</DialogTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</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">
|
||||
<div className="bg-card shadow-sm border p-12 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>
|
||||
@@ -133,8 +110,5 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ type QuestionBankListProps = {
|
||||
questions: Question[]
|
||||
onAdd: (question: Question) => void
|
||||
isAdded: (id: string) => boolean
|
||||
onLoadMore?: () => void
|
||||
hasMore?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) {
|
||||
if (questions.length === 0) {
|
||||
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
|
||||
if (questions.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No questions found matching your filters.
|
||||
@@ -22,7 +25,7 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 pb-4">
|
||||
{questions.map((q) => {
|
||||
const added = isAdded(q.id)
|
||||
const content = q.content as { text?: string }
|
||||
@@ -60,6 +63,28 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -550,6 +550,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="structure-editor-dnd"
|
||||
sensors={sensors}
|
||||
collisionDetection={customCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy }
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -27,13 +28,14 @@ import {
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
|
||||
import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreviewAction } from "../actions"
|
||||
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 {
|
||||
exam: Exam
|
||||
@@ -44,6 +46,46 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = 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 = () => {
|
||||
navigator.clipboard.writeText(exam.id)
|
||||
@@ -112,6 +154,19 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleView()
|
||||
}}
|
||||
title="Preview Exam"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
@@ -125,9 +180,6 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -166,49 +218,21 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<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}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete exam?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<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>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
@@ -220,6 +244,34 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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"
|
||||
|
||||
import { useDeferredValue, useMemo, useState } from "react"
|
||||
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { 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 { updateExamAction } from "@/modules/exams/actions"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
import { StructureEditor } from "./assembly/structure-editor"
|
||||
import { QuestionBankList } from "./assembly/question-bank-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 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
|
||||
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
||||
const questionById = new Map<string, Question>()
|
||||
@@ -76,26 +83,47 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
return []
|
||||
})
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
let list: Question[] = [...props.questionOptions]
|
||||
const fetchQuestions = (reset: boolean = false) => {
|
||||
startBankTransition(async () => {
|
||||
const nextPage = reset ? 1 : page + 1
|
||||
try {
|
||||
const result = await getQuestionsAction({
|
||||
q: deferredSearch,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: typeFilter === 'all' ? undefined : typeFilter as any,
|
||||
difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
|
||||
page: nextPage,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
if (deferredSearch) {
|
||||
const lower = deferredSearch.toLowerCase()
|
||||
list = list.filter(q => {
|
||||
const content = q.content as { text?: string }
|
||||
return content.text?.toLowerCase().includes(lower)
|
||||
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") {
|
||||
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
if (deferredSearch === "" && typeFilter === "all" && difficultyFilter === "all") {
|
||||
return
|
||||
}
|
||||
if (difficultyFilter !== "all") {
|
||||
const d = parseInt(difficultyFilter)
|
||||
list = list.filter((q) => q.difficulty === d)
|
||||
}
|
||||
return list
|
||||
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
|
||||
fetchQuestions(true)
|
||||
}, [deferredSearch, typeFilter, difficultyFilter])
|
||||
|
||||
// Recursively calculate total score
|
||||
const assignedTotal = useMemo(() => {
|
||||
@@ -231,6 +259,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
return clean(structure)
|
||||
}
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
const handleSave = async (formData: FormData) => {
|
||||
formData.set("examId", props.examId)
|
||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
||||
@@ -238,7 +268,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Saved draft")
|
||||
toast.success("Exam draft saved")
|
||||
} else {
|
||||
toast.error(result.message || "Save failed")
|
||||
}
|
||||
@@ -260,13 +290,37 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-12rem)] gap-6 lg:grid-cols-5">
|
||||
{/* Left: Preview (3 cols) */}
|
||||
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="grid h-[calc(100vh-8rem)] gap-4 lg:grid-cols-12">
|
||||
{/* Left: Preview (8 cols) */}
|
||||
<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 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>Exam Structure</CardTitle>
|
||||
<CardTitle className="text-lg">Exam Structure</CardTitle>
|
||||
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{props.subject}</span>
|
||||
<span>•</span>
|
||||
<span>{props.grade}</span>
|
||||
<span>•</span>
|
||||
<span>{props.durationMin} min</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
|
||||
<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}
|
||||
@@ -276,31 +330,36 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
nodes={structure}
|
||||
/>
|
||||
</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>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="h-2 w-24 rounded-full bg-secondary">
|
||||
</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={`h-full rounded-full transition-all ${
|
||||
className={`w-full transition-all ${
|
||||
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
style={{ height: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
|
||||
<ScrollArea className="flex-1 bg-muted/5">
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||
<StructureEditor
|
||||
items={structure}
|
||||
onChange={setStructure}
|
||||
@@ -312,32 +371,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-4 bg-muted/30 flex gap-3 justify-end">
|
||||
<form action={handleSave} className="flex-1">
|
||||
<SubmitButton label="Save Draft" />
|
||||
<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)]">
|
||||
<div className="mr-auto text-xs text-muted-foreground">
|
||||
{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 action={handlePublish} className="flex-1">
|
||||
<SubmitButton label="Publish Exam" />
|
||||
<form action={handlePublish}>
|
||||
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">Publish</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right: Question Bank (2 cols) */}
|
||||
<Card className="lg:col-span-2 flex flex-col overflow-hidden">
|
||||
<CardHeader className="pb-3 space-y-3">
|
||||
<CardTitle className="text-base">Question Bank</CardTitle>
|
||||
{/* Right: Question Bank (4 cols) */}
|
||||
<Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
|
||||
<CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
|
||||
<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">
|
||||
<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
|
||||
placeholder="Search questions..."
|
||||
className="pl-8"
|
||||
placeholder="Search by content..."
|
||||
className="pl-9 h-9 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
@@ -347,7 +414,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="1">Lvl 1</SelectItem>
|
||||
@@ -360,14 +427,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="flex-1 p-4 bg-muted/10">
|
||||
<ScrollArea className="flex-1 p-0 bg-muted/5">
|
||||
<div className="p-3">
|
||||
<QuestionBankList
|
||||
questions={filteredQuestions}
|
||||
questions={bankQuestions}
|
||||
onAdd={handleAdd}
|
||||
isAdded={(id) => addedQuestionIds.has(id)}
|
||||
onLoadMore={() => fetchQuestions(false)}
|
||||
hasMore={hasMore}
|
||||
isLoading={isBankLoading}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</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,34 +30,30 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
header: "Exam Info",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
<span className="font-semibold text-base">{row.original.title}</span>
|
||||
{row.original.tags && row.original.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.tags.slice(0, 2).map((t, idx) => (
|
||||
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
|
||||
<Badge key={`${t}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
{row.original.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{row.original.tags.length - 2}</Badge>
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">+{row.original.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
},
|
||||
{
|
||||
accessorKey: "grade",
|
||||
header: "Grade",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">{row.original.grade}</span>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -65,74 +61,95 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
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"] =
|
||||
status === "published"
|
||||
? "secondary"
|
||||
? "default"
|
||||
: status === "archived"
|
||||
? "destructive"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
|
||||
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}
|
||||
</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",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.original.difficulty
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={cn(
|
||||
"font-medium",
|
||||
diff <= 2 ? "text-green-600" : diff === 3 ? "text-yellow-600" : "text-red-600"
|
||||
"h-1.5 w-3 rounded-full",
|
||||
level <= diff
|
||||
? diff <= 2 ? "bg-green-500" : diff === 3 ? "bg-yellow-500" : "bg-red-500"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{diff === 1
|
||||
? "Easy"
|
||||
: diff === 2
|
||||
? "Easy-Med"
|
||||
: diff === 3
|
||||
? "Medium"
|
||||
: diff === 4
|
||||
? "Med-Hard"
|
||||
: "Hard"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-medium">
|
||||
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "durationMin",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.durationMin} min</span>,
|
||||
id: "dates",
|
||||
header: "Date",
|
||||
cell: ({ row }) => {
|
||||
const scheduled = row.original.scheduledAt
|
||||
const created = row.original.createdAt
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{scheduled ? (
|
||||
<>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">Scheduled</span>
|
||||
<span className="text-muted-foreground">{formatDate(scheduled)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{formatDate(created)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "totalScore",
|
||||
header: "Total",
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.totalScore}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduledAt",
|
||||
header: "Scheduled",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{row.original.scheduledAt ? formatDate(row.original.scheduledAt) : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader className="bg-muted/40">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
@@ -88,23 +88,41 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||
selected.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">Page</p>
|
||||
<span className="text-sm font-medium">
|
||||
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<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" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Next
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,19 +19,20 @@ export function ExamFilters() {
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full md:w-[260px]">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder="Search exams..."
|
||||
className="pl-7"
|
||||
className="pl-9 bg-background border-muted-foreground/20"
|
||||
value={search || ""}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -43,7 +44,7 @@ export function ExamFilters() {
|
||||
</Select>
|
||||
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -64,13 +65,14 @@ export function ExamFilters() {
|
||||
setStatus(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
className="h-10 px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +1,357 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useTransition, useEffect, useState } from "react"
|
||||
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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { createExamAction } from "../actions"
|
||||
import {
|
||||
Select,
|
||||
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() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create Exam"}
|
||||
</Button>
|
||||
)
|
||||
export const formSchema = z.object({
|
||||
title: z.string().min(2, "Title must be at least 2 characters."),
|
||||
subject: z.string().min(1, "Subject is required."),
|
||||
grade: z.string().min(1, "Grade is required."),
|
||||
difficulty: z.string(),
|
||||
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() {
|
||||
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 result = await createExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
if (result.data) {
|
||||
router.push(`/teacher/exams/${result.data}/build`)
|
||||
}
|
||||
const form = useForm<ExamFormValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: defaultValues as unknown as ExamFormValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const [subjectsResult, gradesResult] = await Promise.all([
|
||||
getSubjectsAction(),
|
||||
getGradesAction()
|
||||
])
|
||||
|
||||
if (subjectsResult.success && subjectsResult.data) {
|
||||
setSubjects(subjectsResult.data)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Left Column: Exam Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exam Creator</CardTitle>
|
||||
<CardTitle>Exam Details</CardTitle>
|
||||
<CardDescription>
|
||||
Define the core information for your exam.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<CardContent className="grid gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" name="title" placeholder="e.g. Algebra Midterm" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input id="subject" name="subject" placeholder="e.g. Mathematics" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="grade">Grade</Label>
|
||||
<Input id="grade" name="grade" placeholder="e.g. Grade 10" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Difficulty</Label>
|
||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val)} name="difficulty">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subject</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select difficulty" />
|
||||
<SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Easy (1)</SelectItem>
|
||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
||||
<SelectItem value="3">Medium (3)</SelectItem>
|
||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
||||
<SelectItem value="5">Hard (5)</SelectItem>
|
||||
{subjects.map((subject) => (
|
||||
<SelectItem key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="difficulty" value={difficulty} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalScore">Total Score</Label>
|
||||
<Input id="totalScore" name="totalScore" type="number" min={1} placeholder="e.g. 100" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="durationMin">Duration (min)</Label>
|
||||
<Input id="durationMin" name="durationMin" type="number" min={10} placeholder="e.g. 90" required />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="scheduledAt">Scheduled At (optional)</Label>
|
||||
<Input id="scheduledAt" name="scheduledAt" type="datetime-local" />
|
||||
</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">
|
||||
<SubmitButton />
|
||||
</CardFooter>
|
||||
</form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<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({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(exams.createdAt)],
|
||||
with: {
|
||||
subject: true,
|
||||
gradeEntity: true,
|
||||
}
|
||||
})
|
||||
|
||||
// Transform and Filter (especially for JSON fields)
|
||||
@@ -78,8 +82,8 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: getString(meta, "subject") || "General",
|
||||
grade: getString(meta, "grade") || "General",
|
||||
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
totalScore: getNumber(meta, "totalScore") || 100,
|
||||
durationMin: getNumber(meta, "durationMin") || 60,
|
||||
@@ -103,6 +107,8 @@ export const getExamById = cache(async (id: string) => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, id),
|
||||
with: {
|
||||
subject: true,
|
||||
gradeEntity: true,
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
with: {
|
||||
@@ -120,8 +126,8 @@ export const getExamById = cache(async (id: string) => {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: getString(meta, "subject") || "General",
|
||||
grade: getString(meta, "grade") || "General",
|
||||
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
totalScore: getNumber(meta, "totalScore") || 100,
|
||||
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 }
|
||||
|
||||
return { id: "user_teacher_123", role: roleHint }
|
||||
return { id: "user_teacher_math", role: roleHint }
|
||||
}
|
||||
|
||||
async function ensureTeacher() {
|
||||
|
||||
@@ -17,7 +17,7 @@ export function HomeworkAssignmentExamContentCard({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
<HomeworkAssignmentExamErrorExplorerLazy
|
||||
structure={structure}
|
||||
questions={questions}
|
||||
|
||||
@@ -7,44 +7,47 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
function ExamErrorExplorerFallback() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]">
|
||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">题目</div>
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
<Skeleton className="h-10 w-[40%]" />
|
||||
<Skeleton className="h-10 w-[60%]" />
|
||||
<Skeleton className="h-10 w-[75%]" />
|
||||
<Skeleton className="h-10 w-[55%]" />
|
||||
<Skeleton className="h-10 w-[68%]" />
|
||||
<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">
|
||||
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Question Preview</span>
|
||||
</div>
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-[60%]" />
|
||||
<div className="space-y-3">
|
||||
<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 className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="text-sm font-medium">错题详情</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-full" />
|
||||
<div className="min-w-0 flex-1 grid gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="text-sm font-medium">Error Analysis</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||
<Skeleton className="size-16 rounded-full shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
</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>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function HomeworkAssignmentExamErrorExplorer({
|
||||
}, [questions, selectedQuestionId])
|
||||
|
||||
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
|
||||
structure={structure}
|
||||
questions={questions.map((q) => ({
|
||||
|
||||
@@ -20,15 +20,19 @@ export function HomeworkAssignmentExamPreviewPane({
|
||||
onQuestionSelect: (questionId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">题目</div>
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Question Preview</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 bg-background">
|
||||
<div className="p-6">
|
||||
<ExamViewer
|
||||
structure={structure}
|
||||
questions={questions}
|
||||
selectedQuestionId={selectedQuestionId}
|
||||
onQuestionSelect={onQuestionSelect}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -92,59 +92,63 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
||||
const errorRate = selected?.errorRate ?? 0
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="text-sm font-medium">错题详情</div>
|
||||
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||
<div className="border-b px-6 py-4 bg-muted/5">
|
||||
<div className="text-sm font-medium">Error Analysis</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 space-y-6">
|
||||
{selected ? (
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<>
|
||||
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||
<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 className="min-w-0 flex-1 grid gap-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Question</span>
|
||||
<span className="font-medium">Q{selected.questionId.slice(-4)}</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 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 className="mt-2 text-xs text-muted-foreground">请选择左侧题目</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full p-4">
|
||||
{!selected ? (
|
||||
<div className="text-sm text-muted-foreground">暂无数据</div>
|
||||
) : wrongAnswers.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无错误答案</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">错误答案列表(可滚动)</div>
|
||||
<div className="space-y-2">
|
||||
{wrongAnswers.map((item, idx) => (
|
||||
<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 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>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</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 { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||
|
||||
export function HomeworkAssignmentQuestionErrorOverviewCard({
|
||||
questions,
|
||||
@@ -106,26 +11,78 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
|
||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
||||
gradedSampleCount: number
|
||||
}) {
|
||||
const data = questions.map((q, index) => ({
|
||||
name: `Q${index + 1}`,
|
||||
errorRate: q.errorRate * 100,
|
||||
errorCount: q.errorCount,
|
||||
total: gradedSampleCount,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card className="md:col-span-1">
|
||||
<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>
|
||||
<CardContent>
|
||||
<CardContent className="h-72">
|
||||
{questions.length === 0 || gradedSampleCount === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No graded submissions yet. Error analytics will appear here after grading.
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No graded submissions yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Graded students</span>
|
||||
<span className="font-medium text-foreground">{gradedSampleCount}</span>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||
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="h-56 rounded-md border bg-muted/40 px-3 py-2">
|
||||
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 { 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 { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
@@ -39,20 +49,48 @@ type HomeworkGradingViewProps = {
|
||||
status: string
|
||||
totalScore: number | null
|
||||
answers: Answer[]
|
||||
prevSubmissionId?: string | null
|
||||
nextSubmissionId?: string | null
|
||||
}
|
||||
|
||||
export function HomeworkGradingView({
|
||||
submissionId,
|
||||
answers: initialAnswers,
|
||||
prevSubmissionId,
|
||||
nextSubmissionId,
|
||||
studentName,
|
||||
assignmentTitle,
|
||||
submittedAt,
|
||||
}: HomeworkGradingViewProps) {
|
||||
const router = useRouter()
|
||||
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
||||
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 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)))
|
||||
}
|
||||
|
||||
@@ -69,10 +107,12 @@ export function HomeworkGradingView({
|
||||
}
|
||||
|
||||
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
||||
const binaryAnswers = answers.filter(shouldUseBinaryGrading)
|
||||
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
||||
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
||||
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount
|
||||
const maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
|
||||
const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 0
|
||||
|
||||
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 () => {
|
||||
setIsSubmitting(true)
|
||||
@@ -89,177 +129,357 @@ export function HomeworkGradingView({
|
||||
const result = await gradeHomeworkSubmissionAction(null, formData)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Grading saved")
|
||||
router.push("/teacher/homework/submissions")
|
||||
toast.success("Grading saved successfully")
|
||||
// Optionally redirect or stay
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to save")
|
||||
toast.error(result.message || "Failed to save grading")
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Student Response</h3>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-8">
|
||||
{answers.map((ans, index) => (
|
||||
<div key={ans.id} className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span>
|
||||
<div className="text-sm">{ans.questionContent?.text}</div>
|
||||
</div>
|
||||
<Badge variant="outline">Max: {ans.maxScore}</Badge>
|
||||
</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 />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Grading</h3>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Score</span>
|
||||
<span className="font-bold text-lg text-primary">{currentTotal}</span>
|
||||
</div>
|
||||
{binaryAnswers.length > 0 ? (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700">
|
||||
Correct {correctCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700">
|
||||
Incorrect {incorrectCount}
|
||||
</Badge>
|
||||
{ungradedCount > 0 ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Ungraded {ungradedCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
{answers.map((ans, index) => (
|
||||
<Card key={ans.id} className="border-l-4 border-l-primary/20">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<span>Q{index + 1}</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span>
|
||||
{shouldUseBinaryGrading(ans) ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={getCorrectnessBadgeClassName(ans)}
|
||||
>
|
||||
{getCorrectnessLabel(ans)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{shouldUseBinaryGrading(ans) ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="mark correct"
|
||||
className={getMarkCorrectButtonClassName(ans)}
|
||||
onClick={() => handleMarkCorrect(ans.id)}
|
||||
>
|
||||
<Check />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="mark incorrect"
|
||||
className={getMarkIncorrectButtonClassName(ans)}
|
||||
onClick={() => handleMarkIncorrect(ans.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="add feedback"
|
||||
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)}
|
||||
onClick={() =>
|
||||
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) }))
|
||||
const handleScrollToQuestion = (id: string) => {
|
||||
const el = document.getElementById(`question-card-${id}`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>add feedback</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
{/* Main Content: Questions List */}
|
||||
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
|
||||
<ScrollArea className="flex-1 p-4 lg:p-8">
|
||||
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
||||
{answers.map((ans, index) => (
|
||||
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${
|
||||
ans.score === ans.maxScore ? "border-l-4 border-l-emerald-500" :
|
||||
ans.score === 0 && ans.maxScore > 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted"
|
||||
}`}>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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>
|
||||
) : null}
|
||||
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
|
||||
</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
|
||||
id={`fb-${ans.id}`}
|
||||
placeholder="Optional feedback..."
|
||||
className="min-h-[60px] resize-none"
|
||||
placeholder={`Provide feedback for ${studentName}...`}
|
||||
value={ans.feedback ?? ""}
|
||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
||||
className="min-h-[80px] bg-background"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Submit Grades"}
|
||||
{/* Sidebar: Summary & Actions */}
|
||||
<div className="lg:col-span-3 h-full flex flex-col gap-6">
|
||||
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Grading Summary</CardTitle>
|
||||
<CardDescription>{assignmentTitle}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Score</span>
|
||||
<span className="font-bold">{currentTotal} / {maxTotal}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<User className="h-4 w-4" /> Student
|
||||
</span>
|
||||
<span className="font-medium">{studentName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" /> Submitted
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{submittedAt ? new Date(submittedAt).toLocaleDateString() : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{answers.length > 0 && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<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"
|
||||
onClick={() => handleScrollToQuestion(ans.id)}
|
||||
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}`}
|
||||
title={`Q${i + 1}: ${state}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>Saving...</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" /> Submit Grades
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={!prevSubmissionId}
|
||||
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" /> Prev
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Previous Student</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
type ChoiceOption = { id?: unknown; isCorrect?: unknown }
|
||||
type ChoiceOption = { id?: unknown; isCorrect?: unknown; text?: string }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
|
||||
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
|
||||
@@ -370,39 +582,6 @@ const getCorrectnessState = (ans: Answer): CorrectnessState => {
|
||||
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 v = extractAnswerValue(studentAnswer)
|
||||
if (typeof v === "string") return v
|
||||
|
||||
@@ -3,22 +3,17 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { 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 { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
|
||||
|
||||
import type { StudentHomeworkTakeData } from "../types"
|
||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
||||
@@ -87,6 +82,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
const isStarted = submissionStatus === "started"
|
||||
const canEdit = isStarted && Boolean(submissionId)
|
||||
const showQuestions = submissionStatus !== "not_started"
|
||||
|
||||
const handleStart = async () => {
|
||||
setIsBusy(true)
|
||||
@@ -106,7 +102,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
const handleSaveQuestion = async (questionId: string) => {
|
||||
if (!submissionId) return
|
||||
setIsBusy(true)
|
||||
// setIsBusy(true) // Don't block UI for individual saves
|
||||
const payload = answersByQuestionId[questionId]?.answer ?? null
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
@@ -115,12 +111,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (res.success) toast.success("Saved")
|
||||
else toast.error(res.message || "Failed to save")
|
||||
setIsBusy(false)
|
||||
// setIsBusy(false)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!submissionId) return
|
||||
setIsBusy(true)
|
||||
// Save all first
|
||||
for (const q of initialData.questions) {
|
||||
const payload = answersByQuestionId[q.questionId]?.answer ?? null
|
||||
const fd = new FormData()
|
||||
@@ -149,50 +146,86 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 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="flex items-center gap-2">
|
||||
<h3 className="font-semibold">Questions</h3>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{submissionStatus === "not_started" ? "not started" : submissionStatus}
|
||||
</Badge>
|
||||
<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">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>
|
||||
|
||||
{!canEdit ? (
|
||||
<Button onClick={handleStart} disabled={isBusy}>
|
||||
{isBusy ? "Starting..." : "Start"}
|
||||
<Button onClick={handleStart} disabled={isBusy} size="sm">
|
||||
{isBusy ? "Starting..." : "Start Assignment"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit"}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline-block">
|
||||
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>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
{initialData.questions.map((q, idx) => {
|
||||
<ScrollArea className="flex-1 bg-muted/10">
|
||||
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||
{!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 options = getOptions(q.questionContent)
|
||||
const value = answersByQuestionId[q.questionId]?.answer
|
||||
|
||||
return (
|
||||
<Card key={q.questionId} className="border-l-4 border-l-primary/20">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span>
|
||||
Q{idx + 1} <span className="text-muted-foreground font-normal">({q.questionType})</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Max: {q.maxScore}</span>
|
||||
<Card key={q.questionId} className="border-l-4 border-l-primary shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Question {idx + 1}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-3 px-4 space-y-4">
|
||||
<div className="text-sm">{text || "—"}</div>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||
|
||||
{q.questionType === "text" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Label className="sr-only">Your answer</Label>
|
||||
<Textarea
|
||||
placeholder="Type your answer here..."
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
@@ -200,14 +233,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
[q.questionId]: { answer: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
className="min-h-[120px] resize-y"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
) : q.questionType === "judgment" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Select
|
||||
<RadioGroup
|
||||
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||
onValueChange={(v) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
@@ -216,20 +248,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">True</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">False</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "single_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Select
|
||||
<RadioGroup
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(v) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
@@ -238,28 +271,27 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
|
||||
{o.text}
|
||||
</SelectItem>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "multiple_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((o) => {
|
||||
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||
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
|
||||
id={`${q.questionId}-${o.id}`}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked === true
|
||||
@@ -275,30 +307,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<span className="text-sm">{o.text}</span>
|
||||
</label>
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
|
||||
{o.text}
|
||||
</Label>
|
||||
</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 ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSaveQuestion(q.questionId)}
|
||||
disabled={isBusy}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Save
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
Save Answer
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -308,38 +356,66 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Info</h3>
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Status</span>
|
||||
<span className="capitalize text-foreground">{submissionStatus === "not_started" ? "not started" : submissionStatus}</span>
|
||||
<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 items-center justify-between">
|
||||
<span>Questions</span>
|
||||
<span className="text-foreground tabular-nums">{initialData.questions.length}</span>
|
||||
<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={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
|
||||
{submissionStatus === "not_started" ? "not started" : 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>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="text-muted-foreground">{initialData.assignment.description || "—"}</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 className="border-t p-4 bg-muted/20">
|
||||
{canEdit ? (
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={handleStart} disabled={isBusy}>
|
||||
{isBusy ? "Starting..." : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit All"}
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-center text-muted-foreground">
|
||||
Make sure you have answered all questions.
|
||||
</p>
|
||||
</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,
|
||||
StudentHomeworkScoreAnalytics,
|
||||
StudentRanking,
|
||||
TeacherGradeTrendItem,
|
||||
} 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 toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
||||
@@ -463,6 +524,17 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
})
|
||||
.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 {
|
||||
id: submission.id,
|
||||
assignmentId: submission.assignmentId,
|
||||
@@ -472,6 +544,8 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
status: submission.status as HomeworkSubmissionDetails["status"],
|
||||
totalScore: submission.score,
|
||||
answers: answersWithDetails,
|
||||
prevSubmissionId,
|
||||
nextSubmissionId,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -582,16 +656,32 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
||||
|
||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
with: {
|
||||
question: {
|
||||
with: {
|
||||
knowledgePoints: {
|
||||
with: {
|
||||
knowledgePoint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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) {
|
||||
const answers = await db.query.homeworkAnswers.findMany({
|
||||
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 {
|
||||
@@ -614,15 +704,26 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
||||
score: latestSubmission.score ?? null,
|
||||
}
|
||||
: null,
|
||||
questions: assignmentQuestions.map((aq) => ({
|
||||
questions: assignmentQuestions.map((aq) => {
|
||||
const saved = answersByQuestionId.get(aq.questionId)
|
||||
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
|
||||
const kps = aq.question.knowledgePoints ?? []
|
||||
return {
|
||||
questionId: aq.questionId,
|
||||
questionType: aq.question.type,
|
||||
questionContent: toQuestionContent(aq.question.content),
|
||||
maxScore: aq.score ?? 0,
|
||||
order: aq.order ?? 0,
|
||||
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null,
|
||||
savedAnswer: saved?.answer ?? null,
|
||||
score: saved?.score ?? null,
|
||||
feedback: saved?.feedback ?? null,
|
||||
knowledgePoints: kps.map((kp) => ({
|
||||
id: kp.knowledgePoint.id,
|
||||
name: kp.knowledgePoint.name,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
|
||||
|
||||
@@ -2,6 +2,16 @@ export type HomeworkAssignmentStatus = "draft" | "published" | "archived"
|
||||
|
||||
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
|
||||
|
||||
export interface TeacherGradeTrendItem {
|
||||
id: string
|
||||
title: string
|
||||
averageScore: number
|
||||
maxScore: number
|
||||
submissionCount: number
|
||||
totalStudents: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface HomeworkAssignmentListItem {
|
||||
id: string
|
||||
sourceExamId: string
|
||||
@@ -63,6 +73,8 @@ export type HomeworkSubmissionDetails = {
|
||||
status: HomeworkSubmissionStatus
|
||||
totalScore: number | null
|
||||
answers: HomeworkSubmissionAnswerDetails[]
|
||||
prevSubmissionId?: string | null
|
||||
nextSubmissionId?: string | null
|
||||
}
|
||||
|
||||
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
|
||||
@@ -104,6 +116,9 @@ export type StudentHomeworkTakeQuestion = {
|
||||
maxScore: number
|
||||
order: number
|
||||
savedAnswer: unknown
|
||||
score?: number | null
|
||||
feedback?: string | null
|
||||
knowledgePoints?: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export type StudentHomeworkTakeData = {
|
||||
@@ -135,7 +150,7 @@ export type HomeworkAssignmentQuestionAnalytics = {
|
||||
order: number
|
||||
errorCount: number
|
||||
errorRate: number
|
||||
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }>
|
||||
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown; count?: number }>
|
||||
}
|
||||
|
||||
export type HomeworkAssignmentAnalytics = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Bell, Menu, Search } from "lucide-react"
|
||||
import { signOut, useSession } from "next-auth/react"
|
||||
|
||||
@@ -27,8 +28,21 @@ import {
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
|
||||
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() {
|
||||
const pathname = usePathname()
|
||||
const { toggleSidebar, isMobile } = useSidebar()
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
@@ -44,6 +58,16 @@ export function SiteHeader() {
|
||||
.map((p) => p[0]?.toUpperCase())
|
||||
.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 (
|
||||
<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">
|
||||
@@ -60,13 +84,26 @@ export function SiteHeader() {
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumb className="hidden md:flex">
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.length > 0 ? (
|
||||
breadcrumbs.map((crumb) => (
|
||||
<React.Fragment key={crumb.href}>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard">Dashboard</BreadcrumbLink>
|
||||
{crumb.isLast ? (
|
||||
<BreadcrumbPage>{crumb.title}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={crumb.href}>{crumb.title}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
{!crumb.isLast && <BreadcrumbSeparator />}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Overview</BreadcrumbPage>
|
||||
<BreadcrumbPage>Home</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
FileQuestion,
|
||||
ClipboardList,
|
||||
Library,
|
||||
PenTool
|
||||
PenTool,
|
||||
Briefcase
|
||||
} 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: "Students", href: "/teacher/classes/students" },
|
||||
{ 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