feat(classes): optimize teacher dashboard ui and implement grade management
This commit is contained in:
@@ -91,6 +91,9 @@
|
|||||||
- 若现有基础组件无法满足需求:
|
- 若现有基础组件无法满足需求:
|
||||||
1. 优先通过 Composition 在业务模块里封装“业务组件”
|
1. 优先通过 Composition 在业务模块里封装“业务组件”
|
||||||
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
|
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
|
||||||
|
- **图表库**:统一使用 `Recharts`,禁止引入其他图表库(Chart.js / ECharts 等)。
|
||||||
|
- 使用 `src/shared/components/ui/chart.tsx` 进行封装。
|
||||||
|
- 遵循 Shadcn/UI Chart 规范。
|
||||||
|
|
||||||
### 2.4 Client Component 引用边界(强制)
|
### 2.4 Client Component 引用边界(强制)
|
||||||
|
|
||||||
|
|||||||
@@ -234,3 +234,69 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
|
|||||||
### 7.4 技术细节
|
### 7.4 技术细节
|
||||||
- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。
|
- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。
|
||||||
- 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。
|
- 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。
|
||||||
|
- **New Components**:
|
||||||
|
- `TeacherGradeTrends`: 基于 Recharts 的趋势图组件。
|
||||||
|
- `Chart`: 基于 Shadcn/UI 规范的通用图表包装器 (`src/shared/components/ui/chart.tsx`)。
|
||||||
|
|
||||||
|
### 7.5 代码管理
|
||||||
|
- **Branch**: `ui_opt`
|
||||||
|
- **Scope**: `src/modules/dashboard`, `src/shared/components/ui/chart.tsx`
|
||||||
|
- **Commit**: "feat(dashboard): optimize teacher dashboard ui and layout"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 班级详情页与学生管理优化 (2026-01-14)
|
||||||
|
|
||||||
|
**目标**: 提升班级管理效率与信息可视化程度,优化大班级场景下的性能与体验。
|
||||||
|
|
||||||
|
### 8.1 学生管理列表优化 (Students Table)
|
||||||
|
- **分页 (Pagination)**: 引入客户端分页(每页 10 条),解决大班级列表渲染性能问题。
|
||||||
|
- **信息增强**:
|
||||||
|
- 增加学生头像 (Avatar)、性别、加入时间展示。
|
||||||
|
- 增加可视化状态徽章 (Status Badge):Active (Emerald) / Inactive (Muted)。
|
||||||
|
- **筛选能力**:
|
||||||
|
- 新增状态筛选器 (Active/Inactive),支持服务端过滤。
|
||||||
|
- **涉及组件**:
|
||||||
|
- `src/modules/classes/components/students-table.tsx`
|
||||||
|
- `src/modules/classes/components/students-filters.tsx`
|
||||||
|
|
||||||
|
### 8.2 班级详情页重构 (Class Detail Dashboard)
|
||||||
|
- **布局重构**: 采用响应式双栏布局 (Main Content + Sidebar),提升空间利用率。
|
||||||
|
- **核心指标 (Key Metrics)**: 顶部增加 4 卡片统计网格:
|
||||||
|
- **Total Students**: 活跃/非活跃人数细分。
|
||||||
|
- **Schedule Items**: 每周课程数。
|
||||||
|
- **Active Assignments**: 活跃作业数与逾期数。
|
||||||
|
- **Class Average**: 基于已评分作业的平均分。
|
||||||
|
- **侧边栏小部件**:
|
||||||
|
- **Class Schedule**: 快速查看近期课程。
|
||||||
|
- **Homework History**: 快速查看历史作业状态。
|
||||||
|
- **涉及页面**:
|
||||||
|
- `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx`
|
||||||
|
|
||||||
|
### 8.3 数据访问层更新
|
||||||
|
- **getClassStudents**: 扩展查询字段(头像、性别、加入时间),支持 `status` 过滤参数。
|
||||||
|
|
||||||
|
### 8.4 权限与流程调整 (Role Separation)
|
||||||
|
- **教师端变更**:
|
||||||
|
- 移除了“创建班级”入口,教师不再直接创建班级。
|
||||||
|
- 新增“加入班级” (Join Class) 功能,通过 6 位邀请码加入已由管理员创建的班级。
|
||||||
|
- 涉及组件:`src/modules/classes/components/my-classes-grid.tsx`
|
||||||
|
|
||||||
|
## 9. 年级管理端班级模块 (2026-01-14)
|
||||||
|
|
||||||
|
**目标**: 实现年级维度的班级集中管理,支持年级组长与管理员统一创建与维护班级。
|
||||||
|
|
||||||
|
### 9.1 路由与入口
|
||||||
|
- **路由**: `src/app/(dashboard)/management/grade/classes/page.tsx`
|
||||||
|
- **权限**: 仅限拥有年级管理权限的角色(Grade Director / Teaching Head / Admin)。
|
||||||
|
|
||||||
|
### 9.2 功能特性
|
||||||
|
- **GradeClassesView**:
|
||||||
|
- 展示用户所管理年级的所有班级列表。
|
||||||
|
- 支持按年级筛选。
|
||||||
|
- **CRUD**: 提供创建、编辑、删除班级的完整能力。
|
||||||
|
- **RBAC**: 操作前校验用户对目标年级的管理权限。
|
||||||
|
|
||||||
|
### 9.3 核心变更
|
||||||
|
- **Data Access**: 新增 `getManagedGrades` 与 `getGradeManagedClasses`。
|
||||||
|
- **Actions**: 新增 `createGradeClassAction` 等带权限校验的 Server Actions。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Textbooks Module Implementation Details
|
# Textbooks Module Implementation Details
|
||||||
|
|
||||||
**Date**: 2025-12-23
|
**Date**: 2025-12-23
|
||||||
**Updated**: 2025-12-31
|
**Updated**: 2026-01-13
|
||||||
**Author**: DevOps Architect
|
**Author**: DevOps Architect
|
||||||
**Module**: Textbooks (`src/modules/textbooks`)
|
**Module**: Textbooks (`src/modules/textbooks`)
|
||||||
|
|
||||||
@@ -143,6 +143,51 @@ src/
|
|||||||
* 通过 `npm run lint / typecheck / build`。
|
* 通过 `npm run lint / typecheck / build`。
|
||||||
|
|
||||||
## 8. 后续计划 (Next Steps)
|
## 8. 后续计划 (Next Steps)
|
||||||
* [ ] **富文本编辑器**: 集成编辑器替换当前 Markdown Textarea,提升编辑体验。
|
* [x] **富文本编辑器**: 已集成 Tiptap 富文本编辑器,支持 Markdown 读写、即时预览与工具栏操作。
|
||||||
* [ ] **拖拽排序**: 实现章节树拖拽排序与持久化。
|
* [x] **拖拽排序**: 实现章节树拖拽排序与持久化。
|
||||||
* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。
|
* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 界面与交互优化 (2026-01-12)
|
||||||
|
|
||||||
|
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
|
||||||
|
|
||||||
|
### 9.1 卡片与列表 (Textbook Card & Filters)
|
||||||
|
* **Dynamic Covers**: 卡片封面采用动态渐变色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),提升识别度。
|
||||||
|
* **Information Density**: 增加元数据展示(Grade, Publisher, Chapter Count),并优化排版层级。
|
||||||
|
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
|
||||||
|
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
|
||||||
|
|
||||||
|
### 9.2 详情页工作台 (Detail Workbench)
|
||||||
|
* **Immersive Layout**:
|
||||||
|
* **Full Height**: 采用 `h-[calc(100vh-8rem)]` 撑满剩余空间,移除多余滚动条。
|
||||||
|
* **Sticky Header**: 章节标题与操作栏吸顶,内容区独立滚动。
|
||||||
|
* **Typography**: 引入 `prose-zinc` 与优化的字体排版,提升阅读舒适度。
|
||||||
|
* **Sidebar Refinement**:
|
||||||
|
* **Chapter Tree**: 增加左侧边框线与层级缩进,选中态更明显;操作按钮(添加/删除)仅在 Hover 时显示,减少视觉干扰。
|
||||||
|
* **Knowledge Points**: 改为卡片式列表,Hover 显示删除按钮;增加空状态引导。
|
||||||
|
* **Drag & Drop**: 集成 `@dnd-kit` 实现章节拖拽排序,支持同级拖动并实时持久化到数据库。
|
||||||
|
|
||||||
|
### 9.3 富文本编辑器 (Rich Text Editor)
|
||||||
|
* **Tiptap Integration**: 引入 `@tiptap/react` 替换原有的 Textarea。
|
||||||
|
* **Markdown Support**: 支持 Markdown 源码读写,保持数据格式兼容性。
|
||||||
|
* **Toolbar**: 实现悬浮工具栏,支持 Bold, Italic, Headings, Lists, Blockquote 等常用格式。
|
||||||
|
* **SSR Fix**: 解决 Tiptap 在 Next.js 中的 Hydration Mismatch 问题 (`immediatelyRender: false`)。
|
||||||
|
|
||||||
|
### 9.4 系统组件优化 (UI Components)
|
||||||
|
* **Dialog**:
|
||||||
|
* 优化遮罩层 (`backdrop-blur`) 与弹窗阴影,提升通透感。
|
||||||
|
* 调整动画时长 (`duration-200`) 与缓动,移除位移动画,改为纯净的 Fade + Zoom 效果。
|
||||||
|
* 增加内部间距 (`gap-6`) 与圆角 (`rounded-xl`),使排版更现代。
|
||||||
|
* **Create Chapter Dialog**: 优化触发按钮样式,增加 `sr-only` 辅助文本,修复点击区域过小的问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 近期改进 (2026-01-13)
|
||||||
|
|
||||||
|
### 10.1 导航体验 (Navigation)
|
||||||
|
* **Dynamic Breadcrumbs**: 接入全局动态面包屑系统。
|
||||||
|
* 支持从路由路径(e.g., `/teacher/textbooks/123`)自动生成层级导航。
|
||||||
|
* 解决了深层嵌套页面(如教材详情页)缺乏上下文回退路径的问题。
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,37 @@ type ExamNode = {
|
|||||||
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
|
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
|
||||||
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
|
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
|
||||||
|
|
||||||
## 7. 变更记录(合并 Homework)
|
### 6.5 `getExamPreviewAction` (新增)
|
||||||
|
- **入参**: `examId` (string)
|
||||||
|
- **行为**:
|
||||||
|
- 查询指定 exam 及其关联的 questions (通过 `exam_questions` 关系)。
|
||||||
|
- 返回完整的 `structure` (JSON 树) 和扁平化的 `questions` 列表。
|
||||||
|
- 用于预览弹窗的数据加载。
|
||||||
|
|
||||||
|
## 7. 变更记录
|
||||||
|
|
||||||
|
**日期**:2026-01-12 (当前)
|
||||||
|
|
||||||
|
- **列表页优化 (`/teacher/exams/all`)**:
|
||||||
|
- 移除了冗余的 "All Exams" 页面标题和描述。
|
||||||
|
- 重构了表格列 (`ExamColumns`):
|
||||||
|
- 合并标题、标签、科目、年级为 "Exam Info" 列。
|
||||||
|
- 合并题目数、总分、时长为 "Stats" 列。
|
||||||
|
- 合并创建时间和预定时间为 "Date" 列。
|
||||||
|
- 优化了状态 (Status) 和难度 (Difficulty) 的视觉样式 (Badge, Progress bar)。
|
||||||
|
- 优化了表格分页和布局 (`ExamDataTable`)。
|
||||||
|
|
||||||
|
- **预览功能增强**:
|
||||||
|
- 新增直接预览功能:在操作列添加了 "View" (眼睛图标) 按钮。
|
||||||
|
- 点击 "View" 触发 `getExamPreviewAction` 获取完整试卷数据。
|
||||||
|
- 弹窗 (`Dialog`) 直接展示试卷内容 (`ExamPaperPreview`),移除了冗余的头部描述,优化了滚动体验。
|
||||||
|
- 修复了可访问性问题 (DialogTitle)。
|
||||||
|
|
||||||
|
- **组卷页面升级 (`/teacher/exams/[id]/build`)**:
|
||||||
|
- **布局重构**: 扩展工作区高度,调整左右面板比例 (2:1),优化头部信息展示和进度可视化。
|
||||||
|
- **题库增强**: 实现了基于 Server Action (`getQuestionsAction`) 的分页加载和服务器端筛选,提升大数据量下的性能;优化了搜索和筛选器 UI。
|
||||||
|
- **预览优化**: 移除了内联预览,改为通过 "Preview" 按钮触发弹窗预览,避免干扰编辑流。
|
||||||
|
- **视觉降噪**: 移除了页面顶部冗余的标题和描述。
|
||||||
|
|
||||||
**日期**:2025-12-31
|
**日期**:2025-12-31
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,39 @@
|
|||||||
- `npm run lint`: 通过
|
- `npm run lint`: 通过
|
||||||
- `npm run typecheck`: 通过
|
- `npm run typecheck`: 通过
|
||||||
- `npm run build`: 通过
|
- `npm run build`: 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. UI/UX 优化更新(2026-01-12)
|
||||||
|
|
||||||
|
### 12.1 教师端作业列表 (`/teacher/homework/assignments`)
|
||||||
|
|
||||||
|
- **表格重构**: 从简单的卡片列表升级为功能丰富的数据表格(Table)。
|
||||||
|
- **信息增强**: 合并展示标题/时间,使用 Badge 区分状态,清晰展示截止日期(含 Late 标记),可视化提交进度。
|
||||||
|
- **操作便捷**: 每行增加操作菜单(Actions),支持快速跳转详情或提交列表。
|
||||||
|
|
||||||
|
### 12.2 作业详情页 (`/teacher/homework/assignments/[id]`)
|
||||||
|
|
||||||
|
- **布局重构**:
|
||||||
|
- **Sticky Header**: 头部信息栏(标题、状态、面包屑)随滚动吸顶,但后续优化为随页面滚动(移除 Sticky)以节省空间。
|
||||||
|
- **关键指标**: 将截止日期、目标数、提交数、已批改数整合到头部下方,使用图标增强可读性。
|
||||||
|
- **双栏布局**: 主体内容分为“Performance Analytics”(分析)和“Assignment Content”(内容)两部分。
|
||||||
|
- **图表升级**:
|
||||||
|
- 重构 `HomeworkAssignmentQuestionErrorOverviewCard`,废弃 SVG,改用 **Recharts** 实现柱状图(BarChart)。
|
||||||
|
- 增强交互:支持 Tooltip 悬停查看具体题目错误率和人数。
|
||||||
|
- **详情面板优化**:
|
||||||
|
- 移除了冗余的 `HomeworkAssignmentQuestionErrorDetailsCard`。
|
||||||
|
- 深度优化 `HomeworkAssignmentQuestionErrorDetailPanel`:
|
||||||
|
- 增加饼图展示单题错误率。
|
||||||
|
- 错误答案列表卡片化,清晰展示每个错误答案的内容及选择人数。
|
||||||
|
- 整合预览面板与详情面板,提供更连贯的“左侧选题-右侧分析”体验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Bug 修复与完善 (2026-01-13)
|
||||||
|
|
||||||
|
### 13.1 批改视图 (Grading View)
|
||||||
|
- **Type Safety Fix**: 修复了 `HomeworkGradingView` 组件中的 TypeScript 类型错误。
|
||||||
|
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
|
||||||
|
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,10 @@ Next_Edu 旨在对抗教育系统常见的信息过载。我们的设计风格
|
|||||||
* **Height**: `64px` (h-16).
|
* **Height**: `64px` (h-16).
|
||||||
* **Layout**: `flex items-center justify-between px-6 border-b`.
|
* **Layout**: `flex items-center justify-between px-6 border-b`.
|
||||||
* **Components**:
|
* **Components**:
|
||||||
1. **Breadcrumb**: 显示当前路径,层级清晰。
|
1. **Breadcrumb**: 动态路径导航 (Dynamic Breadcrumb).
|
||||||
|
* **Implementation**: 基于 `usePathname()` 自动解析路由段。
|
||||||
|
* **Mapping**: 通过 `NAV_CONFIG` 或 `BREADCRUMB_MAP` 映射路径到友好标题 (e.g., `/teacher/textbooks` -> "Textbooks").
|
||||||
|
* **Filtering**: 自动过滤根角色路径 (e.g., `/teacher`) 以保持简洁。
|
||||||
2. **Global Search**: `Cmd+K` 触发,居中或靠右。
|
2. **Global Search**: `Cmd+K` 触发,居中或靠右。
|
||||||
3. **User Nav**: 头像 + 下拉菜单。
|
3. **User Nav**: 头像 + 下拉菜单。
|
||||||
|
|
||||||
|
|||||||
78
docs/work_log.md
Normal file
78
docs/work_log.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Work Log
|
||||||
|
|
||||||
|
## 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`);
|
|
||||||
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
3009
drizzle/meta/0009_snapshot.json
Normal file
3009
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -54,16 +54,9 @@
|
|||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1767782500000,
|
"when": 1768205524480,
|
||||||
"tag": "0007_add_class_invitation_code",
|
"tag": "0007_talented_bromley",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1767941300000,
|
|
||||||
"tag": "0008_add_user_profile_fields",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
936
package-lock.json
generated
936
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,8 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
@@ -33,6 +35,10 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-placeholder": "^3.15.3",
|
||||||
|
"@tiptap/pm": "^3.15.3",
|
||||||
|
"@tiptap/react": "^3.15.3",
|
||||||
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -52,6 +58,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
"zod": "^4.2.1",
|
"zod": "^4.2.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
textbooks, chapters,
|
textbooks, chapters,
|
||||||
schools,
|
schools,
|
||||||
grades,
|
grades,
|
||||||
classes, classEnrollments, classSchedule
|
classes, classEnrollments, classSchedule,
|
||||||
|
subjects
|
||||||
} from "../src/shared/db/schema";
|
} from "../src/shared/db/schema";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
@@ -43,7 +44,7 @@ async function seed() {
|
|||||||
"submission_answers", "exam_submissions", "exam_questions", "exams",
|
"submission_answers", "exam_submissions", "exam_questions", "exams",
|
||||||
"questions_to_knowledge_points", "questions", "knowledge_points",
|
"questions_to_knowledge_points", "questions", "knowledge_points",
|
||||||
"chapters", "textbooks",
|
"chapters", "textbooks",
|
||||||
"grades", "schools",
|
"grades", "schools", "subjects",
|
||||||
"users_to_roles", "roles", "users", "accounts", "sessions"
|
"users_to_roles", "roles", "users", "accounts", "sessions"
|
||||||
];
|
];
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
@@ -133,6 +134,17 @@ async function seed() {
|
|||||||
{ id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" },
|
{ id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// --- Seeding Subjects ---
|
||||||
|
await db.insert(subjects).values([
|
||||||
|
{ id: createId(), name: "Mathematics", code: "MATH", order: 1 },
|
||||||
|
{ id: createId(), name: "Physics", code: "PHYS", order: 2 },
|
||||||
|
{ id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
|
||||||
|
{ id: createId(), name: "English", code: "ENG", order: 4 },
|
||||||
|
{ id: createId(), name: "History", code: "HIST", order: 5 },
|
||||||
|
{ id: createId(), name: "Geography", code: "GEO", order: 6 },
|
||||||
|
{ id: createId(), name: "Biology", code: "BIO", order: 7 },
|
||||||
|
])
|
||||||
|
|
||||||
await db.insert(grades).values([
|
await db.insert(grades).values([
|
||||||
{
|
{
|
||||||
id: grade10Id,
|
id: grade10Id,
|
||||||
|
|||||||
31
src/app/(dashboard)/management/grade/classes/page.tsx
Normal file
31
src/app/(dashboard)/management/grade/classes/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { getGradeManagedClasses, getManagedGrades, getTeacherOptions } from "@/modules/classes/data-access"
|
||||||
|
import { GradeClassesClient } from "@/modules/classes/components/grade-classes-view"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function GradeClassesPage() {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id ?? ""
|
||||||
|
|
||||||
|
const [classes, teachers, managedGrades] = await Promise.all([
|
||||||
|
getGradeManagedClasses(userId),
|
||||||
|
getTeacherOptions(),
|
||||||
|
getManagedGrades(userId),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Class Management</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage classes for your grades.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GradeClassesClient classes={classes} teachers={teachers} managedGrades={managedGrades} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
|||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form action="/teacher/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
<form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
<label className="text-sm font-medium">Grade</label>
|
<label className="text-sm font-medium">Grade</label>
|
||||||
<select
|
<select
|
||||||
name="gradeId"
|
name="gradeId"
|
||||||
@@ -4,14 +4,16 @@ import { redirect } from "next/navigation"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||||
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
||||||
import { StudentRankingCard } from "@/modules/dashboard/components/student-dashboard/student-ranking-card"
|
|
||||||
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
||||||
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
||||||
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
||||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||||
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -20,17 +22,31 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
|||||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date | null) => {
|
||||||
|
if (!date) return "-"
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user) redirect("/login")
|
if (!session?.user) redirect("/login")
|
||||||
|
|
||||||
const name = session.user.name ?? "User"
|
|
||||||
const email = session.user.email ?? "-"
|
|
||||||
const role = String(session.user.role ?? "teacher")
|
|
||||||
const userId = String(session.user.id ?? "").trim()
|
const userId = String(session.user.id ?? "").trim()
|
||||||
|
const userProfile = await getUserProfile(userId)
|
||||||
|
|
||||||
|
if (!userProfile) {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = userProfile.role || "student"
|
||||||
|
const isStudent = role === "student"
|
||||||
|
|
||||||
const studentData =
|
const studentData =
|
||||||
role === "student" && userId
|
isStudent
|
||||||
? await (async () => {
|
? await (async () => {
|
||||||
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
||||||
getStudentClasses(userId),
|
getStudentClasses(userId),
|
||||||
@@ -96,36 +112,104 @@ export default async function ProfilePage() {
|
|||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
|
||||||
<div className="text-sm text-muted-foreground">Your account information.</div>
|
<div className="text-sm text-muted-foreground">Manage your personal and account information.</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/settings">Open settings</Link>
|
<Link href="/settings">Edit Profile</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Account</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Signed-in user details from session.</CardDescription>
|
<CardTitle className="flex items-center gap-2">
|
||||||
</CardHeader>
|
<User className="h-5 w-5" />
|
||||||
<CardContent className="space-y-3">
|
Personal Information
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</CardTitle>
|
||||||
<div className="text-sm font-medium">{name}</div>
|
<CardDescription>Basic personal details.</CardDescription>
|
||||||
<Badge variant="secondary" className="capitalize">
|
</CardHeader>
|
||||||
{role}
|
<CardContent className="space-y-4">
|
||||||
</Badge>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
<div className="text-sm text-muted-foreground">{email}</div>
|
<div className="text-sm font-medium text-muted-foreground">Full Name</div>
|
||||||
</CardContent>
|
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
|
||||||
</Card>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Gender</div>
|
||||||
|
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Age</div>
|
||||||
|
<div className="text-sm">{userProfile.age ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Phone</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
|
||||||
|
{userProfile.phone ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 sm:col-span-2 space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Address</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
|
||||||
|
{userProfile.address ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Account Information
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>System account details.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="col-span-1 sm:col-span-2 space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Email</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{userProfile.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Role</div>
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{userProfile.role}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Member Since</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{formatDate(userProfile.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{formatDate(userProfile.onboardedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{studentData ? (
|
{studentData ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Separator />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-semibold tracking-tight">Student</h2>
|
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
|
||||||
<div className="text-sm text-muted-foreground">Your learning overview.</div>
|
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StudentStatsGrid
|
<StudentStatsGrid
|
||||||
@@ -133,16 +217,17 @@ export default async function ProfilePage() {
|
|||||||
dueSoonCount={studentData.dueSoonCount}
|
dueSoonCount={studentData.dueSoonCount}
|
||||||
overdueCount={studentData.overdueCount}
|
overdueCount={studentData.overdueCount}
|
||||||
gradedCount={studentData.gradedCount}
|
gradedCount={studentData.gradedCount}
|
||||||
|
ranking={studentData.grades.ranking}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<StudentGradesCard grades={studentData.grades} />
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<StudentRankingCard ranking={studentData.grades.ranking} />
|
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
|
||||||
</div>
|
<StudentGradesCard grades={studentData.grades} />
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="space-y-6">
|
||||||
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
||||||
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { auth } from "@/auth"
|
|||||||
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
|
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
|
||||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||||
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -11,11 +12,16 @@ export default async function SettingsPage() {
|
|||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user) redirect("/login")
|
if (!session?.user) redirect("/login")
|
||||||
|
|
||||||
const role = String(session.user.role ?? "teacher")
|
const userId = String(session.user.id ?? "").trim()
|
||||||
|
const userProfile = await getUserProfile(userId)
|
||||||
|
|
||||||
if (role === "admin") return <AdminSettingsView />
|
if (!userProfile) redirect("/login")
|
||||||
if (role === "student") return <StudentSettingsView user={session.user} />
|
|
||||||
if (role === "teacher") return <TeacherSettingsView user={session.user} />
|
const role = userProfile.role || "student"
|
||||||
|
|
||||||
|
if (role === "admin") return <AdminSettingsView user={userProfile} />
|
||||||
|
if (role === "student") return <StudentSettingsView user={userProfile} />
|
||||||
|
if (role === "teacher") return <TeacherSettingsView user={userProfile} />
|
||||||
|
|
||||||
redirect("/dashboard")
|
redirect("/dashboard")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation"
|
|||||||
|
|
||||||
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||||
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
|
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
|
||||||
|
import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -18,6 +19,23 @@ export default async function StudentAssignmentTakePage({
|
|||||||
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
||||||
if (!data) return notFound()
|
if (!data) return notFound()
|
||||||
|
|
||||||
|
// If status is graded or submitted, use the review view
|
||||||
|
const status = data.submission?.status
|
||||||
|
if (status === "graded" || status === "submitted") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-4 p-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HomeworkReviewView initialData={data} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-4 p-6">
|
<div className="flex h-full flex-col space-y-4 p-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ const getStatusLabel = (status: string) => {
|
|||||||
return "Not started"
|
return "Not started"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getActionLabel = (status: string) => {
|
||||||
|
if (status === "graded") return "Review"
|
||||||
|
if (status === "submitted") return "View"
|
||||||
|
if (status === "in_progress") return "Continue"
|
||||||
|
return "Start"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||||
|
if (status === "graded" || status === "submitted") return "outline"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
export default async function StudentAssignmentsPage() {
|
export default async function StudentAssignmentsPage() {
|
||||||
const student = await getDemoStudentUser()
|
const student = await getDemoStudentUser()
|
||||||
|
|
||||||
@@ -75,6 +87,7 @@ export default async function StudentAssignmentsPage() {
|
|||||||
<TableHead>Due</TableHead>
|
<TableHead>Due</TableHead>
|
||||||
<TableHead>Attempts</TableHead>
|
<TableHead>Attempts</TableHead>
|
||||||
<TableHead>Score</TableHead>
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead className="text-right">Action</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -95,6 +108,13 @@ export default async function StudentAssignmentsPage() {
|
|||||||
{a.attemptsUsed}/{a.maxAttempts}
|
{a.attemptsUsed}/{a.maxAttempts}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
||||||
|
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||||
|
{getActionLabel(a.progressStatus)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -39,36 +39,40 @@ export default async function StudentTextbookDetailPage({
|
|||||||
if (!textbook) notFound()
|
if (!textbook) notFound()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
|
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10">
|
<div className="flex items-center gap-4 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground" asChild>
|
||||||
<Link href="/student/learning/textbooks">
|
<Link href="/student/learning/textbooks">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="w-px h-8 bg-border mx-2" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">{textbook.subject}</Badge>
|
<h1 className="text-lg font-bold tracking-tight truncate mr-2">{textbook.title}</h1>
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
<Badge variant="secondary" className="font-normal text-xs">{textbook.subject}</Badge>
|
||||||
{textbook.grade ?? "-"}
|
{textbook.grade && (
|
||||||
</span>
|
<span className="text-xs text-muted-foreground border px-1.5 py-0.5 rounded">
|
||||||
|
{textbook.grade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden pt-6">
|
<div className="flex-1 overflow-hidden p-6">
|
||||||
{chapters.length === 0 ? (
|
{chapters.length === 0 ? (
|
||||||
<div className="px-8">
|
<div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title="No chapters"
|
title="No chapters"
|
||||||
description="This textbook has no chapters yet."
|
description="This textbook has no chapters yet."
|
||||||
className="bg-card"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[calc(100vh-140px)] px-8 min-h-0">
|
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
||||||
<TextbookReader chapters={chapters} />
|
<TextbookReader chapters={chapters} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default async function StudentTextbooksPage({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{textbooks.map((textbook) => (
|
{textbooks.map((textbook) => (
|
||||||
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" />
|
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" hideActions />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
import { BookOpen, Calendar, ChevronRight, Clock, Users } from "lucide-react"
|
||||||
|
|
||||||
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
|
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
|
||||||
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -23,6 +25,15 @@ const formatNumber = (v: number | null, digits = 1) => {
|
|||||||
return v.toFixed(digits)
|
return v.toFixed(digits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ClassDetailPage({
|
export default async function ClassDetailPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -63,253 +74,304 @@ export default async function ClassDetailPage({
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex flex-col min-h-full space-y-8 p-8">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Link href="/teacher/classes/my" className="hover:text-foreground transition-colors">
|
||||||
<Link href="/teacher/classes/my">Back</Link>
|
My Classes
|
||||||
</Button>
|
</Link>
|
||||||
<Badge variant="secondary">{insights.class.grade}</Badge>
|
<ChevronRight className="h-4 w-4" />
|
||||||
<Badge variant="outline">{insights.studentCounts.total} students</Badge>
|
<span className="text-foreground font-medium">{insights.class.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2>
|
<h2 className="text-3xl font-bold tracking-tight">{insights.class.name}</h2>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"}
|
<Badge variant="secondary" className="rounded-sm font-normal">
|
||||||
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null}
|
{insights.class.grade}
|
||||||
|
</Badge>
|
||||||
|
{insights.class.homeroom && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-border" />
|
||||||
|
<span>Homeroom: {insights.class.homeroom}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{insights.class.room && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-border" />
|
||||||
|
<span>Room: {insights.class.room}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>Students</Link>
|
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Students
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>Schedule</Link>
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Schedule
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline">
|
<Button asChild>
|
||||||
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(insights.class.id)}`}>Insights</Link>
|
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>
|
||||||
|
Create Homework
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
{/* Stats Grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Students</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Students</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
|
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
|
{insights.studentCounts.active} active · {insights.studentCounts.inactive} inactive
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Schedule items</CardTitle>
|
<CardTitle className="text-sm font-medium">Schedule Items</CardTitle>
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{schedule.length}</div>
|
<div className="text-2xl font-bold">{schedule.length}</div>
|
||||||
<div className="text-xs text-muted-foreground">Weekly timetable entries</div>
|
<div className="text-xs text-muted-foreground">Weekly sessions</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
<CardTitle className="text-sm font-medium">Active Assignments</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{insights.assignments.length}</div>
|
<div className="text-2xl font-bold">
|
||||||
<div className="text-xs text-muted-foreground">{latest ? `Latest ${formatDate(latest.createdAt)}` : "No homework yet"}</div>
|
{insights.assignments.filter((a) => a.isActive).length}
|
||||||
</CardContent>
|
</div>
|
||||||
</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">
|
<div className="text-xs text-muted-foreground">
|
||||||
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
|
{insights.assignments.filter((a) => a.isOverdue).length} overdue
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Class Average</CardTitle>
|
||||||
|
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}%</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Based on {insights.overallScores.count} graded submissions
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{latest ? (
|
<div className="grid gap-6 lg:grid-cols-7">
|
||||||
<Card>
|
{/* Main Content Area */}
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div className="lg:col-span-4 space-y-6">
|
||||||
<div>
|
{/* Latest Homework */}
|
||||||
<CardTitle className="text-base">Latest homework</CardTitle>
|
{latest && (
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<Card>
|
||||||
<span className="font-medium text-foreground">{latest.title}</span>
|
<CardHeader>
|
||||||
<Badge variant="outline" className="capitalize">
|
<div className="flex items-center justify-between">
|
||||||
{latest.status}
|
<div className="space-y-1">
|
||||||
</Badge>
|
<CardTitle>Latest Homework</CardTitle>
|
||||||
<span>·</span>
|
<CardDescription>Most recent assignment activity</CardDescription>
|
||||||
<span>{formatDate(latest.createdAt)}</span>
|
</div>
|
||||||
{latest.dueAt ? (
|
<Badge variant={latest.isActive ? "default" : "secondary"}>
|
||||||
<>
|
{latest.status}
|
||||||
<span>·</span>
|
</Badge>
|
||||||
<span>Due {formatDate(latest.dueAt)}</span>
|
</div>
|
||||||
</>
|
</CardHeader>
|
||||||
) : null}
|
<CardContent className="space-y-6">
|
||||||
</div>
|
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||||
</div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-1">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Link
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
|
href={`/teacher/homework/assignments/${latest.assignmentId}`}
|
||||||
</Button>
|
className="font-semibold hover:underline"
|
||||||
<Button asChild variant="outline" size="sm">
|
>
|
||||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
|
{latest.title}
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
</CardHeader>
|
<span>Due {latest.dueAt ? formatDate(latest.dueAt) : "No due date"}</span>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-5">
|
<span>·</span>
|
||||||
<div>
|
<span>{latest.submittedCount}/{latest.targetCount} Submitted</span>
|
||||||
<div className="text-sm text-muted-foreground">Targeted</div>
|
</div>
|
||||||
<div className="text-lg font-semibold">{latest.targetCount}</div>
|
</div>
|
||||||
</div>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<div>
|
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>
|
||||||
<div className="text-sm text-muted-foreground">Submitted</div>
|
Grade
|
||||||
<div className="text-lg font-semibold">{latest.submittedCount}</div>
|
</Link>
|
||||||
</div>
|
</Button>
|
||||||
<div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Graded</div>
|
|
||||||
<div className="text-lg font-semibold">{latest.gradedCount}</div>
|
<div className="grid grid-cols-3 gap-4 border-t pt-4">
|
||||||
</div>
|
<div className="text-center">
|
||||||
<div>
|
<div className="text-2xl font-bold">{latest.gradedCount}</div>
|
||||||
<div className="text-sm text-muted-foreground">Average</div>
|
<div className="text-xs text-muted-foreground">Graded</div>
|
||||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
|
</div>
|
||||||
</div>
|
<div className="text-center border-l border-r">
|
||||||
<div>
|
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.avg, 1)}</div>
|
||||||
<div className="text-sm text-muted-foreground">Median</div>
|
<div className="text-xs text-muted-foreground">Average</div>
|
||||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
|
</div>
|
||||||
</div>
|
<div className="text-center">
|
||||||
</CardContent>
|
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.median, 1)}</div>
|
||||||
</Card>
|
<div className="text-xs text-muted-foreground">Median</div>
|
||||||
) : null}
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
</div>
|
||||||
<Card>
|
</CardContent>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
</Card>
|
||||||
<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>
|
{/* Students Preview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle>Students</CardTitle>
|
||||||
|
<CardDescription>Recently active students</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
|
||||||
|
View All
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{students.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No students enrolled yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{students.slice(0, 5).map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarImage src={s.image || undefined} />
|
||||||
|
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{s.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{s.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={s.status === "active" ? "outline" : "secondary"} className="text-xs font-normal">
|
||||||
|
{s.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Area */}
|
||||||
|
<div className="lg:col-span-3 space-y-6">
|
||||||
|
{/* Schedule Widget */}
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Schedule</CardTitle>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Homework History */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>History</CardTitle>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={hwFilter === "all" ? "secondary" : "ghost"}
|
||||||
|
asChild
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={hwFilter === "active" ? "secondary" : "ghost"}
|
||||||
|
asChild
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={hwFilter === "overdue" ? "secondary" : "ghost"}
|
||||||
|
asChild
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredAssignments.slice(0, 5).map((a) => (
|
||||||
|
<div key={a.assignmentId} className="p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-2">
|
||||||
|
<Link
|
||||||
|
href={`/teacher/homework/assignments/${a.assignmentId}`}
|
||||||
|
className="text-sm font-medium hover:underline line-clamp-1"
|
||||||
|
>
|
||||||
|
{a.title}
|
||||||
|
</Link>
|
||||||
|
<Badge variant={a.isActive ? "default" : "secondary"} className="shrink-0 text-[10px] h-5">
|
||||||
|
{a.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span>{a.submittedCount} submitted</span>
|
||||||
|
<span>{formatNumber(a.scoreStats.avg, 0)}% avg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredAssignments.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No assignments found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filteredAssignments.length > 5 && (
|
||||||
|
<div className="p-2 border-t text-center">
|
||||||
|
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" asChild>
|
||||||
|
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>
|
||||||
|
View All Assignments
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ export default function MyClassesPage() {
|
|||||||
|
|
||||||
async function MyClassesPageImpl() {
|
async function MyClassesPageImpl() {
|
||||||
const classes = await getTeacherClasses()
|
const classes = await getTeacherClasses()
|
||||||
const session = await auth()
|
|
||||||
const role = String(session?.user?.role ?? "")
|
|
||||||
const userId = String(session?.user?.id ?? "").trim()
|
|
||||||
|
|
||||||
const canCreateClass = await (async () => {
|
|
||||||
if (role === "admin") return true
|
|
||||||
if (!userId) return false
|
|
||||||
const [row] = await db.select({ id: grades.id }).from(grades).where(eq(grades.gradeHeadId, userId)).limit(1)
|
|
||||||
return Boolean(row)
|
|
||||||
})()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
@@ -35,7 +25,7 @@ async function MyClassesPageImpl() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MyClassesGrid classes={classes} canCreateClass={canCreateClass} />
|
<MyClassesGrid classes={classes} canCreateClass={false} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ async function StudentsResults({ searchParams }: { searchParams: Promise<SearchP
|
|||||||
|
|
||||||
const q = getParam(params, "q") || undefined
|
const q = getParam(params, "q") || undefined
|
||||||
const classId = getParam(params, "classId")
|
const classId = getParam(params, "classId")
|
||||||
|
const status = getParam(params, "status")
|
||||||
|
|
||||||
const filteredStudents = await getClassStudents({
|
const filteredStudents = await getClassStudents({
|
||||||
q,
|
q,
|
||||||
classId: classId && classId !== "all" ? classId : undefined,
|
classId: classId && classId !== "all" ? classId : undefined,
|
||||||
|
status: status && status !== "all" ? status : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasFilters = Boolean(q || (classId && classId !== "all"))
|
const hasFilters = Boolean(q || (classId && classId !== "all") || (status && status !== "all"))
|
||||||
|
|
||||||
if (filteredStudents.length === 0) {
|
if (filteredStudents.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
|||||||
const exam = await getExamById(id)
|
const exam = await getExamById(id)
|
||||||
if (!exam) return notFound()
|
if (!exam) return notFound()
|
||||||
|
|
||||||
// Fetch all available questions (for selection pool)
|
// Fetch initial questions for the bank (pagination handled by client)
|
||||||
// In a real app, this might be paginated or filtered by exam subject/grade
|
const { data: questionsData } = await getQuestions({ pageSize: 20 })
|
||||||
const { data: questionsData } = await getQuestions({ pageSize: 100 })
|
|
||||||
|
|
||||||
const initialSelected = (exam.questions || []).map(q => ({
|
const initialSelected = (exam.questions || []).map(q => ({
|
||||||
id: q.id,
|
id: q.id,
|
||||||
@@ -103,13 +102,7 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-4 p-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Build Exam</h2>
|
|
||||||
<p className="text-muted-foreground">Add questions and adjust scores.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ExamAssembly
|
<ExamAssembly
|
||||||
examId={exam.id}
|
examId={exam.id}
|
||||||
title={exam.title}
|
title={exam.title}
|
||||||
|
|||||||
@@ -131,13 +131,6 @@ export default async function AllExamsPage({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
|
|
||||||
<p className="text-muted-foreground">View and manage all your exams.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||||
<ExamFilters />
|
<ExamFilters />
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/shared/components/ui/breadcrumb"
|
||||||
|
|
||||||
export default function CreateExamPage() {
|
export default function CreateExamPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>Create</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Create Exam</h2>
|
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
|
||||||
<p className="text-muted-foreground">Design a new exam for your students.</p>
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Set up a new exam draft and choose your assembly method.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ExamForm />
|
<ExamForm />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import Link from "next/link"
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
|
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
|
||||||
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
|
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
|
||||||
import { HomeworkAssignmentQuestionErrorDetailsCard } from "@/modules/homework/components/homework-assignment-question-error-details-card"
|
|
||||||
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -20,82 +20,82 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
|
|||||||
const { assignment, questions, gradedSampleCount } = analytics
|
const { assignment, questions, gradedSampleCount } = analytics
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex flex-col min-h-full">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
{/* Header */}
|
||||||
<div>
|
<div className="border-b bg-background px-8 py-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2>
|
<div className="flex flex-col gap-2">
|
||||||
<Badge variant="outline" className="capitalize">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
{assignment.status}
|
<Link href="/teacher/homework/assignments" className="flex items-center hover:text-foreground transition-colors">
|
||||||
</Badge>
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
</div>
|
Assignments
|
||||||
<p className="text-muted-foreground mt-1">{assignment.description || "—"}</p>
|
</Link>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
<span>/</span>
|
||||||
<span>Source Exam: {assignment.sourceExamTitle}</span>
|
<span>Details</span>
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<span>Created: {formatDate(assignment.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/teacher/homework/assignments">Back</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>View Submissions</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Targets</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{assignment.targetCount}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Submissions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{assignment.submissionCount}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Due</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Late:{" "}
|
|
||||||
{assignment.allowLate
|
|
||||||
? assignment.lateDueAt
|
|
||||||
? formatDate(assignment.lateDueAt)
|
|
||||||
: "Allowed"
|
|
||||||
: "Not allowed"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex items-center gap-3">
|
||||||
</Card>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{assignment.title}</h1>
|
||||||
|
<Badge variant={assignment.status === "published" ? "default" : "secondary"} className="capitalize">
|
||||||
|
{assignment.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm max-w-2xl">{assignment.description || "No description provided."}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-2 md:mt-0">
|
||||||
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
|
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>
|
||||||
|
<Users className="h-4 w-4 mr-2" />
|
||||||
|
View Submissions
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats Row */}
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>Due: <span className="font-medium text-foreground">{assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>Targets: <span className="font-medium text-foreground">{assignment.targetCount}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<span>Submissions: <span className="font-medium text-foreground">{assignment.submissionCount}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
<span>Graded: <span className="font-medium text-foreground">{gradedSampleCount}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="flex-1 p-8 space-y-8 bg-muted/5">
|
||||||
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
{/* Analytics Section */}
|
||||||
<HomeworkAssignmentQuestionErrorDetailsCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
<section className="space-y-4">
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Performance Analytics</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-1">
|
||||||
|
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<HomeworkAssignmentExamContentCard
|
{/* Content Section */}
|
||||||
structure={assignment.structure}
|
<section className="space-y-4">
|
||||||
questions={questions}
|
<div className="flex items-center justify-between">
|
||||||
gradedSampleCount={gradedSampleCount}
|
<h2 className="text-lg font-semibold tracking-tight">Assignment Content</h2>
|
||||||
/>
|
</div>
|
||||||
|
<HomeworkAssignmentExamContentCard
|
||||||
|
structure={assignment.structure}
|
||||||
|
questions={questions}
|
||||||
|
gradedSampleCount={gradedSampleCount}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
|
|||||||
status={submission.status}
|
status={submission.status}
|
||||||
totalScore={submission.totalScore}
|
totalScore={submission.totalScore}
|
||||||
answers={submission.answers}
|
answers={submission.answers}
|
||||||
|
prevSubmissionId={submission.prevSubmissionId}
|
||||||
|
nextSubmissionId={submission.nextSubmissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
|||||||
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
|
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
|
||||||
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
|
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
|
||||||
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
|
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
|
||||||
className="bg-card"
|
className="min-h-[400px] border-muted-foreground/10"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
|||||||
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { and, eq, sql } from "drizzle-orm"
|
import { and, eq, sql, or, inArray } from "drizzle-orm"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { grades } from "@/shared/db/schema"
|
import { grades, classes } from "@/shared/db/schema"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import {
|
import {
|
||||||
createAdminClass,
|
createAdminClass,
|
||||||
@@ -138,6 +138,201 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createGradeClassAction(
|
||||||
|
prevState: ActionState<string> | undefined,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
if (!userId) return { success: false, message: "Unauthorized" }
|
||||||
|
|
||||||
|
const schoolName = formData.get("schoolName")
|
||||||
|
const schoolId = formData.get("schoolId")
|
||||||
|
const name = formData.get("name")
|
||||||
|
const grade = formData.get("grade")
|
||||||
|
const gradeId = formData.get("gradeId")
|
||||||
|
const teacherId = formData.get("teacherId")
|
||||||
|
const homeroom = formData.get("homeroom")
|
||||||
|
const room = formData.get("room")
|
||||||
|
|
||||||
|
if (typeof name !== "string" || name.trim().length === 0) {
|
||||||
|
return { success: false, message: "Class name is required" }
|
||||||
|
}
|
||||||
|
if (typeof gradeId !== "string" || gradeId.trim().length === 0) {
|
||||||
|
return { success: false, message: "Grade selection is required" }
|
||||||
|
}
|
||||||
|
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
|
||||||
|
return { success: false, message: "Teacher is required" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
const [managedGrade] = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!managedGrade) {
|
||||||
|
return { success: false, message: "You do not have permission to create classes for this grade" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = await createAdminClass({
|
||||||
|
schoolName: typeof schoolName === "string" ? schoolName : null,
|
||||||
|
schoolId: typeof schoolId === "string" ? schoolId : null,
|
||||||
|
name,
|
||||||
|
grade: typeof grade === "string" ? grade : "", // Should be passed from UI based on selected grade
|
||||||
|
gradeId,
|
||||||
|
teacherId,
|
||||||
|
homeroom: typeof homeroom === "string" ? homeroom : null,
|
||||||
|
room: typeof room === "string" ? room : null,
|
||||||
|
})
|
||||||
|
revalidatePath("/management/grade/classes")
|
||||||
|
return { success: true, message: "Class created successfully", data: id }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGradeClassAction(
|
||||||
|
classId: string,
|
||||||
|
prevState: ActionState | undefined,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState> {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
if (!userId) return { success: false, message: "Unauthorized" }
|
||||||
|
|
||||||
|
const schoolName = formData.get("schoolName")
|
||||||
|
const schoolId = formData.get("schoolId")
|
||||||
|
const name = formData.get("name")
|
||||||
|
const grade = formData.get("grade")
|
||||||
|
const gradeId = formData.get("gradeId")
|
||||||
|
const teacherId = formData.get("teacherId")
|
||||||
|
const homeroom = formData.get("homeroom")
|
||||||
|
const room = formData.get("room")
|
||||||
|
const subjectTeachers = formData.get("subjectTeachers")
|
||||||
|
|
||||||
|
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||||
|
return { success: false, message: "Missing class id" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access: Check if the class belongs to a managed grade
|
||||||
|
const [cls] = await db
|
||||||
|
.select({ gradeId: classes.gradeId })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, classId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!cls || !cls.gradeId) {
|
||||||
|
return { success: false, message: "Class not found or not linked to a grade" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [managedGrade] = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!managedGrade) {
|
||||||
|
return { success: false, message: "You do not have permission to update this class" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If changing gradeId, verify target grade too
|
||||||
|
if (typeof gradeId === "string" && gradeId !== cls.gradeId) {
|
||||||
|
const [targetGrade] = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!targetGrade) {
|
||||||
|
return { success: false, message: "You do not have permission to move class to this grade" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateAdminClass(classId, {
|
||||||
|
schoolName: typeof schoolName === "string" ? schoolName : undefined,
|
||||||
|
schoolId: typeof schoolId === "string" ? schoolId : undefined,
|
||||||
|
name: typeof name === "string" ? name : undefined,
|
||||||
|
grade: typeof grade === "string" ? grade : undefined,
|
||||||
|
gradeId: typeof gradeId === "string" ? gradeId : undefined,
|
||||||
|
teacherId: typeof teacherId === "string" ? teacherId : undefined,
|
||||||
|
homeroom: typeof homeroom === "string" ? homeroom : undefined,
|
||||||
|
room: typeof room === "string" ? room : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
|
||||||
|
const parsed = JSON.parse(subjectTeachers) as unknown
|
||||||
|
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
|
||||||
|
|
||||||
|
await setClassSubjectTeachers({
|
||||||
|
classId,
|
||||||
|
assignments: parsed.flatMap((item) => {
|
||||||
|
if (!item || typeof item !== "object") return []
|
||||||
|
const subject = (item as { subject?: unknown }).subject
|
||||||
|
const teacherId = (item as { teacherId?: unknown }).teacherId
|
||||||
|
|
||||||
|
if (typeof subject !== "string" || !isClassSubject(subject)) return []
|
||||||
|
|
||||||
|
if (teacherId === null || typeof teacherId === "undefined") {
|
||||||
|
return [{ subject, teacherId: null }]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof teacherId !== "string") return []
|
||||||
|
const trimmed = teacherId.trim()
|
||||||
|
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/management/grade/classes")
|
||||||
|
return { success: true, message: "Class updated successfully" }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
if (!userId) return { success: false, message: "Unauthorized" }
|
||||||
|
|
||||||
|
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||||
|
return { success: false, message: "Missing class id" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
const [cls] = await db
|
||||||
|
.select({ gradeId: classes.gradeId })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, classId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!cls || !cls.gradeId) {
|
||||||
|
return { success: false, message: "Class not found or not linked to a grade" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [managedGrade] = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!managedGrade) {
|
||||||
|
return { success: false, message: "You do not have permission to delete this class" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAdminClass(classId)
|
||||||
|
revalidatePath("/management/grade/classes")
|
||||||
|
return { success: true, message: "Class deleted successfully" }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function enrollStudentByEmailAction(
|
export async function enrollStudentByEmailAction(
|
||||||
classId: string,
|
classId: string,
|
||||||
prevState: ActionState | null,
|
prevState: ActionState | null,
|
||||||
@@ -171,14 +366,19 @@ export async function joinClassByInvitationCodeAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id || String(session.user.role ?? "") !== "student") {
|
const role = String(session?.user?.role ?? "")
|
||||||
|
if (!session?.user?.id || (role !== "student" && role !== "teacher")) {
|
||||||
return { success: false, message: "Unauthorized" }
|
return { success: false, message: "Unauthorized" }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const classId = await enrollStudentByInvitationCode(session.user.id, code)
|
const classId = await enrollStudentByInvitationCode(session.user.id, code)
|
||||||
revalidatePath("/student/learning/courses")
|
if (role === "student") {
|
||||||
revalidatePath("/student/schedule")
|
revalidatePath("/student/learning/courses")
|
||||||
|
revalidatePath("/student/schedule")
|
||||||
|
} else {
|
||||||
|
revalidatePath("/teacher/classes/my")
|
||||||
|
}
|
||||||
revalidatePath("/profile")
|
revalidatePath("/profile")
|
||||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
return { success: true, message: "Joined class successfully", data: { classId } }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,11 +3,24 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Calendar, Copy, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Users } from "lucide-react"
|
import {
|
||||||
|
Calendar,
|
||||||
|
Copy,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
GraduationCap,
|
||||||
|
MapPin,
|
||||||
|
ChartBar,
|
||||||
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { parseAsString, useQueryState } from "nuqs"
|
import { parseAsString, useQueryState } from "nuqs"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
@@ -41,6 +54,7 @@ import {
|
|||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||||
import type { TeacherClass } from "../types"
|
import type { TeacherClass } from "../types"
|
||||||
import {
|
import {
|
||||||
createTeacherClassAction,
|
createTeacherClassAction,
|
||||||
@@ -48,12 +62,25 @@ import {
|
|||||||
ensureClassInvitationCodeAction,
|
ensureClassInvitationCodeAction,
|
||||||
regenerateClassInvitationCodeAction,
|
regenerateClassInvitationCodeAction,
|
||||||
updateTeacherClassAction,
|
updateTeacherClassAction,
|
||||||
|
joinClassByInvitationCodeAction,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
|
|
||||||
|
const GRADIENTS = [
|
||||||
|
"bg-card border-border",
|
||||||
|
"bg-card border-border",
|
||||||
|
"bg-card border-border",
|
||||||
|
"bg-card border-border",
|
||||||
|
"bg-card border-border",
|
||||||
|
]
|
||||||
|
|
||||||
|
function getClassGradient(id: string) {
|
||||||
|
return "bg-card border-border shadow-sm hover:shadow-md"
|
||||||
|
}
|
||||||
|
|
||||||
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
|
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [joinOpen, setJoinOpen] = useState(false)
|
||||||
|
|
||||||
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
|
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
|
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
|
||||||
@@ -75,41 +102,44 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
|
|
||||||
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
|
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
const handleJoin = async (formData: FormData) => {
|
||||||
setIsWorking(true)
|
setIsWorking(true)
|
||||||
try {
|
try {
|
||||||
const res = await createTeacherClassAction(null, formData)
|
const res = await joinClassByInvitationCodeAction(null, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message)
|
toast.success(res.message || "Joined class successfully")
|
||||||
setCreateOpen(false)
|
setJoinOpen(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to create class")
|
toast.error(res.message || "Failed to join class")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to create class")
|
toast.error("Failed to join class")
|
||||||
} finally {
|
} finally {
|
||||||
setIsWorking(false)
|
setIsWorking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
{/* Filter Bar */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
<div className="relative flex-1 md:max-w-sm">
|
<div className="relative flex-1 md:max-w-[320px]">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search classes..."
|
placeholder="Search classes..."
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value || null)}
|
onChange={(e) => setQ(e.target.value || null)}
|
||||||
|
className="pl-9 bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
|
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectTrigger className="w-[160px] bg-background">
|
||||||
<SelectValue placeholder="Grade" />
|
<SelectValue placeholder="All Grades" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All grades</SelectItem>
|
<SelectItem value="all">All Grades</SelectItem>
|
||||||
{gradeOptions.map((g) => (
|
{gradeOptions.map((g) => (
|
||||||
<SelectItem key={g} value={g}>
|
<SelectItem key={g} value={g}>
|
||||||
{g}
|
{g}
|
||||||
@@ -120,83 +150,56 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
{(q || grade !== "all") && (
|
{(q || grade !== "all") && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-9"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQ(null)
|
setQ(null)
|
||||||
setGrade(null)
|
setGrade(null)
|
||||||
}}
|
}}
|
||||||
|
title="Clear filters"
|
||||||
>
|
>
|
||||||
Reset
|
<RefreshCw className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createOpen}
|
open={joinOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!canCreateClass) return
|
|
||||||
if (isWorking) return
|
if (isWorking) return
|
||||||
setCreateOpen(open)
|
setJoinOpen(open)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="gap-2" disabled={isWorking || !canCreateClass}>
|
<Button className="gap-2 shadow-sm" disabled={isWorking}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New class
|
Join Class
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create class</DialogTitle>
|
<DialogTitle>Join Class</DialogTitle>
|
||||||
<DialogDescription>Add a new class to start managing students.</DialogDescription>
|
<DialogDescription>Enter the invitation code to join a class.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleCreate}>
|
<form action={handleJoin}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="create-school-name" className="text-right">
|
<Label htmlFor="join-code" className="text-right">
|
||||||
School
|
Code
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="create-school-name"
|
id="join-code"
|
||||||
name="schoolName"
|
name="code"
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
placeholder="Optional"
|
placeholder="e.g. 123456"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-name" className="text-right">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 1A" required />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="create-grade" className="text-right">
|
|
||||||
Grade
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="create-grade"
|
|
||||||
name="grade"
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="e.g. Grade 7"
|
|
||||||
defaultValue={defaultGrade}
|
|
||||||
required
|
required
|
||||||
|
maxLength={6}
|
||||||
|
pattern="\d{6}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isWorking}>
|
<Button type="submit" disabled={isWorking}>
|
||||||
{isWorking ? "Creating..." : "Create"}
|
{isWorking ? "Joining..." : "Join Class"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -204,34 +207,33 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
{/* Grid */}
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{classes.length === 0 ? (
|
{classes.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No classes yet"
|
title="No classes yet"
|
||||||
description="Create your first class to start managing students and schedules."
|
description="Join a class to start managing students and schedules."
|
||||||
icon={Users}
|
icon={Users}
|
||||||
action={canCreateClass ? { label: "Create class", onClick: () => setCreateOpen(true) } : undefined}
|
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
||||||
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
|
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
|
||||||
/>
|
/>
|
||||||
) : filteredClasses.length === 0 ? (
|
) : filteredClasses.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No classes match your filters"
|
title="No classes match your filters"
|
||||||
description="Try clearing filters or adjusting keywords."
|
description="Try clearing filters or adjusting keywords."
|
||||||
icon={Users}
|
icon={Search}
|
||||||
action={{ label: "Clear filters", onClick: () => {
|
action={{
|
||||||
setQ(null)
|
label: "Clear filters",
|
||||||
setGrade(null)
|
onClick: () => {
|
||||||
}}}
|
setQ(null)
|
||||||
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
|
setGrade(null)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filteredClasses.map((c) => (
|
filteredClasses.map((c) => (
|
||||||
<ClassCard
|
<ClassCard key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
||||||
key={c.id}
|
|
||||||
c={c}
|
|
||||||
onWorkingChange={setIsWorking}
|
|
||||||
isWorking={isWorking}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -334,92 +336,131 @@ function ClassCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-none">
|
<Card className={cn("group flex flex-col transition-all hover:shadow-md", getClassGradient(c.id))}>
|
||||||
<CardHeader className="space-y-2">
|
<CardHeader className="relative pb-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-base truncate">
|
<CardTitle className="line-clamp-1 text-lg font-bold leading-none tracking-tight">
|
||||||
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
|
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
|
||||||
{c.name}
|
{c.name}
|
||||||
</Link>
|
</Link>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="text-muted-foreground text-sm mt-1">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
{c.room ? `Room: ${c.room}` : "Room: Not set"}
|
<Badge variant="secondary" className="h-5 px-1.5 font-medium">
|
||||||
|
{c.grade}
|
||||||
|
</Badge>
|
||||||
|
{c.homeroom && (
|
||||||
|
<Badge variant="outline" className="h-5 border-dashed bg-transparent px-1.5 font-normal">
|
||||||
|
{c.homeroom}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
<div className="flex items-center gap-2">
|
<DropdownMenuTrigger asChild>
|
||||||
<Badge variant="secondary">{c.grade}</Badge>
|
<Button variant="ghost" size="icon" className="h-8 w-8 -mr-2" disabled={isWorking}>
|
||||||
<DropdownMenu>
|
<MoreHorizontal className="size-4" />
|
||||||
<DropdownMenuTrigger asChild>
|
<span className="sr-only">Actions</span>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
</Button>
|
||||||
<MoreHorizontal className="size-4" />
|
</DropdownMenuTrigger>
|
||||||
</Button>
|
<DropdownMenuContent align="end">
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
||||||
<DropdownMenuContent align="end">
|
<Pencil className="mr-2 size-4" />
|
||||||
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
Edit Class
|
||||||
<Pencil className="mr-2 size-4" />
|
</DropdownMenuItem>
|
||||||
Edit
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
<DropdownMenuSeparator />
|
className="text-destructive focus:text-destructive"
|
||||||
<DropdownMenuItem
|
onClick={() => setShowDelete(true)}
|
||||||
className="text-destructive focus:text-destructive"
|
>
|
||||||
onClick={() => setShowDelete(true)}
|
<Trash2 className="mr-2 size-4" />
|
||||||
>
|
Delete Class
|
||||||
<Trash2 className="mr-2 size-4" />
|
</DropdownMenuItem>
|
||||||
Delete
|
</DropdownMenuContent>
|
||||||
</DropdownMenuItem>
|
</DropdownMenu>
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="flex-1 pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div className="text-sm font-medium tabular-nums">{c.studentCount} students</div>
|
<div className="flex flex-col gap-1">
|
||||||
{c.homeroom ? <Badge variant="outline">{c.homeroom}</Badge> : null}
|
<span className="text-xs text-muted-foreground">Students</span>
|
||||||
</div>
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<Users className="size-3.5 text-muted-foreground" />
|
||||||
<div className="min-w-0">
|
{c.studentCount}
|
||||||
<div className="text-xs uppercase text-muted-foreground">Invitation code</div>
|
</div>
|
||||||
<div className="font-mono tabular-nums text-sm">{c.invitationCode ?? "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Room</span>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
|
<MapPin className="size-3.5 text-muted-foreground" />
|
||||||
|
{c.room || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between rounded-md border bg-background/50 p-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] uppercase text-muted-foreground">Invite Code</span>
|
||||||
|
<span className="font-mono text-sm font-medium tracking-wider">{c.invitationCode || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{c.invitationCode ? (
|
{c.invitationCode ? (
|
||||||
<>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Button variant="outline" size="sm" className="gap-2" onClick={handleCopyCode} disabled={isWorking}>
|
<Tooltip>
|
||||||
<Copy className="size-4" />
|
<TooltipTrigger asChild>
|
||||||
Copy
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopyCode} disabled={isWorking}>
|
||||||
</Button>
|
<Copy className="size-3.5" />
|
||||||
<Button variant="outline" size="sm" className="gap-2" onClick={handleRegenerateCode} disabled={isWorking}>
|
</Button>
|
||||||
<RefreshCw className="size-4" />
|
</TooltipTrigger>
|
||||||
Regenerate
|
<TooltipContent>Copy Code</TooltipContent>
|
||||||
</Button>
|
</Tooltip>
|
||||||
</>
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleRegenerateCode}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Regenerate</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={handleEnsureCode} disabled={isWorking}>
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleEnsureCode} disabled={isWorking}>
|
||||||
Generate
|
Generate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("grid gap-2", "grid-cols-2")}>
|
|
||||||
<Button asChild variant="outline" className="w-full justify-start gap-2">
|
|
||||||
<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>
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="grid grid-cols-3 gap-2 border-t p-2">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
||||||
|
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
|
||||||
|
<Users className="mr-1.5 size-3.5" />
|
||||||
|
Students
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
||||||
|
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
|
||||||
|
<Calendar className="mr-1.5 size-3.5" />
|
||||||
|
Schedule
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
||||||
|
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(c.id)}`}>
|
||||||
|
<ChartBar className="mr-1.5 size-3.5" />
|
||||||
|
Insights
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={showEdit}
|
open={showEdit}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -495,7 +536,7 @@ function ClassCard({
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isWorking}>
|
<Button type="submit" disabled={isWorking}>
|
||||||
{isWorking ? "Saving..." : "Save"}
|
{isWorking ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -524,7 +565,7 @@ function ClassCard({
|
|||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isWorking}
|
disabled={isWorking}
|
||||||
>
|
>
|
||||||
{isWorking ? "Deleting..." : "Delete"}
|
{isWorking ? "Deleting..." : "Delete Class"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { enrollStudentByEmailAction } from "../actions"
|
|||||||
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
||||||
|
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -76,7 +77,7 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="Class" />
|
<SelectValue placeholder="Class" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -89,12 +90,24 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{(search || classId !== "all") && (
|
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{(search || classId !== "all" || status !== "all") && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearch(null)
|
setSearch(null)
|
||||||
setClassId(null)
|
setClassId(null)
|
||||||
|
setStatus(null)
|
||||||
}}
|
}}
|
||||||
className="h-8 px-2 lg:px-3"
|
className="h-8 px-2 lg:px-3"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
|
import { MoreHorizontal, UserCheck, UserX, ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -36,10 +38,17 @@ import {
|
|||||||
import type { ClassStudent } from "../types"
|
import type { ClassStudent } from "../types"
|
||||||
import { setStudentEnrollmentStatusAction } from "../actions"
|
import { setStudentEnrollmentStatusAction } from "../actions"
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10
|
||||||
|
|
||||||
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [workingKey, setWorkingKey] = useState<string | null>(null)
|
const [workingKey, setWorkingKey] = useState<string | null>(null)
|
||||||
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(students.length / ITEMS_PER_PAGE)
|
||||||
|
const startIndex = (page - 1) * ITEMS_PER_PAGE
|
||||||
|
const paginatedStudents = students.slice(startIndex, startIndex + ITEMS_PER_PAGE)
|
||||||
|
|
||||||
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
|
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
|
||||||
const key = `${student.classId}:${student.id}:${status}`
|
const key = `${student.classId}:${student.id}:${status}`
|
||||||
@@ -59,64 +68,144 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table>
|
<Card className="shadow-none">
|
||||||
<TableHeader>
|
<CardHeader className="border-b px-6 py-4">
|
||||||
<TableRow className="bg-muted/50">
|
<div className="flex items-center justify-between">
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
|
<CardTitle className="text-base font-semibold">All Students</CardTitle>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Email</TableHead>
|
<Badge variant="secondary" className="rounded-sm px-1.5 font-normal">
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
|
{students.length} total
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
</Badge>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Actions</TableHead>
|
</div>
|
||||||
</TableRow>
|
</CardHeader>
|
||||||
</TableHeader>
|
<CardContent className="p-0">
|
||||||
<TableBody>
|
<Table>
|
||||||
{students.map((s) => (
|
<TableHeader>
|
||||||
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-12", s.status !== "active" && "opacity-70")}>
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
<TableHead className="pl-6 text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
|
||||||
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
|
||||||
<TableCell>{s.className}</TableCell>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Joined</TableHead>
|
||||||
<TableCell>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
||||||
<Badge variant={s.status === "active" ? "secondary" : "outline"}>
|
<TableHead className="pr-6 text-right text-xs font-medium uppercase text-muted-foreground">
|
||||||
{s.status === "active" ? "Active" : "Inactive"}
|
Actions
|
||||||
</Badge>
|
</TableHead>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
<TableCell className="text-right">
|
</TableHeader>
|
||||||
<DropdownMenu>
|
<TableBody>
|
||||||
<DropdownMenuTrigger asChild>
|
{paginatedStudents.map((s) => (
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
|
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-16", s.status !== "active" && "opacity-70")}>
|
||||||
<MoreHorizontal className="size-4" />
|
<TableCell className="pl-6">
|
||||||
</Button>
|
<div className="flex items-center gap-3">
|
||||||
</DropdownMenuTrigger>
|
<Avatar className="h-9 w-9 border">
|
||||||
<DropdownMenuContent align="end">
|
<AvatarImage src={s.image || undefined} alt={s.name} />
|
||||||
{s.status !== "active" ? (
|
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
||||||
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
</Avatar>
|
||||||
<UserCheck className="mr-2 size-4" />
|
<div className="flex flex-col gap-0.5">
|
||||||
Set active
|
<span className="font-medium leading-none">{s.name}</span>
|
||||||
</DropdownMenuItem>
|
<span className="text-xs text-muted-foreground">{s.email}</span>
|
||||||
) : (
|
</div>
|
||||||
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
</div>
|
||||||
<UserX className="mr-2 size-4" />
|
</TableCell>
|
||||||
Set inactive
|
<TableCell>
|
||||||
</DropdownMenuItem>
|
<Badge variant="outline" className="font-normal">
|
||||||
)}
|
{s.className}
|
||||||
<DropdownMenuSeparator />
|
</Badge>
|
||||||
<DropdownMenuItem
|
</TableCell>
|
||||||
onClick={() => setRemoveTarget(s)}
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
className="text-destructive focus:text-destructive"
|
{formatDate(s.joinedAt)}
|
||||||
disabled={s.status === "inactive" || workingKey !== null}
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={s.status === "active" ? "secondary" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
s.status === "active"
|
||||||
|
? "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 hover:bg-emerald-500/20"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<UserX className="mr-2 size-4" />
|
{s.status === "active" ? "Active" : "Inactive"}
|
||||||
Remove from class
|
</Badge>
|
||||||
</DropdownMenuItem>
|
</TableCell>
|
||||||
</DropdownMenuContent>
|
<TableCell className="pr-6 text-right">
|
||||||
</DropdownMenu>
|
<DropdownMenu>
|
||||||
</TableCell>
|
<DropdownMenuTrigger asChild>
|
||||||
</TableRow>
|
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
|
||||||
))}
|
<MoreHorizontal className="size-4" />
|
||||||
</TableBody>
|
</Button>
|
||||||
</Table>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{s.status !== "active" ? (
|
||||||
|
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
|
||||||
|
<UserCheck className="mr-2 size-4" />
|
||||||
|
Set active
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
|
||||||
|
<UserX className="mr-2 size-4" />
|
||||||
|
Set inactive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setRemoveTarget(s)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
disabled={s.status === "inactive" || workingKey !== null}
|
||||||
|
>
|
||||||
|
<UserX className="mr-2 size-4" />
|
||||||
|
Remove from class
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<CardFooter className="flex items-center justify-between border-t px-6 py-4">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Showing <strong>{startIndex + 1}</strong>-
|
||||||
|
<strong>{Math.min(startIndex + ITEMS_PER_PAGE, students.length)}</strong> of{" "}
|
||||||
|
<strong>{students.length}</strong> students
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={Boolean(removeTarget)}
|
open={Boolean(removeTarget)}
|
||||||
|
|||||||
@@ -122,6 +122,20 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
|||||||
|
|
||||||
const rows = await (async () => {
|
const rows = await (async () => {
|
||||||
try {
|
try {
|
||||||
|
const ownedIds = await db
|
||||||
|
.select({ id: classes.id })
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.teacherId, teacherId))
|
||||||
|
|
||||||
|
const enrolledIds = await db
|
||||||
|
.select({ id: classEnrollments.classId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
|
||||||
|
|
||||||
|
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
|
||||||
|
|
||||||
|
if (allIds.length === 0) return []
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select({
|
||||||
id: classes.id,
|
id: classes.id,
|
||||||
@@ -135,26 +149,11 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
|||||||
})
|
})
|
||||||
.from(classes)
|
.from(classes)
|
||||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||||
.where(eq(classes.teacherId, teacherId))
|
.where(inArray(classes.id, allIds))
|
||||||
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
||||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
} catch {
|
} catch {
|
||||||
return await db
|
return []
|
||||||
.select({
|
|
||||||
id: classes.id,
|
|
||||||
schoolName: sql<string | null>`NULL`.as("schoolName"),
|
|
||||||
name: classes.name,
|
|
||||||
grade: classes.grade,
|
|
||||||
homeroom: classes.homeroom,
|
|
||||||
room: classes.room,
|
|
||||||
invitationCode: sql<string | null>`NULL`.as("invitationCode"),
|
|
||||||
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
|
||||||
})
|
|
||||||
.from(classes)
|
|
||||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
|
||||||
.where(eq(classes.teacherId, teacherId))
|
|
||||||
.groupBy(classes.id, classes.name, classes.grade, classes.homeroom, classes.room)
|
|
||||||
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -331,6 +330,143 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
|
|||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getGradeManagedClasses = cache(async (userId: string): Promise<AdminClassListItem[]> => {
|
||||||
|
const managedGradeIds = await db
|
||||||
|
.select({ id: grades.id })
|
||||||
|
.from(grades)
|
||||||
|
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||||
|
|
||||||
|
if (managedGradeIds.length === 0) return []
|
||||||
|
|
||||||
|
const gradeIds = managedGradeIds.map((g) => g.id)
|
||||||
|
|
||||||
|
const [rows, subjectRows] = await Promise.all([
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: classes.id,
|
||||||
|
schoolName: classes.schoolName,
|
||||||
|
schoolId: classes.schoolId,
|
||||||
|
name: classes.name,
|
||||||
|
grade: classes.grade,
|
||||||
|
gradeId: classes.gradeId,
|
||||||
|
homeroom: classes.homeroom,
|
||||||
|
room: classes.room,
|
||||||
|
invitationCode: classes.invitationCode,
|
||||||
|
teacherId: users.id,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||||
|
createdAt: classes.createdAt,
|
||||||
|
updatedAt: classes.updatedAt,
|
||||||
|
})
|
||||||
|
.from(classes)
|
||||||
|
.innerJoin(users, eq(users.id, classes.teacherId))
|
||||||
|
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||||
|
.where(inArray(classes.gradeId, gradeIds))
|
||||||
|
.groupBy(
|
||||||
|
classes.id,
|
||||||
|
classes.schoolName,
|
||||||
|
classes.schoolId,
|
||||||
|
classes.name,
|
||||||
|
classes.grade,
|
||||||
|
classes.gradeId,
|
||||||
|
classes.homeroom,
|
||||||
|
classes.room,
|
||||||
|
classes.invitationCode,
|
||||||
|
users.id,
|
||||||
|
users.name,
|
||||||
|
users.email,
|
||||||
|
classes.createdAt,
|
||||||
|
classes.updatedAt
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(classes.schoolName),
|
||||||
|
asc(classes.grade),
|
||||||
|
asc(classes.name),
|
||||||
|
asc(classes.homeroom),
|
||||||
|
asc(classes.room)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
classId: classSubjectTeachers.classId,
|
||||||
|
subject: classSubjectTeachers.subject,
|
||||||
|
teacherId: users.id,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
|
})
|
||||||
|
.from(classSubjectTeachers)
|
||||||
|
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||||
|
.where(inArray(classes.gradeId, gradeIds))
|
||||||
|
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||||
|
for (const r of subjectRows) {
|
||||||
|
const subject = r.subject as ClassSubject
|
||||||
|
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
|
||||||
|
const teacher =
|
||||||
|
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||||
|
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||||
|
: null
|
||||||
|
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
|
||||||
|
bySubject.set(subject, teacher)
|
||||||
|
subjectsByClassId.set(r.classId, bySubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = rows.map((r) => {
|
||||||
|
const bySubject = subjectsByClassId.get(r.id)
|
||||||
|
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||||
|
subject,
|
||||||
|
teacher: bySubject?.get(subject) ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
schoolName: r.schoolName,
|
||||||
|
schoolId: r.schoolId,
|
||||||
|
name: r.name,
|
||||||
|
grade: r.grade,
|
||||||
|
gradeId: r.gradeId,
|
||||||
|
homeroom: r.homeroom,
|
||||||
|
room: r.room,
|
||||||
|
invitationCode: r.invitationCode ?? null,
|
||||||
|
teacher: {
|
||||||
|
id: r.teacherId,
|
||||||
|
name: r.teacherName ?? "Unnamed",
|
||||||
|
email: r.teacherEmail,
|
||||||
|
},
|
||||||
|
subjectTeachers,
|
||||||
|
studentCount: Number(r.studentCount ?? 0),
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
list.sort(compareClassLike)
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getManagedGrades = cache(async (userId: string) => {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: grades.id,
|
||||||
|
name: grades.name,
|
||||||
|
schoolId: grades.schoolId,
|
||||||
|
schoolName: schools.name,
|
||||||
|
})
|
||||||
|
.from(grades)
|
||||||
|
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||||||
|
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||||
|
.orderBy(asc(schools.name), asc(grades.name))
|
||||||
|
})
|
||||||
|
|
||||||
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
|
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
|
||||||
const id = studentId.trim()
|
const id = studentId.trim()
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
@@ -345,9 +481,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
|||||||
grade: classes.grade,
|
grade: classes.grade,
|
||||||
homeroom: classes.homeroom,
|
homeroom: classes.homeroom,
|
||||||
room: classes.room,
|
room: classes.room,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
})
|
})
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -359,9 +498,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
|||||||
grade: classes.grade,
|
grade: classes.grade,
|
||||||
homeroom: classes.homeroom,
|
homeroom: classes.homeroom,
|
||||||
room: classes.room,
|
room: classes.room,
|
||||||
|
teacherName: users.name,
|
||||||
|
teacherEmail: users.email,
|
||||||
})
|
})
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||||
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||||
}
|
}
|
||||||
@@ -374,6 +516,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
|||||||
grade: r.grade,
|
grade: r.grade,
|
||||||
homeroom: r.homeroom,
|
homeroom: r.homeroom,
|
||||||
room: r.room,
|
room: r.room,
|
||||||
|
teacherName: r.teacherName,
|
||||||
|
teacherEmail: r.teacherEmail,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
list.sort(compareClassLike)
|
list.sort(compareClassLike)
|
||||||
@@ -414,12 +558,13 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const getClassStudents = cache(
|
export const getClassStudents = cache(
|
||||||
async (params?: { classId?: string; q?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
||||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
||||||
if (!teacherId) return []
|
if (!teacherId) return []
|
||||||
|
|
||||||
const classId = params?.classId?.trim()
|
const classId = params?.classId?.trim()
|
||||||
const q = params?.q?.trim().toLowerCase()
|
const q = params?.q?.trim().toLowerCase()
|
||||||
|
const status = params?.status?.trim().toLowerCase()
|
||||||
|
|
||||||
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
||||||
|
|
||||||
@@ -427,6 +572,10 @@ export const getClassStudents = cache(
|
|||||||
conditions.push(eq(classes.id, classId))
|
conditions.push(eq(classes.id, classId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "active" || status === "inactive") {
|
||||||
|
conditions.push(eq(classEnrollments.status, status))
|
||||||
|
}
|
||||||
|
|
||||||
if (q && q.length > 0) {
|
if (q && q.length > 0) {
|
||||||
const needle = `%${q}%`
|
const needle = `%${q}%`
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -439,9 +588,12 @@ export const getClassStudents = cache(
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
name: users.name,
|
name: users.name,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
|
image: users.image,
|
||||||
|
gender: users.gender,
|
||||||
classId: classes.id,
|
classId: classes.id,
|
||||||
className: classes.name,
|
className: classes.name,
|
||||||
status: classEnrollments.status,
|
status: classEnrollments.status,
|
||||||
|
joinedAt: classEnrollments.createdAt,
|
||||||
})
|
})
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
@@ -453,9 +605,12 @@ export const getClassStudents = cache(
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name ?? "Unnamed",
|
name: r.name ?? "Unnamed",
|
||||||
email: r.email,
|
email: r.email,
|
||||||
|
image: r.image,
|
||||||
|
gender: r.gender,
|
||||||
classId: r.classId,
|
classId: r.classId,
|
||||||
className: r.className,
|
className: r.className,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
|
joinedAt: r.joinedAt,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,9 +65,12 @@ export type ClassStudent = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
|
image?: string | null
|
||||||
|
gender?: string | null
|
||||||
classId: string
|
classId: string
|
||||||
className: string
|
className: string
|
||||||
status: "active" | "inactive"
|
status: "active" | "inactive"
|
||||||
|
joinedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassScheduleItem = {
|
export type ClassScheduleItem = {
|
||||||
@@ -80,26 +83,6 @@ export type ClassScheduleItem = {
|
|||||||
location?: string | null
|
location?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudentEnrolledClass = {
|
|
||||||
id: string
|
|
||||||
schoolName?: string | null
|
|
||||||
name: string
|
|
||||||
grade: string
|
|
||||||
homeroom?: string | null
|
|
||||||
room?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StudentScheduleItem = {
|
|
||||||
id: string
|
|
||||||
classId: string
|
|
||||||
className: string
|
|
||||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
|
||||||
startTime: string
|
|
||||||
endTime: string
|
|
||||||
course: string
|
|
||||||
location?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateClassScheduleItemInput = {
|
export type CreateClassScheduleItemInput = {
|
||||||
classId: string
|
classId: string
|
||||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
@@ -118,13 +101,26 @@ export type UpdateClassScheduleItemInput = {
|
|||||||
location?: string | null
|
location?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassBasicInfo = {
|
export type StudentEnrolledClass = {
|
||||||
id: string
|
id: string
|
||||||
|
schoolName?: string | null
|
||||||
name: string
|
name: string
|
||||||
grade: string
|
grade: string
|
||||||
homeroom?: string | null
|
homeroom?: string | null
|
||||||
room?: string | null
|
room?: string | null
|
||||||
invitationCode?: string | null
|
teacherName?: string | null
|
||||||
|
teacherEmail?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudentScheduleItem = {
|
||||||
|
id: string
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
course: string
|
||||||
|
location?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScoreStats = {
|
export type ScoreStats = {
|
||||||
@@ -151,24 +147,23 @@ export type ClassHomeworkAssignmentStats = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ClassHomeworkInsights = {
|
export type ClassHomeworkInsights = {
|
||||||
class: ClassBasicInfo
|
class: {
|
||||||
studentCounts: {
|
id: string
|
||||||
total: number
|
name: string
|
||||||
active: number
|
grade: string
|
||||||
inactive: number
|
homeroom?: string | null
|
||||||
|
room?: string | null
|
||||||
|
invitationCode?: string | null
|
||||||
}
|
}
|
||||||
|
studentCounts: { total: number; active: number; inactive: number }
|
||||||
assignments: ClassHomeworkAssignmentStats[]
|
assignments: ClassHomeworkAssignmentStats[]
|
||||||
latest: ClassHomeworkAssignmentStats | null
|
latest: ClassHomeworkAssignmentStats | null
|
||||||
overallScores: ScoreStats
|
overallScores: ScoreStats
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GradeHomeworkClassSummary = {
|
export type GradeHomeworkClassSummary = {
|
||||||
class: ClassBasicInfo
|
class: { id: string; name: string; grade: string; homeroom?: string | null; room?: string | null }
|
||||||
studentCounts: {
|
studentCounts: { total: number; active: number; inactive: number }
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
inactive: number
|
|
||||||
}
|
|
||||||
latestAvg: number | null
|
latestAvg: number | null
|
||||||
prevAvg: number | null
|
prevAvg: number | null
|
||||||
deltaAvg: number | null
|
deltaAvg: number | null
|
||||||
@@ -176,17 +171,9 @@ export type GradeHomeworkClassSummary = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GradeHomeworkInsights = {
|
export type GradeHomeworkInsights = {
|
||||||
grade: {
|
grade: { id: string; name: string; school: { id: string; name: string } }
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
school: { id: string; name: string }
|
|
||||||
}
|
|
||||||
classCount: number
|
classCount: number
|
||||||
studentCounts: {
|
studentCounts: { total: number; active: number; inactive: number }
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
inactive: number
|
|
||||||
}
|
|
||||||
assignments: ClassHomeworkAssignmentStats[]
|
assignments: ClassHomeworkAssignmentStats[]
|
||||||
latest: ClassHomeworkAssignmentStats | null
|
latest: ClassHomeworkAssignmentStats | null
|
||||||
overallScores: ScoreStats
|
overallScores: ScoreStats
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { CalendarDays, BookOpen, PenTool } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
|
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
let greeting = "Welcome back"
|
||||||
|
if (hour < 12) greeting = "Good morning"
|
||||||
|
else if (hour < 18) greeting = "Good afternoon"
|
||||||
|
else greeting = "Good evening"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||||
<div className="text-sm text-muted-foreground">Welcome back, {studentName}.</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{greeting}, {studentName}. Here's what's happening today.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||||
|
<Link href="/student/schedule">
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
Schedule
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||||
|
<Link href="/student/learning/textbooks">
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Textbooks
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" className="gap-2">
|
||||||
|
<Link href="/student/learning/assignments">
|
||||||
|
<PenTool className="h-4 w-4" />
|
||||||
|
Assignments
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/student/learning/assignments">View assignments</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { StudentDashboardProps } from "@/modules/dashboard/types"
|
|||||||
|
|
||||||
import { StudentDashboardHeader } from "./student-dashboard-header"
|
import { StudentDashboardHeader } from "./student-dashboard-header"
|
||||||
import { StudentGradesCard } from "./student-grades-card"
|
import { StudentGradesCard } from "./student-grades-card"
|
||||||
import { StudentRankingCard } from "./student-ranking-card"
|
|
||||||
import { StudentStatsGrid } from "./student-stats-grid"
|
import { StudentStatsGrid } from "./student-stats-grid"
|
||||||
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
||||||
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
||||||
@@ -26,16 +25,17 @@ export function StudentDashboard({
|
|||||||
dueSoonCount={dueSoonCount}
|
dueSoonCount={dueSoonCount}
|
||||||
overdueCount={overdueCount}
|
overdueCount={overdueCount}
|
||||||
gradedCount={gradedCount}
|
gradedCount={gradedCount}
|
||||||
|
ranking={grades.ranking}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<StudentGradesCard grades={grades} />
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<StudentRankingCard ranking={grades.ranking} />
|
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
||||||
</div>
|
<StudentGradesCard grades={grades} />
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="space-y-6">
|
||||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||||
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BarChart3 } from "lucide-react"
|
import { BarChart3 } from "lucide-react"
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||||
|
|
||||||
@@ -11,6 +15,24 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
const hasGradeTrend = grades.trend.length > 0
|
const hasGradeTrend = grades.trend.length > 0
|
||||||
const hasRecentGrades = grades.recent.length > 0
|
const hasRecentGrades = grades.recent.length > 0
|
||||||
|
|
||||||
|
const chartData = grades.trend.map((item) => ({
|
||||||
|
title: item.assignmentTitle,
|
||||||
|
score: Math.round(item.percentage),
|
||||||
|
fullTitle: item.assignmentTitle,
|
||||||
|
submittedAt: formatDate(item.submittedAt),
|
||||||
|
rawScore: item.score,
|
||||||
|
maxScore: item.maxScore,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
score: {
|
||||||
|
label: "Score (%)",
|
||||||
|
color: "hsl(var(--primary))",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestGrade = grades.trend[grades.trend.length - 1]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -30,37 +52,79 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border bg-card p-4">
|
<div className="rounded-md border bg-card p-4">
|
||||||
<svg viewBox="0 0 100 40" className="h-24 w-full">
|
<ChartContainer config={chartConfig} className="h-[200px] w-full">
|
||||||
<polyline
|
<LineChart
|
||||||
fill="none"
|
data={chartData}
|
||||||
stroke="currentColor"
|
margin={{
|
||||||
strokeWidth="2"
|
left: 12,
|
||||||
points={grades.trend
|
right: 12,
|
||||||
.map((p, i) => {
|
top: 12,
|
||||||
const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0
|
bottom: 12,
|
||||||
const x = t * 100
|
}}
|
||||||
const v = Number.isFinite(p.percentage) ? Math.max(0, Math.min(100, p.percentage)) : 0
|
>
|
||||||
const y = 40 - (v / 100) * 40
|
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||||
return `${x},${y}`
|
<XAxis
|
||||||
})
|
dataKey="title"
|
||||||
.join(" ")}
|
tickLine={false}
|
||||||
className="text-primary"
|
axisLine={false}
|
||||||
/>
|
tickMargin={8}
|
||||||
</svg>
|
tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
|
||||||
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
/>
|
||||||
<div>
|
<YAxis
|
||||||
Latest:{" "}
|
domain={[0, 100]}
|
||||||
<span className="font-medium text-foreground tabular-nums">
|
tickLine={false}
|
||||||
{Math.round(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}%
|
axisLine={false}
|
||||||
</span>
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={{
|
||||||
|
stroke: "hsl(var(--muted-foreground))",
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: "4 4",
|
||||||
|
}}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
indicator="line"
|
||||||
|
labelKey="fullTitle"
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="score"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--color-score)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{
|
||||||
|
fill: "var(--color-score)",
|
||||||
|
r: 4,
|
||||||
|
strokeWidth: 2,
|
||||||
|
}}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
strokeWidth: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
{latestGrade && (
|
||||||
|
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
Latest:{" "}
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{Math.round(latestGrade.percentage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Points:{" "}
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{latestGrade.score}/{latestGrade.maxScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
Points:{" "}
|
|
||||||
<span className="font-medium text-foreground tabular-nums">
|
|
||||||
{grades.trend[grades.trend.length - 1]?.score ?? 0}/{grades.trend[grades.trend.length - 1]?.maxScore ?? 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasRecentGrades ? null : (
|
{!hasRecentGrades ? null : (
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Trophy } from "lucide-react"
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
||||||
import type { StudentRanking } from "@/modules/homework/types"
|
|
||||||
|
|
||||||
export function StudentRankingCard({ ranking }: { ranking: StudentRanking | null }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Trophy className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Ranking
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!ranking ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Trophy}
|
|
||||||
title="No ranking available"
|
|
||||||
description="Join a class and complete graded work to see your rank."
|
|
||||||
className="border-none h-72"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="rounded-md border bg-card p-4">
|
|
||||||
<div className="text-sm text-muted-foreground">Class Rank</div>
|
|
||||||
<div className="mt-1 text-3xl font-bold tabular-nums">
|
|
||||||
{ranking.rank}/{ranking.classSize}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border bg-card p-4">
|
|
||||||
<div className="text-sm text-muted-foreground">Overall</div>
|
|
||||||
<div className="mt-1 text-3xl font-bold tabular-nums">{Math.round(ranking.percentage)}%</div>
|
|
||||||
<div className="text-xs text-muted-foreground tabular-nums">
|
|
||||||
{ranking.totalScore}/{ranking.totalMaxScore} pts
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">Based on latest graded submissions per assignment for your class.</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { BookOpen, CheckCircle2, PenTool, TriangleAlert } from "lucide-react"
|
import Link from "next/link"
|
||||||
|
import { BookOpen, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import type { StudentRanking } from "@/modules/homework/types"
|
||||||
|
|
||||||
type Stat = {
|
type Stat = {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
description: string
|
description: string
|
||||||
icon: typeof BookOpen
|
icon: typeof BookOpen
|
||||||
|
href: string
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudentStatsGrid({
|
export function StudentStatsGrid({
|
||||||
@@ -14,52 +19,64 @@ export function StudentStatsGrid({
|
|||||||
dueSoonCount,
|
dueSoonCount,
|
||||||
overdueCount,
|
overdueCount,
|
||||||
gradedCount,
|
gradedCount,
|
||||||
|
ranking,
|
||||||
}: {
|
}: {
|
||||||
enrolledClassCount: number
|
enrolledClassCount: number
|
||||||
dueSoonCount: number
|
dueSoonCount: number
|
||||||
overdueCount: number
|
overdueCount: number
|
||||||
gradedCount: number
|
gradedCount: number
|
||||||
|
ranking: StudentRanking | null
|
||||||
}) {
|
}) {
|
||||||
const stats: readonly Stat[] = [
|
const stats: Stat[] = [
|
||||||
{
|
{
|
||||||
title: "My Classes",
|
title: "Average Score",
|
||||||
value: String(enrolledClassCount),
|
value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
|
||||||
description: "Enrolled classes",
|
description: ranking ? "Overall performance" : "No grades yet",
|
||||||
icon: BookOpen,
|
icon: TrendingUp,
|
||||||
|
href: "/student/learning/assignments",
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Class Rank",
|
||||||
|
value: ranking ? `${ranking.rank}/${ranking.classSize}` : "-",
|
||||||
|
description: ranking ? "Current position" : "No ranking yet",
|
||||||
|
icon: Trophy,
|
||||||
|
href: "/student/learning/assignments",
|
||||||
|
color: "text-purple-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Due Soon",
|
title: "Due Soon",
|
||||||
value: String(dueSoonCount),
|
value: String(dueSoonCount),
|
||||||
description: "Next 7 days",
|
description: "Next 7 days",
|
||||||
icon: PenTool,
|
icon: PenTool,
|
||||||
|
href: "/student/learning/assignments",
|
||||||
|
color: dueSoonCount > 0 ? "text-orange-500" : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Overdue",
|
title: "Overdue",
|
||||||
value: String(overdueCount),
|
value: String(overdueCount),
|
||||||
description: "Needs attention",
|
description: "Needs attention",
|
||||||
icon: TriangleAlert,
|
icon: TriangleAlert,
|
||||||
},
|
href: "/student/learning/assignments",
|
||||||
{
|
color: overdueCount > 0 ? "text-red-500" : undefined,
|
||||||
title: "Graded",
|
|
||||||
value: String(gradedCount),
|
|
||||||
description: "With score",
|
|
||||||
icon: CheckCircle2,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<Card key={stat.title}>
|
<Link key={stat.title} href={stat.href}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">
|
||||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||||
</CardHeader>
|
<stat.icon className={cn("h-4 w-4 text-muted-foreground", stat.color)} />
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="text-2xl font-bold tabular-nums">{stat.value}</div>
|
<CardContent>
|
||||||
<div className="text-xs text-muted-foreground">{stat.description}</div>
|
<div className={cn("text-2xl font-bold tabular-nums", stat.color)}>{stat.value}</div>
|
||||||
</CardContent>
|
<div className="text-xs text-muted-foreground">{stat.description}</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Button } from "@/shared/components/ui/button"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate, cn } from "@/shared/lib/utils"
|
||||||
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||||
@@ -23,6 +23,30 @@ const getStatusLabel = (status: string) => {
|
|||||||
return "Not started"
|
return "Not started"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getActionLabel = (status: string) => {
|
||||||
|
if (status === "graded") return "Review"
|
||||||
|
if (status === "submitted") return "View"
|
||||||
|
if (status === "in_progress") return "Continue"
|
||||||
|
return "Start"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||||
|
if (status === "graded" || status === "submitted") return "outline"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDueUrgency = (dueAt: string | null) => {
|
||||||
|
if (!dueAt) return null
|
||||||
|
const now = new Date()
|
||||||
|
const due = new Date(dueAt)
|
||||||
|
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||||
|
|
||||||
|
if (diffHours < 0) return "overdue"
|
||||||
|
if (diffHours < 48) return "urgent" // 2 days
|
||||||
|
if (diffHours < 120) return "warning" // 5 days
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
|
||||||
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||||
const hasAssignments = upcomingAssignments.length > 0
|
const hasAssignments = upcomingAssignments.length > 0
|
||||||
|
|
||||||
@@ -54,25 +78,49 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
|||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
||||||
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Action</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{upcomingAssignments.map((a) => (
|
{upcomingAssignments.map((a) => {
|
||||||
<TableRow key={a.id} className="h-12">
|
const urgency = getDueUrgency(a.dueAt)
|
||||||
<TableCell className="font-medium">
|
const isGraded = a.progressStatus === "graded"
|
||||||
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
|
|
||||||
{a.title}
|
return (
|
||||||
</Link>
|
<TableRow key={a.id} className="h-12">
|
||||||
</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell>
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
|
||||||
{getStatusLabel(a.progressStatus)}
|
{a.title}
|
||||||
</Badge>
|
</Link>
|
||||||
</TableCell>
|
{!isGraded && urgency === "overdue" && (
|
||||||
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">Late</Badge>
|
||||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
)}
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||||
|
{getStatusLabel(a.progressStatus)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={cn(
|
||||||
|
"text-muted-foreground",
|
||||||
|
!isGraded && urgency === "overdue" && "text-destructive font-medium",
|
||||||
|
!isGraded && urgency === "urgent" && "text-orange-500 font-medium"
|
||||||
|
)}>
|
||||||
|
{a.dueAt ? formatDate(a.dueAt) : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)} className="h-7 text-xs">
|
||||||
|
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||||
|
{getActionLabel(a.progressStatus)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Users } from "lucide-react"
|
import { Users } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function TeacherStats({
|
|||||||
description: "Published and ongoing",
|
description: "Published and ongoing",
|
||||||
icon: PenTool,
|
icon: PenTool,
|
||||||
href: "/teacher/homework/assignments?status=published",
|
href: "/teacher/homework/assignments?status=published",
|
||||||
|
highlight: false,
|
||||||
color: "text-blue-500",
|
color: "text-blue-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,6 +63,7 @@ export function TeacherStats({
|
|||||||
description: "Across recent assignments",
|
description: "Across recent assignments",
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
href: "#grade-trends",
|
href: "#grade-trends",
|
||||||
|
highlight: false,
|
||||||
color: "text-emerald-500",
|
color: "text-emerald-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,6 +72,7 @@ export function TeacherStats({
|
|||||||
description: "Overall completion rate",
|
description: "Overall completion rate",
|
||||||
icon: BarChart,
|
icon: BarChart,
|
||||||
href: "#grade-trends",
|
href: "#grade-trends",
|
||||||
|
highlight: false,
|
||||||
color: "text-purple-500",
|
color: "text-purple-500",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { ActionState } from "@/shared/types/action-state"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { exams, examQuestions } from "@/shared/db/schema"
|
import { exams, examQuestions, subjects, grades } from "@/shared/db/schema"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
import { omitScheduledAtFromDescription } from "./data-access"
|
||||||
|
|
||||||
const ExamCreateSchema = z.object({
|
const ExamCreateSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
@@ -56,9 +57,17 @@ export async function createExamAction(
|
|||||||
const examId = createId()
|
const examId = createId()
|
||||||
const scheduled = input.scheduledAt || undefined
|
const scheduled = input.scheduledAt || undefined
|
||||||
|
|
||||||
|
// Retrieve names for JSON description (to maintain compatibility)
|
||||||
|
const subjectRecord = await db.query.subjects.findFirst({
|
||||||
|
where: eq(subjects.id, input.subject),
|
||||||
|
})
|
||||||
|
const gradeRecord = await db.query.grades.findFirst({
|
||||||
|
where: eq(grades.id, input.grade),
|
||||||
|
})
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
subject: input.subject,
|
subject: subjectRecord?.name ?? input.subject,
|
||||||
grade: input.grade,
|
grade: gradeRecord?.name ?? input.grade,
|
||||||
difficulty: input.difficulty,
|
difficulty: input.difficulty,
|
||||||
totalScore: input.totalScore,
|
totalScore: input.totalScore,
|
||||||
durationMin: input.durationMin,
|
durationMin: input.durationMin,
|
||||||
@@ -71,11 +80,14 @@ export async function createExamAction(
|
|||||||
id: examId,
|
id: examId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: JSON.stringify(meta),
|
description: JSON.stringify(meta),
|
||||||
creatorId: user?.id ?? "user_teacher_123",
|
creatorId: user?.id ?? "user_teacher_math",
|
||||||
|
subjectId: input.subject,
|
||||||
|
gradeId: input.grade,
|
||||||
startTime: scheduled ? new Date(scheduled) : null,
|
startTime: scheduled ? new Date(scheduled) : null,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("Failed to create exam:", error)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Database error: Failed to create exam",
|
message: "Database error: Failed to create exam",
|
||||||
@@ -215,19 +227,6 @@ const ExamDuplicateSchema = z.object({
|
|||||||
examId: z.string().min(1),
|
examId: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
const omitScheduledAtFromDescription = (description: string | null) => {
|
|
||||||
if (!description) return null
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(description)
|
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description
|
|
||||||
const meta = parsed as Record<string, unknown>
|
|
||||||
if ("scheduledAt" in meta) delete meta.scheduledAt
|
|
||||||
return JSON.stringify(meta)
|
|
||||||
} catch {
|
|
||||||
return description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function duplicateExamAction(
|
export async function duplicateExamAction(
|
||||||
prevState: ActionState<string> | null,
|
prevState: ActionState<string> | null,
|
||||||
formData: FormData
|
formData: FormData
|
||||||
@@ -271,7 +270,7 @@ export async function duplicateExamAction(
|
|||||||
id: newExamId,
|
id: newExamId,
|
||||||
title: `${source.title} (Copy)`,
|
title: `${source.title} (Copy)`,
|
||||||
description: omitScheduledAtFromDescription(source.description),
|
description: omitScheduledAtFromDescription(source.description),
|
||||||
creatorId: user?.id ?? "user_teacher_123",
|
creatorId: user?.id ?? "user_teacher_math",
|
||||||
startTime: null,
|
startTime: null,
|
||||||
endTime: null,
|
endTime: null,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
@@ -305,6 +304,78 @@ export async function duplicateExamAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentUser() {
|
export async function getExamPreviewAction(examId: string) {
|
||||||
return { id: "user_teacher_123", role: "teacher" }
|
try {
|
||||||
|
const exam = await db.query.exams.findFirst({
|
||||||
|
where: eq(exams.id, examId),
|
||||||
|
with: {
|
||||||
|
questions: {
|
||||||
|
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||||
|
with: {
|
||||||
|
question: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!exam) {
|
||||||
|
return { success: false, message: "Exam not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract questions from the relation
|
||||||
|
const questions = exam.questions.map(eq => eq.question)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
structure: exam.structure,
|
||||||
|
questions: questions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return { success: false, message: "Failed to load exam preview" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||||
|
try {
|
||||||
|
const allSubjects = await db.query.subjects.findMany({
|
||||||
|
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: allSubjects.map((s) => ({ id: s.id, name: s.name })),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch subjects:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to load subjects",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||||
|
try {
|
||||||
|
const allGrades = await db.query.grades.findMany({
|
||||||
|
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: allGrades.map((g) => ({ id: g.id, name: g.name })),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch grades:", error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to load grades",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentUser() {
|
||||||
|
return { id: "user_teacher_math", role: "teacher" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Eye, Printer } from "lucide-react"
|
|
||||||
import type { ExamNode } from "./selected-question-list"
|
import type { ExamNode } from "./selected-question-list"
|
||||||
|
|
||||||
type ChoiceOption = {
|
type ChoiceOption = {
|
||||||
@@ -86,55 +82,33 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<div className="bg-card shadow-sm border p-12 print:shadow-none print:border-none">
|
||||||
<DialogTrigger asChild>
|
{/* Header */}
|
||||||
<Button variant="secondary" size="sm" className="gap-2">
|
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
|
||||||
<Eye className="h-4 w-4" />
|
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
|
||||||
Preview Exam
|
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
|
||||||
</Button>
|
<span>Subject: {subject}</span>
|
||||||
</DialogTrigger>
|
<span>Grade: {grade}</span>
|
||||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
<span>Time: {durationMin} mins</span>
|
||||||
<DialogHeader className="p-6 pb-2 border-b shrink-0">
|
<span>Total: {totalScore} pts</span>
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<DialogTitle>Exam Preview</DialogTitle>
|
<div className="flex justify-center gap-12 text-sm pt-4">
|
||||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
|
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
|
||||||
<Printer className="h-4 w-4 mr-2" />
|
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
|
||||||
Print
|
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
|
|
||||||
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
|
|
||||||
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
|
|
||||||
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
|
|
||||||
<span>Subject: {subject}</span>
|
|
||||||
<span>Grade: {grade}</span>
|
|
||||||
<span>Time: {durationMin} mins</span>
|
|
||||||
<span>Total: {totalScore} pts</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center gap-12 text-sm pt-4">
|
|
||||||
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
|
|
||||||
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
|
|
||||||
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{nodes.length === 0 ? (
|
{nodes.length === 0 ? (
|
||||||
<div className="text-center py-20 text-muted-foreground">
|
<div className="text-center py-20 text-muted-foreground">
|
||||||
Empty Exam Paper
|
Empty Exam Paper
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
nodes.map(node => renderNode(node))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
) : (
|
||||||
</DialogContent>
|
nodes.map(node => renderNode(node))
|
||||||
</Dialog>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ type QuestionBankListProps = {
|
|||||||
questions: Question[]
|
questions: Question[]
|
||||||
onAdd: (question: Question) => void
|
onAdd: (question: Question) => void
|
||||||
isAdded: (id: string) => boolean
|
isAdded: (id: string) => boolean
|
||||||
|
onLoadMore?: () => void
|
||||||
|
hasMore?: boolean
|
||||||
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) {
|
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0 && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
No questions found matching your filters.
|
No questions found matching your filters.
|
||||||
@@ -22,7 +25,7 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 pb-4">
|
||||||
{questions.map((q) => {
|
{questions.map((q) => {
|
||||||
const added = isAdded(q.id)
|
const added = isAdded(q.id)
|
||||||
const content = q.content as { text?: string }
|
const content = q.content as { text?: string }
|
||||||
@@ -60,6 +63,28 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="pt-2 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onLoadMore}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
{isLoading ? "Loading..." : "Load More"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && questions.length === 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1,2,3].map(i => (
|
||||||
|
<div key={i} className="h-20 bg-muted/20 rounded-lg animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy }
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -27,13 +28,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
|
|
||||||
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
|
import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreviewAction } from "../actions"
|
||||||
import { Exam } from "../types"
|
import { Exam } from "../types"
|
||||||
|
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
||||||
|
import type { ExamNode } from "./assembly/selected-question-list"
|
||||||
|
import type { Question } from "@/modules/questions/types"
|
||||||
|
|
||||||
interface ExamActionsProps {
|
interface ExamActionsProps {
|
||||||
exam: Exam
|
exam: Exam
|
||||||
@@ -44,6 +46,46 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
const [previewNodes, setPreviewNodes] = useState<ExamNode[] | null>(null)
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState(false)
|
||||||
|
|
||||||
|
const handleView = async () => {
|
||||||
|
setLoadingPreview(true)
|
||||||
|
setShowViewDialog(true)
|
||||||
|
try {
|
||||||
|
const result = await getExamPreviewAction(exam.id)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const { structure, questions } = result.data
|
||||||
|
const questionById = new Map<string, Question>()
|
||||||
|
for (const q of questions) questionById.set(q.id, q as unknown as Question)
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const hydrate = (nodes: any[]): ExamNode[] => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.type === "question") {
|
||||||
|
const q = node.questionId ? questionById.get(node.questionId) : undefined
|
||||||
|
return { ...node, question: q }
|
||||||
|
}
|
||||||
|
if (node.type === "group") {
|
||||||
|
return { ...node, children: hydrate(node.children || []) }
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = Array.isArray(structure) ? hydrate(structure) : []
|
||||||
|
setPreviewNodes(nodes)
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to load exam preview")
|
||||||
|
setShowViewDialog(false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to load exam preview")
|
||||||
|
setShowViewDialog(false)
|
||||||
|
} finally {
|
||||||
|
setLoadingPreview(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyId = () => {
|
const copyId = () => {
|
||||||
navigator.clipboard.writeText(exam.id)
|
navigator.clipboard.writeText(exam.id)
|
||||||
@@ -112,25 +154,35 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-1">
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
variant="ghost"
|
||||||
<span className="sr-only">Open menu</span>
|
size="icon"
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||||
</Button>
|
onClick={(e) => {
|
||||||
</DropdownMenuTrigger>
|
e.stopPropagation()
|
||||||
<DropdownMenuContent align="end">
|
handleView()
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
}}
|
||||||
<DropdownMenuItem onClick={copyId}>
|
title="Preview Exam"
|
||||||
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
>
|
||||||
</DropdownMenuItem>
|
<Eye className="h-4 w-4" />
|
||||||
<DropdownMenuSeparator />
|
</Button>
|
||||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
<DropdownMenu>
|
||||||
<Eye className="mr-2 h-4 w-4" /> View
|
<DropdownMenuTrigger asChild>
|
||||||
</DropdownMenuItem>
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
<span className="sr-only">Open menu</span>
|
||||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={copyId}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||||
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
|
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -166,49 +218,21 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Exam Details</DialogTitle>
|
|
||||||
<DialogDescription>ID: {exam.id}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Title:</span>
|
|
||||||
<span className="col-span-3">{exam.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Subject:</span>
|
|
||||||
<span className="col-span-3">{exam.subject}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Grade:</span>
|
|
||||||
<span className="col-span-3">{exam.grade}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Total Score:</span>
|
|
||||||
<span className="col-span-3">{exam.totalScore}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<span className="font-medium">Duration:</span>
|
|
||||||
<span className="col-span-3">{exam.durationMin} min</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete exam?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the exam.
|
This action cannot be undone. This will permanently delete the exam
|
||||||
|
"{exam.title}" and remove all associated data.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleDelete()
|
handleDelete()
|
||||||
@@ -220,6 +244,34 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||||
|
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
||||||
|
<DialogTitle className="text-lg font-semibold tracking-tight">{exam.title}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{loadingPreview ? (
|
||||||
|
<div className="py-20 text-center text-muted-foreground">Loading preview...</div>
|
||||||
|
) : previewNodes && previewNodes.length > 0 ? (
|
||||||
|
<div className="max-w-3xl mx-auto py-8 px-6">
|
||||||
|
<ExamPaperPreview
|
||||||
|
title={exam.title}
|
||||||
|
subject={exam.subject}
|
||||||
|
grade={exam.grade}
|
||||||
|
durationMin={exam.durationMin}
|
||||||
|
totalScore={exam.totalScore}
|
||||||
|
nodes={previewNodes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-20 text-center text-muted-foreground">
|
||||||
|
No questions in this exam.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useDeferredValue, useMemo, useState } from "react"
|
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
import { useFormStatus } from "react-dom"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Search } from "lucide-react"
|
import { Search, Eye } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
||||||
import type { Question } from "@/modules/questions/types"
|
import type { Question } from "@/modules/questions/types"
|
||||||
import { updateExamAction } from "@/modules/exams/actions"
|
import { updateExamAction } from "@/modules/exams/actions"
|
||||||
|
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||||
import { StructureEditor } from "./assembly/structure-editor"
|
import { StructureEditor } from "./assembly/structure-editor"
|
||||||
import { QuestionBankList } from "./assembly/question-bank-list"
|
import { QuestionBankList } from "./assembly/question-bank-list"
|
||||||
import type { ExamNode } from "./assembly/selected-question-list"
|
import type { ExamNode } from "./assembly/selected-question-list"
|
||||||
@@ -49,6 +50,12 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||||
const deferredSearch = useDeferredValue(search)
|
const deferredSearch = useDeferredValue(search)
|
||||||
|
|
||||||
|
// Bank state
|
||||||
|
const [bankQuestions, setBankQuestions] = useState<Question[]>(props.questionOptions)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(props.questionOptions.length >= 20)
|
||||||
|
const [isBankLoading, startBankTransition] = useTransition()
|
||||||
|
|
||||||
// Initialize structure state
|
// Initialize structure state
|
||||||
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
||||||
const questionById = new Map<string, Question>()
|
const questionById = new Map<string, Question>()
|
||||||
@@ -76,26 +83,47 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredQuestions = useMemo(() => {
|
const fetchQuestions = (reset: boolean = false) => {
|
||||||
let list: Question[] = [...props.questionOptions]
|
startBankTransition(async () => {
|
||||||
|
const nextPage = reset ? 1 : page + 1
|
||||||
if (deferredSearch) {
|
try {
|
||||||
const lower = deferredSearch.toLowerCase()
|
const result = await getQuestionsAction({
|
||||||
list = list.filter(q => {
|
q: deferredSearch,
|
||||||
const content = q.content as { text?: string }
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return content.text?.toLowerCase().includes(lower)
|
type: typeFilter === 'all' ? undefined : typeFilter as any,
|
||||||
})
|
difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
|
||||||
}
|
page: nextPage,
|
||||||
|
pageSize: 20
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result && result.data) {
|
||||||
|
setBankQuestions(prev => {
|
||||||
|
if (reset) return result.data
|
||||||
|
// Deduplicate just in case
|
||||||
|
const existingIds = new Set(prev.map(q => q.id))
|
||||||
|
const newQuestions = result.data.filter(q => !existingIds.has(q.id))
|
||||||
|
return [...prev, ...newQuestions]
|
||||||
|
})
|
||||||
|
setHasMore(result.data.length === 20)
|
||||||
|
setPage(nextPage)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to load questions")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (typeFilter !== "all") {
|
const isFirstRender = useRef(true)
|
||||||
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
isFirstRender.current = false
|
||||||
|
if (deferredSearch === "" && typeFilter === "all" && difficultyFilter === "all") {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (difficultyFilter !== "all") {
|
fetchQuestions(true)
|
||||||
const d = parseInt(difficultyFilter)
|
}, [deferredSearch, typeFilter, difficultyFilter])
|
||||||
list = list.filter((q) => q.difficulty === d)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
|
|
||||||
|
|
||||||
// Recursively calculate total score
|
// Recursively calculate total score
|
||||||
const assignedTotal = useMemo(() => {
|
const assignedTotal = useMemo(() => {
|
||||||
@@ -231,6 +259,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
return clean(structure)
|
return clean(structure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
|
|
||||||
const handleSave = async (formData: FormData) => {
|
const handleSave = async (formData: FormData) => {
|
||||||
formData.set("examId", props.examId)
|
formData.set("examId", props.examId)
|
||||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
||||||
@@ -238,7 +268,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
|
|
||||||
const result = await updateExamAction(null, formData)
|
const result = await updateExamAction(null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Saved draft")
|
toast.success("Exam draft saved")
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Save failed")
|
toast.error(result.message || "Save failed")
|
||||||
}
|
}
|
||||||
@@ -260,47 +290,76 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-12rem)] gap-6 lg:grid-cols-5">
|
<div className="grid h-[calc(100vh-8rem)] gap-4 lg:grid-cols-12">
|
||||||
{/* Left: Preview (3 cols) */}
|
{/* Left: Preview (8 cols) */}
|
||||||
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
|
<Card className="lg:col-span-8 flex flex-col overflow-hidden border-2 border-primary/10 shadow-sm">
|
||||||
<CardHeader className="bg-muted/30 pb-4">
|
<CardHeader className="bg-muted/30 pb-4 border-b">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CardTitle>Exam Structure</CardTitle>
|
<CardTitle className="text-lg">Exam Structure</CardTitle>
|
||||||
<ExamPaperPreview
|
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||||
title={props.title}
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
subject={props.subject}
|
<span className="font-medium text-foreground">{props.subject}</span>
|
||||||
grade={props.grade}
|
<span>•</span>
|
||||||
durationMin={props.durationMin}
|
<span>{props.grade}</span>
|
||||||
totalScore={props.totalScore}
|
<span>•</span>
|
||||||
nodes={structure}
|
<span>{props.durationMin} min</span>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Total Score</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-24 rounded-full bg-secondary">
|
</div>
|
||||||
<div
|
<div className="flex items-center gap-6">
|
||||||
className={`h-full rounded-full transition-all ${
|
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||||
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
<DialogTrigger asChild>
|
||||||
}`}
|
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
|
||||||
style={{ width: `${progress}%` }}
|
<Eye className="h-4 w-4" />
|
||||||
/>
|
Preview
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
||||||
|
<DialogTitle className="text-lg font-semibold tracking-tight">{props.title}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="max-w-3xl mx-auto py-8 px-6">
|
||||||
|
<ExamPaperPreview
|
||||||
|
title={props.title}
|
||||||
|
subject={props.subject}
|
||||||
|
grade={props.grade}
|
||||||
|
durationMin={props.durationMin}
|
||||||
|
totalScore={props.totalScore}
|
||||||
|
nodes={structure}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-lg font-bold ${assignedTotal > props.totalScore ? "text-destructive" : "text-primary"}`}>
|
||||||
|
{assignedTotal}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">/ {props.totalScore}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Total Score</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-2 rounded-full bg-secondary overflow-hidden flex flex-col-reverse">
|
||||||
|
<div
|
||||||
|
className={`w-full transition-all ${
|
||||||
|
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
||||||
|
}`}
|
||||||
|
style={{ height: `${Math.min(progress, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
<ScrollArea className="flex-1 bg-muted/5">
|
||||||
<div className="space-y-6">
|
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||||
<div className="grid grid-cols-3 gap-4 text-sm text-muted-foreground bg-muted/20 p-3 rounded-md">
|
|
||||||
<div><span className="font-medium text-foreground">{props.subject}</span></div>
|
|
||||||
<div><span className="font-medium text-foreground">{props.grade}</span></div>
|
|
||||||
<div>Duration: <span className="font-medium text-foreground">{props.durationMin} min</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StructureEditor
|
<StructureEditor
|
||||||
items={structure}
|
items={structure}
|
||||||
onChange={setStructure}
|
onChange={setStructure}
|
||||||
@@ -312,32 +371,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="border-t p-4 bg-muted/30 flex gap-3 justify-end">
|
<div className="border-t p-4 bg-background flex gap-3 justify-end items-center shadow-[0_-1px_2px_rgba(0,0,0,0.03)]">
|
||||||
<form action={handleSave} className="flex-1">
|
<div className="mr-auto text-xs text-muted-foreground">
|
||||||
<SubmitButton label="Save Draft" />
|
{structure.length === 0 ? "Start by adding questions from the right panel" : `${structure.length} items in structure`}
|
||||||
|
</div>
|
||||||
|
<form action={handleSave}>
|
||||||
|
<Button variant="outline" size="sm" type="submit" className="w-24">Save Draft</Button>
|
||||||
</form>
|
</form>
|
||||||
<form action={handlePublish} className="flex-1">
|
<form action={handlePublish}>
|
||||||
<SubmitButton label="Publish Exam" />
|
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">Publish</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Right: Question Bank (2 cols) */}
|
{/* Right: Question Bank (4 cols) */}
|
||||||
<Card className="lg:col-span-2 flex flex-col overflow-hidden">
|
<Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
|
||||||
<CardHeader className="pb-3 space-y-3">
|
<CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
|
||||||
<CardTitle className="text-base">Question Bank</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold">Question Bank</CardTitle>
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||||
|
{bankQuestions.length}{hasMore ? "+" : ""} loaded
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search questions..."
|
placeholder="Search by content..."
|
||||||
className="pl-8"
|
className="pl-9 h-9 text-sm"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="flex-1 h-8 text-xs"><SelectValue placeholder="Type" /></SelectTrigger>
|
<SelectTrigger className="flex-1 h-8 text-xs bg-background"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||||
@@ -347,7 +414,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
||||||
<SelectTrigger className="w-[80px] h-8 text-xs"><SelectValue placeholder="Diff" /></SelectTrigger>
|
<SelectTrigger className="w-[80px] h-8 text-xs bg-background"><SelectValue placeholder="Diff" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<SelectItem value="1">Lvl 1</SelectItem>
|
<SelectItem value="1">Lvl 1</SelectItem>
|
||||||
@@ -360,14 +427,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<Separator />
|
<ScrollArea className="flex-1 p-0 bg-muted/5">
|
||||||
|
<div className="p-3">
|
||||||
<ScrollArea className="flex-1 p-4 bg-muted/10">
|
<QuestionBankList
|
||||||
<QuestionBankList
|
questions={bankQuestions}
|
||||||
questions={filteredQuestions}
|
onAdd={handleAdd}
|
||||||
onAdd={handleAdd}
|
isAdded={(id) => addedQuestionIds.has(id)}
|
||||||
isAdded={(id) => addedQuestionIds.has(id)}
|
onLoadMore={() => fetchQuestions(false)}
|
||||||
/>
|
hasMore={hasMore}
|
||||||
|
isLoading={isBankLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
src/modules/exams/components/exam-card.tsx
Normal file
103
src/modules/exams/components/exam-card.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Book, Clock, GraduationCap, Trophy, HelpCircle } from "lucide-react"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import { Badge, BadgeProps } from "@/shared/components/ui/badge"
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
|
import { Exam } from "../types"
|
||||||
|
import { ExamActions } from "./exam-actions"
|
||||||
|
|
||||||
|
interface ExamCardProps {
|
||||||
|
exam: Exam
|
||||||
|
hrefBase?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectColorMap: Record<string, string> = {
|
||||||
|
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
|
||||||
|
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
|
||||||
|
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
|
||||||
|
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
|
||||||
|
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
|
||||||
|
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
|
||||||
|
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamCard({ exam, hrefBase }: ExamCardProps) {
|
||||||
|
const base = hrefBase || "/teacher/exams"
|
||||||
|
const colorClass = subjectColorMap[exam.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800"
|
||||||
|
|
||||||
|
const statusVariant: BadgeProps["variant"] =
|
||||||
|
exam.status === "published"
|
||||||
|
? "secondary"
|
||||||
|
: exam.status === "archived"
|
||||||
|
? "destructive"
|
||||||
|
: "outline"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
||||||
|
<Link href={`${base}/${exam.id}/build`} className="flex-1">
|
||||||
|
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
|
||||||
|
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
|
||||||
|
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<Badge variant={statusVariant} className="bg-background/50 backdrop-blur-sm shadow-none border-transparent">
|
||||||
|
{exam.status}
|
||||||
|
</Badge>
|
||||||
|
{exam.difficulty && (
|
||||||
|
<Badge variant="outline" className="bg-background/50 backdrop-blur-sm border-transparent shadow-none">
|
||||||
|
Lvl {exam.difficulty}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Book className="h-8 w-8 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-lg leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{exam.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-4 pt-1 pb-2">
|
||||||
|
<div className="flex flex-wrap gap-y-2 gap-x-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<GraduationCap className="h-3.5 w-3.5" />
|
||||||
|
<span>{exam.grade}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
<span>{exam.durationMin} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Trophy className="h-3.5 w-3.5" />
|
||||||
|
<span>{exam.totalScore} pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||||
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
|
<span>{exam.questionCount || 0} Questions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 mr-2">
|
||||||
|
{formatDate(exam.updatedAt || exam.createdAt)}
|
||||||
|
</span>
|
||||||
|
<ExamActions exam={exam} />
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,109 +30,126 @@ export const examColumns: ColumnDef<Exam>[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "title",
|
accessorKey: "title",
|
||||||
header: "Title",
|
header: "Exam Info",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium">{row.original.title}</span>
|
<div className="flex items-center gap-2">
|
||||||
{row.original.tags && row.original.tags.length > 0 && (
|
<span className="font-semibold text-base">{row.original.title}</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
{row.original.tags && row.original.tags.length > 0 && (
|
||||||
{row.original.tags.slice(0, 2).map((t, idx) => (
|
<div className="flex flex-wrap gap-1">
|
||||||
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
|
{row.original.tags.slice(0, 2).map((t, idx) => (
|
||||||
{t}
|
<Badge key={`${t}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||||
</Badge>
|
{t}
|
||||||
))}
|
</Badge>
|
||||||
{row.original.tags.length > 2 && (
|
))}
|
||||||
<Badge variant="outline" className="text-xs">+{row.original.tags.length - 2}</Badge>
|
{row.original.tags.length > 2 && (
|
||||||
)}
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">+{row.original.tags.length - 2}</Badge>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground/80">{row.original.subject}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{row.original.grade}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "subject",
|
|
||||||
header: "Subject",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "grade",
|
|
||||||
header: "Grade",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-muted-foreground text-xs">{row.original.grade}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.original.status
|
const status = row.original.status
|
||||||
|
// Use 'default' as base for published/success to ensure type safety,
|
||||||
|
// but override with className below
|
||||||
const variant: BadgeProps["variant"] =
|
const variant: BadgeProps["variant"] =
|
||||||
status === "published"
|
status === "published"
|
||||||
? "secondary"
|
? "default"
|
||||||
: status === "archived"
|
: status === "archived"
|
||||||
? "destructive"
|
? "secondary"
|
||||||
: "outline"
|
: "outline"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={variant} className="capitalize">
|
<Badge
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"capitalize",
|
||||||
|
status === "published" && "bg-green-600 hover:bg-green-700 border-transparent",
|
||||||
|
status === "draft" && "bg-amber-100 text-amber-800 hover:bg-amber-200 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "stats",
|
||||||
|
header: "Stats",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">{row.original.questionCount} Qs</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{row.original.totalScore} Pts</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>{row.original.durationMin} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "difficulty",
|
accessorKey: "difficulty",
|
||||||
header: "Difficulty",
|
header: "Difficulty",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const diff = row.original.difficulty
|
const diff = row.original.difficulty
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex flex-col gap-1">
|
||||||
<span
|
<div className="flex gap-0.5">
|
||||||
className={cn(
|
{[1, 2, 3, 4, 5].map((level) => (
|
||||||
"font-medium",
|
<div
|
||||||
diff <= 2 ? "text-green-600" : diff === 3 ? "text-yellow-600" : "text-red-600"
|
key={level}
|
||||||
)}
|
className={cn(
|
||||||
>
|
"h-1.5 w-3 rounded-full",
|
||||||
{diff === 1
|
level <= diff
|
||||||
? "Easy"
|
? diff <= 2 ? "bg-green-500" : diff === 3 ? "bg-yellow-500" : "bg-red-500"
|
||||||
: diff === 2
|
: "bg-muted"
|
||||||
? "Easy-Med"
|
)}
|
||||||
: diff === 3
|
/>
|
||||||
? "Medium"
|
))}
|
||||||
: diff === 4
|
</div>
|
||||||
? "Med-Hard"
|
<span className="text-[10px] text-muted-foreground font-medium">
|
||||||
: "Hard"}
|
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "durationMin",
|
id: "dates",
|
||||||
header: "Duration",
|
header: "Date",
|
||||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.durationMin} min</span>,
|
cell: ({ row }) => {
|
||||||
},
|
const scheduled = row.original.scheduledAt
|
||||||
{
|
const created = row.original.createdAt
|
||||||
accessorKey: "totalScore",
|
|
||||||
header: "Total",
|
return (
|
||||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.totalScore}</span>,
|
<div className="flex flex-col gap-0.5 text-xs">
|
||||||
},
|
{scheduled ? (
|
||||||
{
|
<>
|
||||||
accessorKey: "scheduledAt",
|
<span className="font-medium text-blue-600 dark:text-blue-400">Scheduled</span>
|
||||||
header: "Scheduled",
|
<span className="text-muted-foreground">{formatDate(scheduled)}</span>
|
||||||
cell: ({ row }) => (
|
</>
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
) : (
|
||||||
{row.original.scheduledAt ? formatDate(row.original.scheduledAt) : "-"}
|
<>
|
||||||
</span>
|
<span className="text-muted-foreground">Created</span>
|
||||||
),
|
<span>{formatDate(created)}</span>
|
||||||
},
|
</>
|
||||||
{
|
)}
|
||||||
accessorKey: "createdAt",
|
</div>
|
||||||
header: "Created",
|
)
|
||||||
cell: ({ row }) => (
|
},
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
|
||||||
{formatDate(row.original.createdAt)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-muted/40">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
@@ -88,20 +88,38 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
<div className="flex items-center justify-between px-2 py-4">
|
||||||
<div className="flex-1 text-sm text-muted-foreground">
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||||
selected.
|
selected.
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-2">
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
<div className="flex items-center space-x-2">
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<p className="text-sm font-medium">Page</p>
|
||||||
Previous
|
<span className="text-sm font-medium">
|
||||||
</Button>
|
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
</span>
|
||||||
Next
|
</div>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<div className="flex items-center space-x-2">
|
||||||
</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,57 +19,59 @@ export function ExamFilters() {
|
|||||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
<div className="relative w-full md:w-[260px]">
|
<div className="relative w-full md:w-80">
|
||||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search exams..."
|
placeholder="Search exams..."
|
||||||
className="pl-7"
|
className="pl-9 bg-background border-muted-foreground/20"
|
||||||
value={search || ""}
|
value={search || ""}
|
||||||
onChange={(e) => setSearch(e.target.value || null)}
|
onChange={(e) => setSearch(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||||
<SelectTrigger className="w-[160px]">
|
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||||
<SelectValue placeholder="Status" />
|
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Status" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="all">Any Status</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
<SelectItem value="all">Any Status</SelectItem>
|
||||||
<SelectItem value="published">Published</SelectItem>
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
<SelectItem value="archived">Archived</SelectItem>
|
<SelectItem value="published">Published</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="archived">Archived</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||||
<SelectValue placeholder="Difficulty" />
|
<SelectValue placeholder="Difficulty" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Any Difficulty</SelectItem>
|
<SelectItem value="all">Any Difficulty</SelectItem>
|
||||||
<SelectItem value="1">Easy (1)</SelectItem>
|
<SelectItem value="1">Easy (1)</SelectItem>
|
||||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
||||||
<SelectItem value="3">Medium (3)</SelectItem>
|
<SelectItem value="3">Medium (3)</SelectItem>
|
||||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
||||||
<SelectItem value="5">Hard (5)</SelectItem>
|
<SelectItem value="5">Hard (5)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
|
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearch(null)
|
setSearch(null)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
setDifficulty(null)
|
setDifficulty(null)
|
||||||
}}
|
}}
|
||||||
className="h-8 px-2 lg:px-3"
|
className="h-10 px-3"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
<X className="ml-2 h-4 w-4" />
|
<X className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,357 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useTransition, useEffect, useState } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Loader2, Sparkles, BookOpen } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/components/ui/form"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import {
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
Select,
|
||||||
import { createExamAction } from "../actions"
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import { createExamAction, getSubjectsAction, getGradesAction } from "../actions"
|
||||||
|
|
||||||
function SubmitButton() {
|
export const formSchema = z.object({
|
||||||
const { pending } = useFormStatus()
|
title: z.string().min(2, "Title must be at least 2 characters."),
|
||||||
return (
|
subject: z.string().min(1, "Subject is required."),
|
||||||
<Button type="submit" disabled={pending}>
|
grade: z.string().min(1, "Grade is required."),
|
||||||
{pending ? "Creating..." : "Create Exam"}
|
difficulty: z.string(),
|
||||||
</Button>
|
totalScore: z.coerce.number().min(1, "Total score must be at least 1."),
|
||||||
)
|
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes."),
|
||||||
|
scheduledAt: z.string().optional(),
|
||||||
|
mode: z.enum(["manual", "ai"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ExamFormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
const defaultValues: Partial<ExamFormValues> = {
|
||||||
|
title: "",
|
||||||
|
subject: "",
|
||||||
|
grade: "",
|
||||||
|
difficulty: "3",
|
||||||
|
totalScore: 100,
|
||||||
|
durationMin: 90,
|
||||||
|
mode: "manual",
|
||||||
|
scheduledAt: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExamForm() {
|
export function ExamForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [difficulty, setDifficulty] = useState<string>("3")
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
||||||
|
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
||||||
|
const [grades, setGrades] = useState<{ id: string; name: string }[]>([])
|
||||||
|
const [loadingGrades, setLoadingGrades] = useState(true)
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const form = useForm<ExamFormValues>({
|
||||||
const result = await createExamAction(null, formData)
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (result.success) {
|
resolver: zodResolver(formSchema) as any,
|
||||||
toast.success(result.message)
|
defaultValues: defaultValues as unknown as ExamFormValues,
|
||||||
if (result.data) {
|
})
|
||||||
router.push(`/teacher/exams/${result.data}/build`)
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMetadata = async () => {
|
||||||
|
try {
|
||||||
|
const [subjectsResult, gradesResult] = await Promise.all([
|
||||||
|
getSubjectsAction(),
|
||||||
|
getGradesAction()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (subjectsResult.success && subjectsResult.data) {
|
||||||
|
setSubjects(subjectsResult.data)
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to load subjects")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gradesResult.success && gradesResult.data) {
|
||||||
|
setGrades(gradesResult.data)
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to load grades")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Failed to load form data")
|
||||||
|
} finally {
|
||||||
|
setLoadingSubjects(false)
|
||||||
|
setLoadingGrades(false)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
toast.error(result.message)
|
|
||||||
}
|
}
|
||||||
|
fetchMetadata()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function onSubmit(data: ExamFormValues) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("title", data.title)
|
||||||
|
formData.append("subject", data.subject)
|
||||||
|
formData.append("grade", data.grade)
|
||||||
|
formData.append("difficulty", data.difficulty)
|
||||||
|
formData.append("totalScore", data.totalScore.toString())
|
||||||
|
formData.append("durationMin", data.durationMin.toString())
|
||||||
|
if (data.scheduledAt) {
|
||||||
|
formData.append("scheduledAt", data.scheduledAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createExamAction(null, formData)
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
toast.success("Exam draft created", {
|
||||||
|
description: "Redirecting to exam builder...",
|
||||||
|
})
|
||||||
|
router.push(`/teacher/exams/${result.data}/build`)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to create exam")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const handleSubmit = (e: any) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
form.handleSubmit(onSubmit as any)(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Form {...form}>
|
||||||
<CardHeader>
|
<form onSubmit={handleSubmit} className="grid gap-8 lg:grid-cols-3">
|
||||||
<CardTitle>Exam Creator</CardTitle>
|
{/* Left Column: Exam Details */}
|
||||||
</CardHeader>
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<CardContent>
|
<Card>
|
||||||
<form action={handleSubmit} className="space-y-6">
|
<CardHeader>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardTitle>Exam Details</CardTitle>
|
||||||
<div className="grid gap-2">
|
<CardDescription>
|
||||||
<Label htmlFor="title">Title</Label>
|
Define the core information for your exam.
|
||||||
<Input id="title" name="title" placeholder="e.g. Algebra Midterm" required />
|
</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="grid gap-2">
|
<CardContent className="grid gap-6">
|
||||||
<Label htmlFor="subject">Subject</Label>
|
<FormField
|
||||||
<Input id="subject" name="subject" placeholder="e.g. Mathematics" required />
|
control={form.control}
|
||||||
</div>
|
name="title"
|
||||||
<div className="grid gap-2">
|
render={({ field }) => (
|
||||||
<Label htmlFor="grade">Grade</Label>
|
<FormItem>
|
||||||
<Input id="grade" name="grade" placeholder="e.g. Grade 10" required />
|
<FormLabel>Title</FormLabel>
|
||||||
</div>
|
<FormControl>
|
||||||
<div className="grid gap-2">
|
<Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
|
||||||
<Label>Difficulty</Label>
|
</FormControl>
|
||||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val)} name="difficulty">
|
<FormMessage />
|
||||||
<SelectTrigger>
|
</FormItem>
|
||||||
<SelectValue placeholder="Select difficulty" />
|
)}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">Easy (1)</SelectItem>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
<FormField
|
||||||
<SelectItem value="3">Medium (3)</SelectItem>
|
control={form.control}
|
||||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
name="subject"
|
||||||
<SelectItem value="5">Hard (5)</SelectItem>
|
render={({ field }) => (
|
||||||
</SelectContent>
|
<FormItem>
|
||||||
</Select>
|
<FormLabel>Subject</FormLabel>
|
||||||
<input type="hidden" name="difficulty" value={difficulty} />
|
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
|
||||||
</div>
|
<FormControl>
|
||||||
<div className="grid gap-2">
|
<SelectTrigger>
|
||||||
<Label htmlFor="totalScore">Total Score</Label>
|
<SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
|
||||||
<Input id="totalScore" name="totalScore" type="number" min={1} placeholder="e.g. 100" required />
|
</SelectTrigger>
|
||||||
</div>
|
</FormControl>
|
||||||
<div className="grid gap-2">
|
<SelectContent>
|
||||||
<Label htmlFor="durationMin">Duration (min)</Label>
|
{subjects.map((subject) => (
|
||||||
<Input id="durationMin" name="durationMin" type="number" min={10} placeholder="e.g. 90" required />
|
<SelectItem key={subject.id} value={subject.id}>
|
||||||
</div>
|
{subject.name}
|
||||||
<div className="grid gap-2 md:col-span-2">
|
</SelectItem>
|
||||||
<Label htmlFor="scheduledAt">Scheduled At (optional)</Label>
|
))}
|
||||||
<Input id="scheduledAt" name="scheduledAt" type="datetime-local" />
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
</div>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="grade"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Grade Level</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingGrades}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{grades.map((grade) => (
|
||||||
|
<SelectItem key={grade.id} value={grade.id}>
|
||||||
|
{grade.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="difficulty"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Difficulty</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select level" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">Level 1 (Easy)</SelectItem>
|
||||||
|
<SelectItem value="2">Level 2</SelectItem>
|
||||||
|
<SelectItem value="3">Level 3 (Medium)</SelectItem>
|
||||||
|
<SelectItem value="4">Level 4</SelectItem>
|
||||||
|
<SelectItem value="5">Level 5 (Hard)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="totalScore"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Total Score</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="durationMin"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Duration (min)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardFooter className="justify-end">
|
<FormField
|
||||||
<SubmitButton />
|
control={form.control}
|
||||||
</CardFooter>
|
name="scheduledAt"
|
||||||
</form>
|
render={({ field }) => (
|
||||||
</CardContent>
|
<FormItem>
|
||||||
</Card>
|
<FormLabel>Schedule Start Time (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="datetime-local" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
If set, this exam will be scheduled for a specific time.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Mode & Actions */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Assembly Mode</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose how to build the exam structure.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-3">
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
{/* Manual Mode */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground",
|
||||||
|
field.value === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
|
||||||
|
)}
|
||||||
|
onClick={() => field.onChange("manual")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-medium">Manual Assembly</span>
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Manually select questions from the bank and organize structure.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Mode (Disabled) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-not-allowed flex-col rounded-lg border p-4 shadow-sm outline-none opacity-50 bg-muted/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||||
|
<span className="font-medium">AI Generation</span>
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Automatically generate exam structure based on topics. (Coming Soon)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isPending ? "Creating Draft..." : "Create & Start Building"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/modules/exams/components/exam-grid.tsx
Normal file
16
src/modules/exams/components/exam-grid.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Exam } from "../types"
|
||||||
|
import { ExamCard } from "./exam-card"
|
||||||
|
|
||||||
|
interface ExamGridProps {
|
||||||
|
exams: Exam[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamGrid({ exams }: ExamGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{exams.map((exam) => (
|
||||||
|
<ExamCard key={exam.id} exam={exam} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -68,6 +68,10 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
|||||||
const data = await db.query.exams.findMany({
|
const data = await db.query.exams.findMany({
|
||||||
where: conditions.length ? and(...conditions) : undefined,
|
where: conditions.length ? and(...conditions) : undefined,
|
||||||
orderBy: [desc(exams.createdAt)],
|
orderBy: [desc(exams.createdAt)],
|
||||||
|
with: {
|
||||||
|
subject: true,
|
||||||
|
gradeEntity: true,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Transform and Filter (especially for JSON fields)
|
// Transform and Filter (especially for JSON fields)
|
||||||
@@ -78,8 +82,8 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
|||||||
id: exam.id,
|
id: exam.id,
|
||||||
title: exam.title,
|
title: exam.title,
|
||||||
status: (exam.status as ExamStatus) || "draft",
|
status: (exam.status as ExamStatus) || "draft",
|
||||||
subject: getString(meta, "subject") || "General",
|
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||||
grade: getString(meta, "grade") || "General",
|
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||||
totalScore: getNumber(meta, "totalScore") || 100,
|
totalScore: getNumber(meta, "totalScore") || 100,
|
||||||
durationMin: getNumber(meta, "durationMin") || 60,
|
durationMin: getNumber(meta, "durationMin") || 60,
|
||||||
@@ -103,6 +107,8 @@ export const getExamById = cache(async (id: string) => {
|
|||||||
const exam = await db.query.exams.findFirst({
|
const exam = await db.query.exams.findFirst({
|
||||||
where: eq(exams.id, id),
|
where: eq(exams.id, id),
|
||||||
with: {
|
with: {
|
||||||
|
subject: true,
|
||||||
|
gradeEntity: true,
|
||||||
questions: {
|
questions: {
|
||||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||||
with: {
|
with: {
|
||||||
@@ -120,8 +126,8 @@ export const getExamById = cache(async (id: string) => {
|
|||||||
id: exam.id,
|
id: exam.id,
|
||||||
title: exam.title,
|
title: exam.title,
|
||||||
status: (exam.status as ExamStatus) || "draft",
|
status: (exam.status as ExamStatus) || "draft",
|
||||||
subject: getString(meta, "subject") || "General",
|
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||||
grade: getString(meta, "grade") || "General",
|
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||||
totalScore: getNumber(meta, "totalScore") || 100,
|
totalScore: getNumber(meta, "totalScore") || 100,
|
||||||
durationMin: getNumber(meta, "durationMin") || 60,
|
durationMin: getNumber(meta, "durationMin") || 60,
|
||||||
@@ -137,3 +143,18 @@ export const getExamById = cache(async (id: string) => {
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const omitScheduledAtFromDescription = (description: string | null): string => {
|
||||||
|
if (!description) return "{}"
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(description)
|
||||||
|
if (typeof meta === "object" && meta !== null) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { scheduledAt, ...rest } = meta as any
|
||||||
|
return JSON.stringify(rest)
|
||||||
|
}
|
||||||
|
return description
|
||||||
|
} catch {
|
||||||
|
return description || "{}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async function getCurrentUser() {
|
|||||||
|
|
||||||
if (anyUser) return { id: anyUser.id, role: roleHint }
|
if (anyUser) return { id: anyUser.id, role: roleHint }
|
||||||
|
|
||||||
return { id: "user_teacher_123", role: roleHint }
|
return { id: "user_teacher_math", role: roleHint }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTeacher() {
|
async function ensureTeacher() {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function HomeworkAssignmentExamContentCard({
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<HomeworkAssignmentExamErrorExplorerLazy
|
<HomeworkAssignmentExamErrorExplorerLazy
|
||||||
structure={structure}
|
structure={structure}
|
||||||
questions={questions}
|
questions={questions}
|
||||||
|
|||||||
@@ -7,44 +7,47 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
|
|||||||
|
|
||||||
function ExamErrorExplorerFallback() {
|
function ExamErrorExplorerFallback() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]">
|
<div className="grid grid-cols-1 gap-0 md:grid-cols-3 h-[600px] divide-y md:divide-y-0 md:divide-x">
|
||||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||||
<div className="border-b px-4 py-3 text-sm font-medium">题目</div>
|
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||||
<div className="flex-1 p-4 space-y-3">
|
<span className="text-sm font-medium">Question Preview</span>
|
||||||
<Skeleton className="h-10 w-[40%]" />
|
</div>
|
||||||
<Skeleton className="h-10 w-[60%]" />
|
<div className="flex-1 p-6 space-y-6">
|
||||||
<Skeleton className="h-10 w-[75%]" />
|
<Skeleton className="h-8 w-[60%]" />
|
||||||
<Skeleton className="h-10 w-[55%]" />
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-10 w-[68%]" />
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-[90%]" />
|
||||||
|
<Skeleton className="h-4 w-[80%]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 pt-4">
|
||||||
|
<Skeleton className="h-12 w-full rounded-md" />
|
||||||
|
<Skeleton className="h-12 w-full rounded-md" />
|
||||||
|
<Skeleton className="h-12 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="border-b px-4 py-3">
|
<div className="border-b px-6 py-4">
|
||||||
<div className="text-sm font-medium">错题详情</div>
|
<div className="text-sm font-medium">Error Analysis</div>
|
||||||
<div className="mt-2 flex items-center gap-3">
|
</div>
|
||||||
<Skeleton className="size-12 rounded-full" />
|
<div className="flex-1 p-6 space-y-6">
|
||||||
<div className="min-w-0 flex-1 grid gap-1">
|
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<Skeleton className="size-16 rounded-full shrink-0" />
|
||||||
<Skeleton className="h-3 w-16" />
|
<div className="space-y-2 flex-1">
|
||||||
<Skeleton className="h-3 w-10" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
<Skeleton className="h-3 w-32" />
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<Skeleton className="h-3 w-16" />
|
</div>
|
||||||
<Skeleton className="h-3 w-12" />
|
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers</div>
|
||||||
<Skeleton className="h-3 w-16" />
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-3 w-10" />
|
<Skeleton className="h-14 w-full rounded-md" />
|
||||||
</div>
|
<Skeleton className="h-14 w-full rounded-md" />
|
||||||
|
<Skeleton className="h-14 w-full rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4 space-y-3">
|
|
||||||
<Skeleton className="h-4 w-[45%]" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function HomeworkAssignmentExamErrorExplorer({
|
|||||||
}, [questions, selectedQuestionId])
|
}, [questions, selectedQuestionId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-1 gap-4 md:grid-cols-3 ${heightClassName}`}>
|
<div className={`grid grid-cols-1 gap-0 md:grid-cols-3 ${heightClassName} divide-y md:divide-y-0 md:divide-x border rounded-md bg-background overflow-hidden`}>
|
||||||
<HomeworkAssignmentExamPreviewPane
|
<HomeworkAssignmentExamPreviewPane
|
||||||
structure={structure}
|
structure={structure}
|
||||||
questions={questions.map((q) => ({
|
questions={questions.map((q) => ({
|
||||||
|
|||||||
@@ -20,15 +20,19 @@ export function HomeworkAssignmentExamPreviewPane({
|
|||||||
onQuestionSelect: (questionId: string) => void
|
onQuestionSelect: (questionId: string) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||||
<div className="border-b px-4 py-3 text-sm font-medium">题目</div>
|
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||||
<ScrollArea className="flex-1 p-4">
|
<span className="text-sm font-medium">Question Preview</span>
|
||||||
<ExamViewer
|
</div>
|
||||||
structure={structure}
|
<ScrollArea className="flex-1 bg-background">
|
||||||
questions={questions}
|
<div className="p-6">
|
||||||
selectedQuestionId={selectedQuestionId}
|
<ExamViewer
|
||||||
onQuestionSelect={onQuestionSelect}
|
structure={structure}
|
||||||
/>
|
questions={questions}
|
||||||
|
selectedQuestionId={selectedQuestionId}
|
||||||
|
onQuestionSelect={onQuestionSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,59 +92,63 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
|||||||
const errorRate = selected?.errorRate ?? 0
|
const errorRate = selected?.errorRate ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||||
<div className="border-b px-4 py-3">
|
<div className="border-b px-6 py-4 bg-muted/5">
|
||||||
<div className="text-sm font-medium">错题详情</div>
|
<div className="text-sm font-medium">Error Analysis</div>
|
||||||
{selected ? (
|
|
||||||
<div className="mt-2 flex items-center gap-3">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<ErrorRatePieChart errorRate={errorRate} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 grid gap-1 text-xs text-muted-foreground">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>错误人数</span>
|
|
||||||
<span className="tabular-nums text-foreground">{errorCount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>错误率</span>
|
|
||||||
<span className="tabular-nums text-foreground">{(errorRate * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>统计样本</span>
|
|
||||||
<span className="tabular-nums text-foreground">{gradedSampleCount}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">请选择左侧题目</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="p-6 space-y-6">
|
||||||
<ScrollArea className="h-full p-4">
|
{selected ? (
|
||||||
{!selected ? (
|
<>
|
||||||
<div className="text-sm text-muted-foreground">暂无数据</div>
|
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||||
) : wrongAnswers.length === 0 ? (
|
<div className="shrink-0">
|
||||||
<div className="text-sm text-muted-foreground">暂无错误答案</div>
|
<ErrorRatePieChart errorRate={errorRate} />
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 flex-1 grid gap-1">
|
||||||
<div className="text-xs text-muted-foreground">错误答案列表(可滚动)</div>
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="space-y-2">
|
<span className="text-muted-foreground">Question</span>
|
||||||
{wrongAnswers.map((item, idx) => (
|
<span className="font-medium">Q{selected.questionId.slice(-4)}</span>
|
||||||
<div key={`${selected.questionId}-${idx}`} className="rounded-md border bg-muted/20 px-3 py-2">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-24 shrink-0 text-xs text-muted-foreground truncate">{item.studentName}</div>
|
|
||||||
<div className="min-w-0 flex-1 text-sm wrap-break-word whitespace-pre-wrap">
|
|
||||||
{formatAnswer(item.answerContent, selected)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Errors</span>
|
||||||
|
<span className="font-medium text-destructive">
|
||||||
|
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers ({wrongAnswers.length})</div>
|
||||||
|
{wrongAnswers.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
|
||||||
|
No wrong answers recorded.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{wrongAnswers.map((wa, i) => (
|
||||||
|
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-destructive break-words">
|
||||||
|
{formatAnswer(wa.answerContent, selected)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
|
||||||
|
<p>Select a question from the left</p>
|
||||||
|
<p className="text-xs mt-1">to view error analysis</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
|
||||||
|
|
||||||
export function HomeworkAssignmentQuestionErrorDetailsCard({
|
|
||||||
questions,
|
|
||||||
gradedSampleCount,
|
|
||||||
}: {
|
|
||||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
|
||||||
gradedSampleCount: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="md:col-span-1">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Details</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{questions.length === 0 || gradedSampleCount === 0 ? (
|
|
||||||
<div className="p-4 text-sm text-muted-foreground">No data available.</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="h-72">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[70px]">Question</TableHead>
|
|
||||||
<TableHead className="text-right">Error Count</TableHead>
|
|
||||||
<TableHead className="text-right">Error Rate</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{questions.map((q, index) => (
|
|
||||||
<TableRow key={q.questionId}>
|
|
||||||
<TableCell className="text-sm">
|
|
||||||
<div className="font-medium">Q{index + 1}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-sm tabular-nums">{q.errorCount}</TableCell>
|
|
||||||
<TableCell className="text-right text-sm tabular-nums">{(q.errorRate * 100).toFixed(1)}%</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,103 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||||
function ErrorRateChart({
|
|
||||||
questions,
|
|
||||||
gradedSampleCount,
|
|
||||||
}: {
|
|
||||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
|
||||||
gradedSampleCount: number
|
|
||||||
}) {
|
|
||||||
const w = 100
|
|
||||||
const h = 60
|
|
||||||
const padL = 10
|
|
||||||
const padR = 3
|
|
||||||
const padT = 4
|
|
||||||
const padB = 10
|
|
||||||
const plotW = w - padL - padR
|
|
||||||
const plotH = h - padT - padB
|
|
||||||
const n = questions.length
|
|
||||||
|
|
||||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
|
||||||
const xFor = (i: number) => padL + (n <= 1 ? 0 : (i / (n - 1)) * plotW)
|
|
||||||
const yFor = (rate: number) => padT + (1 - clamp01(rate)) * plotH
|
|
||||||
|
|
||||||
const points = questions.map((q, i) => `${xFor(i)},${yFor(q.errorRate)}`).join(" ")
|
|
||||||
const areaD =
|
|
||||||
n === 0
|
|
||||||
? ""
|
|
||||||
: `M ${padL} ${padT + plotH} L ${points.split(" ").join(" L ")} L ${padL + plotW} ${padT + plotH} Z`
|
|
||||||
|
|
||||||
const gridYs = [
|
|
||||||
{ v: 1, label: "100%" },
|
|
||||||
{ v: 0.5, label: "50%" },
|
|
||||||
{ v: 0, label: "0%" },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
|
|
||||||
{gridYs.map((g) => {
|
|
||||||
const y = yFor(g.v)
|
|
||||||
return (
|
|
||||||
<g key={g.label}>
|
|
||||||
<line x1={padL} y1={y} x2={padL + plotW} y2={y} className="stroke-border" strokeWidth={0.5} />
|
|
||||||
<text x={2} y={y + 1.2} className="fill-muted-foreground text-[3px]">
|
|
||||||
{g.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} className="stroke-border" strokeWidth={0.7} />
|
|
||||||
<line
|
|
||||||
x1={padL}
|
|
||||||
y1={padT + plotH}
|
|
||||||
x2={padL + plotW}
|
|
||||||
y2={padT + plotH}
|
|
||||||
className="stroke-border"
|
|
||||||
strokeWidth={0.7}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{n >= 2 ? <path d={areaD} className="fill-primary/10" /> : null}
|
|
||||||
<polyline
|
|
||||||
points={points}
|
|
||||||
fill="none"
|
|
||||||
className="stroke-primary"
|
|
||||||
strokeWidth={1.2}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{questions.map((q, i) => {
|
|
||||||
const cx = xFor(i)
|
|
||||||
const cy = yFor(q.errorRate)
|
|
||||||
const label = `Q${i + 1}`
|
|
||||||
return (
|
|
||||||
<g key={q.questionId}>
|
|
||||||
<circle cx={cx} cy={cy} r={1.2} className="fill-primary" />
|
|
||||||
<title>{`${label}: ${(q.errorRate * 100).toFixed(1)}% (${q.errorCount} / ${gradedSampleCount})`}</title>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{questions.map((q, i) => {
|
|
||||||
if (n > 12 && i % 2 === 1) return null
|
|
||||||
const x = xFor(i)
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={`x-${q.questionId}`}
|
|
||||||
x={x}
|
|
||||||
y={h - 2}
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-muted-foreground text-[3px]"
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeworkAssignmentQuestionErrorOverviewCard({
|
export function HomeworkAssignmentQuestionErrorOverviewCard({
|
||||||
questions,
|
questions,
|
||||||
@@ -106,26 +11,78 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
|
|||||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
questions: HomeworkAssignmentQuestionAnalytics[]
|
||||||
gradedSampleCount: number
|
gradedSampleCount: number
|
||||||
}) {
|
}) {
|
||||||
|
const data = questions.map((q, index) => ({
|
||||||
|
name: `Q${index + 1}`,
|
||||||
|
errorRate: q.errorRate * 100,
|
||||||
|
errorCount: q.errorCount,
|
||||||
|
total: gradedSampleCount,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="md:col-span-1">
|
<Card className="md:col-span-1">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Overview</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Error Rate Overview</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="h-72">
|
||||||
{questions.length === 0 || gradedSampleCount === 0 ? (
|
{questions.length === 0 || gradedSampleCount === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
No graded submissions yet. Error analytics will appear here after grading.
|
No graded submissions yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
<span>Graded students</span>
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
<span className="font-medium text-foreground">{gradedSampleCount}</span>
|
<XAxis
|
||||||
</div>
|
dataKey="name"
|
||||||
<div className="h-56 rounded-md border bg-muted/40 px-3 py-2">
|
tickLine={false}
|
||||||
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} />
|
axisLine={false}
|
||||||
</div>
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
</div>
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "hsl(var(--muted)/0.2)" }}
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const d = payload[0].payload
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[0.70rem] uppercase text-muted-foreground">Question</span>
|
||||||
|
<span className="font-bold text-muted-foreground">{d.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[0.70rem] uppercase text-muted-foreground">Error Rate</span>
|
||||||
|
<span className="font-bold">{d.errorRate.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[0.70rem] uppercase text-muted-foreground">Errors</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{d.errorCount} / {d.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="errorRate"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={40}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,10 +3,20 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Check, MessageSquarePlus, X } from "lucide-react"
|
import {
|
||||||
|
Check,
|
||||||
|
MessageSquarePlus,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Save,
|
||||||
|
User,
|
||||||
|
AlertCircle,
|
||||||
|
Clock
|
||||||
|
} from "lucide-react"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
@@ -39,20 +49,48 @@ type HomeworkGradingViewProps = {
|
|||||||
status: string
|
status: string
|
||||||
totalScore: number | null
|
totalScore: number | null
|
||||||
answers: Answer[]
|
answers: Answer[]
|
||||||
|
prevSubmissionId?: string | null
|
||||||
|
nextSubmissionId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeworkGradingView({
|
export function HomeworkGradingView({
|
||||||
submissionId,
|
submissionId,
|
||||||
answers: initialAnswers,
|
answers: initialAnswers,
|
||||||
|
prevSubmissionId,
|
||||||
|
nextSubmissionId,
|
||||||
|
studentName,
|
||||||
|
assignmentTitle,
|
||||||
|
submittedAt,
|
||||||
}: HomeworkGradingViewProps) {
|
}: HomeworkGradingViewProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>({})
|
|
||||||
|
// Initialize feedback visibility for answers that already have feedback
|
||||||
|
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>(() => {
|
||||||
|
const initialVisibility: Record<string, boolean> = {}
|
||||||
|
if (initialAnswers) {
|
||||||
|
initialAnswers.forEach(a => {
|
||||||
|
if (a.feedback && a.feedback.trim().length > 0) {
|
||||||
|
initialVisibility[a.id] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return initialVisibility
|
||||||
|
})
|
||||||
|
|
||||||
const handleManualScoreChange = (id: string, val: string) => {
|
const handleManualScoreChange = (id: string, val: string) => {
|
||||||
const parsed = val === "" ? 0 : Number(val)
|
const parsed = val === "" ? 0 : Number(val)
|
||||||
const nextScore = Number.isFinite(parsed) ? parsed : 0
|
// Clamp score between 0 and maxScore? Or allow extra credit?
|
||||||
|
// Usually maxScore is the limit, but let's just ensure it's a number.
|
||||||
|
// Ideally we should clamp it to [0, maxScore] to avoid errors, but sometimes teachers want to give 0 for invalid input.
|
||||||
|
const targetAnswer = answers.find(a => a.id === id)
|
||||||
|
const max = targetAnswer?.maxScore ?? 100
|
||||||
|
|
||||||
|
let nextScore = Number.isFinite(parsed) ? parsed : 0
|
||||||
|
if (nextScore > max) nextScore = max
|
||||||
|
if (nextScore < 0) nextScore = 0
|
||||||
|
|
||||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +107,12 @@ export function HomeworkGradingView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
||||||
const binaryAnswers = answers.filter(shouldUseBinaryGrading)
|
const maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
|
||||||
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 0
|
||||||
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
|
||||||
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount
|
const correctCount = answers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
||||||
|
const incorrectCount = answers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
||||||
|
const partialCount = answers.reduce((sum, a) => sum + (a.score !== null && a.score > 0 && a.score < a.maxScore ? 1 : 0), 0)
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
@@ -89,177 +129,357 @@ export function HomeworkGradingView({
|
|||||||
const result = await gradeHomeworkSubmissionAction(null, formData)
|
const result = await gradeHomeworkSubmissionAction(null, formData)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Grading saved")
|
toast.success("Grading saved successfully")
|
||||||
router.push("/teacher/homework/submissions")
|
// Optionally redirect or stay
|
||||||
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to save")
|
toast.error(result.message || "Failed to save grading")
|
||||||
}
|
}
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScrollToQuestion = (id: string) => {
|
||||||
|
const el = document.getElementById(`question-card-${id}`)
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
{/* Main Content: Questions List */}
|
||||||
<div className="border-b p-4">
|
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
|
||||||
<h3 className="font-semibold">Student Response</h3>
|
<ScrollArea className="flex-1 p-4 lg:p-8">
|
||||||
</div>
|
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
||||||
<ScrollArea className="flex-1 p-4">
|
|
||||||
<div className="space-y-8">
|
|
||||||
{answers.map((ans, index) => (
|
{answers.map((ans, index) => (
|
||||||
<div key={ans.id} className="space-y-4">
|
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${
|
||||||
<div className="flex items-start justify-between">
|
ans.score === ans.maxScore ? "border-l-4 border-l-emerald-500" :
|
||||||
<div className="space-y-1">
|
ans.score === 0 && ans.maxScore > 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted"
|
||||||
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span>
|
}`}>
|
||||||
<div className="text-sm">{ans.questionContent?.text}</div>
|
<CardHeader className="bg-card pb-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="h-6 w-6 shrink-0 justify-center rounded-full p-0">
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{ans.questionType.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
{isAutoGradable(ans) && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">Auto-graded</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-medium leading-relaxed pt-2">
|
||||||
|
{ans.questionContent?.text || "No question text"}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="outline" className="whitespace-nowrap">
|
||||||
|
{ans.score ?? 0} / {ans.maxScore} pts
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">Max: {ans.maxScore}</Badge>
|
</CardHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md bg-muted/50 p-4">
|
|
||||||
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{formatStudentAnswer(ans.studentAnswer)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
|
||||||
|
<CardContent className="bg-card/50 p-6 space-y-6">
|
||||||
|
{/* Student Answer Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<User className="h-3 w-3" /> Student Answer
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-background p-4 shadow-sm">
|
||||||
|
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
|
||||||
|
Array.isArray(ans.questionContent?.options) ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(ans.questionContent.options as ChoiceOption[]).map((opt: ChoiceOption) => {
|
||||||
|
const isSelected = Array.isArray(extractAnswerValue(ans.studentAnswer))
|
||||||
|
? (extractAnswerValue(ans.studentAnswer) as string[]).includes(opt.id as string)
|
||||||
|
: extractAnswerValue(ans.studentAnswer) === opt.id
|
||||||
|
|
||||||
|
const isCorrect = opt.isCorrect === true
|
||||||
|
|
||||||
|
// Visual logic:
|
||||||
|
// If selected and correct -> Green + Check
|
||||||
|
// If selected and wrong -> Red + X
|
||||||
|
// If not selected but correct -> Green outline (show missed correct answer)
|
||||||
|
|
||||||
|
let containerClass = "border-transparent hover:bg-muted/50"
|
||||||
|
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
if (isCorrect) {
|
||||||
|
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
|
||||||
|
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
|
||||||
|
} else {
|
||||||
|
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
|
||||||
|
indicatorClass = "border-red-500 bg-red-500 text-white"
|
||||||
|
}
|
||||||
|
} else if (isCorrect) {
|
||||||
|
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
|
||||||
|
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.id as string}
|
||||||
|
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
|
||||||
|
>
|
||||||
|
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
|
||||||
|
{opt.id as string}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1">{opt.text}</span>
|
||||||
|
{isCorrect && <Check className="h-4 w-4 text-emerald-600" />}
|
||||||
|
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{formatStudentAnswer(ans.studentAnswer)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference Answer (for text/non-choice questions) */}
|
||||||
|
{ans.questionType === "text" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<Check className="h-3 w-3" /> Reference Answer
|
||||||
|
</Label>
|
||||||
|
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
|
||||||
|
{getTextCorrectAnswers(ans.questionContent).join(" / ") || "No reference answer provided."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between w-full gap-4">
|
||||||
|
{/* Grading Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={getCorrectnessState(ans) === "correct" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={getCorrectnessState(ans) === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
|
||||||
|
onClick={() => handleMarkCorrect(ans.id)}
|
||||||
|
>
|
||||||
|
<Check className="mr-1 h-4 w-4" /> Correct
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={getCorrectnessState(ans) === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
|
||||||
|
onClick={() => handleMarkIncorrect(ans.id)}
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-4 w-4" /> Incorrect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">Score:</Label>
|
||||||
|
<Input
|
||||||
|
id={`score-${ans.id}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={ans.maxScore}
|
||||||
|
className="w-20 h-8"
|
||||||
|
value={ans.score ?? ""}
|
||||||
|
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
|
||||||
|
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||||
|
{showFeedbackByAnswerId[ans.id] ? "Hide Feedback" : "Add Feedback"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Textarea */}
|
||||||
|
{showFeedbackByAnswerId[ans.id] && (
|
||||||
|
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<Textarea
|
||||||
|
placeholder={`Provide feedback for ${studentName}...`}
|
||||||
|
value={ans.feedback ?? ""}
|
||||||
|
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
||||||
|
className="min-h-[80px] bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
{/* Sidebar: Summary & Actions */}
|
||||||
<div className="border-b p-4">
|
<div className="lg:col-span-3 h-full flex flex-col gap-6">
|
||||||
<h3 className="font-semibold">Grading</h3>
|
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
|
||||||
<div className="mt-2 flex items-center justify-between text-sm">
|
<CardHeader className="pb-2">
|
||||||
<span className="text-muted-foreground">Total Score</span>
|
<CardTitle className="text-lg">Grading Summary</CardTitle>
|
||||||
<span className="font-bold text-lg text-primary">{currentTotal}</span>
|
<CardDescription>{assignmentTitle}</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
{binaryAnswers.length > 0 ? (
|
<CardContent className="space-y-6">
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="space-y-1">
|
||||||
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700">
|
<div className="flex items-center justify-between text-sm">
|
||||||
Correct {correctCount}
|
<span className="text-muted-foreground">Total Score</span>
|
||||||
</Badge>
|
<span className="font-bold">{currentTotal} / {maxTotal}</span>
|
||||||
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700">
|
</div>
|
||||||
Incorrect {incorrectCount}
|
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
</Badge>
|
<div
|
||||||
{ungradedCount > 0 ? (
|
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||||
<Badge variant="outline" className="text-muted-foreground">
|
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||||
Ungraded {ungradedCount}
|
/>
|
||||||
</Badge>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
<div className="space-y-3 pt-2">
|
||||||
<div className="space-y-6">
|
<div className="flex items-center justify-between text-sm">
|
||||||
{answers.map((ans, index) => (
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Card key={ans.id} className="border-l-4 border-l-primary/20">
|
<User className="h-4 w-4" /> Student
|
||||||
<CardHeader className="py-3 px-4">
|
</span>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<span className="font-medium">{studentName}</span>
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
</div>
|
||||||
<span>Q{index + 1}</span>
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span>
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
{shouldUseBinaryGrading(ans) ? (
|
<Clock className="h-4 w-4" /> Submitted
|
||||||
<Badge
|
</span>
|
||||||
variant="outline"
|
<span className="font-medium">
|
||||||
className={getCorrectnessBadgeClassName(ans)}
|
{submittedAt ? new Date(submittedAt).toLocaleDateString() : "N/A"}
|
||||||
>
|
</span>
|
||||||
{getCorrectnessLabel(ans)}
|
</div>
|
||||||
</Badge>
|
</div>
|
||||||
) : null}
|
|
||||||
</CardTitle>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{answers.length > 0 && (
|
||||||
{shouldUseBinaryGrading(ans) ? (
|
<div className="space-y-4 pt-2">
|
||||||
<>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Button
|
<div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
|
||||||
|
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Correct</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
|
||||||
|
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Incorrect</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
|
||||||
|
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Partial</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
|
||||||
|
Question Status
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{answers.map((ans, i) => {
|
||||||
|
const state = getCorrectnessState(ans)
|
||||||
|
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
|
||||||
|
|
||||||
|
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
|
||||||
|
else if (state === "incorrect") badgeClass = "border-red-200 bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:border-red-800 dark:text-red-400"
|
||||||
|
else if (state === "partial") badgeClass = "border-amber-200 bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-400"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ans.id}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
onClick={() => handleScrollToQuestion(ans.id)}
|
||||||
size="icon"
|
className={`flex h-8 items-center justify-center rounded border text-xs font-medium transition-colors cursor-pointer hover:ring-2 hover:ring-ring hover:ring-offset-2 ${badgeClass}`}
|
||||||
aria-label="mark correct"
|
title={`Q${i + 1}: ${state}`}
|
||||||
className={getMarkCorrectButtonClassName(ans)}
|
|
||||||
onClick={() => handleMarkCorrect(ans.id)}
|
|
||||||
>
|
>
|
||||||
<Check />
|
{i + 1}
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
)
|
||||||
type="button"
|
})}
|
||||||
variant="outline"
|
</div>
|
||||||
size="icon"
|
</div>
|
||||||
aria-label="mark incorrect"
|
</div>
|
||||||
className={getMarkIncorrectButtonClassName(ans)}
|
)}
|
||||||
onClick={() => handleMarkIncorrect(ans.id)}
|
</CardContent>
|
||||||
>
|
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||||
<X />
|
<Button
|
||||||
</Button>
|
className="w-full"
|
||||||
</>
|
size="lg"
|
||||||
) : null}
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>Saving...</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" /> Submit Grades
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Tooltip>
|
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
aria-label="add feedback"
|
className="flex-1"
|
||||||
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)}
|
disabled={!prevSubmissionId}
|
||||||
onClick={() =>
|
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
|
||||||
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) }))
|
>
|
||||||
}
|
<ChevronLeft className="mr-1 h-4 w-4" /> Prev
|
||||||
>
|
</Button>
|
||||||
<MessageSquarePlus />
|
</TooltipTrigger>
|
||||||
</Button>
|
<TooltipContent>Previous Student</TooltipContent>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
<TooltipContent>add feedback</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="py-3 px-4 space-y-3">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{!shouldUseBinaryGrading(ans) ? (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`score-${ans.id}`}>Score</Label>
|
|
||||||
<Input
|
|
||||||
id={`score-${ans.id}`}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={ans.maxScore}
|
|
||||||
value={ans.score ?? ""}
|
|
||||||
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
|
|
||||||
<Textarea
|
|
||||||
id={`fb-${ans.id}`}
|
|
||||||
placeholder="Optional feedback..."
|
|
||||||
className="min-h-[60px] resize-none"
|
|
||||||
value={ans.feedback ?? ""}
|
|
||||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="border-t p-4 bg-muted/20">
|
<Tooltip>
|
||||||
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitting}>
|
<TooltipTrigger asChild>
|
||||||
{isSubmitting ? "Saving..." : "Submit Grades"}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!nextSubmissionId}
|
||||||
|
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
|
||||||
|
>
|
||||||
|
Next <ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Next Student</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-950/30 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChoiceOption = { id?: unknown; isCorrect?: unknown }
|
type ChoiceOption = { id?: unknown; isCorrect?: unknown; text?: string }
|
||||||
|
|
||||||
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
|
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
|
||||||
|
|
||||||
@@ -295,14 +515,6 @@ const getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | nu
|
|||||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldUseBinaryGrading = (ans: Answer): boolean => {
|
|
||||||
if (ans.questionType === "single_choice") return true
|
|
||||||
if (ans.questionType === "multiple_choice") return true
|
|
||||||
if (ans.questionType === "judgment") return true
|
|
||||||
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAutoGradable = (ans: Answer): boolean => {
|
const isAutoGradable = (ans: Answer): boolean => {
|
||||||
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
|
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
|
||||||
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
|
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
|
||||||
@@ -370,39 +582,6 @@ const getCorrectnessState = (ans: Answer): CorrectnessState => {
|
|||||||
return "partial"
|
return "partial"
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCorrectnessLabel = (ans: Answer): string => {
|
|
||||||
const s = getCorrectnessState(ans)
|
|
||||||
if (s === "correct") return "Correct"
|
|
||||||
if (s === "incorrect") return "Incorrect"
|
|
||||||
if (s === "partial") return "Partial"
|
|
||||||
return "Ungraded"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCorrectnessBadgeClassName = (ans: Answer): string => {
|
|
||||||
const s = getCorrectnessState(ans)
|
|
||||||
if (s === "correct") return "border-emerald-200 bg-emerald-50 text-emerald-700"
|
|
||||||
if (s === "incorrect") return "border-red-200 bg-red-50 text-red-700"
|
|
||||||
if (s === "partial") return "border-amber-200 bg-amber-50 text-amber-800"
|
|
||||||
return "text-muted-foreground"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMarkCorrectButtonClassName = (ans: Answer): string => {
|
|
||||||
const active = getCorrectnessState(ans) === "correct"
|
|
||||||
return active ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "text-muted-foreground"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMarkIncorrectButtonClassName = (ans: Answer): string => {
|
|
||||||
const active = getCorrectnessState(ans) === "incorrect"
|
|
||||||
return active ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-100" : "text-muted-foreground"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFeedbackIconButtonClassName = (ans: Answer, isOpen: boolean): string => {
|
|
||||||
const hasFeedback = typeof ans.feedback === "string" && ans.feedback.trim().length > 0
|
|
||||||
if (isOpen) return "text-primary"
|
|
||||||
if (hasFeedback) return "text-primary/80"
|
|
||||||
return "text-muted-foreground"
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatStudentAnswer = (studentAnswer: unknown): string => {
|
const formatStudentAnswer = (studentAnswer: unknown): string => {
|
||||||
const v = extractAnswerValue(studentAnswer)
|
const v = extractAnswerValue(studentAnswer)
|
||||||
if (typeof v === "string") return v
|
if (typeof v === "string") return v
|
||||||
|
|||||||
@@ -3,22 +3,17 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
import {
|
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select"
|
|
||||||
|
|
||||||
import type { StudentHomeworkTakeData } from "../types"
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
||||||
@@ -87,6 +82,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
|
|
||||||
const isStarted = submissionStatus === "started"
|
const isStarted = submissionStatus === "started"
|
||||||
const canEdit = isStarted && Boolean(submissionId)
|
const canEdit = isStarted && Boolean(submissionId)
|
||||||
|
const showQuestions = submissionStatus !== "not_started"
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
@@ -106,7 +102,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
|
|
||||||
const handleSaveQuestion = async (questionId: string) => {
|
const handleSaveQuestion = async (questionId: string) => {
|
||||||
if (!submissionId) return
|
if (!submissionId) return
|
||||||
setIsBusy(true)
|
// setIsBusy(true) // Don't block UI for individual saves
|
||||||
const payload = answersByQuestionId[questionId]?.answer ?? null
|
const payload = answersByQuestionId[questionId]?.answer ?? null
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("submissionId", submissionId)
|
fd.set("submissionId", submissionId)
|
||||||
@@ -115,12 +111,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
const res = await saveHomeworkAnswerAction(null, fd)
|
const res = await saveHomeworkAnswerAction(null, fd)
|
||||||
if (res.success) toast.success("Saved")
|
if (res.success) toast.success("Saved")
|
||||||
else toast.error(res.message || "Failed to save")
|
else toast.error(res.message || "Failed to save")
|
||||||
setIsBusy(false)
|
// setIsBusy(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!submissionId) return
|
if (!submissionId) return
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
|
// Save all first
|
||||||
for (const q of initialData.questions) {
|
for (const q of initialData.questions) {
|
||||||
const payload = answersByQuestionId[q.questionId]?.answer ?? null
|
const payload = answersByQuestionId[q.questionId]?.answer ?? null
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
@@ -149,50 +146,86 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
<div className="border-b p-4 flex items-center justify-between">
|
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="font-semibold">Questions</h3>
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||||
<Badge variant="outline" className="capitalize">
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
{submissionStatus === "not_started" ? "not started" : submissionStatus}
|
</div>
|
||||||
</Badge>
|
<div>
|
||||||
|
<h3 className="font-semibold leading-none">Questions</h3>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
|
||||||
|
{submissionStatus === "not_started" ? "Not Started" : submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{initialData.questions.length} Questions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!canEdit ? (
|
{!canEdit ? (
|
||||||
<Button onClick={handleStart} disabled={isBusy}>
|
<Button onClick={handleStart} disabled={isBusy} size="sm">
|
||||||
{isBusy ? "Starting..." : "Start"}
|
{isBusy ? "Starting..." : "Start Assignment"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={handleSubmit} disabled={isBusy}>
|
<div className="flex items-center gap-2">
|
||||||
{isBusy ? "Submitting..." : "Submit"}
|
<span className="text-xs text-muted-foreground hidden sm:inline-block">
|
||||||
</Button>
|
Auto-saving enabled
|
||||||
|
</span>
|
||||||
|
<Button onClick={handleSubmit} disabled={isBusy} size="sm">
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
{isBusy ? "Submitting..." : "Submit Assignment"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
<ScrollArea className="flex-1 bg-muted/10">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||||
{initialData.questions.map((q, idx) => {
|
{!isStarted && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||||
|
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium">Ready to start?</h3>
|
||||||
|
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
|
||||||
|
Click the "Start Assignment" button above to begin. The timer will start once you confirm.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleStart} disabled={isBusy}>
|
||||||
|
Start Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showQuestions && initialData.questions.map((q, idx) => {
|
||||||
const text = getQuestionText(q.questionContent)
|
const text = getQuestionText(q.questionContent)
|
||||||
const options = getOptions(q.questionContent)
|
const options = getOptions(q.questionContent)
|
||||||
const value = answersByQuestionId[q.questionId]?.answer
|
const value = answersByQuestionId[q.questionId]?.answer
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={q.questionId} className="border-l-4 border-l-primary/20">
|
<Card key={q.questionId} className="border-l-4 border-l-primary shadow-sm">
|
||||||
<CardHeader className="py-3 px-4">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<span>
|
<div className="space-y-1">
|
||||||
Q{idx + 1} <span className="text-muted-foreground font-normal">({q.questionType})</span>
|
<CardTitle className="text-base font-medium">
|
||||||
</span>
|
Question {idx + 1}
|
||||||
<span className="text-xs text-muted-foreground">Max: {q.maxScore}</span>
|
</CardTitle>
|
||||||
</CardTitle>
|
<CardDescription className="text-xs">
|
||||||
|
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="py-3 px-4 space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="text-sm">{text || "—"}</div>
|
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||||
|
|
||||||
{q.questionType === "text" ? (
|
{q.questionType === "text" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Your answer</Label>
|
<Label className="sr-only">Your answer</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
placeholder="Type your answer here..."
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setAnswersByQuestionId((prev) => ({
|
setAnswersByQuestionId((prev) => ({
|
||||||
@@ -200,14 +233,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
[q.questionId]: { answer: e.target.value },
|
[q.questionId]: { answer: e.target.value },
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="min-h-[100px]"
|
className="min-h-[120px] resize-y"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : q.questionType === "judgment" ? (
|
) : q.questionType === "judgment" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Your answer</Label>
|
<RadioGroup
|
||||||
<Select
|
|
||||||
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setAnswersByQuestionId((prev) => ({
|
setAnswersByQuestionId((prev) => ({
|
||||||
@@ -216,20 +248,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||||
<SelectValue placeholder="Select" />
|
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||||
</SelectTrigger>
|
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">True</Label>
|
||||||
<SelectContent>
|
</div>
|
||||||
<SelectItem value="true">True</SelectItem>
|
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||||
<SelectItem value="false">False</SelectItem>
|
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||||
</SelectContent>
|
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">False</Label>
|
||||||
</Select>
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
) : q.questionType === "single_choice" ? (
|
) : q.questionType === "single_choice" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Your answer</Label>
|
<RadioGroup
|
||||||
<Select
|
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setAnswersByQuestionId((prev) => ({
|
setAnswersByQuestionId((prev) => ({
|
||||||
@@ -238,28 +271,27 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
{options.map((o) => (
|
||||||
<SelectValue placeholder="Select an option" />
|
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||||
</SelectTrigger>
|
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||||
<SelectContent>
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
|
||||||
{options.map((o) => (
|
|
||||||
<SelectItem key={o.id} value={o.id}>
|
|
||||||
{o.text}
|
{o.text}
|
||||||
</SelectItem>
|
</Label>
|
||||||
))}
|
</div>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
) : q.questionType === "multiple_choice" ? (
|
) : q.questionType === "multiple_choice" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Your answer</Label>
|
<div className="flex flex-col gap-2">
|
||||||
<div className="space-y-2">
|
|
||||||
{options.map((o) => {
|
{options.map((o) => {
|
||||||
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||||
return (
|
return (
|
||||||
<label key={o.id} className="flex items-start gap-3 rounded-md border p-3">
|
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
id={`${q.questionId}-${o.id}`}
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
const isChecked = checked === true
|
const isChecked = checked === true
|
||||||
@@ -275,30 +307,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
}}
|
}}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">{o.text}</span>
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
|
||||||
</label>
|
{o.text}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">Unsupported question type</div>
|
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submissionStatus === "graded" && (
|
||||||
|
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||||
|
{q.feedback ? (
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<div className="font-medium text-foreground">Teacher Feedback</div>
|
||||||
|
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||||
|
{q.feedback}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<>
|
<div className="flex justify-end pt-2">
|
||||||
<Separator />
|
<Button
|
||||||
<div className="flex justify-end">
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
onClick={() => handleSaveQuestion(q.questionId)}
|
||||||
size="sm"
|
disabled={isBusy}
|
||||||
onClick={() => handleSaveQuestion(q.questionId)}
|
className="text-muted-foreground hover:text-foreground"
|
||||||
disabled={isBusy}
|
>
|
||||||
>
|
<Save className="mr-2 h-3 w-3" />
|
||||||
Save
|
Save Answer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -308,38 +356,66 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
<div className="border-b p-4">
|
<div className="border-b p-4 bg-muted/30">
|
||||||
<h3 className="font-semibold">Info</h3>
|
<h3 className="font-semibold">Assignment Info</h3>
|
||||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex-1 p-4 overflow-y-auto">
|
||||||
<span>Status</span>
|
<div className="space-y-6">
|
||||||
<span className="capitalize text-foreground">{submissionStatus === "not_started" ? "not started" : submissionStatus}</span>
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
|
||||||
|
{submissionStatus === "not_started" ? "not started" : submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Questions</span>
|
<div>
|
||||||
<span className="text-foreground tabular-nums">{initialData.questions.length}</span>
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{initialData.assignment.description || "No description provided."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showQuestions && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Progress</Label>
|
||||||
|
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||||
|
{initialData.questions.map((q, i) => {
|
||||||
|
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||||
|
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||||
|
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.questionId}
|
||||||
|
className={`
|
||||||
|
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
|
||||||
|
${hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="space-y-3 text-sm">
|
{canEdit && (
|
||||||
<div className="text-muted-foreground">{initialData.assignment.description || "—"}</div>
|
<div className="border-t p-4 bg-muted/20">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t p-4 bg-muted/20">
|
|
||||||
{canEdit ? (
|
|
||||||
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
||||||
{isBusy ? "Submitting..." : "Submit"}
|
{isBusy ? "Submitting..." : "Submit All"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
<p className="mt-2 text-xs text-center text-muted-foreground">
|
||||||
<Button className="w-full" onClick={handleStart} disabled={isBusy}>
|
Make sure you have answered all questions.
|
||||||
{isBusy ? "Starting..." : "Start"}
|
</p>
|
||||||
</Button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
320
src/modules/homework/components/student-homework-review-view.tsx
Normal file
320
src/modules/homework/components/student-homework-review-view.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
import { CheckCircle2, FileText, ChevronLeft } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||||
|
|
||||||
|
type Option = { id: string; text: string }
|
||||||
|
|
||||||
|
const getQuestionText = (content: unknown): string => {
|
||||||
|
if (!isRecord(content)) return ""
|
||||||
|
return typeof content.text === "string" ? content.text : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOptions = (content: unknown): Option[] => {
|
||||||
|
if (!isRecord(content)) return []
|
||||||
|
const raw = content.options
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const out: Option[] = []
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!isRecord(item)) continue
|
||||||
|
const id = typeof item.id === "string" ? item.id : ""
|
||||||
|
const text = typeof item.text === "string" ? item.text : ""
|
||||||
|
if (!id || !text) continue
|
||||||
|
out.push({ id, text })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const toAnswerShape = (questionType: string, v: unknown) => {
|
||||||
|
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
||||||
|
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
||||||
|
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
||||||
|
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
|
||||||
|
return { answer: v }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSavedAnswer = (saved: unknown, questionType: string) => {
|
||||||
|
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
|
||||||
|
return toAnswerShape(questionType, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeworkReviewViewProps = {
|
||||||
|
initialData: StudentHomeworkTakeData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||||
|
const submissionStatus = initialData.submission?.status ?? "not_started"
|
||||||
|
const isGraded = submissionStatus === "graded"
|
||||||
|
const isSubmitted = submissionStatus === "submitted"
|
||||||
|
|
||||||
|
const answersByQuestionId = useMemo(() => {
|
||||||
|
const map = new Map<string, { answer: unknown }>()
|
||||||
|
for (const q of initialData.questions) {
|
||||||
|
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
|
||||||
|
}
|
||||||
|
const obj: Record<string, { answer: unknown }> = {}
|
||||||
|
for (const [k, v] of map.entries()) obj[k] = v
|
||||||
|
return obj
|
||||||
|
}, [initialData.questions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
|
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold leading-none">
|
||||||
|
{isGraded ? "Graded Report" : "Submission Details"}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] capitalize">
|
||||||
|
{submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{initialData.questions.length} Questions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/student/learning/assignments">
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to List
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 bg-muted/10">
|
||||||
|
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||||
|
{initialData.questions.map((q, idx) => {
|
||||||
|
const text = getQuestionText(q.questionContent)
|
||||||
|
const options = getOptions(q.questionContent)
|
||||||
|
const value = answersByQuestionId[q.questionId]?.answer
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={q.questionId} className={`shadow-sm ${isGraded ? 'border-l-4' : 'border-l-4 border-l-primary'}`}
|
||||||
|
style={isGraded ? { borderLeftColor: q.score === q.maxScore && q.maxScore > 0 ? '#10b981' : q.score && q.score > 0 ? '#eab308' : '#ef4444' } : undefined}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
Question {idx + 1}
|
||||||
|
{isGraded && (
|
||||||
|
<Badge variant="outline" className={`ml-2 ${q.score === q.maxScore ? "text-emerald-600 border-emerald-200 bg-emerald-50" : "text-red-600 border-red-200 bg-red-50"}`}>
|
||||||
|
{q.score} / {q.maxScore}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs flex flex-col gap-1.5">
|
||||||
|
<span>{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points</span>
|
||||||
|
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{q.knowledgePoints.map((kp) => (
|
||||||
|
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
|
||||||
|
{kp.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||||
|
|
||||||
|
{q.questionType === "text" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="sr-only">Your answer</Label>
|
||||||
|
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
|
||||||
|
{typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : q.questionType === "judgment" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<RadioGroup
|
||||||
|
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||||
|
disabled
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||||
|
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||||
|
<Label htmlFor={`${q.questionId}-true`} className="flex-1 font-normal">True</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||||
|
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||||
|
<Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
) : q.questionType === "single_choice" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<RadioGroup
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
disabled
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||||
|
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||||
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
|
||||||
|
{o.text}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
) : q.questionType === "multiple_choice" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{options.map((o) => {
|
||||||
|
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||||
|
return (
|
||||||
|
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 bg-muted/20">
|
||||||
|
<Checkbox
|
||||||
|
id={`${q.questionId}-${o.id}`}
|
||||||
|
checked={selected}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal">
|
||||||
|
{o.text}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGraded && (
|
||||||
|
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||||
|
{q.feedback ? (
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<div className="font-medium text-foreground">Teacher Feedback</div>
|
||||||
|
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||||
|
{q.feedback}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
|
<div className="border-b p-4 bg-muted/30">
|
||||||
|
<h3 className="font-semibold">Assignment Info</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4 overflow-y-auto">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{initialData.assignment.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGraded && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Total Score</Label>
|
||||||
|
<div className="mt-2 flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold text-primary">
|
||||||
|
{initialData.submission?.score ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
/ {initialData.questions.reduce((acc, q) => acc + q.maxScore, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-emerald-600"></div>
|
||||||
|
<span>Correct</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
|
||||||
|
<span>Partial</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||||
|
<span>Incorrect</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
|
{isGraded ? "Question Breakdown" : "Response Summary"}
|
||||||
|
</Label>
|
||||||
|
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||||
|
{initialData.questions.map((q, i) => {
|
||||||
|
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||||
|
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||||
|
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||||
|
|
||||||
|
const score = q.score ?? 0
|
||||||
|
const max = q.maxScore
|
||||||
|
let statusClass = "bg-background text-muted-foreground border-input"
|
||||||
|
|
||||||
|
if (isGraded) {
|
||||||
|
if (score === max && max > 0) statusClass = "bg-emerald-600 text-white border-emerald-600"
|
||||||
|
else if (score > 0) statusClass = "bg-yellow-500 text-white border-yellow-500"
|
||||||
|
else statusClass = "bg-red-500 text-white border-red-500"
|
||||||
|
} else if (hasAnswer) {
|
||||||
|
statusClass = "bg-primary text-primary-foreground border-primary"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.questionId}
|
||||||
|
className={`
|
||||||
|
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
|
||||||
|
${statusClass}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -524,6 +524,17 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
|||||||
})
|
})
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
// Fetch adjacent submissions for navigation
|
||||||
|
const allSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||||
|
where: eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||||
|
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||||
|
columns: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId)
|
||||||
|
const prevSubmissionId = currentIndex > 0 ? allSubmissions[currentIndex - 1].id : null
|
||||||
|
const nextSubmissionId = currentIndex >= 0 && currentIndex < allSubmissions.length - 1 ? allSubmissions[currentIndex + 1].id : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: submission.id,
|
id: submission.id,
|
||||||
assignmentId: submission.assignmentId,
|
assignmentId: submission.assignmentId,
|
||||||
@@ -533,6 +544,8 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
|||||||
status: submission.status as HomeworkSubmissionDetails["status"],
|
status: submission.status as HomeworkSubmissionDetails["status"],
|
||||||
totalScore: submission.score,
|
totalScore: submission.score,
|
||||||
answers: answersWithDetails,
|
answers: answersWithDetails,
|
||||||
|
prevSubmissionId,
|
||||||
|
nextSubmissionId,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -643,16 +656,32 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
|||||||
|
|
||||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||||
with: { question: true },
|
with: {
|
||||||
|
question: {
|
||||||
|
with: {
|
||||||
|
knowledgePoints: {
|
||||||
|
with: {
|
||||||
|
knowledgePoint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: (q, { asc }) => [asc(q.order)],
|
orderBy: (q, { asc }) => [asc(q.order)],
|
||||||
})
|
})
|
||||||
|
|
||||||
const savedByQuestionId = new Map<string, unknown>()
|
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
|
||||||
if (latestSubmission) {
|
if (latestSubmission) {
|
||||||
const answers = await db.query.homeworkAnswers.findMany({
|
const answers = await db.query.homeworkAnswers.findMany({
|
||||||
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
|
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
|
||||||
})
|
})
|
||||||
for (const ans of answers) savedByQuestionId.set(ans.questionId, ans.answerContent)
|
for (const ans of answers) {
|
||||||
|
answersByQuestionId.set(ans.questionId, {
|
||||||
|
answer: ans.answerContent,
|
||||||
|
score: ans.score,
|
||||||
|
feedback: ans.feedback,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -675,14 +704,25 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
|||||||
score: latestSubmission.score ?? null,
|
score: latestSubmission.score ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
questions: assignmentQuestions.map((aq) => ({
|
questions: assignmentQuestions.map((aq) => {
|
||||||
questionId: aq.questionId,
|
const saved = answersByQuestionId.get(aq.questionId)
|
||||||
questionType: aq.question.type,
|
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
|
||||||
questionContent: toQuestionContent(aq.question.content),
|
const kps = aq.question.knowledgePoints ?? []
|
||||||
maxScore: aq.score ?? 0,
|
return {
|
||||||
order: aq.order ?? 0,
|
questionId: aq.questionId,
|
||||||
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null,
|
questionType: aq.question.type,
|
||||||
})),
|
questionContent: toQuestionContent(aq.question.content),
|
||||||
|
maxScore: aq.score ?? 0,
|
||||||
|
order: aq.order ?? 0,
|
||||||
|
savedAnswer: saved?.answer ?? null,
|
||||||
|
score: saved?.score ?? null,
|
||||||
|
feedback: saved?.feedback ?? null,
|
||||||
|
knowledgePoints: kps.map((kp) => ({
|
||||||
|
id: kp.knowledgePoint.id,
|
||||||
|
name: kp.knowledgePoint.name,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export type HomeworkSubmissionDetails = {
|
|||||||
status: HomeworkSubmissionStatus
|
status: HomeworkSubmissionStatus
|
||||||
totalScore: number | null
|
totalScore: number | null
|
||||||
answers: HomeworkSubmissionAnswerDetails[]
|
answers: HomeworkSubmissionAnswerDetails[]
|
||||||
|
prevSubmissionId?: string | null
|
||||||
|
nextSubmissionId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
|
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
|
||||||
@@ -114,6 +116,9 @@ export type StudentHomeworkTakeQuestion = {
|
|||||||
maxScore: number
|
maxScore: number
|
||||||
order: number
|
order: number
|
||||||
savedAnswer: unknown
|
savedAnswer: unknown
|
||||||
|
score?: number | null
|
||||||
|
feedback?: string | null
|
||||||
|
knowledgePoints?: Array<{ id: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudentHomeworkTakeData = {
|
export type StudentHomeworkTakeData = {
|
||||||
@@ -145,7 +150,7 @@ export type HomeworkAssignmentQuestionAnalytics = {
|
|||||||
order: number
|
order: number
|
||||||
errorCount: number
|
errorCount: number
|
||||||
errorRate: number
|
errorRate: number
|
||||||
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }>
|
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown; count?: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HomeworkAssignmentAnalytics = {
|
export type HomeworkAssignmentAnalytics = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
import { Bell, Menu, Search } from "lucide-react"
|
import { Bell, Menu, Search } from "lucide-react"
|
||||||
import { signOut, useSession } from "next-auth/react"
|
import { signOut, useSession } from "next-auth/react"
|
||||||
|
|
||||||
@@ -27,8 +28,21 @@ import {
|
|||||||
} from "@/shared/components/ui/dropdown-menu"
|
} from "@/shared/components/ui/dropdown-menu"
|
||||||
|
|
||||||
import { useSidebar } from "./sidebar-provider"
|
import { useSidebar } from "./sidebar-provider"
|
||||||
|
import { NAV_CONFIG } from "../config/navigation"
|
||||||
|
|
||||||
|
// Build lookup map for breadcrumbs
|
||||||
|
const BREADCRUMB_MAP = new Map<string, string>()
|
||||||
|
Object.values(NAV_CONFIG).forEach((items) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
BREADCRUMB_MAP.set(item.href, item.title)
|
||||||
|
item.items?.forEach((subItem) => {
|
||||||
|
BREADCRUMB_MAP.set(subItem.href, subItem.title)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
|
const pathname = usePathname()
|
||||||
const { toggleSidebar, isMobile } = useSidebar()
|
const { toggleSidebar, isMobile } = useSidebar()
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
|
|
||||||
@@ -44,6 +58,16 @@ export function SiteHeader() {
|
|||||||
.map((p) => p[0]?.toUpperCase())
|
.map((p) => p[0]?.toUpperCase())
|
||||||
.join("")
|
.join("")
|
||||||
|
|
||||||
|
// Generate breadcrumbs
|
||||||
|
const segments = pathname.split("/").filter(Boolean)
|
||||||
|
const breadcrumbs = segments
|
||||||
|
.map((segment, index) => {
|
||||||
|
const href = `/${segments.slice(0, index + 1).join("/")}`
|
||||||
|
const title = BREADCRUMB_MAP.get(href) || segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||||
|
return { href, title, isLast: index === segments.length - 1 }
|
||||||
|
})
|
||||||
|
.filter((b) => !["admin", "teacher", "student", "parent"].includes(b.title.toLowerCase()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
|
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
|
||||||
<div className="flex flex-1 items-center gap-4">
|
<div className="flex flex-1 items-center gap-4">
|
||||||
@@ -60,13 +84,26 @@ export function SiteHeader() {
|
|||||||
{/* Breadcrumbs */}
|
{/* Breadcrumbs */}
|
||||||
<Breadcrumb className="hidden md:flex">
|
<Breadcrumb className="hidden md:flex">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
{breadcrumbs.length > 0 ? (
|
||||||
<BreadcrumbLink href="/dashboard">Dashboard</BreadcrumbLink>
|
breadcrumbs.map((crumb) => (
|
||||||
</BreadcrumbItem>
|
<React.Fragment key={crumb.href}>
|
||||||
<BreadcrumbSeparator />
|
<BreadcrumbItem>
|
||||||
<BreadcrumbItem>
|
{crumb.isLast ? (
|
||||||
<BreadcrumbPage>Overview</BreadcrumbPage>
|
<BreadcrumbPage>{crumb.title}</BreadcrumbPage>
|
||||||
</BreadcrumbItem>
|
) : (
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link href={crumb.href}>{crumb.title}</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{!crumb.isLast && <BreadcrumbSeparator />}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>Home</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
)}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
FileQuestion,
|
FileQuestion,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Library,
|
Library,
|
||||||
PenTool
|
PenTool,
|
||||||
|
Briefcase
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -124,7 +125,14 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
|||||||
{ title: "Students", href: "/teacher/classes/students" },
|
{ title: "Students", href: "/teacher/classes/students" },
|
||||||
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
||||||
{ title: "Insights", href: "/teacher/classes/insights" },
|
{ title: "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" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { getQuestions, type GetQuestionsParams } from "./data-access";
|
||||||
|
|
||||||
async function getCurrentUser() {
|
async function getCurrentUser() {
|
||||||
return {
|
return {
|
||||||
id: "user_teacher_123",
|
id: "user_teacher_math",
|
||||||
role: "teacher",
|
role: "teacher",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -205,25 +206,27 @@ export async function deleteQuestionAction(
|
|||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
const user = await ensureTeacher();
|
const user = await ensureTeacher();
|
||||||
const canEditAll = user.role === "admin";
|
const canDeleteAll = user.role === "admin";
|
||||||
|
|
||||||
const id = formData.get("id");
|
const questionId = formData.get("questionId");
|
||||||
if (typeof id !== "string" || id.length === 0) {
|
if (typeof questionId !== "string") {
|
||||||
return { success: false, message: "Missing question id" };
|
return { success: false, message: "Invalid question ID" };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const [owned] = await tx
|
const q = await tx.query.questions.findFirst({
|
||||||
.select({ id: questions.id })
|
where: eq(questions.id, questionId),
|
||||||
.from(questions)
|
});
|
||||||
.where(canEditAll ? eq(questions.id, id) : and(eq(questions.id, id), eq(questions.authorId, user.id)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!owned) {
|
if (!q) {
|
||||||
|
throw new Error("Question not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canDeleteAll && q.authorId !== user.id) {
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteQuestionRecursive(tx, id);
|
await deleteQuestionRecursive(tx, questionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/teacher/questions");
|
revalidatePath("/teacher/questions");
|
||||||
@@ -233,6 +236,11 @@ export async function deleteQuestionAction(
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: error.message };
|
||||||
}
|
}
|
||||||
return { success: false, message: "An unexpected error occurred" };
|
return { success: false, message: "Failed to delete question" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getQuestionsAction(params: GetQuestionsParams) {
|
||||||
|
await ensureTeacher();
|
||||||
|
return await getQuestions(params);
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const getQuestions = cache(async ({
|
|||||||
offset: offset,
|
offset: offset,
|
||||||
orderBy: [desc(questions.createdAt)],
|
orderBy: [desc(questions.createdAt)],
|
||||||
with: {
|
with: {
|
||||||
questionsToKnowledgePoints: {
|
knowledgePoints: {
|
||||||
with: {
|
with: {
|
||||||
knowledgePoint: true,
|
knowledgePoint: true,
|
||||||
},
|
},
|
||||||
@@ -95,7 +95,7 @@ export const getQuestions = cache(async ({
|
|||||||
return {
|
return {
|
||||||
data: rows.map((row) => {
|
data: rows.map((row) => {
|
||||||
const knowledgePoints =
|
const knowledgePoints =
|
||||||
row.questionsToKnowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
|
row.knowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
|
||||||
|
|
||||||
const author = row.author
|
const author = row.author
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { Label } from "@/shared/components/ui/label"
|
|||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
import { ThemePreferencesCard } from "./theme-preferences-card"
|
import { ThemePreferencesCard } from "./theme-preferences-card"
|
||||||
|
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
|
||||||
|
import { UserProfile } from "@/modules/users/data-access"
|
||||||
|
|
||||||
export function AdminSettingsView() {
|
export function AdminSettingsView({ user }: { user: UserProfile }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-8 p-8">
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
@@ -44,32 +46,7 @@ export function AdminSettingsView() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="general" className="mt-6 space-y-6">
|
<TabsContent value="general" className="mt-6 space-y-6">
|
||||||
<Card>
|
<ProfileSettingsForm user={user} />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Account</CardTitle>
|
|
||||||
<CardDescription>Basic profile information for this admin account.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input id="name" defaultValue="Admin User" disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input id="email" defaultValue="admin@nextedu.com" disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role">Role</Label>
|
|
||||||
<Input id="role" defaultValue="admin" className="tabular-nums" disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<Input id="status" defaultValue="active" disabled />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -100,8 +77,8 @@ export function AdminSettingsView() {
|
|||||||
Departments, classes, and academic year settings live under the School Management section.
|
Departments, classes, and academic year settings live under the School Management section.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" className="shrink-0">
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link href="/admin/school/departments">Open</Link>
|
<Link href="/admin/school">Manage</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -128,29 +105,6 @@ export function AdminSettingsView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Danger zone</CardTitle>
|
|
||||||
<CardDescription>Destructive actions are disabled in demo mode.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-background">
|
|
||||||
<Shield className="h-4 w-4 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<div className="text-sm font-medium">Reset system</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
This action would clear all data and cannot be undone.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="destructive" disabled className="shrink-0">
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
199
src/modules/settings/components/profile-settings-form.tsx
Normal file
199
src/modules/settings/components/profile-settings-form.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTransition } from "react"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { Loader2, Save } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
|
||||||
|
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 { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
|
||||||
|
import { UserProfile } from "@/modules/users/data-access"
|
||||||
|
import { updateUserProfile } from "@/modules/users/actions"
|
||||||
|
|
||||||
|
const profileFormSchema = z.object({
|
||||||
|
name: z.string().min(2, "Name must be at least 2 characters."),
|
||||||
|
email: z.string().email().optional(), // Read only
|
||||||
|
role: z.string().optional(), // Read only
|
||||||
|
phone: z.string().optional(),
|
||||||
|
address: z.string().optional(),
|
||||||
|
gender: z.string().optional(),
|
||||||
|
age: z.coerce.number().min(0).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ProfileFormValues = z.infer<typeof profileFormSchema>
|
||||||
|
|
||||||
|
export function ProfileSettingsForm({ user }: { user: UserProfile }) {
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const form = useForm<ProfileFormValues>({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
resolver: zodResolver(profileFormSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
role: user.role ?? "",
|
||||||
|
phone: user.phone ?? "",
|
||||||
|
address: user.address ?? "",
|
||||||
|
gender: user.gender ?? "",
|
||||||
|
age: user.age ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(data: ProfileFormValues) {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await updateUserProfile({
|
||||||
|
name: data.name,
|
||||||
|
phone: data.phone || undefined,
|
||||||
|
address: data.address || undefined,
|
||||||
|
gender: data.gender || undefined,
|
||||||
|
age: data.age || undefined,
|
||||||
|
})
|
||||||
|
toast.success("Profile updated successfully")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to update profile")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile Information</CardTitle>
|
||||||
|
<CardDescription>Update your personal information.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Full Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Your name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Email cannot be changed.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Phone</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="+1 234 567 890" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="gender"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Gender</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select gender" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="male">Male</SelectItem>
|
||||||
|
<SelectItem value="female">Female</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
<SelectItem value="prefer_not_to_say">Prefer not to say</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="age"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Age</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="Age" {...field} value={field.value ?? ""} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled className="capitalize" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="address"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="123 Main St, City, Country" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-end border-t px-6 py-4">
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,24 +5,13 @@ import { User, Palette, Lock, LayoutDashboard, PenTool, CalendarDays } from "luc
|
|||||||
import { signOut } from "next-auth/react"
|
import { signOut } from "next-auth/react"
|
||||||
|
|
||||||
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
|
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
|
||||||
|
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
|
||||||
import { Label } from "@/shared/components/ui/label"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
|
import { UserProfile } from "@/modules/users/data-access"
|
||||||
|
|
||||||
type SettingsUser = {
|
export function StudentSettingsView({ user }: { user: UserProfile }) {
|
||||||
id?: string | null
|
|
||||||
name?: string | null
|
|
||||||
email?: string | null
|
|
||||||
role?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StudentSettingsView({ user }: { user: SettingsUser }) {
|
|
||||||
const role = "student"
|
|
||||||
const name = user.name ?? "-"
|
|
||||||
const email = user.email ?? "-"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-8 p-8">
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
@@ -54,28 +43,7 @@ export function StudentSettingsView({ user }: { user: SettingsUser }) {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="general" className="mt-6 space-y-6">
|
<TabsContent value="general" className="mt-6 space-y-6">
|
||||||
<Card>
|
<ProfileSettingsForm user={user} />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Account</CardTitle>
|
|
||||||
<CardDescription>Signed-in user details from session.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input id="name" value={name} disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input id="email" value={email} disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role">Role</Label>
|
|
||||||
<Input id="role" value={role} className="tabular-nums" disabled />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -133,4 +101,3 @@ export function StudentSettingsView({ user }: { user: SettingsUser }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,13 @@ import { User, Palette, Lock, LayoutDashboard, PenTool, CalendarDays, Library, F
|
|||||||
import { signOut } from "next-auth/react"
|
import { signOut } from "next-auth/react"
|
||||||
|
|
||||||
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
|
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
|
||||||
|
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
|
||||||
import { Label } from "@/shared/components/ui/label"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
|
import { UserProfile } from "@/modules/users/data-access"
|
||||||
|
|
||||||
type SettingsUser = {
|
export function TeacherSettingsView({ user }: { user: UserProfile }) {
|
||||||
id?: string | null
|
|
||||||
name?: string | null
|
|
||||||
email?: string | null
|
|
||||||
role?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TeacherSettingsView({ user }: { user: SettingsUser }) {
|
|
||||||
const role = "teacher"
|
|
||||||
const name = user.name ?? "-"
|
|
||||||
const email = user.email ?? "-"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-8 p-8">
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
@@ -54,28 +43,7 @@ export function TeacherSettingsView({ user }: { user: SettingsUser }) {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="general" className="mt-6 space-y-6">
|
<TabsContent value="general" className="mt-6 space-y-6">
|
||||||
<Card>
|
<ProfileSettingsForm user={user} />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Account</CardTitle>
|
|
||||||
<CardDescription>Signed-in user details from session.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input id="name" value={name} disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input id="email" value={email} disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role">Role</Label>
|
|
||||||
<Input id="role" value={role} className="tabular-nums" disabled />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -145,4 +113,3 @@ export function TeacherSettingsView({ user }: { user: SettingsUser }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { BookOpen, Building2, Inbox } from "lucide-react"
|
import { BookOpen, Building2, Inbox, CalendarDays, User, PlusCircle, PenTool } from "lucide-react"
|
||||||
|
|
||||||
import type { StudentEnrolledClass } from "@/modules/classes/types"
|
import type { StudentEnrolledClass } from "@/modules/classes/types"
|
||||||
import { joinClassByInvitationCodeAction } from "@/modules/classes/actions"
|
import { joinClassByInvitationCodeAction } from "@/modules/classes/actions"
|
||||||
@@ -43,113 +43,113 @@ export function StudentCoursesView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (classes.length === 0) {
|
return (
|
||||||
return (
|
<div className="space-y-8">
|
||||||
<div className="space-y-4">
|
{classes.length > 0 && (
|
||||||
<Card>
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<CardHeader className="pb-2">
|
{classes.map((c) => (
|
||||||
<CardTitle className="text-base">Join a class</CardTitle>
|
<Card key={c.id} className="flex flex-col overflow-hidden transition-all hover:shadow-md">
|
||||||
</CardHeader>
|
<CardHeader className="bg-muted/30 pb-4">
|
||||||
<CardContent>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<form action={handleJoin} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
<div className="space-y-1">
|
||||||
<div className="space-y-2">
|
<CardTitle className="line-clamp-1 text-lg">{c.name}</CardTitle>
|
||||||
<Label htmlFor="join-invitation-code">Invitation code</Label>
|
<CardDescription className="flex items-center gap-2 text-xs">
|
||||||
<Input
|
<span className="flex items-center gap-1">
|
||||||
id="join-invitation-code"
|
<BookOpen className="h-3 w-3" />
|
||||||
name="code"
|
Grade {c.grade}
|
||||||
inputMode="numeric"
|
</span>
|
||||||
autoComplete="one-time-code"
|
{c.homeroom && (
|
||||||
placeholder="6-digit code"
|
<>
|
||||||
value={code}
|
<span>•</span>
|
||||||
onChange={(e) => setCode(e.target.value)}
|
<span>{c.homeroom}</span>
|
||||||
maxLength={6}
|
</>
|
||||||
required
|
)}
|
||||||
/>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={isWorking}>
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{isWorking ? "Joining..." : "Join"}
|
Active
|
||||||
</Button>
|
</Badge>
|
||||||
</form>
|
</div>
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
</Card>
|
|
||||||
|
<CardContent className="flex-1 space-y-4 py-4">
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{c.teacherName && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
<span>{c.teacherName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.room && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
<span>Room {c.room}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex gap-2 border-t bg-muted/10 p-4">
|
||||||
|
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||||
|
<Link href={`/student/schedule?classId=${encodeURIComponent(c.id)}`}>
|
||||||
|
<CalendarDays className="mr-2 h-4 w-4" />
|
||||||
|
Schedule
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" className="flex-1">
|
||||||
|
<Link href={`/student/learning/assignments?classId=${encodeURIComponent(c.id)}`}>
|
||||||
|
<PenTool className="mr-2 h-4 w-4" />
|
||||||
|
Assignments
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{classes.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
title="No courses"
|
title="No courses yet"
|
||||||
description="You are not enrolled in any class yet."
|
description="You are not enrolled in any classes. Join a class to get started."
|
||||||
className="h-80"
|
className="py-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||||
<div className="space-y-4">
|
<div className="mb-6 flex items-center gap-3">
|
||||||
<Card>
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
<CardHeader className="pb-2">
|
<PlusCircle className="h-5 w-5 text-primary" />
|
||||||
<CardTitle className="text-base">Join a class</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent>
|
<h3 className="text-lg font-semibold">Join a Class</h3>
|
||||||
<form action={handleJoin} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="space-y-2">
|
Enter the invitation code provided by your teacher to enroll.
|
||||||
<Label htmlFor="join-invitation-code">Invitation code</Label>
|
</p>
|
||||||
<Input
|
</div>
|
||||||
id="join-invitation-code"
|
</div>
|
||||||
name="code"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
placeholder="6-digit code"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
maxLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={isWorking}>
|
|
||||||
{isWorking ? "Joining..." : "Join"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<form action={handleJoin} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
{classes.map((c) => (
|
<div className="flex-1 space-y-2">
|
||||||
<Card key={c.id} className="overflow-hidden">
|
<Label htmlFor="join-invitation-code">Invitation Code</Label>
|
||||||
<CardHeader className="pb-2">
|
<Input
|
||||||
<CardTitle className="flex items-center justify-between gap-3">
|
id="join-invitation-code"
|
||||||
<div className="min-w-0">
|
name="code"
|
||||||
<div className="truncate text-base font-semibold leading-none">{c.name}</div>
|
inputMode="numeric"
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
autoComplete="one-time-code"
|
||||||
<span className="inline-flex items-center gap-1">
|
placeholder="Enter 6-digit code"
|
||||||
<BookOpen className="h-3 w-3" />
|
value={code}
|
||||||
Grade {c.grade}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
</span>
|
maxLength={6}
|
||||||
{c.homeroom ? (
|
className="max-w-md font-mono tracking-widest"
|
||||||
<Badge variant="outline" className="font-normal">
|
required
|
||||||
{c.homeroom}
|
/>
|
||||||
</Badge>
|
</div>
|
||||||
) : null}
|
<Button type="submit" disabled={isWorking} size="lg">
|
||||||
{c.room ? (
|
{isWorking ? "Joining..." : "Join Class"}
|
||||||
<span className="inline-flex items-center gap-1">
|
</Button>
|
||||||
<Building2 className="h-3 w-3" />
|
</form>
|
||||||
{c.room}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="shrink-0">
|
|
||||||
Enrolled
|
|
||||||
</Badge>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex items-center justify-between gap-3 pt-2">
|
|
||||||
<div className="text-sm text-muted-foreground">Open schedule for this class.</div>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/student/schedule?classId=${encodeURIComponent(c.id)}`}>Schedule</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,10 +9,28 @@ import {
|
|||||||
createKnowledgePoint,
|
createKnowledgePoint,
|
||||||
deleteKnowledgePoint,
|
deleteKnowledgePoint,
|
||||||
updateTextbook,
|
updateTextbook,
|
||||||
deleteTextbook
|
deleteTextbook,
|
||||||
|
reorderChapters
|
||||||
} from "./data-access";
|
} from "./data-access";
|
||||||
import { CreateTextbookInput, UpdateTextbookInput } from "./types";
|
import { CreateTextbookInput, UpdateTextbookInput } from "./types";
|
||||||
|
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
export async function reorderChaptersAction(
|
||||||
|
chapterId: string,
|
||||||
|
newIndex: number,
|
||||||
|
parentId: string | null,
|
||||||
|
textbookId: string
|
||||||
|
): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
await reorderChapters(chapterId, newIndex, parentId);
|
||||||
|
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||||
|
return { success: true, message: "Chapters reordered successfully" };
|
||||||
|
} catch {
|
||||||
|
return { success: false, message: "Failed to reorder chapters" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ActionState = {
|
export type ActionState = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function ChapterItem({ chapter, level = 0, onView, showActions = true }: Chapter
|
|||||||
const hasChildren = chapter.children && chapter.children.length > 0
|
const hasChildren = chapter.children && chapter.children.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
|
<div className={cn(level > 0 && "ml-2 border-l pl-2")}>
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<div className="flex items-center group py-1">
|
<div className="flex items-center group py-1">
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { ChevronRight, FileText, Folder, MoreHorizontal, Plus, Trash2 } from "lucide-react"
|
import { ChevronRight, FileText, Folder, Plus, Trash2, GripVertical } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core"
|
||||||
|
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
import { Chapter } from "../types"
|
import { Chapter } from "../types"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -10,13 +13,6 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/shared/components/ui/collapsible"
|
} from "@/shared/components/ui/collapsible"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -29,54 +25,62 @@ import {
|
|||||||
} from "@/shared/components/ui/alert-dialog"
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||||
import { deleteChapterAction } from "../actions"
|
import { deleteChapterAction, reorderChaptersAction } from "../actions"
|
||||||
|
|
||||||
interface ChapterItemProps {
|
interface SortableChapterItemProps {
|
||||||
chapter: Chapter
|
chapter: Chapter
|
||||||
level?: number
|
level: number
|
||||||
selectedId?: string
|
selectedId?: string
|
||||||
onSelect: (chapter: Chapter) => void
|
onSelect: (chapter: Chapter) => void
|
||||||
textbookId: string
|
textbookId: string
|
||||||
|
onDelete: (chapter: Chapter) => void
|
||||||
|
onCreateSub: (parentId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChapterItem({ chapter, level = 0, selectedId, onSelect, textbookId }: ChapterItemProps) {
|
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: SortableChapterItemProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(level === 0)
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
|
||||||
const hasChildren = chapter.children && chapter.children.length > 0
|
const hasChildren = chapter.children && chapter.children.length > 0
|
||||||
const isSelected = chapter.id === selectedId
|
const isSelected = chapter.id === selectedId
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const {
|
||||||
setIsDeleting(true)
|
attributes,
|
||||||
const res = await deleteChapterAction(chapter.id, textbookId)
|
listeners,
|
||||||
setIsDeleting(false)
|
setNodeRef,
|
||||||
if (res.success) {
|
transform,
|
||||||
toast.success(res.message)
|
transition,
|
||||||
setShowDeleteDialog(false)
|
isDragging,
|
||||||
} else {
|
} = useSortable({ id: chapter.id })
|
||||||
toast.error(res.message)
|
|
||||||
}
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 10 : 1,
|
||||||
|
position: "relative" as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
|
<div ref={setNodeRef} style={style} className={cn(level > 0 && "ml-2 border-l border-muted/30 pl-2")}>
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex items-center group py-1 rounded-md transition-colors",
|
"flex items-center group py-1.5 px-2 rounded-md transition-colors cursor-pointer select-none",
|
||||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
isSelected ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted/50 text-muted-foreground hover:text-foreground",
|
||||||
|
isDragging && "opacity-50"
|
||||||
)}>
|
)}>
|
||||||
|
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
|
||||||
|
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
|
className="h-5 w-5 shrink-0 p-0 mr-1 hover:bg-transparent text-muted-foreground/70"
|
||||||
onClick={(e) => e.stopPropagation()} // Prevent selecting parent when toggling
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-transform duration-200 text-muted-foreground",
|
"h-3.5 w-3.5 transition-transform duration-200",
|
||||||
isOpen && "rotate-90"
|
isOpen && "rotate-90"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -84,124 +88,257 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect, textbookId }: C
|
|||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-6 shrink-0" />
|
<div className="w-5 shrink-0 mr-1" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="flex-1 min-w-0 flex items-center gap-2"
|
||||||
"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-sm cursor-pointer",
|
|
||||||
level === 0 ? "font-medium" : "text-muted-foreground",
|
|
||||||
isSelected && "text-accent-foreground font-medium"
|
|
||||||
)}
|
|
||||||
onClick={() => onSelect(chapter)}
|
onClick={() => onSelect(chapter)}
|
||||||
>
|
>
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<Folder className={cn("h-4 w-4", isOpen ? "text-primary" : "text-muted-foreground/70")} />
|
<Folder className={cn("h-4 w-4 shrink-0 transition-colors", isOpen || isSelected ? "text-blue-500/80" : "text-muted-foreground/50")} />
|
||||||
) : (
|
) : (
|
||||||
<FileText className="h-4 w-4 text-muted-foreground/50" />
|
<FileText className={cn("h-4 w-4 shrink-0 transition-colors", isSelected ? "text-blue-500/80" : "text-muted-foreground/50")} />
|
||||||
)}
|
)}
|
||||||
<span className="truncate flex-1 min-w-0">{chapter.title}</span>
|
<span className="truncate text-sm">{chapter.title}</span>
|
||||||
|
</div>
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity ml-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="ml-auto h-6 w-6 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity focus:opacity-100"
|
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation()
|
||||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
onCreateSub(chapter.id)
|
||||||
</Button>
|
}}
|
||||||
</DropdownMenuTrigger>
|
title="Add Subchapter"
|
||||||
<DropdownMenuContent align="end">
|
>
|
||||||
<DropdownMenuItem
|
<Plus className="h-3.5 w-3.5" />
|
||||||
onSelect={() => setShowCreateDialog(true)}
|
</Button>
|
||||||
>
|
<Button
|
||||||
<Plus />
|
variant="ghost"
|
||||||
Add Subchapter
|
size="icon"
|
||||||
</DropdownMenuItem>
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
<DropdownMenuSeparator />
|
onClick={(e) => {
|
||||||
<DropdownMenuItem variant="destructive" onSelect={() => setShowDeleteDialog(true)}>
|
e.stopPropagation()
|
||||||
<Trash2 />
|
onDelete(chapter)
|
||||||
Delete
|
}}
|
||||||
</DropdownMenuItem>
|
title="Delete Chapter"
|
||||||
</DropdownMenuContent>
|
>
|
||||||
</DropdownMenu>
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasChildren && (
|
<CollapsibleContent>
|
||||||
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
<div className="pt-1">
|
||||||
<div className="pt-1">
|
{hasChildren && (
|
||||||
{chapter.children!.map((child) => (
|
<RecursiveSortableList
|
||||||
<ChapterItem
|
items={chapter.children!}
|
||||||
key={child.id}
|
level={level + 1}
|
||||||
chapter={child}
|
|
||||||
level={level + 1}
|
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onCreateSub={onCreateSub}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
)}
|
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete chapter?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will delete this chapter and all its subchapters and linked knowledge points.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? "Deleting..." : "Delete"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
<CreateChapterDialog
|
|
||||||
textbookId={textbookId}
|
|
||||||
parentId={chapter.id}
|
|
||||||
trigger={null}
|
|
||||||
open={showCreateDialog}
|
|
||||||
onOpenChange={setShowCreateDialog}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChapterSidebarList({
|
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: {
|
||||||
chapters,
|
items: Chapter[],
|
||||||
selectedChapterId,
|
level: number,
|
||||||
onSelectChapter,
|
selectedId?: string,
|
||||||
textbookId,
|
onSelect: (c: Chapter) => void,
|
||||||
}: {
|
textbookId: string,
|
||||||
chapters: Chapter[],
|
onDelete: (c: Chapter) => void,
|
||||||
selectedChapterId?: string,
|
onCreateSub: (pid: string) => void
|
||||||
onSelectChapter: (chapter: Chapter) => void
|
|
||||||
textbookId: string
|
|
||||||
}) {
|
}) {
|
||||||
|
return (
|
||||||
|
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map((chapter) => (
|
||||||
|
<SortableChapterItem
|
||||||
|
key={chapter.id}
|
||||||
|
chapter={chapter}
|
||||||
|
level={level}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
textbookId={textbookId}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onCreateSub={onCreateSub}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChapterSidebarListProps {
|
||||||
|
chapters: Chapter[]
|
||||||
|
selectedChapterId?: string
|
||||||
|
onSelectChapter: (chapter: Chapter) => void
|
||||||
|
textbookId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId }: ChapterSidebarListProps) {
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||||
|
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Chapter | null>(null)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
// Find which list the items belong to
|
||||||
|
// Since we only support sibling reordering for now, we assume active and over are in the same list
|
||||||
|
// We need a helper to find the parent of an item in the tree
|
||||||
|
const findParent = (items: Chapter[], id: string): Chapter | null => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.children?.some(c => c.id === id)) return item
|
||||||
|
if (item.children) {
|
||||||
|
const found = findParent(item.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeParent = findParent(chapters, active.id as string)
|
||||||
|
const overParent = findParent(chapters, over.id as string)
|
||||||
|
|
||||||
|
// If parents don't match (and neither is root), we can't reorder easily in this simplified version
|
||||||
|
// But actually, we need to check if they are in the same list.
|
||||||
|
// If both are root items (activeParent is null), they are siblings.
|
||||||
|
|
||||||
|
const getSiblings = (parentId: string | null) => {
|
||||||
|
if (!parentId) return chapters
|
||||||
|
const parent = chapters.find(c => c.id === parentId) // This only finds root parents, we need recursive find
|
||||||
|
|
||||||
|
const findNode = (nodes: Chapter[], id: string): Chapter | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === id) return node
|
||||||
|
if (node.children) {
|
||||||
|
const found = findNode(node.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return findNode(chapters, parentId)?.children || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it?
|
||||||
|
// No, dnd-kit allows dropping anywhere by default unless restricted.
|
||||||
|
|
||||||
|
// We need to find the list that contains the 'active' item
|
||||||
|
// And the list that contains the 'over' item.
|
||||||
|
// If they are the same list, we reorder.
|
||||||
|
|
||||||
|
let activeList: Chapter[] = chapters
|
||||||
|
let activeParentId: string | null = null
|
||||||
|
|
||||||
|
if (activeParent) {
|
||||||
|
activeList = activeParent.children || []
|
||||||
|
activeParentId = activeParent.id
|
||||||
|
} else {
|
||||||
|
// Check if active is in root
|
||||||
|
if (!chapters.some(c => c.id === active.id)) {
|
||||||
|
// Should not happen if tree is consistent
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if over is in the same list
|
||||||
|
if (activeList.some(c => c.id === over.id)) {
|
||||||
|
const oldIndex = activeList.findIndex((item) => item.id === active.id)
|
||||||
|
const newIndex = activeList.findIndex((item) => item.id === over.id)
|
||||||
|
|
||||||
|
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)
|
||||||
|
toast.success("Order updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setIsDeleting(true)
|
||||||
|
const res = await deleteChapterAction(deleteTarget.id, textbookId)
|
||||||
|
setIsDeleting(false)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
setShowDeleteDialog(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
} else {
|
||||||
|
toast.error(res.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteRequest = (chapter: Chapter) => {
|
||||||
|
if (chapter.children && chapter.children.length > 0) {
|
||||||
|
toast.error("Cannot delete chapter with subchapters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDeleteTarget(chapter)
|
||||||
|
setShowDeleteDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateSubRequest = (parentId: string) => {
|
||||||
|
setCreateParentId(parentId)
|
||||||
|
setShowCreateDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
{chapters.map((chapter) => (
|
<RecursiveSortableList
|
||||||
<ChapterItem
|
items={chapters}
|
||||||
key={chapter.id}
|
level={0}
|
||||||
chapter={chapter}
|
|
||||||
selectedId={selectedChapterId}
|
selectedId={selectedChapterId}
|
||||||
onSelect={onSelectChapter}
|
onSelect={onSelectChapter}
|
||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
|
onDelete={handleDeleteRequest}
|
||||||
|
onCreateSub={handleCreateSubRequest}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
<CreateChapterDialog
|
||||||
|
textbookId={textbookId}
|
||||||
|
parentId={createParentId}
|
||||||
|
open={showCreateDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setShowCreateDialog(open)
|
||||||
|
if (!open) setCreateParentId(undefined)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Chapter?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete <span className="font-medium text-foreground">{deleteTarget?.title}</span>.
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr
|
|||||||
trigger === null
|
trigger === null
|
||||||
? null
|
? null
|
||||||
: trigger || (
|
: trigger || (
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Add Chapter</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ export function KnowledgePointPanel({
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-4">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
<h3 className="font-semibold flex items-center gap-2">
|
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||||
<Tag className="h-4 w-4" />
|
<Tag className="h-4 w-4" />
|
||||||
Knowledge Points
|
Knowledge Points
|
||||||
</h3>
|
</h3>
|
||||||
@@ -82,16 +82,17 @@ export function KnowledgePointPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0 -mx-2 px-2">
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
{selectedChapterId ? (
|
{selectedChapterId ? (
|
||||||
chapterKPs.length > 0 ? (
|
chapterKPs.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<>
|
||||||
{chapterKPs.map((kp) => (
|
{chapterKPs.map((kp) => (
|
||||||
<Card key={kp.id} className="relative group">
|
<Card key={kp.id} className="relative group hover:shadow-sm transition-shadow">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex justify-between items-start gap-2">
|
<div className="flex justify-between items-start gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="font-medium text-sm leading-tight">
|
<div className="font-medium text-sm leading-tight text-foreground">
|
||||||
{kp.name}
|
{kp.name}
|
||||||
</div>
|
</div>
|
||||||
{kp.description && (
|
{kp.description && (
|
||||||
@@ -103,7 +104,7 @@ export function KnowledgePointPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10 -mt-1 -mr-1"
|
className="h-6 w-6 -mr-1 -mt-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => requestDelete(kp)}
|
onClick={() => requestDelete(kp)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
@@ -112,47 +113,40 @@ export function KnowledgePointPanel({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md border-dashed bg-muted/30">
|
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
|
||||||
No knowledge points linked to this chapter yet.
|
<div className="p-3 rounded-full bg-muted/50">
|
||||||
|
<Tag className="h-6 w-6 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No points yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60 max-w-[160px]">
|
||||||
|
Add knowledge points to tag content in this chapter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground text-center py-8">
|
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground space-y-2">
|
||||||
Select a chapter to manage its knowledge points.
|
<p className="text-sm">Select a chapter to manage knowledge points</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
open={showDeleteDialog}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (isDeleting) return
|
|
||||||
setShowDeleteDialog(open)
|
|
||||||
if (!open) setDeleteTarget(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete knowledge point?</AlertDialogTitle>
|
<AlertDialogTitle>Delete Knowledge Point?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{deleteTarget ? (
|
This action cannot be undone. This will permanently delete the knowledge point
|
||||||
<>
|
<span className="font-medium text-foreground"> {deleteTarget?.name}</span>.
|
||||||
This will permanently delete <span className="font-medium text-foreground">{deleteTarget.name}</span>.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"This will permanently delete the selected knowledge point."
|
|
||||||
)}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? "Deleting..." : "Delete"}
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@@ -1,77 +1,113 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GraduationCap, Building2, BookOpen } from "lucide-react";
|
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
|
||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu";
|
||||||
|
import { cn, formatDate } from "@/shared/lib/utils";
|
||||||
import { Textbook } from "../types";
|
import { Textbook } from "../types";
|
||||||
|
|
||||||
interface TextbookCardProps {
|
interface TextbookCardProps {
|
||||||
textbook: Textbook;
|
textbook: Textbook;
|
||||||
hrefBase?: string;
|
hrefBase?: string;
|
||||||
|
hideActions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextbookCard({ textbook, hrefBase }: TextbookCardProps) {
|
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 TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||||
const base = hrefBase || "/teacher/textbooks";
|
const base = hrefBase || "/teacher/textbooks";
|
||||||
|
const colorClass = subjectColorMap[textbook.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`${base}/${textbook.id}`} className="block h-full">
|
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
||||||
<Card
|
<Link href={`${base}/${textbook.id}`} className="flex-1">
|
||||||
className={cn(
|
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
|
||||||
"group h-full overflow-hidden transition-all duration-300 ease-out",
|
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
|
||||||
"hover:-translate-y-1 hover:shadow-md hover:border-primary/50"
|
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||||
)}
|
<Badge variant="secondary" className="w-fit bg-background/50 backdrop-blur-sm border-transparent shadow-none">
|
||||||
>
|
{textbook.subject}
|
||||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-muted/30 p-6 flex items-center justify-center">
|
</Badge>
|
||||||
{/* Fallback Cover Visualization */}
|
<Book className="h-8 w-8 opacity-50" />
|
||||||
<div className="relative z-10 flex h-24 w-20 flex-col items-center justify-center rounded-sm bg-background shadow-sm border transition-transform duration-300 group-hover:scale-110">
|
|
||||||
<div className="h-full w-full bg-gradient-to-br from-primary/10 to-primary/5 p-2">
|
|
||||||
<div className="h-1 w-full rounded-full bg-primary/20 mb-1" />
|
|
||||||
<div className="h-1 w-2/3 rounded-full bg-primary/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative Background Pattern */}
|
|
||||||
<div className="absolute inset-0 bg-grid-black/[0.02] dark:bg-grid-white/[0.02]" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardHeader className="p-4 pb-2">
|
<CardHeader className="p-4 pb-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="space-y-1">
|
<h3 className="font-semibold text-lg leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
<Badge variant="outline" className="w-fit text-[10px] h-5 px-1.5 font-normal border-primary/20 text-primary bg-primary/5">
|
{textbook.title}
|
||||||
{textbook.subject}
|
</h3>
|
||||||
</Badge>
|
|
||||||
<CardTitle className="line-clamp-2 text-base leading-tight">
|
|
||||||
{textbook.title}
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="p-4 pt-0 text-sm text-muted-foreground">
|
<CardContent className="p-4 pt-1 pb-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-wrap gap-y-1 gap-x-4 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-1.5">
|
||||||
<GraduationCap className="h-3.5 w-3.5 text-muted-foreground/70" />
|
<GraduationCap className="h-3.5 w-3.5" />
|
||||||
<span>{textbook.grade}</span>
|
<span>{textbook.grade || "Grade N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-1.5">
|
||||||
<Building2 className="h-3.5 w-3.5 text-muted-foreground/70" />
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
<span className="line-clamp-1">{textbook.publisher || "Unknown Publisher"}</span>
|
<span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
|
||||||
|
{textbook.publisher || "Publisher N/A"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<CardFooter className="p-4 pt-0 mt-auto">
|
<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/80 bg-muted/30 px-2 py-1 rounded-md w-full">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||||
<BookOpen className="h-3.5 w-3.5" />
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
<span>{textbook._count?.chapters || 0} Chapters</span>
|
<span>{textbook._count?.chapters || 0} Chapters</span>
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
<div className="flex items-center gap-1">
|
||||||
</Link>
|
<span className="text-[10px] text-muted-foreground/60 mr-2">
|
||||||
|
Updated {formatDate(textbook.updatedAt)}
|
||||||
|
</span>
|
||||||
|
{!hideActions && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 -mr-2">
|
||||||
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">More options</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`${base}/${textbook.id}`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Content
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { ChapterSidebarList } from "./chapter-sidebar-list"
|
|||||||
import { KnowledgePointPanel } from "./knowledge-point-panel"
|
import { KnowledgePointPanel } from "./knowledge-point-panel"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Edit2, Save } from "lucide-react"
|
import { Edit2, Save, Folder } from "lucide-react"
|
||||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||||
import { updateChapterContentAction } from "../actions"
|
import { updateChapterContentAction } from "../actions"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
||||||
|
|
||||||
interface TextbookContentLayoutProps {
|
interface TextbookContentLayoutProps {
|
||||||
chapters: Chapter[]
|
chapters: Chapter[]
|
||||||
@@ -50,29 +50,31 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-140px)]">
|
<div className="grid grid-cols-12 h-[calc(100vh-8rem)]">
|
||||||
{/* Left Sidebar: TOC (3 cols) */}
|
{/* Left Sidebar: TOC (3 cols) */}
|
||||||
<div className="col-span-3 border-r pr-6 flex flex-col h-full">
|
<div className="col-span-3 border-r flex flex-col h-full bg-muted/10">
|
||||||
<div className="flex items-center justify-between mb-4 px-2">
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
<h3 className="font-semibold">Chapters</h3>
|
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground">Contents</h3>
|
||||||
<CreateChapterDialog textbookId={textbookId} />
|
<CreateChapterDialog textbookId={textbookId} />
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1 px-2">
|
<ScrollArea className="flex-1">
|
||||||
<ChapterSidebarList
|
<div className="p-3">
|
||||||
chapters={chapters}
|
<ChapterSidebarList
|
||||||
selectedChapterId={selectedChapter?.id}
|
chapters={chapters}
|
||||||
onSelectChapter={handleSelectChapter}
|
selectedChapterId={selectedChapter?.id}
|
||||||
textbookId={textbookId}
|
onSelectChapter={handleSelectChapter}
|
||||||
/>
|
textbookId={textbookId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Middle: Content Viewer/Editor (6 cols) */}
|
{/* Middle: Content Viewer/Editor (6 cols) */}
|
||||||
<div className="col-span-6 flex flex-col h-full px-2 min-h-0">
|
<div className="col-span-6 flex flex-col h-full min-h-0 bg-background">
|
||||||
{selectedChapter ? (
|
{selectedChapter ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4 pb-2 border-b">
|
<div className="flex items-center justify-between px-8 py-4 border-b sticky top-0 bg-background/95 backdrop-blur z-10">
|
||||||
<h2 className="text-xl font-bold tracking-tight">{selectedChapter.title}</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{selectedChapter.title}</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
@@ -93,24 +95,29 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-4 min-h-full">
|
<div className="max-w-3xl mx-auto px-8 py-8 min-h-full">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Textarea
|
<RichTextEditor
|
||||||
className="min-h-[500px] font-mono text-sm"
|
|
||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
onChange={setEditContent}
|
||||||
placeholder="# Write markdown content here..."
|
className="min-h-[500px] border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
<div className="prose prose-zinc dark:prose-invert max-w-none prose-headings:font-bold prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-p:leading-relaxed">
|
||||||
{selectedChapter.content ? (
|
{selectedChapter.content ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
|
||||||
{selectedChapter.content}
|
{selectedChapter.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground italic py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground space-y-4">
|
||||||
No content available. Click edit to add content.
|
<div className="p-4 rounded-full bg-muted">
|
||||||
|
<Edit2 className="h-8 w-8 opacity-50" />
|
||||||
|
</div>
|
||||||
|
<p className="italic">No content available yet.</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
||||||
|
Start Writing
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,14 +126,17 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
|
||||||
Select a chapter from the left sidebar to view its content.
|
<div className="p-6 rounded-full bg-muted/30">
|
||||||
|
<Folder className="h-12 w-12 opacity-20" />
|
||||||
|
</div>
|
||||||
|
<p>Select a chapter to view or edit content</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar: Knowledge Points (3 cols) */}
|
{/* Right Sidebar: Knowledge Points (3 cols) */}
|
||||||
<div className="col-span-3 border-l pl-6 flex flex-col h-full">
|
<div className="col-span-3 border-l flex flex-col h-full bg-muted/10">
|
||||||
<KnowledgePointPanel
|
<KnowledgePointPanel
|
||||||
knowledgePoints={knowledgePoints}
|
knowledgePoints={knowledgePoints}
|
||||||
selectedChapterId={selectedChapter?.id || null}
|
selectedChapterId={selectedChapter?.id || null}
|
||||||
|
|||||||
@@ -21,21 +21,20 @@ export function TextbookFilters() {
|
|||||||
const hasFilters = Boolean(search || subject !== "all" || grade !== "all")
|
const hasFilters = Boolean(search || subject !== "all" || grade !== "all")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between bg-card p-4 rounded-lg border shadow-sm">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="relative w-full md:w-96">
|
<div className="relative w-full md:w-80">
|
||||||
<Search className="absolute left-2.5 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/50" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search textbooks..."
|
placeholder="Search by title, publisher..."
|
||||||
className="pl-9 bg-background"
|
className="pl-9 bg-background border-muted-foreground/20"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value || null)}
|
onChange={(e) => setSearch(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||||
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
|
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[160px] bg-background">
|
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||||
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<SelectValue placeholder="Subject" />
|
<SelectValue placeholder="Subject" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -43,17 +42,21 @@ export function TextbookFilters() {
|
|||||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
||||||
<SelectItem value="Physics">Physics</SelectItem>
|
<SelectItem value="Physics">Physics</SelectItem>
|
||||||
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
||||||
|
<SelectItem value="Biology">Biology</SelectItem>
|
||||||
<SelectItem value="English">English</SelectItem>
|
<SelectItem value="English">English</SelectItem>
|
||||||
<SelectItem value="History">History</SelectItem>
|
<SelectItem value="History">History</SelectItem>
|
||||||
|
<SelectItem value="Geography">Geography</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
|
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[160px] bg-background">
|
<SelectTrigger className="w-[130px] bg-background border-muted-foreground/20">
|
||||||
<SelectValue placeholder="Grade" />
|
<SelectValue placeholder="Grade" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Grades</SelectItem>
|
<SelectItem value="all">All Grades</SelectItem>
|
||||||
|
<SelectItem value="Grade 7">Grade 7</SelectItem>
|
||||||
|
<SelectItem value="Grade 8">Grade 8</SelectItem>
|
||||||
<SelectItem value="Grade 9">Grade 9</SelectItem>
|
<SelectItem value="Grade 9">Grade 9</SelectItem>
|
||||||
<SelectItem value="Grade 10">Grade 10</SelectItem>
|
<SelectItem value="Grade 10">Grade 10</SelectItem>
|
||||||
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
||||||
@@ -69,7 +72,7 @@ export function TextbookFilters() {
|
|||||||
setSubject(null)
|
setSubject(null)
|
||||||
setGrade(null)
|
setGrade(null)
|
||||||
}}
|
}}
|
||||||
className="h-10 px-3"
|
className="h-10 px-3 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
<X className="ml-2 h-4 w-4" />
|
<X className="ml-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -89,9 +89,11 @@ export function TextbookFormDialog() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
||||||
<SelectItem value="Physics">Physics</SelectItem>
|
<SelectItem value="Physics">Physics</SelectItem>
|
||||||
<SelectItem value="History">History</SelectItem>
|
|
||||||
<SelectItem value="English">English</SelectItem>
|
|
||||||
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
<SelectItem value="Chemistry">Chemistry</SelectItem>
|
||||||
|
<SelectItem value="Biology">Biology</SelectItem>
|
||||||
|
<SelectItem value="English">English</SelectItem>
|
||||||
|
<SelectItem value="History">History</SelectItem>
|
||||||
|
<SelectItem value="Geography">Geography</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function ReaderChapterItem({
|
|||||||
const isSelected = selectedId === chapter.id
|
const isSelected = selectedId === chapter.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
|
<div className={cn(level > 0 && "ml-2 border-l pl-2")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center group py-1 rounded-md transition-colors",
|
"flex items-center group py-1 rounded-md transition-colors",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { and, asc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm"
|
import { and, asc, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
@@ -394,3 +394,37 @@ export async function createKnowledgePoint(data: CreateKnowledgePointInput): Pro
|
|||||||
export async function deleteKnowledgePoint(id: string): Promise<void> {
|
export async function deleteKnowledgePoint(id: string): Promise<void> {
|
||||||
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id))
|
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise<void> {
|
||||||
|
const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1)
|
||||||
|
if (!target) throw new Error("Chapter not found")
|
||||||
|
|
||||||
|
const siblings = await db
|
||||||
|
.select()
|
||||||
|
.from(chapters)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chapters.textbookId, target.textbookId),
|
||||||
|
parentId ? eq(chapters.parentId, parentId) : isNull(chapters.parentId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(chapters.order))
|
||||||
|
|
||||||
|
const currentSiblings = siblings.filter((c) => c.id !== chapterId)
|
||||||
|
currentSiblings.splice(newIndex, 0, target)
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (let i = 0; i < currentSiblings.length; i++) {
|
||||||
|
const ch = currentSiblings[i]
|
||||||
|
if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) {
|
||||||
|
await tx
|
||||||
|
.update(chapters)
|
||||||
|
.set({
|
||||||
|
order: i,
|
||||||
|
parentId: ch.id === chapterId ? parentId : ch.parentId
|
||||||
|
})
|
||||||
|
.where(eq(chapters.id, ch.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
44
src/modules/users/actions.ts
Normal file
44
src/modules/users/actions.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { users } from "@/shared/db/schema"
|
||||||
|
|
||||||
|
export type UpdateUserProfileInput = {
|
||||||
|
name?: string
|
||||||
|
phone?: string
|
||||||
|
address?: string
|
||||||
|
gender?: string
|
||||||
|
age?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserProfile(data: UpdateUserProfileInput) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw new Error("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
const updateData: Partial<typeof users.$inferInsert> = {}
|
||||||
|
|
||||||
|
if (data.name !== undefined) updateData.name = data.name
|
||||||
|
// Convert empty strings to null for cleaner DB
|
||||||
|
if (data.phone !== undefined) updateData.phone = data.phone || null
|
||||||
|
if (data.address !== undefined) updateData.address = data.address || null
|
||||||
|
if (data.gender !== undefined) updateData.gender = data.gender || null
|
||||||
|
|
||||||
|
if (data.age !== undefined) updateData.age = data.age
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) return
|
||||||
|
|
||||||
|
await db.update(users)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
|
||||||
|
revalidatePath("/profile")
|
||||||
|
revalidatePath("/settings")
|
||||||
|
}
|
||||||
45
src/modules/users/data-access.ts
Normal file
45
src/modules/users/data-access.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { users } from "@/shared/db/schema"
|
||||||
|
|
||||||
|
export type UserProfile = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
image: string | null
|
||||||
|
role: string | null
|
||||||
|
phone: string | null
|
||||||
|
address: string | null
|
||||||
|
gender: string | null
|
||||||
|
age: number | null
|
||||||
|
onboardedAt: Date | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUserProfile = cache(async (userId: string): Promise<UserProfile | null> => {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
image: user.image,
|
||||||
|
role: user.role,
|
||||||
|
phone: user.phone,
|
||||||
|
address: user.address,
|
||||||
|
gender: user.gender,
|
||||||
|
age: user.age,
|
||||||
|
onboardedAt: user.onboardedAt,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-6 border bg-background p-6 shadow-xl shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -65,7 +65,7 @@ const AlertDialogFooter = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -79,7 +79,7 @@ const AlertDialogTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-lg font-semibold", className)}
|
className={cn("text-xl font-semibold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
@@ -109,6 +110,12 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
indicator?: "line" | "dot" | "dashed"
|
indicator?: "line" | "dot" | "dashed"
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
labelKey?: string
|
labelKey?: string
|
||||||
|
payload?: any[]
|
||||||
|
label?: any
|
||||||
|
labelFormatter?: any
|
||||||
|
labelClassName?: string
|
||||||
|
formatter?: any
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -256,8 +263,9 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
|
|
||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> & {
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
payload?: any[]
|
||||||
|
verticalAlign?: "top" | "middle" | "bottom"
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-6 border bg-background p-6 shadow-xl shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -59,7 +59,7 @@ const DialogHeader = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -73,7 +73,7 @@ const DialogFooter = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
|
|||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-xl font-semibold leading-none tracking-tight",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
28
src/shared/components/ui/progress.tsx
Normal file
28
src/shared/components/ui/progress.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
40
src/shared/components/ui/radio-group.tsx
Normal file
40
src/shared/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
))
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user