feat(classes): optimize teacher dashboard ui and implement grade management
This commit is contained in:
@@ -91,6 +91,9 @@
|
||||
- 若现有基础组件无法满足需求:
|
||||
1. 优先通过 Composition 在业务模块里封装“业务组件”
|
||||
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
|
||||
- **图表库**:统一使用 `Recharts`,禁止引入其他图表库(Chart.js / ECharts 等)。
|
||||
- 使用 `src/shared/components/ui/chart.tsx` 进行封装。
|
||||
- 遵循 Shadcn/UI Chart 规范。
|
||||
|
||||
### 2.4 Client Component 引用边界(强制)
|
||||
|
||||
|
||||
@@ -234,3 +234,69 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
|
||||
### 7.4 技术细节
|
||||
- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。
|
||||
- 优化 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
|
||||
|
||||
**Date**: 2025-12-23
|
||||
**Updated**: 2025-12-31
|
||||
**Updated**: 2026-01-13
|
||||
**Author**: DevOps Architect
|
||||
**Module**: Textbooks (`src/modules/textbooks`)
|
||||
|
||||
@@ -143,6 +143,51 @@ src/
|
||||
* 通过 `npm run lint / typecheck / build`。
|
||||
|
||||
## 8. 后续计划 (Next Steps)
|
||||
* [ ] **富文本编辑器**: 集成编辑器替换当前 Markdown Textarea,提升编辑体验。
|
||||
* [ ] **拖拽排序**: 实现章节树拖拽排序与持久化。
|
||||
* [x] **富文本编辑器**: 已集成 Tiptap 富文本编辑器,支持 Markdown 读写、即时预览与工具栏操作。
|
||||
* [x] **拖拽排序**: 实现章节树拖拽排序与持久化。
|
||||
* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 界面与交互优化 (2026-01-12)
|
||||
|
||||
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
|
||||
|
||||
### 9.1 卡片与列表 (Textbook Card & Filters)
|
||||
* **Dynamic Covers**: 卡片封面采用动态渐变色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),提升识别度。
|
||||
* **Information Density**: 增加元数据展示(Grade, Publisher, Chapter Count),并优化排版层级。
|
||||
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
|
||||
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
|
||||
|
||||
### 9.2 详情页工作台 (Detail Workbench)
|
||||
* **Immersive Layout**:
|
||||
* **Full Height**: 采用 `h-[calc(100vh-8rem)]` 撑满剩余空间,移除多余滚动条。
|
||||
* **Sticky Header**: 章节标题与操作栏吸顶,内容区独立滚动。
|
||||
* **Typography**: 引入 `prose-zinc` 与优化的字体排版,提升阅读舒适度。
|
||||
* **Sidebar Refinement**:
|
||||
* **Chapter Tree**: 增加左侧边框线与层级缩进,选中态更明显;操作按钮(添加/删除)仅在 Hover 时显示,减少视觉干扰。
|
||||
* **Knowledge Points**: 改为卡片式列表,Hover 显示删除按钮;增加空状态引导。
|
||||
* **Drag & Drop**: 集成 `@dnd-kit` 实现章节拖拽排序,支持同级拖动并实时持久化到数据库。
|
||||
|
||||
### 9.3 富文本编辑器 (Rich Text Editor)
|
||||
* **Tiptap Integration**: 引入 `@tiptap/react` 替换原有的 Textarea。
|
||||
* **Markdown Support**: 支持 Markdown 源码读写,保持数据格式兼容性。
|
||||
* **Toolbar**: 实现悬浮工具栏,支持 Bold, Italic, Headings, Lists, Blockquote 等常用格式。
|
||||
* **SSR Fix**: 解决 Tiptap 在 Next.js 中的 Hydration Mismatch 问题 (`immediatelyRender: false`)。
|
||||
|
||||
### 9.4 系统组件优化 (UI Components)
|
||||
* **Dialog**:
|
||||
* 优化遮罩层 (`backdrop-blur`) 与弹窗阴影,提升通透感。
|
||||
* 调整动画时长 (`duration-200`) 与缓动,移除位移动画,改为纯净的 Fade + Zoom 效果。
|
||||
* 增加内部间距 (`gap-6`) 与圆角 (`rounded-xl`),使排版更现代。
|
||||
* **Create Chapter Dialog**: 优化触发按钮样式,增加 `sr-only` 辅助文本,修复点击区域过小的问题。
|
||||
|
||||
---
|
||||
|
||||
## 10. 近期改进 (2026-01-13)
|
||||
|
||||
### 10.1 导航体验 (Navigation)
|
||||
* **Dynamic Breadcrumbs**: 接入全局动态面包屑系统。
|
||||
* 支持从路由路径(e.g., `/teacher/textbooks/123`)自动生成层级导航。
|
||||
* 解决了深层嵌套页面(如教材详情页)缺乏上下文回退路径的问题。
|
||||
|
||||
|
||||
@@ -153,7 +153,37 @@ type ExamNode = {
|
||||
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
|
||||
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
|
||||
|
||||
## 7. 变更记录(合并 Homework)
|
||||
### 6.5 `getExamPreviewAction` (新增)
|
||||
- **入参**: `examId` (string)
|
||||
- **行为**:
|
||||
- 查询指定 exam 及其关联的 questions (通过 `exam_questions` 关系)。
|
||||
- 返回完整的 `structure` (JSON 树) 和扁平化的 `questions` 列表。
|
||||
- 用于预览弹窗的数据加载。
|
||||
|
||||
## 7. 变更记录
|
||||
|
||||
**日期**:2026-01-12 (当前)
|
||||
|
||||
- **列表页优化 (`/teacher/exams/all`)**:
|
||||
- 移除了冗余的 "All Exams" 页面标题和描述。
|
||||
- 重构了表格列 (`ExamColumns`):
|
||||
- 合并标题、标签、科目、年级为 "Exam Info" 列。
|
||||
- 合并题目数、总分、时长为 "Stats" 列。
|
||||
- 合并创建时间和预定时间为 "Date" 列。
|
||||
- 优化了状态 (Status) 和难度 (Difficulty) 的视觉样式 (Badge, Progress bar)。
|
||||
- 优化了表格分页和布局 (`ExamDataTable`)。
|
||||
|
||||
- **预览功能增强**:
|
||||
- 新增直接预览功能:在操作列添加了 "View" (眼睛图标) 按钮。
|
||||
- 点击 "View" 触发 `getExamPreviewAction` 获取完整试卷数据。
|
||||
- 弹窗 (`Dialog`) 直接展示试卷内容 (`ExamPaperPreview`),移除了冗余的头部描述,优化了滚动体验。
|
||||
- 修复了可访问性问题 (DialogTitle)。
|
||||
|
||||
- **组卷页面升级 (`/teacher/exams/[id]/build`)**:
|
||||
- **布局重构**: 扩展工作区高度,调整左右面板比例 (2:1),优化头部信息展示和进度可视化。
|
||||
- **题库增强**: 实现了基于 Server Action (`getQuestionsAction`) 的分页加载和服务器端筛选,提升大数据量下的性能;优化了搜索和筛选器 UI。
|
||||
- **预览优化**: 移除了内联预览,改为通过 "Preview" 按钮触发弹窗预览,避免干扰编辑流。
|
||||
- **视觉降噪**: 移除了页面顶部冗余的标题和描述。
|
||||
|
||||
**日期**:2025-12-31
|
||||
|
||||
|
||||
@@ -268,3 +268,39 @@
|
||||
- `npm run lint`: 通过
|
||||
- `npm run typecheck`: 通过
|
||||
- `npm run build`: 通过
|
||||
|
||||
---
|
||||
|
||||
## 12. UI/UX 优化更新(2026-01-12)
|
||||
|
||||
### 12.1 教师端作业列表 (`/teacher/homework/assignments`)
|
||||
|
||||
- **表格重构**: 从简单的卡片列表升级为功能丰富的数据表格(Table)。
|
||||
- **信息增强**: 合并展示标题/时间,使用 Badge 区分状态,清晰展示截止日期(含 Late 标记),可视化提交进度。
|
||||
- **操作便捷**: 每行增加操作菜单(Actions),支持快速跳转详情或提交列表。
|
||||
|
||||
### 12.2 作业详情页 (`/teacher/homework/assignments/[id]`)
|
||||
|
||||
- **布局重构**:
|
||||
- **Sticky Header**: 头部信息栏(标题、状态、面包屑)随滚动吸顶,但后续优化为随页面滚动(移除 Sticky)以节省空间。
|
||||
- **关键指标**: 将截止日期、目标数、提交数、已批改数整合到头部下方,使用图标增强可读性。
|
||||
- **双栏布局**: 主体内容分为“Performance Analytics”(分析)和“Assignment Content”(内容)两部分。
|
||||
- **图表升级**:
|
||||
- 重构 `HomeworkAssignmentQuestionErrorOverviewCard`,废弃 SVG,改用 **Recharts** 实现柱状图(BarChart)。
|
||||
- 增强交互:支持 Tooltip 悬停查看具体题目错误率和人数。
|
||||
- **详情面板优化**:
|
||||
- 移除了冗余的 `HomeworkAssignmentQuestionErrorDetailsCard`。
|
||||
- 深度优化 `HomeworkAssignmentQuestionErrorDetailPanel`:
|
||||
- 增加饼图展示单题错误率。
|
||||
- 错误答案列表卡片化,清晰展示每个错误答案的内容及选择人数。
|
||||
- 整合预览面板与详情面板,提供更连贯的“左侧选题-右侧分析”体验。
|
||||
|
||||
---
|
||||
|
||||
## 13. Bug 修复与完善 (2026-01-13)
|
||||
|
||||
### 13.1 批改视图 (Grading View)
|
||||
- **Type Safety Fix**: 修复了 `HomeworkGradingView` 组件中的 TypeScript 类型错误。
|
||||
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
|
||||
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。
|
||||
|
||||
|
||||
@@ -114,7 +114,10 @@ Next_Edu 旨在对抗教育系统常见的信息过载。我们的设计风格
|
||||
* **Height**: `64px` (h-16).
|
||||
* **Layout**: `flex items-center justify-between px-6 border-b`.
|
||||
* **Components**:
|
||||
1. **Breadcrumb**: 显示当前路径,层级清晰。
|
||||
1. **Breadcrumb**: 动态路径导航 (Dynamic Breadcrumb).
|
||||
* **Implementation**: 基于 `usePathname()` 自动解析路由段。
|
||||
* **Mapping**: 通过 `NAV_CONFIG` 或 `BREADCRUMB_MAP` 映射路径到友好标题 (e.g., `/teacher/textbooks` -> "Textbooks").
|
||||
* **Filtering**: 自动过滤根角色路径 (e.g., `/teacher`) 以保持简洁。
|
||||
2. **Global Search**: `Cmd+K` 触发,居中或靠右。
|
||||
3. **User Nav**: 头像 + 下拉菜单。
|
||||
|
||||
|
||||
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,15 +54,8 @@
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1767782500000,
|
||||
"tag": "0007_add_class_invitation_code",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1767941300000,
|
||||
"tag": "0008_add_user_profile_fields",
|
||||
"when": 1768205524480,
|
||||
"tag": "0007_talented_bromley",
|
||||
"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-dialog": "^1.1.15",
|
||||
"@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-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -33,6 +35,10 @@
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-placeholder": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3",
|
||||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -52,6 +58,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
textbooks, chapters,
|
||||
schools,
|
||||
grades,
|
||||
classes, classEnrollments, classSchedule
|
||||
classes, classEnrollments, classSchedule,
|
||||
subjects
|
||||
} from "../src/shared/db/schema";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { faker } from "@faker-js/faker";
|
||||
@@ -43,7 +44,7 @@ async function seed() {
|
||||
"submission_answers", "exam_submissions", "exam_questions", "exams",
|
||||
"questions_to_knowledge_points", "questions", "knowledge_points",
|
||||
"chapters", "textbooks",
|
||||
"grades", "schools",
|
||||
"grades", "schools", "subjects",
|
||||
"users_to_roles", "roles", "users", "accounts", "sessions"
|
||||
];
|
||||
for (const table of tables) {
|
||||
@@ -133,6 +134,17 @@ async function seed() {
|
||||
{ id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" },
|
||||
])
|
||||
|
||||
// --- Seeding Subjects ---
|
||||
await db.insert(subjects).values([
|
||||
{ id: createId(), name: "Mathematics", code: "MATH", order: 1 },
|
||||
{ id: createId(), name: "Physics", code: "PHYS", order: 2 },
|
||||
{ id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
|
||||
{ id: createId(), name: "English", code: "ENG", order: 4 },
|
||||
{ id: createId(), name: "History", code: "HIST", order: 5 },
|
||||
{ id: createId(), name: "Geography", code: "GEO", order: 6 },
|
||||
{ id: createId(), name: "Biology", code: "BIO", order: 7 },
|
||||
])
|
||||
|
||||
await db.insert(grades).values([
|
||||
{
|
||||
id: grade10Id,
|
||||
|
||||
31
src/app/(dashboard)/management/grade/classes/page.tsx
Normal file
31
src/app/(dashboard)/management/grade/classes/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { auth } from "@/auth"
|
||||
import { getGradeManagedClasses, getManagedGrades, getTeacherOptions } from "@/modules/classes/data-access"
|
||||
import { GradeClassesClient } from "@/modules/classes/components/grade-classes-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function GradeClassesPage() {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id ?? ""
|
||||
|
||||
const [classes, teachers, managedGrades] = await Promise.all([
|
||||
getGradeManagedClasses(userId),
|
||||
getTeacherOptions(),
|
||||
getManagedGrades(userId),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Class Management</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage classes for your grades.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GradeClassesClient classes={classes} teachers={teachers} managedGrades={managedGrades} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action="/teacher/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label className="text-sm font-medium">Grade</label>
|
||||
<select
|
||||
name="gradeId"
|
||||
@@ -4,14 +4,16 @@ import { redirect } from "next/navigation"
|
||||
import { auth } from "@/auth"
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
||||
import { StudentRankingCard } from "@/modules/dashboard/components/student-dashboard/student-ranking-card"
|
||||
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
||||
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
||||
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -20,17 +22,31 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return "-"
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
|
||||
const name = session.user.name ?? "User"
|
||||
const email = session.user.email ?? "-"
|
||||
const role = String(session.user.role ?? "teacher")
|
||||
const userId = String(session.user.id ?? "").trim()
|
||||
const userProfile = await getUserProfile(userId)
|
||||
|
||||
if (!userProfile) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const role = userProfile.role || "student"
|
||||
const isStudent = role === "student"
|
||||
|
||||
const studentData =
|
||||
role === "student" && userId
|
||||
isStudent
|
||||
? await (async () => {
|
||||
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
|
||||
getStudentClasses(userId),
|
||||
@@ -96,36 +112,104 @@ export default async function ProfilePage() {
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
|
||||
<div className="text-sm text-muted-foreground">Your account information.</div>
|
||||
<div className="text-sm text-muted-foreground">Manage your personal and account information.</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings">Open settings</Link>
|
||||
<Link href="/settings">Edit Profile</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
<CardDescription>Signed-in user details from session.</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Personal Information
|
||||
</CardTitle>
|
||||
<CardDescription>Basic personal details.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{role}
|
||||
</Badge>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Full Name</div>
|
||||
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Gender</div>
|
||||
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Age</div>
|
||||
<div className="text-sm">{userProfile.age ?? "-"}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Phone</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
|
||||
{userProfile.phone ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 sm:col-span-2 space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Address</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
|
||||
{userProfile.address ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{email}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Account Information
|
||||
</CardTitle>
|
||||
<CardDescription>System account details.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="col-span-1 sm:col-span-2 space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Email</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-3 w-3 text-muted-foreground" />
|
||||
{userProfile.email}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Role</div>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{userProfile.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Member Since</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||
{formatDate(userProfile.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
{formatDate(userProfile.onboardedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{studentData ? (
|
||||
<div className="space-y-6">
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold tracking-tight">Student</h2>
|
||||
<div className="text-sm text-muted-foreground">Your learning overview.</div>
|
||||
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
|
||||
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
|
||||
</div>
|
||||
|
||||
<StudentStatsGrid
|
||||
@@ -133,16 +217,17 @@ export default async function ProfilePage() {
|
||||
dueSoonCount={studentData.dueSoonCount}
|
||||
overdueCount={studentData.overdueCount}
|
||||
gradedCount={studentData.gradedCount}
|
||||
ranking={studentData.grades.ranking}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StudentGradesCard grades={studentData.grades} />
|
||||
<StudentRankingCard ranking={studentData.grades.ranking} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
|
||||
<StudentGradesCard grades={studentData.grades} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { auth } from "@/auth"
|
||||
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
|
||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -11,11 +12,16 @@ export default async function SettingsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
|
||||
const role = String(session.user.role ?? "teacher")
|
||||
const userId = String(session.user.id ?? "").trim()
|
||||
const userProfile = await getUserProfile(userId)
|
||||
|
||||
if (role === "admin") return <AdminSettingsView />
|
||||
if (role === "student") return <StudentSettingsView user={session.user} />
|
||||
if (role === "teacher") return <TeacherSettingsView user={session.user} />
|
||||
if (!userProfile) redirect("/login")
|
||||
|
||||
const role = userProfile.role || "student"
|
||||
|
||||
if (role === "admin") return <AdminSettingsView user={userProfile} />
|
||||
if (role === "student") return <StudentSettingsView user={userProfile} />
|
||||
if (role === "teacher") return <TeacherSettingsView user={userProfile} />
|
||||
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation"
|
||||
|
||||
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
|
||||
import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -18,6 +19,23 @@ export default async function StudentAssignmentTakePage({
|
||||
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
||||
if (!data) return notFound()
|
||||
|
||||
// If status is graded or submitted, use the review view
|
||||
const status = data.submission?.status
|
||||
if (status === "graded" || status === "submitted") {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeworkReviewView initialData={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -31,6 +31,18 @@ const getStatusLabel = (status: string) => {
|
||||
return "Not started"
|
||||
}
|
||||
|
||||
const getActionLabel = (status: string) => {
|
||||
if (status === "graded") return "Review"
|
||||
if (status === "submitted") return "View"
|
||||
if (status === "in_progress") return "Continue"
|
||||
return "Start"
|
||||
}
|
||||
|
||||
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
if (status === "graded" || status === "submitted") return "outline"
|
||||
return "default"
|
||||
}
|
||||
|
||||
export default async function StudentAssignmentsPage() {
|
||||
const student = await getDemoStudentUser()
|
||||
|
||||
@@ -75,6 +87,7 @@ export default async function StudentAssignmentsPage() {
|
||||
<TableHead>Due</TableHead>
|
||||
<TableHead>Attempts</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -95,6 +108,13 @@ export default async function StudentAssignmentsPage() {
|
||||
{a.attemptsUsed}/{a.maxAttempts}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||
{getActionLabel(a.progressStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -39,36 +39,40 @@ export default async function StudentTextbookDetailPage({
|
||||
if (!textbook) notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<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/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
|
||||
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground" asChild>
|
||||
<Link href="/student/learning/textbooks">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="w-px h-8 bg-border mx-2" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{textbook.subject}</Badge>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||
{textbook.grade ?? "-"}
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold tracking-tight truncate mr-2">{textbook.title}</h1>
|
||||
<Badge variant="secondary" className="font-normal text-xs">{textbook.subject}</Badge>
|
||||
{textbook.grade && (
|
||||
<span className="text-xs text-muted-foreground border px-1.5 py-0.5 rounded">
|
||||
{textbook.grade}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden pt-6">
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
{chapters.length === 0 ? (
|
||||
<div className="px-8">
|
||||
<div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title="No chapters"
|
||||
description="This textbook has no chapters yet."
|
||||
className="bg-card"
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[calc(100vh-140px)] px-8 min-h-0">
|
||||
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
||||
<TextbookReader chapters={chapters} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default async function StudentTextbooksPage({
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{textbooks.map((textbook) => (
|
||||
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" />
|
||||
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" hideActions />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { BookOpen, Calendar, ChevronRight, Clock, Users } from "lucide-react"
|
||||
|
||||
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
|
||||
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -23,6 +25,15 @@ const formatNumber = (v: number | null, digits = 1) => {
|
||||
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({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -63,253 +74,304 @@ export default async function ClassDetailPage({
|
||||
]
|
||||
|
||||
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="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/classes/my">Back</Link>
|
||||
</Button>
|
||||
<Badge variant="secondary">{insights.class.grade}</Badge>
|
||||
<Badge variant="outline">{insights.studentCounts.total} students</Badge>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
||||
<Link href="/teacher/classes/my" className="hover:text-foreground transition-colors">
|
||||
My Classes
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="text-foreground font-medium">{insights.class.name}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"}
|
||||
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null}
|
||||
<h2 className="text-3xl font-bold tracking-tight">{insights.class.name}</h2>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary" className="rounded-sm font-normal">
|
||||
{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 className="flex flex-wrap items-center gap-2">
|
||||
<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 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 asChild variant="outline">
|
||||
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(insights.class.id)}`}>Insights</Link>
|
||||
<Button asChild>
|
||||
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>
|
||||
Create Homework
|
||||
</Link>
|
||||
</Button>
|
||||
</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>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Students</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Students</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
|
||||
{insights.studentCounts.active} active · {insights.studentCounts.inactive} inactive
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Schedule items</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Schedule Items</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Assignments</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{insights.assignments.length}</div>
|
||||
<div className="text-xs text-muted-foreground">{latest ? `Latest ${formatDate(latest.createdAt)}` : "No homework yet"}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Overall avg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{insights.assignments.filter((a) => a.isActive).length}
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{latest ? (
|
||||
<div className="grid gap-6 lg:grid-cols-7">
|
||||
{/* Main Content Area */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
{/* Latest Homework */}
|
||||
{latest && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Latest homework</CardTitle>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{latest.title}</span>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Latest Homework</CardTitle>
|
||||
<CardDescription>Most recent assignment activity</CardDescription>
|
||||
</div>
|
||||
<Badge variant={latest.isActive ? "default" : "secondary"}>
|
||||
{latest.status}
|
||||
</Badge>
|
||||
<span>·</span>
|
||||
<span>{formatDate(latest.createdAt)}</span>
|
||||
{latest.dueAt ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>Due {formatDate(latest.dueAt)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-5">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Targeted</div>
|
||||
<div className="text-lg font-semibold">{latest.targetCount}</div>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href={`/teacher/homework/assignments/${latest.assignmentId}`}
|
||||
className="font-semibold hover:underline"
|
||||
>
|
||||
{latest.title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Due {latest.dueAt ? formatDate(latest.dueAt) : "No due date"}</span>
|
||||
<span>·</span>
|
||||
<span>{latest.submittedCount}/{latest.targetCount} Submitted</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Submitted</div>
|
||||
<div className="text-lg font-semibold">{latest.submittedCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Graded</div>
|
||||
<div className="text-lg font-semibold">{latest.gradedCount}</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>
|
||||
Grade
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 border-t pt-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{latest.gradedCount}</div>
|
||||
<div className="text-xs text-muted-foreground">Graded</div>
|
||||
</div>
|
||||
<div className="text-center border-l border-r">
|
||||
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.avg, 1)}</div>
|
||||
<div className="text-xs text-muted-foreground">Average</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.median, 1)}</div>
|
||||
<div className="text-xs text-muted-foreground">Median</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Average</div>
|
||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Median</div>
|
||||
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Students Preview */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Students (preview)</CardTitle>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
|
||||
<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-sm text-muted-foreground">No students enrolled.</div>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No students enrolled yet.
|
||||
</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">
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{/* 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 className="text-base">Schedule</CardTitle>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Homework History */}
|
||||
<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"}>
|
||||
<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 asChild size="sm" variant={hwFilter === "active" ? "secondary" : "outline"}>
|
||||
<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 asChild size="sm" variant={hwFilter === "overdue" ? "secondary" : "outline"}>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
<Badge variant={a.isActive ? "default" : "secondary"} className="shrink-0 text-[10px] h-5">
|
||||
{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>
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,16 +13,6 @@ export default function MyClassesPage() {
|
||||
|
||||
async function MyClassesPageImpl() {
|
||||
const classes = await getTeacherClasses()
|
||||
const session = await auth()
|
||||
const role = String(session?.user?.role ?? "")
|
||||
const userId = String(session?.user?.id ?? "").trim()
|
||||
|
||||
const canCreateClass = await (async () => {
|
||||
if (role === "admin") return true
|
||||
if (!userId) return false
|
||||
const [row] = await db.select({ id: grades.id }).from(grades).where(eq(grades.gradeHeadId, userId)).limit(1)
|
||||
return Boolean(row)
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
@@ -35,7 +25,7 @@ async function MyClassesPageImpl() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MyClassesGrid classes={classes} canCreateClass={canCreateClass} />
|
||||
<MyClassesGrid classes={classes} canCreateClass={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,13 +21,15 @@ async function StudentsResults({ searchParams }: { searchParams: Promise<SearchP
|
||||
|
||||
const q = getParam(params, "q") || undefined
|
||||
const classId = getParam(params, "classId")
|
||||
const status = getParam(params, "status")
|
||||
|
||||
const filteredStudents = await getClassStudents({
|
||||
q,
|
||||
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) {
|
||||
return (
|
||||
|
||||
@@ -12,9 +12,8 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
const exam = await getExamById(id)
|
||||
if (!exam) return notFound()
|
||||
|
||||
// Fetch all available questions (for selection pool)
|
||||
// In a real app, this might be paginated or filtered by exam subject/grade
|
||||
const { data: questionsData } = await getQuestions({ pageSize: 100 })
|
||||
// Fetch initial questions for the bank (pagination handled by client)
|
||||
const { data: questionsData } = await getQuestions({ pageSize: 20 })
|
||||
|
||||
const initialSelected = (exam.questions || []).map(q => ({
|
||||
id: q.id,
|
||||
@@ -103,13 +102,7 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Build Exam</h2>
|
||||
<p className="text-muted-foreground">Add questions and adjust scores.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<ExamAssembly
|
||||
examId={exam.id}
|
||||
title={exam.title}
|
||||
|
||||
@@ -131,13 +131,6 @@ export default async function AllExamsPage({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
|
||||
<p className="text-muted-foreground">View and manage all your exams.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ExamFilters />
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/shared/components/ui/breadcrumb"
|
||||
|
||||
export default function CreateExamPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
|
||||
<div className="space-y-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Create</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Create Exam</h2>
|
||||
<p className="text-muted-foreground">Design a new exam for your students.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Set up a new exam draft and choose your assembly method.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExamForm />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,12 +2,12 @@ import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
|
||||
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
|
||||
import { HomeworkAssignmentQuestionErrorDetailsCard } from "@/modules/homework/components/homework-assignment-question-error-details-card"
|
||||
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -20,82 +20,82 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
|
||||
const { assignment, questions, gradedSampleCount } = analytics
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<div className="flex flex-col min-h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background px-8 py-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<Link href="/teacher/homework/assignments" className="flex items-center hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Assignments
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>Details</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{assignment.title}</h1>
|
||||
<Badge variant={assignment.status === "published" ? "default" : "secondary"} className="capitalize">
|
||||
{assignment.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">{assignment.description || "—"}</p>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
<span>Source Exam: {assignment.sourceExamTitle}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Created: {formatDate(assignment.createdAt)}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm max-w-2xl">{assignment.description || "No description provided."}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/homework/assignments">Back</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>View Submissions</Link>
|
||||
<div className="flex items-center gap-3 mt-2 md:mt-0">
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
View Submissions
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Targets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{assignment.targetCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Submissions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{assignment.submissionCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Due</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Late:{" "}
|
||||
{assignment.allowLate
|
||||
? assignment.lateDueAt
|
||||
? formatDate(assignment.lateDueAt)
|
||||
: "Allowed"
|
||||
: "Not allowed"}
|
||||
{/* Quick Stats Row */}
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-6 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Due: <span className="font-medium text-foreground">{assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>Targets: <span className="font-medium text-foreground">{assignment.targetCount}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<span>Submissions: <span className="font-medium text-foreground">{assignment.submissionCount}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span>Graded: <span className="font-medium text-foreground">{gradedSampleCount}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="flex-1 p-8 space-y-8 bg-muted/5">
|
||||
{/* Analytics Section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Performance Analytics</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-1">
|
||||
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||
<HomeworkAssignmentQuestionErrorDetailsCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content Section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Assignment Content</h2>
|
||||
</div>
|
||||
<HomeworkAssignmentExamContentCard
|
||||
structure={assignment.structure}
|
||||
questions={questions}
|
||||
gradedSampleCount={gradedSampleCount}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
|
||||
status={submission.status}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
prevSubmissionId={submission.prevSubmissionId}
|
||||
nextSubmissionId={submission.nextSubmissionId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
||||
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
|
||||
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
|
||||
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
|
||||
className="bg-card"
|
||||
className="min-h-[400px] border-muted-foreground/10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
|
||||
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 p-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { and, eq, sql, or, inArray } from "drizzle-orm"
|
||||
import { auth } from "@/auth"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { grades } from "@/shared/db/schema"
|
||||
import { grades, classes } from "@/shared/db/schema"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import {
|
||||
createAdminClass,
|
||||
@@ -138,6 +138,201 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGradeClassAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) return { success: false, message: "Unauthorized" }
|
||||
|
||||
const schoolName = formData.get("schoolName")
|
||||
const schoolId = formData.get("schoolId")
|
||||
const name = formData.get("name")
|
||||
const grade = formData.get("grade")
|
||||
const gradeId = formData.get("gradeId")
|
||||
const teacherId = formData.get("teacherId")
|
||||
const homeroom = formData.get("homeroom")
|
||||
const room = formData.get("room")
|
||||
|
||||
if (typeof name !== "string" || name.trim().length === 0) {
|
||||
return { success: false, message: "Class name is required" }
|
||||
}
|
||||
if (typeof gradeId !== "string" || gradeId.trim().length === 0) {
|
||||
return { success: false, message: "Grade selection is required" }
|
||||
}
|
||||
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
|
||||
return { success: false, message: "Teacher is required" }
|
||||
}
|
||||
|
||||
// Verify access
|
||||
const [managedGrade] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||
.limit(1)
|
||||
|
||||
if (!managedGrade) {
|
||||
return { success: false, message: "You do not have permission to create classes for this grade" }
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createAdminClass({
|
||||
schoolName: typeof schoolName === "string" ? schoolName : null,
|
||||
schoolId: typeof schoolId === "string" ? schoolId : null,
|
||||
name,
|
||||
grade: typeof grade === "string" ? grade : "", // Should be passed from UI based on selected grade
|
||||
gradeId,
|
||||
teacherId,
|
||||
homeroom: typeof homeroom === "string" ? homeroom : null,
|
||||
room: typeof room === "string" ? room : null,
|
||||
})
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGradeClassAction(
|
||||
classId: string,
|
||||
prevState: ActionState | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) return { success: false, message: "Unauthorized" }
|
||||
|
||||
const schoolName = formData.get("schoolName")
|
||||
const schoolId = formData.get("schoolId")
|
||||
const name = formData.get("name")
|
||||
const grade = formData.get("grade")
|
||||
const gradeId = formData.get("gradeId")
|
||||
const teacherId = formData.get("teacherId")
|
||||
const homeroom = formData.get("homeroom")
|
||||
const room = formData.get("room")
|
||||
const subjectTeachers = formData.get("subjectTeachers")
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
// Verify access: Check if the class belongs to a managed grade
|
||||
const [cls] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
|
||||
if (!cls || !cls.gradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
}
|
||||
|
||||
const [managedGrade] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||
.limit(1)
|
||||
|
||||
if (!managedGrade) {
|
||||
return { success: false, message: "You do not have permission to update this class" }
|
||||
}
|
||||
|
||||
// If changing gradeId, verify target grade too
|
||||
if (typeof gradeId === "string" && gradeId !== cls.gradeId) {
|
||||
const [targetGrade] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||
.limit(1)
|
||||
|
||||
if (!targetGrade) {
|
||||
return { success: false, message: "You do not have permission to move class to this grade" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAdminClass(classId, {
|
||||
schoolName: typeof schoolName === "string" ? schoolName : undefined,
|
||||
schoolId: typeof schoolId === "string" ? schoolId : undefined,
|
||||
name: typeof name === "string" ? name : undefined,
|
||||
grade: typeof grade === "string" ? grade : undefined,
|
||||
gradeId: typeof gradeId === "string" ? gradeId : undefined,
|
||||
teacherId: typeof teacherId === "string" ? teacherId : undefined,
|
||||
homeroom: typeof homeroom === "string" ? homeroom : undefined,
|
||||
room: typeof room === "string" ? room : undefined,
|
||||
})
|
||||
|
||||
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
|
||||
const parsed = JSON.parse(subjectTeachers) as unknown
|
||||
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
|
||||
|
||||
await setClassSubjectTeachers({
|
||||
classId,
|
||||
assignments: parsed.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const subject = (item as { subject?: unknown }).subject
|
||||
const teacherId = (item as { teacherId?: unknown }).teacherId
|
||||
|
||||
if (typeof subject !== "string" || !isClassSubject(subject)) return []
|
||||
|
||||
if (teacherId === null || typeof teacherId === "undefined") {
|
||||
return [{ subject, teacherId: null }]
|
||||
}
|
||||
|
||||
if (typeof teacherId !== "string") return []
|
||||
const trimmed = teacherId.trim()
|
||||
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class updated successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) return { success: false, message: "Unauthorized" }
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
// Verify access
|
||||
const [cls] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
|
||||
if (!cls || !cls.gradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
}
|
||||
|
||||
const [managedGrade] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
|
||||
.limit(1)
|
||||
|
||||
if (!managedGrade) {
|
||||
return { success: false, message: "You do not have permission to delete this class" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminClass(classId)
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrollStudentByEmailAction(
|
||||
classId: string,
|
||||
prevState: ActionState | null,
|
||||
@@ -171,14 +366,19 @@ export async function joinClassByInvitationCodeAction(
|
||||
}
|
||||
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || String(session.user.role ?? "") !== "student") {
|
||||
const role = String(session?.user?.role ?? "")
|
||||
if (!session?.user?.id || (role !== "student" && role !== "teacher")) {
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
try {
|
||||
const classId = await enrollStudentByInvitationCode(session.user.id, code)
|
||||
if (role === "student") {
|
||||
revalidatePath("/student/learning/courses")
|
||||
revalidatePath("/student/schedule")
|
||||
} else {
|
||||
revalidatePath("/teacher/classes/my")
|
||||
}
|
||||
revalidatePath("/profile")
|
||||
return { success: true, message: "Joined class successfully", data: { classId } }
|
||||
} catch (error) {
|
||||
|
||||
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 { useMemo, useState } from "react"
|
||||
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 { parseAsString, useQueryState } from "nuqs"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -41,6 +54,7 @@ import {
|
||||
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import type { TeacherClass } from "../types"
|
||||
import {
|
||||
createTeacherClassAction,
|
||||
@@ -48,12 +62,25 @@ import {
|
||||
ensureClassInvitationCodeAction,
|
||||
regenerateClassInvitationCodeAction,
|
||||
updateTeacherClassAction,
|
||||
joinClassByInvitationCodeAction,
|
||||
} from "../actions"
|
||||
|
||||
const GRADIENTS = [
|
||||
"bg-card border-border",
|
||||
"bg-card border-border",
|
||||
"bg-card border-border",
|
||||
"bg-card border-border",
|
||||
"bg-card border-border",
|
||||
]
|
||||
|
||||
function getClassGradient(id: string) {
|
||||
return "bg-card border-border shadow-sm hover:shadow-md"
|
||||
}
|
||||
|
||||
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
|
||||
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
|
||||
@@ -75,41 +102,44 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
|
||||
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
const handleJoin = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await createTeacherClassAction(null, formData)
|
||||
const res = await joinClassByInvitationCodeAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setCreateOpen(false)
|
||||
toast.success(res.message || "Joined class successfully")
|
||||
setJoinOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to create class")
|
||||
toast.error(res.message || "Failed to join class")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create class")
|
||||
toast.error("Failed to join class")
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-6">
|
||||
{/* 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="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
|
||||
placeholder="Search classes..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value || null)}
|
||||
className="pl-9 bg-background"
|
||||
/>
|
||||
</div>
|
||||
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Grade" />
|
||||
<SelectTrigger className="w-[160px] bg-background">
|
||||
<SelectValue placeholder="All Grades" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All grades</SelectItem>
|
||||
<SelectItem value="all">All Grades</SelectItem>
|
||||
{gradeOptions.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{g}
|
||||
@@ -120,83 +150,56 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
{(q || grade !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setQ(null)
|
||||
setGrade(null)
|
||||
}}
|
||||
title="Clear filters"
|
||||
>
|
||||
Reset
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
open={joinOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!canCreateClass) return
|
||||
if (isWorking) return
|
||||
setCreateOpen(open)
|
||||
setJoinOpen(open)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" disabled={isWorking || !canCreateClass}>
|
||||
<Button className="gap-2 shadow-sm" disabled={isWorking}>
|
||||
<Plus className="size-4" />
|
||||
New class
|
||||
Join Class
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create class</DialogTitle>
|
||||
<DialogDescription>Add a new class to start managing students.</DialogDescription>
|
||||
<DialogTitle>Join Class</DialogTitle>
|
||||
<DialogDescription>Enter the invitation code to join a class.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleCreate}>
|
||||
<form action={handleJoin}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-school-name" className="text-right">
|
||||
School
|
||||
<Label htmlFor="join-code" className="text-right">
|
||||
Code
|
||||
</Label>
|
||||
<Input
|
||||
id="create-school-name"
|
||||
name="schoolName"
|
||||
id="join-code"
|
||||
name="code"
|
||||
className="col-span-3"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 1A" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-grade" className="text-right">
|
||||
Grade
|
||||
</Label>
|
||||
<Input
|
||||
id="create-grade"
|
||||
name="grade"
|
||||
className="col-span-3"
|
||||
placeholder="e.g. Grade 7"
|
||||
defaultValue={defaultGrade}
|
||||
placeholder="e.g. 123456"
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="\d{6}"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-homeroom" className="text-right">
|
||||
Homeroom
|
||||
</Label>
|
||||
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="create-room" className="text-right">
|
||||
Room
|
||||
</Label>
|
||||
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Creating..." : "Create"}
|
||||
{isWorking ? "Joining..." : "Join Class"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -204,34 +207,33 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
</Dialog>
|
||||
</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 ? (
|
||||
<EmptyState
|
||||
title="No classes yet"
|
||||
description="Create your first class to start managing students and schedules."
|
||||
description="Join a class to start managing students and schedules."
|
||||
icon={Users}
|
||||
action={canCreateClass ? { label: "Create class", onClick: () => setCreateOpen(true) } : undefined}
|
||||
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
|
||||
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
||||
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
|
||||
/>
|
||||
) : filteredClasses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No classes match your filters"
|
||||
description="Try clearing filters or adjusting keywords."
|
||||
icon={Users}
|
||||
action={{ label: "Clear filters", onClick: () => {
|
||||
icon={Search}
|
||||
action={{
|
||||
label: "Clear filters",
|
||||
onClick: () => {
|
||||
setQ(null)
|
||||
setGrade(null)
|
||||
}}}
|
||||
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.map((c) => (
|
||||
<ClassCard
|
||||
key={c.id}
|
||||
c={c}
|
||||
onWorkingChange={setIsWorking}
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
<ClassCard key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -334,32 +336,37 @@ function ClassCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="space-y-2">
|
||||
<Card className={cn("group flex flex-col transition-all hover:shadow-md", getClassGradient(c.id))}>
|
||||
<CardHeader className="relative pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="line-clamp-1 text-lg font-bold leading-none tracking-tight">
|
||||
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<div className="text-muted-foreground text-sm mt-1">
|
||||
{c.room ? `Room: ${c.room}` : "Room: Not set"}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<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 className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{c.grade}</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 -mr-2" disabled={isWorking}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Edit
|
||||
Edit Class
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -367,59 +374,93 @@ function ClassCard({
|
||||
onClick={() => setShowDelete(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
Delete Class
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium tabular-nums">{c.studentCount} students</div>
|
||||
{c.homeroom ? <Badge variant="outline">{c.homeroom}</Badge> : null}
|
||||
<CardContent className="flex-1 pb-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Students</span>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
{c.studentCount}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase text-muted-foreground">Invitation code</div>
|
||||
<div className="font-mono tabular-nums text-sm">{c.invitationCode ?? "-"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex 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 ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={handleCopyCode} disabled={isWorking}>
|
||||
<Copy className="size-4" />
|
||||
Copy
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopyCode} disabled={isWorking}>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={handleRegenerateCode} disabled={isWorking}>
|
||||
<RefreshCw className="size-4" />
|
||||
Regenerate
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy Code</TooltipContent>
|
||||
</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
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("grid gap-2", "grid-cols-2")}>
|
||||
<Button asChild variant="outline" className="w-full justify-start gap-2">
|
||||
</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="size-4" />
|
||||
<Users className="mr-1.5 size-3.5" />
|
||||
Students
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full justify-start gap-2">
|
||||
<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="size-4" />
|
||||
<Calendar className="mr-1.5 size-3.5" />
|
||||
Schedule
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<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
|
||||
open={showEdit}
|
||||
onOpenChange={(open) => {
|
||||
@@ -495,7 +536,7 @@ function ClassCard({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : "Save"}
|
||||
{isWorking ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -524,7 +565,7 @@ function ClassCard({
|
||||
onClick={handleDelete}
|
||||
disabled={isWorking}
|
||||
>
|
||||
{isWorking ? "Deleting..." : "Delete"}
|
||||
{isWorking ? "Deleting..." : "Delete Class"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { enrollStudentByEmailAction } from "../actions"
|
||||
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -76,7 +77,7 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
</div>
|
||||
|
||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Class" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -89,12 +90,24 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
</SelectContent>
|
||||
</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
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setClassId(null)
|
||||
setStatus(null)
|
||||
}}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
>
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useState } from "react"
|
||||
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 { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -36,10 +38,17 @@ import {
|
||||
import type { ClassStudent } from "../types"
|
||||
import { setStudentEnrollmentStatusAction } from "../actions"
|
||||
|
||||
const ITEMS_PER_PAGE = 10
|
||||
|
||||
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
const router = useRouter()
|
||||
const [workingKey, setWorkingKey] = useState<string | 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 key = `${student.classId}:${student.id}:${status}`
|
||||
@@ -59,30 +68,76 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">All Students</CardTitle>
|
||||
<Badge variant="secondary" className="rounded-sm px-1.5 font-normal">
|
||||
{students.length} total
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Email</TableHead>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="pl-6 text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Joined</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Actions</TableHead>
|
||||
<TableHead className="pr-6 text-right text-xs font-medium uppercase text-muted-foreground">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{students.map((s) => (
|
||||
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-12", s.status !== "active" && "opacity-70")}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
||||
<TableCell>{s.className}</TableCell>
|
||||
{paginatedStudents.map((s) => (
|
||||
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-16", s.status !== "active" && "opacity-70")}>
|
||||
<TableCell className="pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9 border">
|
||||
<AvatarImage src={s.image || undefined} alt={s.name} />
|
||||
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium leading-none">{s.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.status === "active" ? "secondary" : "outline"}>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{s.className}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDate(s.joinedAt)}
|
||||
</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"
|
||||
)}
|
||||
>
|
||||
{s.status === "active" ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="pr-6 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
|
||||
@@ -117,6 +172,40 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
))}
|
||||
</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
|
||||
open={Boolean(removeTarget)}
|
||||
|
||||
@@ -122,6 +122,20 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
||||
|
||||
const rows = await (async () => {
|
||||
try {
|
||||
const ownedIds = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.teacherId, teacherId))
|
||||
|
||||
const enrolledIds = await db
|
||||
.select({ id: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
|
||||
|
||||
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
|
||||
|
||||
if (allIds.length === 0) return []
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
@@ -135,26 +149,11 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
||||
})
|
||||
.from(classes)
|
||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||
.where(eq(classes.teacherId, teacherId))
|
||||
.where(inArray(classes.id, allIds))
|
||||
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch {
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
schoolName: sql<string | null>`NULL`.as("schoolName"),
|
||||
name: classes.name,
|
||||
grade: classes.grade,
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
invitationCode: sql<string | null>`NULL`.as("invitationCode"),
|
||||
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(classes)
|
||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||
.where(eq(classes.teacherId, teacherId))
|
||||
.groupBy(classes.id, classes.name, classes.grade, classes.homeroom, classes.room)
|
||||
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -331,6 +330,143 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
|
||||
return list
|
||||
})
|
||||
|
||||
export const getGradeManagedClasses = cache(async (userId: string): Promise<AdminClassListItem[]> => {
|
||||
const managedGradeIds = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||
|
||||
if (managedGradeIds.length === 0) return []
|
||||
|
||||
const gradeIds = managedGradeIds.map((g) => g.id)
|
||||
|
||||
const [rows, subjectRows] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
schoolName: classes.schoolName,
|
||||
schoolId: classes.schoolId,
|
||||
name: classes.name,
|
||||
grade: classes.grade,
|
||||
gradeId: classes.gradeId,
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
invitationCode: classes.invitationCode,
|
||||
teacherId: users.id,
|
||||
teacherName: users.name,
|
||||
teacherEmail: users.email,
|
||||
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
|
||||
createdAt: classes.createdAt,
|
||||
updatedAt: classes.updatedAt,
|
||||
})
|
||||
.from(classes)
|
||||
.innerJoin(users, eq(users.id, classes.teacherId))
|
||||
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classes.gradeId, gradeIds))
|
||||
.groupBy(
|
||||
classes.id,
|
||||
classes.schoolName,
|
||||
classes.schoolId,
|
||||
classes.name,
|
||||
classes.grade,
|
||||
classes.gradeId,
|
||||
classes.homeroom,
|
||||
classes.room,
|
||||
classes.invitationCode,
|
||||
users.id,
|
||||
users.name,
|
||||
users.email,
|
||||
classes.createdAt,
|
||||
classes.updatedAt
|
||||
)
|
||||
.orderBy(
|
||||
asc(classes.schoolName),
|
||||
asc(classes.grade),
|
||||
asc(classes.name),
|
||||
asc(classes.homeroom),
|
||||
asc(classes.room)
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})(),
|
||||
db
|
||||
.select({
|
||||
classId: classSubjectTeachers.classId,
|
||||
subject: classSubjectTeachers.subject,
|
||||
teacherId: users.id,
|
||||
teacherName: users.name,
|
||||
teacherEmail: users.email,
|
||||
})
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
|
||||
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||
.where(inArray(classes.gradeId, gradeIds))
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
|
||||
])
|
||||
|
||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = r.subject as ClassSubject
|
||||
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
|
||||
const teacher =
|
||||
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||
: null
|
||||
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
|
||||
bySubject.set(subject, teacher)
|
||||
subjectsByClassId.set(r.classId, bySubject)
|
||||
}
|
||||
|
||||
const list = rows.map((r) => {
|
||||
const bySubject = subjectsByClassId.get(r.id)
|
||||
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||
subject,
|
||||
teacher: bySubject?.get(subject) ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
schoolName: r.schoolName,
|
||||
schoolId: r.schoolId,
|
||||
name: r.name,
|
||||
grade: r.grade,
|
||||
gradeId: r.gradeId,
|
||||
homeroom: r.homeroom,
|
||||
room: r.room,
|
||||
invitationCode: r.invitationCode ?? null,
|
||||
teacher: {
|
||||
id: r.teacherId,
|
||||
name: r.teacherName ?? "Unnamed",
|
||||
email: r.teacherEmail,
|
||||
},
|
||||
subjectTeachers,
|
||||
studentCount: Number(r.studentCount ?? 0),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
list.sort(compareClassLike)
|
||||
return list
|
||||
})
|
||||
|
||||
export const getManagedGrades = cache(async (userId: string) => {
|
||||
return await db
|
||||
.select({
|
||||
id: grades.id,
|
||||
name: grades.name,
|
||||
schoolId: grades.schoolId,
|
||||
schoolName: schools.name,
|
||||
})
|
||||
.from(grades)
|
||||
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||||
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||
.orderBy(asc(schools.name), asc(grades.name))
|
||||
})
|
||||
|
||||
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
|
||||
const id = studentId.trim()
|
||||
if (!id) return []
|
||||
@@ -345,9 +481,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
||||
grade: classes.grade,
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
teacherName: users.name,
|
||||
teacherEmail: users.email,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch {
|
||||
@@ -359,9 +498,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
||||
grade: classes.grade,
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
teacherName: users.name,
|
||||
teacherEmail: users.email,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
}
|
||||
@@ -374,6 +516,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
||||
grade: r.grade,
|
||||
homeroom: r.homeroom,
|
||||
room: r.room,
|
||||
teacherName: r.teacherName,
|
||||
teacherEmail: r.teacherEmail,
|
||||
}))
|
||||
|
||||
list.sort(compareClassLike)
|
||||
@@ -414,12 +558,13 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
|
||||
})
|
||||
|
||||
export const getClassStudents = cache(
|
||||
async (params?: { classId?: string; q?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
||||
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
||||
if (!teacherId) return []
|
||||
|
||||
const classId = params?.classId?.trim()
|
||||
const q = params?.q?.trim().toLowerCase()
|
||||
const status = params?.status?.trim().toLowerCase()
|
||||
|
||||
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
||||
|
||||
@@ -427,6 +572,10 @@ export const getClassStudents = cache(
|
||||
conditions.push(eq(classes.id, classId))
|
||||
}
|
||||
|
||||
if (status === "active" || status === "inactive") {
|
||||
conditions.push(eq(classEnrollments.status, status))
|
||||
}
|
||||
|
||||
if (q && q.length > 0) {
|
||||
const needle = `%${q}%`
|
||||
conditions.push(
|
||||
@@ -439,9 +588,12 @@ export const getClassStudents = cache(
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
image: users.image,
|
||||
gender: users.gender,
|
||||
classId: classes.id,
|
||||
className: classes.name,
|
||||
status: classEnrollments.status,
|
||||
joinedAt: classEnrollments.createdAt,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
@@ -453,9 +605,12 @@ export const getClassStudents = cache(
|
||||
id: r.id,
|
||||
name: r.name ?? "Unnamed",
|
||||
email: r.email,
|
||||
image: r.image,
|
||||
gender: r.gender,
|
||||
classId: r.classId,
|
||||
className: r.className,
|
||||
status: r.status,
|
||||
joinedAt: r.joinedAt,
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -65,9 +65,12 @@ export type ClassStudent = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image?: string | null
|
||||
gender?: string | null
|
||||
classId: string
|
||||
className: string
|
||||
status: "active" | "inactive"
|
||||
joinedAt: Date
|
||||
}
|
||||
|
||||
export type ClassScheduleItem = {
|
||||
@@ -80,26 +83,6 @@ export type ClassScheduleItem = {
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type StudentEnrolledClass = {
|
||||
id: string
|
||||
schoolName?: string | null
|
||||
name: string
|
||||
grade: string
|
||||
homeroom?: string | null
|
||||
room?: string | null
|
||||
}
|
||||
|
||||
export type StudentScheduleItem = {
|
||||
id: string
|
||||
classId: string
|
||||
className: string
|
||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
startTime: string
|
||||
endTime: string
|
||||
course: string
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type CreateClassScheduleItemInput = {
|
||||
classId: string
|
||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
@@ -118,13 +101,26 @@ export type UpdateClassScheduleItemInput = {
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type ClassBasicInfo = {
|
||||
export type StudentEnrolledClass = {
|
||||
id: string
|
||||
schoolName?: string | null
|
||||
name: string
|
||||
grade: string
|
||||
homeroom?: string | null
|
||||
room?: string | null
|
||||
invitationCode?: string | null
|
||||
teacherName?: string | null
|
||||
teacherEmail?: string | null
|
||||
}
|
||||
|
||||
export type StudentScheduleItem = {
|
||||
id: string
|
||||
classId: string
|
||||
className: string
|
||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
startTime: string
|
||||
endTime: string
|
||||
course: string
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type ScoreStats = {
|
||||
@@ -151,24 +147,23 @@ export type ClassHomeworkAssignmentStats = {
|
||||
}
|
||||
|
||||
export type ClassHomeworkInsights = {
|
||||
class: ClassBasicInfo
|
||||
studentCounts: {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
class: {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
homeroom?: string | null
|
||||
room?: string | null
|
||||
invitationCode?: string | null
|
||||
}
|
||||
studentCounts: { total: number; active: number; inactive: number }
|
||||
assignments: ClassHomeworkAssignmentStats[]
|
||||
latest: ClassHomeworkAssignmentStats | null
|
||||
overallScores: ScoreStats
|
||||
}
|
||||
|
||||
export type GradeHomeworkClassSummary = {
|
||||
class: ClassBasicInfo
|
||||
studentCounts: {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
}
|
||||
class: { id: string; name: string; grade: string; homeroom?: string | null; room?: string | null }
|
||||
studentCounts: { total: number; active: number; inactive: number }
|
||||
latestAvg: number | null
|
||||
prevAvg: number | null
|
||||
deltaAvg: number | null
|
||||
@@ -176,17 +171,9 @@ export type GradeHomeworkClassSummary = {
|
||||
}
|
||||
|
||||
export type GradeHomeworkInsights = {
|
||||
grade: {
|
||||
id: string
|
||||
name: string
|
||||
school: { id: string; name: string }
|
||||
}
|
||||
grade: { id: string; name: string; school: { id: string; name: string } }
|
||||
classCount: number
|
||||
studentCounts: {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
}
|
||||
studentCounts: { total: number; active: number; inactive: number }
|
||||
assignments: ClassHomeworkAssignmentStats[]
|
||||
latest: ClassHomeworkAssignmentStats | null
|
||||
overallScores: ScoreStats
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { CalendarDays, BookOpen, PenTool } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
|
||||
const hour = new Date().getHours()
|
||||
let greeting = "Welcome back"
|
||||
if (hour < 12) greeting = "Good morning"
|
||||
else if (hour < 18) greeting = "Good afternoon"
|
||||
else greeting = "Good evening"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<div className="text-sm text-muted-foreground">Welcome back, {studentName}.</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{greeting}, {studentName}. Here's what's happening today.
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/learning/assignments">View assignments</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/student/schedule">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
Schedule
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/student/learning/textbooks">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Textbooks
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="gap-2">
|
||||
<Link href="/student/learning/assignments">
|
||||
<PenTool className="h-4 w-4" />
|
||||
Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { StudentDashboardProps } from "@/modules/dashboard/types"
|
||||
|
||||
import { StudentDashboardHeader } from "./student-dashboard-header"
|
||||
import { StudentGradesCard } from "./student-grades-card"
|
||||
import { StudentRankingCard } from "./student-ranking-card"
|
||||
import { StudentStatsGrid } from "./student-stats-grid"
|
||||
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
||||
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
||||
@@ -26,16 +25,17 @@ export function StudentDashboard({
|
||||
dueSoonCount={dueSoonCount}
|
||||
overdueCount={overdueCount}
|
||||
gradedCount={gradedCount}
|
||||
ranking={grades.ranking}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StudentGradesCard grades={grades} />
|
||||
<StudentRankingCard ranking={grades.ranking} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
||||
<StudentGradesCard grades={grades} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||
|
||||
@@ -11,6 +15,24 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
const hasGradeTrend = grades.trend.length > 0
|
||||
const hasRecentGrades = grades.recent.length > 0
|
||||
|
||||
const chartData = grades.trend.map((item) => ({
|
||||
title: item.assignmentTitle,
|
||||
score: Math.round(item.percentage),
|
||||
fullTitle: item.assignmentTitle,
|
||||
submittedAt: formatDate(item.submittedAt),
|
||||
rawScore: item.score,
|
||||
maxScore: item.maxScore,
|
||||
}))
|
||||
|
||||
const chartConfig = {
|
||||
score: {
|
||||
label: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
}
|
||||
|
||||
const latestGrade = grades.trend[grades.trend.length - 1]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -30,37 +52,79 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<svg viewBox="0 0 100 40" className="h-24 w-full">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
points={grades.trend
|
||||
.map((p, i) => {
|
||||
const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0
|
||||
const x = t * 100
|
||||
const v = Number.isFinite(p.percentage) ? Math.max(0, Math.min(100, p.percentage)) : 0
|
||||
const y = 40 - (v / 100) * 40
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(" ")}
|
||||
className="text-primary"
|
||||
<ChartContainer config={chartConfig} className="h-[200px] w-full">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<XAxis
|
||||
dataKey="title"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
|
||||
/>
|
||||
</svg>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
width={30}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="line"
|
||||
labelKey="fullTitle"
|
||||
className="w-[200px]"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="score"
|
||||
type="monotone"
|
||||
stroke="var(--color-score)"
|
||||
strokeWidth={2}
|
||||
dot={{
|
||||
fill: "var(--color-score)",
|
||||
r: 4,
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
strokeWidth: 0,
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
{latestGrade && (
|
||||
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div>
|
||||
Latest:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{Math.round(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}%
|
||||
{Math.round(latestGrade.percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Points:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{grades.trend[grades.trend.length - 1]?.score ?? 0}/{grades.trend[grades.trend.length - 1]?.maxScore ?? 0}
|
||||
{latestGrade.score}/{latestGrade.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasRecentGrades ? null : (
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Trophy } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { StudentRanking } from "@/modules/homework/types"
|
||||
|
||||
export function StudentRankingCard({ ranking }: { ranking: StudentRanking | null }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-muted-foreground" />
|
||||
Ranking
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!ranking ? (
|
||||
<EmptyState
|
||||
icon={Trophy}
|
||||
title="No ranking available"
|
||||
description="Join a class and complete graded work to see your rank."
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">Class Rank</div>
|
||||
<div className="mt-1 text-3xl font-bold tabular-nums">
|
||||
{ranking.rank}/{ranking.classSize}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<div className="text-sm text-muted-foreground">Overall</div>
|
||||
<div className="mt-1 text-3xl font-bold tabular-nums">{Math.round(ranking.percentage)}%</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{ranking.totalScore}/{ranking.totalMaxScore} pts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Based on latest graded submissions per assignment for your class.</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { BookOpen, CheckCircle2, PenTool, TriangleAlert } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { BookOpen, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { StudentRanking } from "@/modules/homework/types"
|
||||
|
||||
type Stat = {
|
||||
title: string
|
||||
value: string
|
||||
description: string
|
||||
icon: typeof BookOpen
|
||||
href: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function StudentStatsGrid({
|
||||
@@ -14,52 +19,64 @@ export function StudentStatsGrid({
|
||||
dueSoonCount,
|
||||
overdueCount,
|
||||
gradedCount,
|
||||
ranking,
|
||||
}: {
|
||||
enrolledClassCount: number
|
||||
dueSoonCount: number
|
||||
overdueCount: number
|
||||
gradedCount: number
|
||||
ranking: StudentRanking | null
|
||||
}) {
|
||||
const stats: readonly Stat[] = [
|
||||
const stats: Stat[] = [
|
||||
{
|
||||
title: "My Classes",
|
||||
value: String(enrolledClassCount),
|
||||
description: "Enrolled classes",
|
||||
icon: BookOpen,
|
||||
title: "Average Score",
|
||||
value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
|
||||
description: ranking ? "Overall performance" : "No grades yet",
|
||||
icon: TrendingUp,
|
||||
href: "/student/learning/assignments",
|
||||
color: "text-blue-500",
|
||||
},
|
||||
{
|
||||
title: "Class Rank",
|
||||
value: ranking ? `${ranking.rank}/${ranking.classSize}` : "-",
|
||||
description: ranking ? "Current position" : "No ranking yet",
|
||||
icon: Trophy,
|
||||
href: "/student/learning/assignments",
|
||||
color: "text-purple-500",
|
||||
},
|
||||
{
|
||||
title: "Due Soon",
|
||||
value: String(dueSoonCount),
|
||||
description: "Next 7 days",
|
||||
icon: PenTool,
|
||||
href: "/student/learning/assignments",
|
||||
color: dueSoonCount > 0 ? "text-orange-500" : undefined,
|
||||
},
|
||||
{
|
||||
title: "Overdue",
|
||||
value: String(overdueCount),
|
||||
description: "Needs attention",
|
||||
icon: TriangleAlert,
|
||||
},
|
||||
{
|
||||
title: "Graded",
|
||||
value: String(gradedCount),
|
||||
description: "With score",
|
||||
icon: CheckCircle2,
|
||||
href: "/student/learning/assignments",
|
||||
color: overdueCount > 0 ? "text-red-500" : undefined,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title}>
|
||||
<Link key={stat.title} href={stat.href}>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<stat.icon className={cn("h-4 w-4 text-muted-foreground", stat.color)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{stat.value}</div>
|
||||
<div className={cn("text-2xl font-bold tabular-nums", stat.color)}>{stat.value}</div>
|
||||
<div className="text-xs text-muted-foreground">{stat.description}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { formatDate, cn } from "@/shared/lib/utils"
|
||||
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
@@ -23,6 +23,30 @@ const getStatusLabel = (status: string) => {
|
||||
return "Not started"
|
||||
}
|
||||
|
||||
const getActionLabel = (status: string) => {
|
||||
if (status === "graded") return "Review"
|
||||
if (status === "submitted") return "View"
|
||||
if (status === "in_progress") return "Continue"
|
||||
return "Start"
|
||||
}
|
||||
|
||||
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
if (status === "graded" || status === "submitted") return "outline"
|
||||
return "default"
|
||||
}
|
||||
|
||||
const getDueUrgency = (dueAt: string | null) => {
|
||||
if (!dueAt) return null
|
||||
const now = new Date()
|
||||
const due = new Date(dueAt)
|
||||
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||
|
||||
if (diffHours < 0) return "overdue"
|
||||
if (diffHours < 48) return "urgent" // 2 days
|
||||
if (diffHours < 120) return "warning" // 5 days
|
||||
return "normal"
|
||||
}
|
||||
|
||||
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||
const hasAssignments = upcomingAssignments.length > 0
|
||||
|
||||
@@ -54,25 +78,49 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{upcomingAssignments.map((a) => (
|
||||
{upcomingAssignments.map((a) => {
|
||||
const urgency = getDueUrgency(a.dueAt)
|
||||
const isGraded = a.progressStatus === "graded"
|
||||
|
||||
return (
|
||||
<TableRow key={a.id} className="h-12">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
{!isGraded && urgency === "overdue" && (
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">Late</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
||||
<TableCell className={cn(
|
||||
"text-muted-foreground",
|
||||
!isGraded && urgency === "overdue" && "text-destructive font-medium",
|
||||
!isGraded && urgency === "urgent" && "text-orange-500 font-medium"
|
||||
)}>
|
||||
{a.dueAt ? formatDate(a.dueAt) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)} className="h-7 text-xs">
|
||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||
{getActionLabel(a.progressStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { Users } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -54,6 +54,7 @@ export function TeacherStats({
|
||||
description: "Published and ongoing",
|
||||
icon: PenTool,
|
||||
href: "/teacher/homework/assignments?status=published",
|
||||
highlight: false,
|
||||
color: "text-blue-500",
|
||||
},
|
||||
{
|
||||
@@ -62,6 +63,7 @@ export function TeacherStats({
|
||||
description: "Across recent assignments",
|
||||
icon: TrendingUp,
|
||||
href: "#grade-trends",
|
||||
highlight: false,
|
||||
color: "text-emerald-500",
|
||||
},
|
||||
{
|
||||
@@ -70,6 +72,7 @@ export function TeacherStats({
|
||||
description: "Overall completion rate",
|
||||
icon: BarChart,
|
||||
href: "#grade-trends",
|
||||
highlight: false,
|
||||
color: "text-purple-500",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -5,8 +5,9 @@ import { ActionState } from "@/shared/types/action-state"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions } from "@/shared/db/schema"
|
||||
import { exams, examQuestions, subjects, grades } from "@/shared/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { omitScheduledAtFromDescription } from "./data-access"
|
||||
|
||||
const ExamCreateSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
@@ -56,9 +57,17 @@ export async function createExamAction(
|
||||
const examId = createId()
|
||||
const scheduled = input.scheduledAt || undefined
|
||||
|
||||
// Retrieve names for JSON description (to maintain compatibility)
|
||||
const subjectRecord = await db.query.subjects.findFirst({
|
||||
where: eq(subjects.id, input.subject),
|
||||
})
|
||||
const gradeRecord = await db.query.grades.findFirst({
|
||||
where: eq(grades.id, input.grade),
|
||||
})
|
||||
|
||||
const meta = {
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
subject: subjectRecord?.name ?? input.subject,
|
||||
grade: gradeRecord?.name ?? input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
@@ -71,11 +80,14 @@ export async function createExamAction(
|
||||
id: examId,
|
||||
title: input.title,
|
||||
description: JSON.stringify(meta),
|
||||
creatorId: user?.id ?? "user_teacher_123",
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
startTime: scheduled ? new Date(scheduled) : null,
|
||||
status: "draft",
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to create exam",
|
||||
@@ -215,19 +227,6 @@ const ExamDuplicateSchema = z.object({
|
||||
examId: z.string().min(1),
|
||||
})
|
||||
|
||||
const omitScheduledAtFromDescription = (description: string | null) => {
|
||||
if (!description) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(description)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description
|
||||
const meta = parsed as Record<string, unknown>
|
||||
if ("scheduledAt" in meta) delete meta.scheduledAt
|
||||
return JSON.stringify(meta)
|
||||
} catch {
|
||||
return description
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -271,7 +270,7 @@ export async function duplicateExamAction(
|
||||
id: newExamId,
|
||||
title: `${source.title} (Copy)`,
|
||||
description: omitScheduledAtFromDescription(source.description),
|
||||
creatorId: user?.id ?? "user_teacher_123",
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
status: "draft",
|
||||
@@ -305,6 +304,78 @@ export async function duplicateExamAction(
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_123", role: "teacher" }
|
||||
export async function getExamPreviewAction(examId: string) {
|
||||
try {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
with: {
|
||||
question: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!exam) {
|
||||
return { success: false, message: "Exam not found" }
|
||||
}
|
||||
|
||||
// Extract questions from the relation
|
||||
const questions = exam.questions.map(eq => eq.question)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
structure: exam.structure,
|
||||
questions: questions
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return { success: false, message: "Failed to load exam preview" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||
try {
|
||||
const allSubjects = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: allSubjects.map((s) => ({ id: s.id, name: s.name })),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch subjects:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load subjects",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||
try {
|
||||
const allGrades = await db.query.grades.findMany({
|
||||
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: allGrades.map((g) => ({ id: g.id, name: g.name })),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch grades:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load grades",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_math", role: "teacher" }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Eye, Printer } from "lucide-react"
|
||||
import type { ExamNode } from "./selected-question-list"
|
||||
|
||||
type ChoiceOption = {
|
||||
@@ -86,26 +82,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" size="sm" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview Exam
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="p-6 pb-2 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Exam Preview</DialogTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
|
||||
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
|
||||
<div className="bg-card shadow-sm border p-12 print:shadow-none print:border-none">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
|
||||
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
|
||||
@@ -133,8 +110,5 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ type QuestionBankListProps = {
|
||||
questions: Question[]
|
||||
onAdd: (question: Question) => void
|
||||
isAdded: (id: string) => boolean
|
||||
onLoadMore?: () => void
|
||||
hasMore?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) {
|
||||
if (questions.length === 0) {
|
||||
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
|
||||
if (questions.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No questions found matching your filters.
|
||||
@@ -22,7 +25,7 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 pb-4">
|
||||
{questions.map((q) => {
|
||||
const added = isAdded(q.id)
|
||||
const content = q.content as { text?: string }
|
||||
@@ -60,6 +63,28 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoading}
|
||||
className="w-full text-muted-foreground"
|
||||
>
|
||||
{isLoading ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && questions.length === 0 && (
|
||||
<div className="space-y-3">
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="h-20 bg-muted/20 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy }
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -27,13 +28,14 @@ import {
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
|
||||
import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreviewAction } from "../actions"
|
||||
import { Exam } from "../types"
|
||||
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
interface ExamActionsProps {
|
||||
exam: Exam
|
||||
@@ -44,6 +46,46 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [previewNodes, setPreviewNodes] = useState<ExamNode[] | null>(null)
|
||||
const [loadingPreview, setLoadingPreview] = useState(false)
|
||||
|
||||
const handleView = async () => {
|
||||
setLoadingPreview(true)
|
||||
setShowViewDialog(true)
|
||||
try {
|
||||
const result = await getExamPreviewAction(exam.id)
|
||||
if (result.success && result.data) {
|
||||
const { structure, questions } = result.data
|
||||
const questionById = new Map<string, Question>()
|
||||
for (const q of questions) questionById.set(q.id, q as unknown as Question)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hydrate = (nodes: any[]): ExamNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "question") {
|
||||
const q = node.questionId ? questionById.get(node.questionId) : undefined
|
||||
return { ...node, question: q }
|
||||
}
|
||||
if (node.type === "group") {
|
||||
return { ...node, children: hydrate(node.children || []) }
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
const nodes = Array.isArray(structure) ? hydrate(structure) : []
|
||||
setPreviewNodes(nodes)
|
||||
} else {
|
||||
toast.error("Failed to load exam preview")
|
||||
setShowViewDialog(false)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Failed to load exam preview")
|
||||
setShowViewDialog(false)
|
||||
} finally {
|
||||
setLoadingPreview(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(exam.id)
|
||||
@@ -112,6 +154,19 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleView()
|
||||
}}
|
||||
title="Preview Exam"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
@@ -125,9 +180,6 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -166,49 +218,21 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Exam Details</DialogTitle>
|
||||
<DialogDescription>ID: {exam.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Title:</span>
|
||||
<span className="col-span-3">{exam.title}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Subject:</span>
|
||||
<span className="col-span-3">{exam.subject}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Grade:</span>
|
||||
<span className="col-span-3">{exam.grade}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Total Score:</span>
|
||||
<span className="col-span-3">{exam.totalScore}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Duration:</span>
|
||||
<span className="col-span-3">{exam.durationMin} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete exam?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the exam.
|
||||
This action cannot be undone. This will permanently delete the exam
|
||||
"{exam.title}" and remove all associated data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
@@ -220,6 +244,34 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-semibold tracking-tight">{exam.title}</DialogTitle>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{loadingPreview ? (
|
||||
<div className="py-20 text-center text-muted-foreground">Loading preview...</div>
|
||||
) : previewNodes && previewNodes.length > 0 ? (
|
||||
<div className="max-w-3xl mx-auto py-8 px-6">
|
||||
<ExamPaperPreview
|
||||
title={exam.title}
|
||||
subject={exam.subject}
|
||||
grade={exam.grade}
|
||||
durationMin={exam.durationMin}
|
||||
totalScore={exam.totalScore}
|
||||
nodes={previewNodes}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-20 text-center text-muted-foreground">
|
||||
No questions in this exam.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useDeferredValue, useMemo, useState } from "react"
|
||||
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Search } from "lucide-react"
|
||||
import { Search, Eye } from "lucide-react"
|
||||
|
||||
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import { updateExamAction } from "@/modules/exams/actions"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
import { StructureEditor } from "./assembly/structure-editor"
|
||||
import { QuestionBankList } from "./assembly/question-bank-list"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
@@ -49,6 +50,12 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||
const deferredSearch = useDeferredValue(search)
|
||||
|
||||
// Bank state
|
||||
const [bankQuestions, setBankQuestions] = useState<Question[]>(props.questionOptions)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(props.questionOptions.length >= 20)
|
||||
const [isBankLoading, startBankTransition] = useTransition()
|
||||
|
||||
// Initialize structure state
|
||||
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
||||
const questionById = new Map<string, Question>()
|
||||
@@ -76,26 +83,47 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
return []
|
||||
})
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
let list: Question[] = [...props.questionOptions]
|
||||
const fetchQuestions = (reset: boolean = false) => {
|
||||
startBankTransition(async () => {
|
||||
const nextPage = reset ? 1 : page + 1
|
||||
try {
|
||||
const result = await getQuestionsAction({
|
||||
q: deferredSearch,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: typeFilter === 'all' ? undefined : typeFilter as any,
|
||||
difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
|
||||
page: nextPage,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
if (deferredSearch) {
|
||||
const lower = deferredSearch.toLowerCase()
|
||||
list = list.filter(q => {
|
||||
const content = q.content as { text?: string }
|
||||
return content.text?.toLowerCase().includes(lower)
|
||||
if (result && result.data) {
|
||||
setBankQuestions(prev => {
|
||||
if (reset) return result.data
|
||||
// Deduplicate just in case
|
||||
const existingIds = new Set(prev.map(q => q.id))
|
||||
const newQuestions = result.data.filter(q => !existingIds.has(q.id))
|
||||
return [...prev, ...newQuestions]
|
||||
})
|
||||
setHasMore(result.data.length === 20)
|
||||
setPage(nextPage)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to load questions")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (typeFilter !== "all") {
|
||||
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
if (deferredSearch === "" && typeFilter === "all" && difficultyFilter === "all") {
|
||||
return
|
||||
}
|
||||
if (difficultyFilter !== "all") {
|
||||
const d = parseInt(difficultyFilter)
|
||||
list = list.filter((q) => q.difficulty === d)
|
||||
}
|
||||
return list
|
||||
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
|
||||
fetchQuestions(true)
|
||||
}, [deferredSearch, typeFilter, difficultyFilter])
|
||||
|
||||
// Recursively calculate total score
|
||||
const assignedTotal = useMemo(() => {
|
||||
@@ -231,6 +259,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
return clean(structure)
|
||||
}
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
const handleSave = async (formData: FormData) => {
|
||||
formData.set("examId", props.examId)
|
||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
||||
@@ -238,7 +268,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Saved draft")
|
||||
toast.success("Exam draft saved")
|
||||
} else {
|
||||
toast.error(result.message || "Save failed")
|
||||
}
|
||||
@@ -260,13 +290,37 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-12rem)] gap-6 lg:grid-cols-5">
|
||||
{/* Left: Preview (3 cols) */}
|
||||
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="grid h-[calc(100vh-8rem)] gap-4 lg:grid-cols-12">
|
||||
{/* Left: Preview (8 cols) */}
|
||||
<Card className="lg:col-span-8 flex flex-col overflow-hidden border-2 border-primary/10 shadow-sm">
|
||||
<CardHeader className="bg-muted/30 pb-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>Exam Structure</CardTitle>
|
||||
<CardTitle className="text-lg">Exam Structure</CardTitle>
|
||||
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{props.subject}</span>
|
||||
<span>•</span>
|
||||
<span>{props.grade}</span>
|
||||
<span>•</span>
|
||||
<span>{props.durationMin} min</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-semibold tracking-tight">{props.title}</DialogTitle>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="max-w-3xl mx-auto py-8 px-6">
|
||||
<ExamPaperPreview
|
||||
title={props.title}
|
||||
subject={props.subject}
|
||||
@@ -276,31 +330,36 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
nodes={structure}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
|
||||
<span className="text-xs text-muted-foreground">Total Score</span>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="h-2 w-24 rounded-full bg-secondary">
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-lg font-bold ${assignedTotal > props.totalScore ? "text-destructive" : "text-primary"}`}>
|
||||
{assignedTotal}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/ {props.totalScore}</span>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Total Score</span>
|
||||
</div>
|
||||
<div className="h-10 w-2 rounded-full bg-secondary overflow-hidden flex flex-col-reverse">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
className={`w-full transition-all ${
|
||||
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
style={{ height: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm text-muted-foreground bg-muted/20 p-3 rounded-md">
|
||||
<div><span className="font-medium text-foreground">{props.subject}</span></div>
|
||||
<div><span className="font-medium text-foreground">{props.grade}</span></div>
|
||||
<div>Duration: <span className="font-medium text-foreground">{props.durationMin} min</span></div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 bg-muted/5">
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||
<StructureEditor
|
||||
items={structure}
|
||||
onChange={setStructure}
|
||||
@@ -312,32 +371,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-4 bg-muted/30 flex gap-3 justify-end">
|
||||
<form action={handleSave} className="flex-1">
|
||||
<SubmitButton label="Save Draft" />
|
||||
<div className="border-t p-4 bg-background flex gap-3 justify-end items-center shadow-[0_-1px_2px_rgba(0,0,0,0.03)]">
|
||||
<div className="mr-auto text-xs text-muted-foreground">
|
||||
{structure.length === 0 ? "Start by adding questions from the right panel" : `${structure.length} items in structure`}
|
||||
</div>
|
||||
<form action={handleSave}>
|
||||
<Button variant="outline" size="sm" type="submit" className="w-24">Save Draft</Button>
|
||||
</form>
|
||||
<form action={handlePublish} className="flex-1">
|
||||
<SubmitButton label="Publish Exam" />
|
||||
<form action={handlePublish}>
|
||||
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">Publish</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right: Question Bank (2 cols) */}
|
||||
<Card className="lg:col-span-2 flex flex-col overflow-hidden">
|
||||
<CardHeader className="pb-3 space-y-3">
|
||||
<CardTitle className="text-base">Question Bank</CardTitle>
|
||||
{/* Right: Question Bank (4 cols) */}
|
||||
<Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
|
||||
<CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">Question Bank</CardTitle>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{bankQuestions.length}{hasMore ? "+" : ""} loaded
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search questions..."
|
||||
className="pl-8"
|
||||
placeholder="Search by content..."
|
||||
className="pl-9 h-9 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="flex-1 h-8 text-xs"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||
<SelectTrigger className="flex-1 h-8 text-xs bg-background"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
@@ -347,7 +414,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
||||
<SelectTrigger className="w-[80px] h-8 text-xs"><SelectValue placeholder="Diff" /></SelectTrigger>
|
||||
<SelectTrigger className="w-[80px] h-8 text-xs bg-background"><SelectValue placeholder="Diff" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="1">Lvl 1</SelectItem>
|
||||
@@ -360,14 +427,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="flex-1 p-4 bg-muted/10">
|
||||
<ScrollArea className="flex-1 p-0 bg-muted/5">
|
||||
<div className="p-3">
|
||||
<QuestionBankList
|
||||
questions={filteredQuestions}
|
||||
questions={bankQuestions}
|
||||
onAdd={handleAdd}
|
||||
isAdded={(id) => addedQuestionIds.has(id)}
|
||||
onLoadMore={() => fetchQuestions(false)}
|
||||
hasMore={hasMore}
|
||||
isLoading={isBankLoading}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
103
src/modules/exams/components/exam-card.tsx
Normal file
103
src/modules/exams/components/exam-card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Book, Clock, GraduationCap, Trophy, HelpCircle } from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge, BadgeProps } from "@/shared/components/ui/badge"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import { Exam } from "../types"
|
||||
import { ExamActions } from "./exam-actions"
|
||||
|
||||
interface ExamCardProps {
|
||||
exam: Exam
|
||||
hrefBase?: string
|
||||
}
|
||||
|
||||
const subjectColorMap: Record<string, string> = {
|
||||
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
|
||||
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
|
||||
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
|
||||
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
|
||||
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
|
||||
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
|
||||
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
|
||||
}
|
||||
|
||||
export function ExamCard({ exam, hrefBase }: ExamCardProps) {
|
||||
const base = hrefBase || "/teacher/exams"
|
||||
const colorClass = subjectColorMap[exam.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800"
|
||||
|
||||
const statusVariant: BadgeProps["variant"] =
|
||||
exam.status === "published"
|
||||
? "secondary"
|
||||
: exam.status === "archived"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
|
||||
return (
|
||||
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
||||
<Link href={`${base}/${exam.id}/build`} className="flex-1">
|
||||
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
|
||||
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
|
||||
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||
<div className="flex justify-between items-start">
|
||||
<Badge variant={statusVariant} className="bg-background/50 backdrop-blur-sm shadow-none border-transparent">
|
||||
{exam.status}
|
||||
</Badge>
|
||||
{exam.difficulty && (
|
||||
<Badge variant="outline" className="bg-background/50 backdrop-blur-sm border-transparent shadow-none">
|
||||
Lvl {exam.difficulty}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Book className="h-8 w-8 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-lg leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{exam.title}
|
||||
</h3>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-1 pb-2">
|
||||
<div className="flex flex-wrap gap-y-2 gap-x-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GraduationCap className="h-3.5 w-3.5" />
|
||||
<span>{exam.grade}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{exam.durationMin} min</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Trophy className="h-3.5 w-3.5" />
|
||||
<span>{exam.totalScore} pts</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
|
||||
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
<span>{exam.questionCount || 0} Questions</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-muted-foreground/60 mr-2">
|
||||
{formatDate(exam.updatedAt || exam.createdAt)}
|
||||
</span>
|
||||
<ExamActions exam={exam} />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -30,34 +30,30 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
header: "Exam Info",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
<span className="font-semibold text-base">{row.original.title}</span>
|
||||
{row.original.tags && row.original.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.tags.slice(0, 2).map((t, idx) => (
|
||||
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
|
||||
<Badge key={`${t}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
{row.original.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{row.original.tags.length - 2}</Badge>
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">+{row.original.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
},
|
||||
{
|
||||
accessorKey: "grade",
|
||||
header: "Grade",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">{row.original.grade}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground/80">{row.original.subject}</span>
|
||||
<span>•</span>
|
||||
<span>{row.original.grade}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -65,74 +61,95 @@ export const examColumns: ColumnDef<Exam>[] = [
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
// Use 'default' as base for published/success to ensure type safety,
|
||||
// but override with className below
|
||||
const variant: BadgeProps["variant"] =
|
||||
status === "published"
|
||||
? "secondary"
|
||||
? "default"
|
||||
: status === "archived"
|
||||
? "destructive"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="capitalize">
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"capitalize",
|
||||
status === "published" && "bg-green-600 hover:bg-green-700 border-transparent",
|
||||
status === "draft" && "bg-amber-100 text-amber-800 hover:bg-amber-200 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800"
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
header: "Stats",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{row.original.questionCount} Qs</span>
|
||||
<span>•</span>
|
||||
<span>{row.original.totalScore} Pts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{row.original.durationMin} min</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.original.difficulty
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={cn(
|
||||
"font-medium",
|
||||
diff <= 2 ? "text-green-600" : diff === 3 ? "text-yellow-600" : "text-red-600"
|
||||
"h-1.5 w-3 rounded-full",
|
||||
level <= diff
|
||||
? diff <= 2 ? "bg-green-500" : diff === 3 ? "bg-yellow-500" : "bg-red-500"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{diff === 1
|
||||
? "Easy"
|
||||
: diff === 2
|
||||
? "Easy-Med"
|
||||
: diff === 3
|
||||
? "Medium"
|
||||
: diff === 4
|
||||
? "Med-Hard"
|
||||
: "Hard"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-medium">
|
||||
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "durationMin",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.durationMin} min</span>,
|
||||
id: "dates",
|
||||
header: "Date",
|
||||
cell: ({ row }) => {
|
||||
const scheduled = row.original.scheduledAt
|
||||
const created = row.original.createdAt
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{scheduled ? (
|
||||
<>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">Scheduled</span>
|
||||
<span className="text-muted-foreground">{formatDate(scheduled)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{formatDate(created)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "totalScore",
|
||||
header: "Total",
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.totalScore}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduledAt",
|
||||
header: "Scheduled",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{row.original.scheduledAt ? formatDate(row.original.scheduledAt) : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader className="bg-muted/40">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
@@ -88,23 +88,41 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="flex items-center justify-between px-2 py-4">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||
selected.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">Page</p>
|
||||
<span className="text-sm font-medium">
|
||||
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Next
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,19 +19,20 @@ export function ExamFilters() {
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full md:w-[260px]">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder="Search exams..."
|
||||
className="pl-7"
|
||||
className="pl-9 bg-background border-muted-foreground/20"
|
||||
value={search || ""}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -43,7 +44,7 @@ export function ExamFilters() {
|
||||
</Select>
|
||||
|
||||
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder="Difficulty" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -64,13 +65,14 @@ export function ExamFilters() {
|
||||
setStatus(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
className="h-10 px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +1,357 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useTransition, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { toast } from "sonner"
|
||||
import { Loader2, Sparkles, BookOpen } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { createExamAction } from "../actions"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { createExamAction, getSubjectsAction, getGradesAction } from "../actions"
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create Exam"}
|
||||
</Button>
|
||||
)
|
||||
export const formSchema = z.object({
|
||||
title: z.string().min(2, "Title must be at least 2 characters."),
|
||||
subject: z.string().min(1, "Subject is required."),
|
||||
grade: z.string().min(1, "Grade is required."),
|
||||
difficulty: z.string(),
|
||||
totalScore: z.coerce.number().min(1, "Total score must be at least 1."),
|
||||
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes."),
|
||||
scheduledAt: z.string().optional(),
|
||||
mode: z.enum(["manual", "ai"]),
|
||||
})
|
||||
|
||||
type ExamFormValues = z.infer<typeof formSchema>
|
||||
|
||||
const defaultValues: Partial<ExamFormValues> = {
|
||||
title: "",
|
||||
subject: "",
|
||||
grade: "",
|
||||
difficulty: "3",
|
||||
totalScore: 100,
|
||||
durationMin: 90,
|
||||
mode: "manual",
|
||||
scheduledAt: "",
|
||||
}
|
||||
|
||||
export function ExamForm() {
|
||||
const router = useRouter()
|
||||
const [difficulty, setDifficulty] = useState<string>("3")
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
||||
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
||||
const [grades, setGrades] = useState<{ id: string; name: string }[]>([])
|
||||
const [loadingGrades, setLoadingGrades] = useState(true)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const result = await createExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
if (result.data) {
|
||||
router.push(`/teacher/exams/${result.data}/build`)
|
||||
}
|
||||
const form = useForm<ExamFormValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: defaultValues as unknown as ExamFormValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const [subjectsResult, gradesResult] = await Promise.all([
|
||||
getSubjectsAction(),
|
||||
getGradesAction()
|
||||
])
|
||||
|
||||
if (subjectsResult.success && subjectsResult.data) {
|
||||
setSubjects(subjectsResult.data)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
toast.error("Failed to load subjects")
|
||||
}
|
||||
|
||||
if (gradesResult.success && gradesResult.data) {
|
||||
setGrades(gradesResult.data)
|
||||
} else {
|
||||
toast.error("Failed to load grades")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to load form data")
|
||||
} finally {
|
||||
setLoadingSubjects(false)
|
||||
setLoadingGrades(false)
|
||||
}
|
||||
}
|
||||
fetchMetadata()
|
||||
}, [])
|
||||
|
||||
function onSubmit(data: ExamFormValues) {
|
||||
const formData = new FormData()
|
||||
formData.append("title", data.title)
|
||||
formData.append("subject", data.subject)
|
||||
formData.append("grade", data.grade)
|
||||
formData.append("difficulty", data.difficulty)
|
||||
formData.append("totalScore", data.totalScore.toString())
|
||||
formData.append("durationMin", data.durationMin.toString())
|
||||
if (data.scheduledAt) {
|
||||
formData.append("scheduledAt", data.scheduledAt)
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createExamAction(null, formData)
|
||||
|
||||
if (result.success && result.data) {
|
||||
toast.success("Exam draft created", {
|
||||
description: "Redirecting to exam builder...",
|
||||
})
|
||||
router.push(`/teacher/exams/${result.data}/build`)
|
||||
} else {
|
||||
toast.error(result.message || "Failed to create exam")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSubmit = (e: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.handleSubmit(onSubmit as any)(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Left Column: Exam Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exam Creator</CardTitle>
|
||||
<CardTitle>Exam Details</CardTitle>
|
||||
<CardDescription>
|
||||
Define the core information for your exam.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<CardContent className="grid gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" name="title" placeholder="e.g. Algebra Midterm" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input id="subject" name="subject" placeholder="e.g. Mathematics" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="grade">Grade</Label>
|
||||
<Input id="grade" name="grade" placeholder="e.g. Grade 10" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Difficulty</Label>
|
||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val)} name="difficulty">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subject</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select difficulty" />
|
||||
<SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Easy (1)</SelectItem>
|
||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
||||
<SelectItem value="3">Medium (3)</SelectItem>
|
||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
||||
<SelectItem value="5">Hard (5)</SelectItem>
|
||||
{subjects.map((subject) => (
|
||||
<SelectItem key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="difficulty" value={difficulty} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalScore">Total Score</Label>
|
||||
<Input id="totalScore" name="totalScore" type="number" min={1} placeholder="e.g. 100" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="durationMin">Duration (min)</Label>
|
||||
<Input id="durationMin" name="durationMin" type="number" min={10} placeholder="e.g. 90" required />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="scheduledAt">Scheduled At (optional)</Label>
|
||||
<Input id="scheduledAt" name="scheduledAt" type="datetime-local" />
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grade"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Grade Level</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingGrades}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{grades.map((grade) => (
|
||||
<SelectItem key={grade.id} value={grade.id}>
|
||||
{grade.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="difficulty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select level" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Level 1 (Easy)</SelectItem>
|
||||
<SelectItem value="2">Level 2</SelectItem>
|
||||
<SelectItem value="3">Level 3 (Medium)</SelectItem>
|
||||
<SelectItem value="4">Level 4</SelectItem>
|
||||
<SelectItem value="5">Level 5 (Hard)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totalScore"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total Score</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="durationMin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Duration (min)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<CardFooter className="justify-end">
|
||||
<SubmitButton />
|
||||
</CardFooter>
|
||||
</form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Schedule Start Time (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If set, this exam will be scheduled for a specific time.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Mode & Actions */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Assembly Mode</CardTitle>
|
||||
<CardDescription>
|
||||
Choose how to build the exam structure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormControl>
|
||||
<div className="flex flex-col space-y-3">
|
||||
{/* Manual Mode */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground",
|
||||
field.value === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
|
||||
)}
|
||||
onClick={() => field.onChange("manual")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">Manual Assembly</span>
|
||||
</div>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
Manually select questions from the bank and organize structure.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* AI Mode (Disabled) */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex cursor-not-allowed flex-col rounded-lg border p-4 shadow-sm outline-none opacity-50 bg-muted/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium">AI Generation</span>
|
||||
</div>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
Automatically generate exam structure based on topics. (Coming Soon)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isPending ? "Creating Draft..." : "Create & Start Building"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
16
src/modules/exams/components/exam-grid.tsx
Normal file
16
src/modules/exams/components/exam-grid.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Exam } from "../types"
|
||||
import { ExamCard } from "./exam-card"
|
||||
|
||||
interface ExamGridProps {
|
||||
exams: Exam[]
|
||||
}
|
||||
|
||||
export function ExamGrid({ exams }: ExamGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{exams.map((exam) => (
|
||||
<ExamCard key={exam.id} exam={exam} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -68,6 +68,10 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
const data = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(exams.createdAt)],
|
||||
with: {
|
||||
subject: true,
|
||||
gradeEntity: true,
|
||||
}
|
||||
})
|
||||
|
||||
// Transform and Filter (especially for JSON fields)
|
||||
@@ -78,8 +82,8 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: getString(meta, "subject") || "General",
|
||||
grade: getString(meta, "grade") || "General",
|
||||
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
totalScore: getNumber(meta, "totalScore") || 100,
|
||||
durationMin: getNumber(meta, "durationMin") || 60,
|
||||
@@ -103,6 +107,8 @@ export const getExamById = cache(async (id: string) => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, id),
|
||||
with: {
|
||||
subject: true,
|
||||
gradeEntity: true,
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
with: {
|
||||
@@ -120,8 +126,8 @@ export const getExamById = cache(async (id: string) => {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: getString(meta, "subject") || "General",
|
||||
grade: getString(meta, "grade") || "General",
|
||||
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
totalScore: getNumber(meta, "totalScore") || 100,
|
||||
durationMin: getNumber(meta, "durationMin") || 60,
|
||||
@@ -137,3 +143,18 @@ export const getExamById = cache(async (id: string) => {
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
export const omitScheduledAtFromDescription = (description: string | null): string => {
|
||||
if (!description) return "{}"
|
||||
try {
|
||||
const meta = JSON.parse(description)
|
||||
if (typeof meta === "object" && meta !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { scheduledAt, ...rest } = meta as any
|
||||
return JSON.stringify(rest)
|
||||
}
|
||||
return description
|
||||
} catch {
|
||||
return description || "{}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ async function getCurrentUser() {
|
||||
|
||||
if (anyUser) return { id: anyUser.id, role: roleHint }
|
||||
|
||||
return { id: "user_teacher_123", role: roleHint }
|
||||
return { id: "user_teacher_math", role: roleHint }
|
||||
}
|
||||
|
||||
async function ensureTeacher() {
|
||||
|
||||
@@ -17,7 +17,7 @@ export function HomeworkAssignmentExamContentCard({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
<HomeworkAssignmentExamErrorExplorerLazy
|
||||
structure={structure}
|
||||
questions={questions}
|
||||
|
||||
@@ -7,44 +7,47 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
function ExamErrorExplorerFallback() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]">
|
||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">题目</div>
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
<Skeleton className="h-10 w-[40%]" />
|
||||
<Skeleton className="h-10 w-[60%]" />
|
||||
<Skeleton className="h-10 w-[75%]" />
|
||||
<Skeleton className="h-10 w-[55%]" />
|
||||
<Skeleton className="h-10 w-[68%]" />
|
||||
<div className="grid grid-cols-1 gap-0 md:grid-cols-3 h-[600px] divide-y md:divide-y-0 md:divide-x">
|
||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Question Preview</span>
|
||||
</div>
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-[60%]" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-[90%]" />
|
||||
<Skeleton className="h-4 w-[80%]" />
|
||||
</div>
|
||||
<div className="space-y-3 pt-4">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="text-sm font-medium">错题详情</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-full" />
|
||||
<div className="min-w-0 flex-1 grid gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="text-sm font-medium">Error Analysis</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||
<Skeleton className="size-16 rounded-full shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-[45%]" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function HomeworkAssignmentExamErrorExplorer({
|
||||
}, [questions, selectedQuestionId])
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 gap-4 md:grid-cols-3 ${heightClassName}`}>
|
||||
<div className={`grid grid-cols-1 gap-0 md:grid-cols-3 ${heightClassName} divide-y md:divide-y-0 md:divide-x border rounded-md bg-background overflow-hidden`}>
|
||||
<HomeworkAssignmentExamPreviewPane
|
||||
structure={structure}
|
||||
questions={questions.map((q) => ({
|
||||
|
||||
@@ -20,15 +20,19 @@ export function HomeworkAssignmentExamPreviewPane({
|
||||
onQuestionSelect: (questionId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">题目</div>
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
|
||||
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Question Preview</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 bg-background">
|
||||
<div className="p-6">
|
||||
<ExamViewer
|
||||
structure={structure}
|
||||
questions={questions}
|
||||
selectedQuestionId={selectedQuestionId}
|
||||
onQuestionSelect={onQuestionSelect}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -92,59 +92,63 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
||||
const errorRate = selected?.errorRate ?? 0
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="text-sm font-medium">错题详情</div>
|
||||
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
|
||||
<div className="border-b px-6 py-4 bg-muted/5">
|
||||
<div className="text-sm font-medium">Error Analysis</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 space-y-6">
|
||||
{selected ? (
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<>
|
||||
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
||||
<div className="shrink-0">
|
||||
<ErrorRatePieChart errorRate={errorRate} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 grid gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>错误人数</span>
|
||||
<span className="tabular-nums text-foreground">{errorCount}</span>
|
||||
<div className="min-w-0 flex-1 grid gap-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Question</span>
|
||||
<span className="font-medium">Q{selected.questionId.slice(-4)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>错误率</span>
|
||||
<span className="tabular-nums text-foreground">{(errorRate * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>统计样本</span>
|
||||
<span className="tabular-nums text-foreground">{gradedSampleCount}</span>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Errors</span>
|
||||
<span className="font-medium text-destructive">
|
||||
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-xs text-muted-foreground">请选择左侧题目</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full p-4">
|
||||
{!selected ? (
|
||||
<div className="text-sm text-muted-foreground">暂无数据</div>
|
||||
) : wrongAnswers.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无错误答案</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">错误答案列表(可滚动)</div>
|
||||
<div className="space-y-2">
|
||||
{wrongAnswers.map((item, idx) => (
|
||||
<div key={`${selected.questionId}-${idx}`} className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-24 shrink-0 text-xs text-muted-foreground truncate">{item.studentName}</div>
|
||||
<div className="min-w-0 flex-1 text-sm wrap-break-word whitespace-pre-wrap">
|
||||
{formatAnswer(item.answerContent, selected)}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers ({wrongAnswers.length})</div>
|
||||
{wrongAnswers.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
|
||||
No wrong answers recorded.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{wrongAnswers.map((wa, i) => (
|
||||
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
|
||||
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<div className="font-medium text-destructive break-words">
|
||||
{formatAnswer(wa.answerContent, selected)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
|
||||
<p>Select a question from the left</p>
|
||||
<p className="text-xs mt-1">to view error analysis</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
|
||||
export function HomeworkAssignmentQuestionErrorDetailsCard({
|
||||
questions,
|
||||
gradedSampleCount,
|
||||
}: {
|
||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
||||
gradedSampleCount: number
|
||||
}) {
|
||||
return (
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{questions.length === 0 || gradedSampleCount === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No data available.</div>
|
||||
) : (
|
||||
<ScrollArea className="h-72">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[70px]">Question</TableHead>
|
||||
<TableHead className="text-right">Error Count</TableHead>
|
||||
<TableHead className="text-right">Error Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questions.map((q, index) => (
|
||||
<TableRow key={q.questionId}>
|
||||
<TableCell className="text-sm">
|
||||
<div className="font-medium">Q{index + 1}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">{q.errorCount}</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">{(q.errorRate * 100).toFixed(1)}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,103 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
function ErrorRateChart({
|
||||
questions,
|
||||
gradedSampleCount,
|
||||
}: {
|
||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
||||
gradedSampleCount: number
|
||||
}) {
|
||||
const w = 100
|
||||
const h = 60
|
||||
const padL = 10
|
||||
const padR = 3
|
||||
const padT = 4
|
||||
const padB = 10
|
||||
const plotW = w - padL - padR
|
||||
const plotH = h - padT - padB
|
||||
const n = questions.length
|
||||
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||
const xFor = (i: number) => padL + (n <= 1 ? 0 : (i / (n - 1)) * plotW)
|
||||
const yFor = (rate: number) => padT + (1 - clamp01(rate)) * plotH
|
||||
|
||||
const points = questions.map((q, i) => `${xFor(i)},${yFor(q.errorRate)}`).join(" ")
|
||||
const areaD =
|
||||
n === 0
|
||||
? ""
|
||||
: `M ${padL} ${padT + plotH} L ${points.split(" ").join(" L ")} L ${padL + plotW} ${padT + plotH} Z`
|
||||
|
||||
const gridYs = [
|
||||
{ v: 1, label: "100%" },
|
||||
{ v: 0.5, label: "50%" },
|
||||
{ v: 0, label: "0%" },
|
||||
]
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
|
||||
{gridYs.map((g) => {
|
||||
const y = yFor(g.v)
|
||||
return (
|
||||
<g key={g.label}>
|
||||
<line x1={padL} y1={y} x2={padL + plotW} y2={y} className="stroke-border" strokeWidth={0.5} />
|
||||
<text x={2} y={y + 1.2} className="fill-muted-foreground text-[3px]">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} className="stroke-border" strokeWidth={0.7} />
|
||||
<line
|
||||
x1={padL}
|
||||
y1={padT + plotH}
|
||||
x2={padL + plotW}
|
||||
y2={padT + plotH}
|
||||
className="stroke-border"
|
||||
strokeWidth={0.7}
|
||||
/>
|
||||
|
||||
{n >= 2 ? <path d={areaD} className="fill-primary/10" /> : null}
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{questions.map((q, i) => {
|
||||
const cx = xFor(i)
|
||||
const cy = yFor(q.errorRate)
|
||||
const label = `Q${i + 1}`
|
||||
return (
|
||||
<g key={q.questionId}>
|
||||
<circle cx={cx} cy={cy} r={1.2} className="fill-primary" />
|
||||
<title>{`${label}: ${(q.errorRate * 100).toFixed(1)}% (${q.errorCount} / ${gradedSampleCount})`}</title>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{questions.map((q, i) => {
|
||||
if (n > 12 && i % 2 === 1) return null
|
||||
const x = xFor(i)
|
||||
return (
|
||||
<text
|
||||
key={`x-${q.questionId}`}
|
||||
x={x}
|
||||
y={h - 2}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[3px]"
|
||||
>
|
||||
{i + 1}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||
|
||||
export function HomeworkAssignmentQuestionErrorOverviewCard({
|
||||
questions,
|
||||
@@ -106,26 +11,78 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
|
||||
questions: HomeworkAssignmentQuestionAnalytics[]
|
||||
gradedSampleCount: number
|
||||
}) {
|
||||
const data = questions.map((q, index) => ({
|
||||
name: `Q${index + 1}`,
|
||||
errorRate: q.errorRate * 100,
|
||||
errorCount: q.errorCount,
|
||||
total: gradedSampleCount,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Overview</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Error Rate Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="h-72">
|
||||
{questions.length === 0 || gradedSampleCount === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No graded submissions yet. Error analytics will appear here after grading.
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No graded submissions yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Graded students</span>
|
||||
<span className="font-medium text-foreground">{gradedSampleCount}</span>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "hsl(var(--muted)/0.2)" }}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const d = payload[0].payload
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">Question</span>
|
||||
<span className="font-bold text-muted-foreground">{d.name}</span>
|
||||
</div>
|
||||
<div className="h-56 rounded-md border bg-muted/40 px-3 py-2">
|
||||
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">Error Rate</span>
|
||||
<span className="font-bold">{d.errorRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">Errors</span>
|
||||
<span className="font-bold">
|
||||
{d.errorCount} / {d.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="errorRate"
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={40}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Check, MessageSquarePlus, X } from "lucide-react"
|
||||
import {
|
||||
Check,
|
||||
MessageSquarePlus,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Save,
|
||||
User,
|
||||
AlertCircle,
|
||||
Clock
|
||||
} from "lucide-react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
@@ -39,20 +49,48 @@ type HomeworkGradingViewProps = {
|
||||
status: string
|
||||
totalScore: number | null
|
||||
answers: Answer[]
|
||||
prevSubmissionId?: string | null
|
||||
nextSubmissionId?: string | null
|
||||
}
|
||||
|
||||
export function HomeworkGradingView({
|
||||
submissionId,
|
||||
answers: initialAnswers,
|
||||
prevSubmissionId,
|
||||
nextSubmissionId,
|
||||
studentName,
|
||||
assignmentTitle,
|
||||
submittedAt,
|
||||
}: HomeworkGradingViewProps) {
|
||||
const router = useRouter()
|
||||
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Initialize feedback visibility for answers that already have feedback
|
||||
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>(() => {
|
||||
const initialVisibility: Record<string, boolean> = {}
|
||||
if (initialAnswers) {
|
||||
initialAnswers.forEach(a => {
|
||||
if (a.feedback && a.feedback.trim().length > 0) {
|
||||
initialVisibility[a.id] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
return initialVisibility
|
||||
})
|
||||
|
||||
const handleManualScoreChange = (id: string, val: string) => {
|
||||
const parsed = val === "" ? 0 : Number(val)
|
||||
const nextScore = Number.isFinite(parsed) ? parsed : 0
|
||||
// Clamp score between 0 and maxScore? Or allow extra credit?
|
||||
// Usually maxScore is the limit, but let's just ensure it's a number.
|
||||
// Ideally we should clamp it to [0, maxScore] to avoid errors, but sometimes teachers want to give 0 for invalid input.
|
||||
const targetAnswer = answers.find(a => a.id === id)
|
||||
const max = targetAnswer?.maxScore ?? 100
|
||||
|
||||
let nextScore = Number.isFinite(parsed) ? parsed : 0
|
||||
if (nextScore > max) nextScore = max
|
||||
if (nextScore < 0) nextScore = 0
|
||||
|
||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
|
||||
}
|
||||
|
||||
@@ -69,10 +107,12 @@ export function HomeworkGradingView({
|
||||
}
|
||||
|
||||
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
||||
const binaryAnswers = answers.filter(shouldUseBinaryGrading)
|
||||
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
||||
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
||||
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount
|
||||
const maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
|
||||
const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 0
|
||||
|
||||
const correctCount = answers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
||||
const incorrectCount = answers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
||||
const partialCount = answers.reduce((sum, a) => sum + (a.score !== null && a.score > 0 && a.score < a.maxScore ? 1 : 0), 0)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
@@ -89,177 +129,357 @@ export function HomeworkGradingView({
|
||||
const result = await gradeHomeworkSubmissionAction(null, formData)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Grading saved")
|
||||
router.push("/teacher/homework/submissions")
|
||||
toast.success("Grading saved successfully")
|
||||
// Optionally redirect or stay
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to save")
|
||||
toast.error(result.message || "Failed to save grading")
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Student Response</h3>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-8">
|
||||
{answers.map((ans, index) => (
|
||||
<div key={ans.id} className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span>
|
||||
<div className="text-sm">{ans.questionContent?.text}</div>
|
||||
</div>
|
||||
<Badge variant="outline">Max: {ans.maxScore}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/50 p-4">
|
||||
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{formatStudentAnswer(ans.studentAnswer)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Grading</h3>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Score</span>
|
||||
<span className="font-bold text-lg text-primary">{currentTotal}</span>
|
||||
</div>
|
||||
{binaryAnswers.length > 0 ? (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700">
|
||||
Correct {correctCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700">
|
||||
Incorrect {incorrectCount}
|
||||
</Badge>
|
||||
{ungradedCount > 0 ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Ungraded {ungradedCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
{answers.map((ans, index) => (
|
||||
<Card key={ans.id} className="border-l-4 border-l-primary/20">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<span>Q{index + 1}</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span>
|
||||
{shouldUseBinaryGrading(ans) ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={getCorrectnessBadgeClassName(ans)}
|
||||
>
|
||||
{getCorrectnessLabel(ans)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{shouldUseBinaryGrading(ans) ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="mark correct"
|
||||
className={getMarkCorrectButtonClassName(ans)}
|
||||
onClick={() => handleMarkCorrect(ans.id)}
|
||||
>
|
||||
<Check />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="mark incorrect"
|
||||
className={getMarkIncorrectButtonClassName(ans)}
|
||||
onClick={() => handleMarkIncorrect(ans.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="add feedback"
|
||||
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)}
|
||||
onClick={() =>
|
||||
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) }))
|
||||
const handleScrollToQuestion = (id: string) => {
|
||||
const el = document.getElementById(`question-card-${id}`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>add feedback</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
{/* Main Content: Questions List */}
|
||||
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
|
||||
<ScrollArea className="flex-1 p-4 lg:p-8">
|
||||
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
||||
{answers.map((ans, index) => (
|
||||
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${
|
||||
ans.score === ans.maxScore ? "border-l-4 border-l-emerald-500" :
|
||||
ans.score === 0 && ans.maxScore > 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted"
|
||||
}`}>
|
||||
<CardHeader className="bg-card pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-6 w-6 shrink-0 justify-center rounded-full p-0">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{ans.questionType.replace("_", " ")}
|
||||
</span>
|
||||
{isAutoGradable(ans) && (
|
||||
<Badge variant="secondary" className="text-[10px] h-5">Auto-graded</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base font-medium leading-relaxed pt-2">
|
||||
{ans.questionContent?.text || "No question text"}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="outline" className="whitespace-nowrap">
|
||||
{ans.score ?? 0} / {ans.maxScore} pts
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-3 px-4 space-y-3">
|
||||
<div className="grid gap-2">
|
||||
{!shouldUseBinaryGrading(ans) ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`}>Score</Label>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="bg-card/50 p-6 space-y-6">
|
||||
{/* Student Answer Display */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<User className="h-3 w-3" /> Student Answer
|
||||
</Label>
|
||||
|
||||
<div className="rounded-md border bg-background p-4 shadow-sm">
|
||||
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
|
||||
Array.isArray(ans.questionContent?.options) ? (
|
||||
<div className="space-y-2">
|
||||
{(ans.questionContent.options as ChoiceOption[]).map((opt: ChoiceOption) => {
|
||||
const isSelected = Array.isArray(extractAnswerValue(ans.studentAnswer))
|
||||
? (extractAnswerValue(ans.studentAnswer) as string[]).includes(opt.id as string)
|
||||
: extractAnswerValue(ans.studentAnswer) === opt.id
|
||||
|
||||
const isCorrect = opt.isCorrect === true
|
||||
|
||||
// Visual logic:
|
||||
// If selected and correct -> Green + Check
|
||||
// If selected and wrong -> Red + X
|
||||
// If not selected but correct -> Green outline (show missed correct answer)
|
||||
|
||||
let containerClass = "border-transparent hover:bg-muted/50"
|
||||
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
|
||||
|
||||
if (isSelected) {
|
||||
if (isCorrect) {
|
||||
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
|
||||
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
|
||||
} else {
|
||||
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
|
||||
indicatorClass = "border-red-500 bg-red-500 text-white"
|
||||
}
|
||||
} else if (isCorrect) {
|
||||
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
|
||||
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={opt.id as string}
|
||||
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
|
||||
>
|
||||
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
|
||||
{opt.id as string}
|
||||
</div>
|
||||
<span className="flex-1">{opt.text}</span>
|
||||
{isCorrect && <Check className="h-4 w-4 text-emerald-600" />}
|
||||
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{formatStudentAnswer(ans.studentAnswer)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference Answer (for text/non-choice questions) */}
|
||||
{ans.questionType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
|
||||
<Check className="h-3 w-3" /> Reference Answer
|
||||
</Label>
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
|
||||
{getTextCorrectAnswers(ans.questionContent).join(" / ") || "No reference answer provided."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between w-full gap-4">
|
||||
{/* Grading Controls */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={getCorrectnessState(ans) === "correct" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={getCorrectnessState(ans) === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
|
||||
onClick={() => handleMarkCorrect(ans.id)}
|
||||
>
|
||||
<Check className="mr-1 h-4 w-4" /> Correct
|
||||
</Button>
|
||||
<Button
|
||||
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
className={getCorrectnessState(ans) === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
|
||||
onClick={() => handleMarkIncorrect(ans.id)}
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" /> Incorrect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">Score:</Label>
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ans.maxScore}
|
||||
className="w-20 h-8"
|
||||
value={ans.score ?? ""}
|
||||
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
|
||||
</div>
|
||||
|
||||
{/* Feedback Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
|
||||
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
|
||||
>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
{showFeedbackByAnswerId[ans.id] ? "Hide Feedback" : "Add Feedback"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Feedback Textarea */}
|
||||
{showFeedbackByAnswerId[ans.id] && (
|
||||
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<Textarea
|
||||
id={`fb-${ans.id}`}
|
||||
placeholder="Optional feedback..."
|
||||
className="min-h-[60px] resize-none"
|
||||
placeholder={`Provide feedback for ${studentName}...`}
|
||||
value={ans.feedback ?? ""}
|
||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
||||
className="min-h-[80px] bg-background"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Submit Grades"}
|
||||
{/* Sidebar: Summary & Actions */}
|
||||
<div className="lg:col-span-3 h-full flex flex-col gap-6">
|
||||
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Grading Summary</CardTitle>
|
||||
<CardDescription>{assignmentTitle}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Score</span>
|
||||
<span className="font-bold">{currentTotal} / {maxTotal}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<User className="h-4 w-4" /> Student
|
||||
</span>
|
||||
<span className="font-medium">{studentName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" /> Submitted
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{submittedAt ? new Date(submittedAt).toLocaleDateString() : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{answers.length > 0 && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
|
||||
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Correct</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
|
||||
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Incorrect</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
|
||||
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Partial</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
|
||||
Question Status
|
||||
</Label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{answers.map((ans, i) => {
|
||||
const state = getCorrectnessState(ans)
|
||||
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
|
||||
|
||||
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
|
||||
else if (state === "incorrect") badgeClass = "border-red-200 bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:border-red-800 dark:text-red-400"
|
||||
else if (state === "partial") badgeClass = "border-amber-200 bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-400"
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ans.id}
|
||||
type="button"
|
||||
onClick={() => handleScrollToQuestion(ans.id)}
|
||||
className={`flex h-8 items-center justify-center rounded border text-xs font-medium transition-colors cursor-pointer hover:ring-2 hover:ring-ring hover:ring-offset-2 ${badgeClass}`}
|
||||
title={`Q${i + 1}: ${state}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>Saving...</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" /> Submit Grades
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={!prevSubmissionId}
|
||||
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" /> Prev
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Previous Student</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={!nextSubmissionId}
|
||||
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
|
||||
>
|
||||
Next <ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Next Student</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-950/30 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ChoiceOption = { id?: unknown; isCorrect?: unknown }
|
||||
type ChoiceOption = { id?: unknown; isCorrect?: unknown; text?: string }
|
||||
|
||||
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
|
||||
|
||||
@@ -295,14 +515,6 @@ const getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | nu
|
||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||
}
|
||||
|
||||
const shouldUseBinaryGrading = (ans: Answer): boolean => {
|
||||
if (ans.questionType === "single_choice") return true
|
||||
if (ans.questionType === "multiple_choice") return true
|
||||
if (ans.questionType === "judgment") return true
|
||||
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
const isAutoGradable = (ans: Answer): boolean => {
|
||||
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
|
||||
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
|
||||
@@ -370,39 +582,6 @@ const getCorrectnessState = (ans: Answer): CorrectnessState => {
|
||||
return "partial"
|
||||
}
|
||||
|
||||
const getCorrectnessLabel = (ans: Answer): string => {
|
||||
const s = getCorrectnessState(ans)
|
||||
if (s === "correct") return "Correct"
|
||||
if (s === "incorrect") return "Incorrect"
|
||||
if (s === "partial") return "Partial"
|
||||
return "Ungraded"
|
||||
}
|
||||
|
||||
const getCorrectnessBadgeClassName = (ans: Answer): string => {
|
||||
const s = getCorrectnessState(ans)
|
||||
if (s === "correct") return "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
if (s === "incorrect") return "border-red-200 bg-red-50 text-red-700"
|
||||
if (s === "partial") return "border-amber-200 bg-amber-50 text-amber-800"
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
|
||||
const getMarkCorrectButtonClassName = (ans: Answer): string => {
|
||||
const active = getCorrectnessState(ans) === "correct"
|
||||
return active ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "text-muted-foreground"
|
||||
}
|
||||
|
||||
const getMarkIncorrectButtonClassName = (ans: Answer): string => {
|
||||
const active = getCorrectnessState(ans) === "incorrect"
|
||||
return active ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-100" : "text-muted-foreground"
|
||||
}
|
||||
|
||||
const getFeedbackIconButtonClassName = (ans: Answer, isOpen: boolean): string => {
|
||||
const hasFeedback = typeof ans.feedback === "string" && ans.feedback.trim().length > 0
|
||||
if (isOpen) return "text-primary"
|
||||
if (hasFeedback) return "text-primary/80"
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
|
||||
const formatStudentAnswer = (studentAnswer: unknown): string => {
|
||||
const v = extractAnswerValue(studentAnswer)
|
||||
if (typeof v === "string") return v
|
||||
|
||||
@@ -3,22 +3,17 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
|
||||
|
||||
import type { StudentHomeworkTakeData } from "../types"
|
||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
||||
@@ -87,6 +82,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
const isStarted = submissionStatus === "started"
|
||||
const canEdit = isStarted && Boolean(submissionId)
|
||||
const showQuestions = submissionStatus !== "not_started"
|
||||
|
||||
const handleStart = async () => {
|
||||
setIsBusy(true)
|
||||
@@ -106,7 +102,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
const handleSaveQuestion = async (questionId: string) => {
|
||||
if (!submissionId) return
|
||||
setIsBusy(true)
|
||||
// setIsBusy(true) // Don't block UI for individual saves
|
||||
const payload = answersByQuestionId[questionId]?.answer ?? null
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
@@ -115,12 +111,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (res.success) toast.success("Saved")
|
||||
else toast.error(res.message || "Failed to save")
|
||||
setIsBusy(false)
|
||||
// setIsBusy(false)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!submissionId) return
|
||||
setIsBusy(true)
|
||||
// Save all first
|
||||
for (const q of initialData.questions) {
|
||||
const payload = answersByQuestionId[q.questionId]?.answer ?? null
|
||||
const fd = new FormData()
|
||||
@@ -149,50 +146,86 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">Questions</h3>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{submissionStatus === "not_started" ? "not started" : submissionStatus}
|
||||
</Badge>
|
||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold leading-none">Questions</h3>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
|
||||
{submissionStatus === "not_started" ? "Not Started" : submissionStatus}
|
||||
</Badge>
|
||||
<span>•</span>
|
||||
<span>{initialData.questions.length} Questions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canEdit ? (
|
||||
<Button onClick={handleStart} disabled={isBusy}>
|
||||
{isBusy ? "Starting..." : "Start"}
|
||||
<Button onClick={handleStart} disabled={isBusy} size="sm">
|
||||
{isBusy ? "Starting..." : "Start Assignment"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit"}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline-block">
|
||||
Auto-saving enabled
|
||||
</span>
|
||||
<Button onClick={handleSubmit} disabled={isBusy} size="sm">
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{isBusy ? "Submitting..." : "Submit Assignment"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
{initialData.questions.map((q, idx) => {
|
||||
<ScrollArea className="flex-1 bg-muted/10">
|
||||
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||
{!isStarted && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">Ready to start?</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
|
||||
Click the "Start Assignment" button above to begin. The timer will start once you confirm.
|
||||
</p>
|
||||
<Button onClick={handleStart} disabled={isBusy}>
|
||||
Start Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQuestions && initialData.questions.map((q, idx) => {
|
||||
const text = getQuestionText(q.questionContent)
|
||||
const options = getOptions(q.questionContent)
|
||||
const value = answersByQuestionId[q.questionId]?.answer
|
||||
|
||||
return (
|
||||
<Card key={q.questionId} className="border-l-4 border-l-primary/20">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span>
|
||||
Q{idx + 1} <span className="text-muted-foreground font-normal">({q.questionType})</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Max: {q.maxScore}</span>
|
||||
<Card key={q.questionId} className="border-l-4 border-l-primary shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Question {idx + 1}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-3 px-4 space-y-4">
|
||||
<div className="text-sm">{text || "—"}</div>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||
|
||||
{q.questionType === "text" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Label className="sr-only">Your answer</Label>
|
||||
<Textarea
|
||||
placeholder="Type your answer here..."
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
@@ -200,14 +233,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
[q.questionId]: { answer: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
className="min-h-[120px] resize-y"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
) : q.questionType === "judgment" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Select
|
||||
<RadioGroup
|
||||
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||
onValueChange={(v) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
@@ -216,20 +248,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">True</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">False</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "single_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<Select
|
||||
<RadioGroup
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(v) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
@@ -238,28 +271,27 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
|
||||
{o.text}
|
||||
</SelectItem>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "multiple_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Your answer</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((o) => {
|
||||
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||
return (
|
||||
<label key={o.id} className="flex items-start gap-3 rounded-md border p-3">
|
||||
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<Checkbox
|
||||
id={`${q.questionId}-${o.id}`}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked === true
|
||||
@@ -275,30 +307,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<span className="text-sm">{o.text}</span>
|
||||
</label>
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
|
||||
{o.text}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Unsupported question type</div>
|
||||
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
|
||||
)}
|
||||
|
||||
{submissionStatus === "graded" && (
|
||||
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||
{q.feedback ? (
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="font-medium text-foreground">Teacher Feedback</div>
|
||||
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||
{q.feedback}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSaveQuestion(q.questionId)}
|
||||
disabled={isBusy}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Save
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
Save Answer
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -308,38 +356,66 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Info</h3>
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Status</span>
|
||||
<span className="capitalize text-foreground">{submissionStatus === "not_started" ? "not started" : submissionStatus}</span>
|
||||
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 bg-muted/30">
|
||||
<h3 className="font-semibold">Assignment Info</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Questions</span>
|
||||
<span className="text-foreground tabular-nums">{initialData.questions.length}</span>
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
|
||||
{submissionStatus === "not_started" ? "not started" : submissionStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
{initialData.assignment.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="text-muted-foreground">{initialData.assignment.description || "—"}</div>
|
||||
|
||||
{showQuestions && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Progress</Label>
|
||||
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||
{initialData.questions.map((q, i) => {
|
||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.questionId}
|
||||
className={`
|
||||
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
|
||||
${hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"}
|
||||
`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
{canEdit ? (
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={handleStart} disabled={isBusy}>
|
||||
{isBusy ? "Starting..." : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit All"}
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-center text-muted-foreground">
|
||||
Make sure you have answered all questions.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
320
src/modules/homework/components/student-homework-review-view.tsx
Normal file
320
src/modules/homework/components/student-homework-review-view.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { CheckCircle2, FileText, ChevronLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import type { StudentHomeworkTakeData } from "../types"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
type Option = { id: string; text: string }
|
||||
|
||||
const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
const getOptions = (content: unknown): Option[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: Option[] = []
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue
|
||||
const id = typeof item.id === "string" ? item.id : ""
|
||||
const text = typeof item.text === "string" ? item.text : ""
|
||||
if (!id || !text) continue
|
||||
out.push({ id, text })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const toAnswerShape = (questionType: string, v: unknown) => {
|
||||
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
||||
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
|
||||
return { answer: v }
|
||||
}
|
||||
|
||||
const parseSavedAnswer = (saved: unknown, questionType: string) => {
|
||||
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
|
||||
return toAnswerShape(questionType, saved)
|
||||
}
|
||||
|
||||
type HomeworkReviewViewProps = {
|
||||
initialData: StudentHomeworkTakeData
|
||||
}
|
||||
|
||||
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||
const submissionStatus = initialData.submission?.status ?? "not_started"
|
||||
const isGraded = submissionStatus === "graded"
|
||||
const isSubmitted = submissionStatus === "submitted"
|
||||
|
||||
const answersByQuestionId = useMemo(() => {
|
||||
const map = new Map<string, { answer: unknown }>()
|
||||
for (const q of initialData.questions) {
|
||||
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
|
||||
}
|
||||
const obj: Record<string, { answer: unknown }> = {}
|
||||
for (const [k, v] of map.entries()) obj[k] = v
|
||||
return obj
|
||||
}, [initialData.questions])
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold leading-none">
|
||||
{isGraded ? "Graded Report" : "Submission Details"}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] capitalize">
|
||||
{submissionStatus}
|
||||
</Badge>
|
||||
<span>•</span>
|
||||
<span>{initialData.questions.length} Questions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/student/learning/assignments">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to List
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 bg-muted/10">
|
||||
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||
{initialData.questions.map((q, idx) => {
|
||||
const text = getQuestionText(q.questionContent)
|
||||
const options = getOptions(q.questionContent)
|
||||
const value = answersByQuestionId[q.questionId]?.answer
|
||||
|
||||
return (
|
||||
<Card key={q.questionId} className={`shadow-sm ${isGraded ? 'border-l-4' : 'border-l-4 border-l-primary'}`}
|
||||
style={isGraded ? { borderLeftColor: q.score === q.maxScore && q.maxScore > 0 ? '#10b981' : q.score && q.score > 0 ? '#eab308' : '#ef4444' } : undefined}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||
Question {idx + 1}
|
||||
{isGraded && (
|
||||
<Badge variant="outline" className={`ml-2 ${q.score === q.maxScore ? "text-emerald-600 border-emerald-200 bg-emerald-50" : "text-red-600 border-red-200 bg-red-50"}`}>
|
||||
{q.score} / {q.maxScore}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs flex flex-col gap-1.5">
|
||||
<span>{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points</span>
|
||||
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{q.knowledgePoints.map((kp) => (
|
||||
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||
|
||||
{q.questionType === "text" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label className="sr-only">Your answer</Label>
|
||||
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
|
||||
{typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>}
|
||||
</div>
|
||||
</div>
|
||||
) : q.questionType === "judgment" ? (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||
disabled
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||
<Label htmlFor={`${q.questionId}-true`} className="flex-1 font-normal">True</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||
<Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "single_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={typeof value === "string" ? value : ""}
|
||||
disabled
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
|
||||
{o.text}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "multiple_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((o) => {
|
||||
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||
return (
|
||||
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<Checkbox
|
||||
id={`${q.questionId}-${o.id}`}
|
||||
checked={selected}
|
||||
disabled
|
||||
/>
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal">
|
||||
{o.text}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
|
||||
)}
|
||||
|
||||
{isGraded && (
|
||||
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||
{q.feedback ? (
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="font-medium text-foreground">Teacher Feedback</div>
|
||||
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||
{q.feedback}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 bg-muted/30">
|
||||
<h3 className="font-semibold">Assignment Info</h3>
|
||||
</div>
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{submissionStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
{initialData.assignment.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isGraded && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Total Score</Label>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-primary">
|
||||
{initialData.submission?.score ?? 0}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
/ {initialData.questions.reduce((acc, q) => acc + q.maxScore, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-600"></div>
|
||||
<span>Correct</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
|
||||
<span>Partial</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<span>Incorrect</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
{isGraded ? "Question Breakdown" : "Response Summary"}
|
||||
</Label>
|
||||
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||
{initialData.questions.map((q, i) => {
|
||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||
|
||||
const score = q.score ?? 0
|
||||
const max = q.maxScore
|
||||
let statusClass = "bg-background text-muted-foreground border-input"
|
||||
|
||||
if (isGraded) {
|
||||
if (score === max && max > 0) statusClass = "bg-emerald-600 text-white border-emerald-600"
|
||||
else if (score > 0) statusClass = "bg-yellow-500 text-white border-yellow-500"
|
||||
else statusClass = "bg-red-500 text-white border-red-500"
|
||||
} else if (hasAnswer) {
|
||||
statusClass = "bg-primary text-primary-foreground border-primary"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.questionId}
|
||||
className={`
|
||||
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
|
||||
${statusClass}
|
||||
`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -524,6 +524,17 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
// Fetch adjacent submissions for navigation
|
||||
const allSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||
columns: { id: true },
|
||||
})
|
||||
|
||||
const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId)
|
||||
const prevSubmissionId = currentIndex > 0 ? allSubmissions[currentIndex - 1].id : null
|
||||
const nextSubmissionId = currentIndex >= 0 && currentIndex < allSubmissions.length - 1 ? allSubmissions[currentIndex + 1].id : null
|
||||
|
||||
return {
|
||||
id: submission.id,
|
||||
assignmentId: submission.assignmentId,
|
||||
@@ -533,6 +544,8 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
status: submission.status as HomeworkSubmissionDetails["status"],
|
||||
totalScore: submission.score,
|
||||
answers: answersWithDetails,
|
||||
prevSubmissionId,
|
||||
nextSubmissionId,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -643,16 +656,32 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
||||
|
||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
with: {
|
||||
question: {
|
||||
with: {
|
||||
knowledgePoints: {
|
||||
with: {
|
||||
knowledgePoint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: (q, { asc }) => [asc(q.order)],
|
||||
})
|
||||
|
||||
const savedByQuestionId = new Map<string, unknown>()
|
||||
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
|
||||
if (latestSubmission) {
|
||||
const answers = await db.query.homeworkAnswers.findMany({
|
||||
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
|
||||
})
|
||||
for (const ans of answers) savedByQuestionId.set(ans.questionId, ans.answerContent)
|
||||
for (const ans of answers) {
|
||||
answersByQuestionId.set(ans.questionId, {
|
||||
answer: ans.answerContent,
|
||||
score: ans.score,
|
||||
feedback: ans.feedback,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -675,15 +704,26 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
||||
score: latestSubmission.score ?? null,
|
||||
}
|
||||
: null,
|
||||
questions: assignmentQuestions.map((aq) => ({
|
||||
questions: assignmentQuestions.map((aq) => {
|
||||
const saved = answersByQuestionId.get(aq.questionId)
|
||||
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
|
||||
const kps = aq.question.knowledgePoints ?? []
|
||||
return {
|
||||
questionId: aq.questionId,
|
||||
questionType: aq.question.type,
|
||||
questionContent: toQuestionContent(aq.question.content),
|
||||
maxScore: aq.score ?? 0,
|
||||
order: aq.order ?? 0,
|
||||
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null,
|
||||
savedAnswer: saved?.answer ?? null,
|
||||
score: saved?.score ?? null,
|
||||
feedback: saved?.feedback ?? null,
|
||||
knowledgePoints: kps.map((kp) => ({
|
||||
id: kp.knowledgePoint.id,
|
||||
name: kp.knowledgePoint.name,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
|
||||
|
||||
@@ -73,6 +73,8 @@ export type HomeworkSubmissionDetails = {
|
||||
status: HomeworkSubmissionStatus
|
||||
totalScore: number | null
|
||||
answers: HomeworkSubmissionAnswerDetails[]
|
||||
prevSubmissionId?: string | null
|
||||
nextSubmissionId?: string | null
|
||||
}
|
||||
|
||||
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
|
||||
@@ -114,6 +116,9 @@ export type StudentHomeworkTakeQuestion = {
|
||||
maxScore: number
|
||||
order: number
|
||||
savedAnswer: unknown
|
||||
score?: number | null
|
||||
feedback?: string | null
|
||||
knowledgePoints?: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export type StudentHomeworkTakeData = {
|
||||
@@ -145,7 +150,7 @@ export type HomeworkAssignmentQuestionAnalytics = {
|
||||
order: number
|
||||
errorCount: number
|
||||
errorRate: number
|
||||
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }>
|
||||
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown; count?: number }>
|
||||
}
|
||||
|
||||
export type HomeworkAssignmentAnalytics = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Bell, Menu, Search } from "lucide-react"
|
||||
import { signOut, useSession } from "next-auth/react"
|
||||
|
||||
@@ -27,8 +28,21 @@ import {
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
|
||||
import { useSidebar } from "./sidebar-provider"
|
||||
import { NAV_CONFIG } from "../config/navigation"
|
||||
|
||||
// Build lookup map for breadcrumbs
|
||||
const BREADCRUMB_MAP = new Map<string, string>()
|
||||
Object.values(NAV_CONFIG).forEach((items) => {
|
||||
items.forEach((item) => {
|
||||
BREADCRUMB_MAP.set(item.href, item.title)
|
||||
item.items?.forEach((subItem) => {
|
||||
BREADCRUMB_MAP.set(subItem.href, subItem.title)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export function SiteHeader() {
|
||||
const pathname = usePathname()
|
||||
const { toggleSidebar, isMobile } = useSidebar()
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
@@ -44,6 +58,16 @@ export function SiteHeader() {
|
||||
.map((p) => p[0]?.toUpperCase())
|
||||
.join("")
|
||||
|
||||
// Generate breadcrumbs
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
const breadcrumbs = segments
|
||||
.map((segment, index) => {
|
||||
const href = `/${segments.slice(0, index + 1).join("/")}`
|
||||
const title = BREADCRUMB_MAP.get(href) || segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
return { href, title, isLast: index === segments.length - 1 }
|
||||
})
|
||||
.filter((b) => !["admin", "teacher", "student", "parent"].includes(b.title.toLowerCase()))
|
||||
|
||||
return (
|
||||
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
@@ -60,13 +84,26 @@ export function SiteHeader() {
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumb className="hidden md:flex">
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.length > 0 ? (
|
||||
breadcrumbs.map((crumb) => (
|
||||
<React.Fragment key={crumb.href}>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard">Dashboard</BreadcrumbLink>
|
||||
{crumb.isLast ? (
|
||||
<BreadcrumbPage>{crumb.title}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={crumb.href}>{crumb.title}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
{!crumb.isLast && <BreadcrumbSeparator />}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Overview</BreadcrumbPage>
|
||||
<BreadcrumbPage>Home</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
FileQuestion,
|
||||
ClipboardList,
|
||||
Library,
|
||||
PenTool
|
||||
PenTool,
|
||||
Briefcase
|
||||
} from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
@@ -124,7 +125,14 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
{ title: "Students", href: "/teacher/classes/students" },
|
||||
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
||||
{ title: "Insights", href: "/teacher/classes/insights" },
|
||||
{ title: "Grade Insights", href: "/teacher/grades/insights" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Management",
|
||||
icon: Briefcase,
|
||||
href: "/management",
|
||||
items: [
|
||||
{ title: "Grade Insights", href: "/management/grade/insights" },
|
||||
]
|
||||
},
|
||||
],
|
||||
|
||||
@@ -9,10 +9,11 @@ import { revalidatePath } from "next/cache";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getQuestions, type GetQuestionsParams } from "./data-access";
|
||||
|
||||
async function getCurrentUser() {
|
||||
return {
|
||||
id: "user_teacher_123",
|
||||
id: "user_teacher_math",
|
||||
role: "teacher",
|
||||
};
|
||||
}
|
||||
@@ -205,25 +206,27 @@ export async function deleteQuestionAction(
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher();
|
||||
const canEditAll = user.role === "admin";
|
||||
const canDeleteAll = user.role === "admin";
|
||||
|
||||
const id = formData.get("id");
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
return { success: false, message: "Missing question id" };
|
||||
const questionId = formData.get("questionId");
|
||||
if (typeof questionId !== "string") {
|
||||
return { success: false, message: "Invalid question ID" };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [owned] = await tx
|
||||
.select({ id: questions.id })
|
||||
.from(questions)
|
||||
.where(canEditAll ? eq(questions.id, id) : and(eq(questions.id, id), eq(questions.authorId, user.id)))
|
||||
.limit(1);
|
||||
const q = await tx.query.questions.findFirst({
|
||||
where: eq(questions.id, questionId),
|
||||
});
|
||||
|
||||
if (!owned) {
|
||||
if (!q) {
|
||||
throw new Error("Question not found");
|
||||
}
|
||||
|
||||
if (!canDeleteAll && q.authorId !== user.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await deleteQuestionRecursive(tx, id);
|
||||
await deleteQuestionRecursive(tx, questionId);
|
||||
});
|
||||
|
||||
revalidatePath("/teacher/questions");
|
||||
@@ -233,6 +236,11 @@ export async function deleteQuestionAction(
|
||||
if (error instanceof Error) {
|
||||
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,
|
||||
orderBy: [desc(questions.createdAt)],
|
||||
with: {
|
||||
questionsToKnowledgePoints: {
|
||||
knowledgePoints: {
|
||||
with: {
|
||||
knowledgePoint: true,
|
||||
},
|
||||
@@ -95,7 +95,7 @@ export const getQuestions = cache(async ({
|
||||
return {
|
||||
data: rows.map((row) => {
|
||||
const knowledgePoints =
|
||||
row.questionsToKnowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
|
||||
row.knowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
|
||||
|
||||
const author = row.author
|
||||
? {
|
||||
|
||||
@@ -11,8 +11,10 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||
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 (
|
||||
<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">
|
||||
@@ -44,32 +46,7 @@ export function AdminSettingsView() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<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>
|
||||
<ProfileSettingsForm user={user} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -100,8 +77,8 @@ export function AdminSettingsView() {
|
||||
Departments, classes, and academic year settings live under the School Management section.
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="shrink-0">
|
||||
<Link href="/admin/school/departments">Open</Link>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/school">Manage</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -128,29 +105,6 @@ export function AdminSettingsView() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</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>
|
||||
</Tabs>
|
||||
</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 { 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 { 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 { UserProfile } from "@/modules/users/data-access"
|
||||
|
||||
type SettingsUser = {
|
||||
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 ?? "-"
|
||||
|
||||
export function StudentSettingsView({ user }: { user: UserProfile }) {
|
||||
return (
|
||||
<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">
|
||||
@@ -54,28 +43,7 @@ export function StudentSettingsView({ user }: { user: SettingsUser }) {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<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>
|
||||
<ProfileSettingsForm user={user} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -133,4 +101,3 @@ export function StudentSettingsView({ user }: { user: SettingsUser }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,24 +5,13 @@ import { User, Palette, Lock, LayoutDashboard, PenTool, CalendarDays, Library, F
|
||||
import { signOut } from "next-auth/react"
|
||||
|
||||
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 { 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 { UserProfile } from "@/modules/users/data-access"
|
||||
|
||||
type SettingsUser = {
|
||||
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 ?? "-"
|
||||
|
||||
export function TeacherSettingsView({ user }: { user: UserProfile }) {
|
||||
return (
|
||||
<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">
|
||||
@@ -54,28 +43,7 @@ export function TeacherSettingsView({ user }: { user: SettingsUser }) {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<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>
|
||||
<ProfileSettingsForm user={user} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -145,4 +113,3 @@ export function TeacherSettingsView({ user }: { user: SettingsUser }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
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 { joinClassByInvitationCodeAction } from "@/modules/classes/actions"
|
||||
@@ -43,114 +43,114 @@ export function StudentCoursesView({
|
||||
}
|
||||
}
|
||||
|
||||
if (classes.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Join a class</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleJoin} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="join-invitation-code">Invitation code</Label>
|
||||
<Input
|
||||
id="join-invitation-code"
|
||||
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>
|
||||
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No courses"
|
||||
description="You are not enrolled in any class yet."
|
||||
className="h-80"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Join a class</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleJoin} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="join-invitation-code">Invitation code</Label>
|
||||
<Input
|
||||
id="join-invitation-code"
|
||||
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">
|
||||
<div className="space-y-8">
|
||||
{classes.length > 0 && (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{classes.map((c) => (
|
||||
<Card key={c.id} className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold leading-none">{c.name}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Card key={c.id} className="flex flex-col overflow-hidden transition-all hover:shadow-md">
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="line-clamp-1 text-lg">{c.name}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
Grade {c.grade}
|
||||
</span>
|
||||
{c.homeroom ? (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{c.homeroom}
|
||||
</Badge>
|
||||
) : null}
|
||||
{c.room ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{c.room}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{c.homeroom && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{c.homeroom}</span>
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
Enrolled
|
||||
Active
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</div>
|
||||
</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 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
|
||||
icon={Inbox}
|
||||
title="No courses yet"
|
||||
description="You are not enrolled in any classes. Join a class to get started."
|
||||
className="py-12"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<PlusCircle className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Join a Class</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the invitation code provided by your teacher to enroll.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={handleJoin} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="join-invitation-code">Invitation Code</Label>
|
||||
<Input
|
||||
id="join-invitation-code"
|
||||
name="code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="Enter 6-digit code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
maxLength={6}
|
||||
className="max-w-md font-mono tracking-widest"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isWorking} size="lg">
|
||||
{isWorking ? "Joining..." : "Join Class"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,28 @@ import {
|
||||
createKnowledgePoint,
|
||||
deleteKnowledgePoint,
|
||||
updateTextbook,
|
||||
deleteTextbook
|
||||
deleteTextbook,
|
||||
reorderChapters
|
||||
} from "./data-access";
|
||||
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 = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
|
||||
@@ -30,7 +30,7 @@ function ChapterItem({ chapter, level = 0, onView, showActions = true }: Chapter
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
|
||||
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}>
|
||||
<div className="flex items-center group py-1">
|
||||
{hasChildren ? (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client"
|
||||
|
||||
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 { 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 {
|
||||
Collapsible,
|
||||
@@ -10,13 +13,6 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -29,54 +25,62 @@ import {
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { CreateChapterDialog } from "./create-chapter-dialog"
|
||||
import { deleteChapterAction } from "../actions"
|
||||
import { deleteChapterAction, reorderChaptersAction } from "../actions"
|
||||
|
||||
interface ChapterItemProps {
|
||||
interface SortableChapterItemProps {
|
||||
chapter: Chapter
|
||||
level?: number
|
||||
level: number
|
||||
selectedId?: string
|
||||
onSelect: (chapter: Chapter) => void
|
||||
textbookId: string
|
||||
onDelete: (chapter: Chapter) => void
|
||||
onCreateSub: (parentId: string) => void
|
||||
}
|
||||
|
||||
function ChapterItem({ chapter, level = 0, selectedId, onSelect, textbookId }: ChapterItemProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: SortableChapterItemProps) {
|
||||
const [isOpen, setIsOpen] = useState(level === 0)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
const isSelected = chapter.id === selectedId
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
const res = await deleteChapterAction(chapter.id, textbookId)
|
||||
setIsDeleting(false)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
setShowDeleteDialog(false)
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
}
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: chapter.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
position: "relative" as const,
|
||||
}
|
||||
|
||||
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}>
|
||||
<div className={cn(
|
||||
"flex items-center group py-1 rounded-md transition-colors",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||
"flex items-center group py-1.5 px-2 rounded-md transition-colors cursor-pointer select-none",
|
||||
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 ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent selecting parent when toggling
|
||||
className="h-5 w-5 shrink-0 p-0 mr-1 hover:bg-transparent text-muted-foreground/70"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronRight
|
||||
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"
|
||||
)}
|
||||
/>
|
||||
@@ -84,124 +88,257 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect, textbookId }: C
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<div className="w-6 shrink-0" />
|
||||
<div className="w-5 shrink-0 mr-1" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
className="flex-1 min-w-0 flex items-center gap-2"
|
||||
onClick={() => onSelect(chapter)}
|
||||
>
|
||||
{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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto h-6 w-6 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity focus:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCreateSub(chapter.id)
|
||||
}}
|
||||
title="Add Subchapter"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setShowCreateDialog(true)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(chapter)
|
||||
}}
|
||||
title="Delete Chapter"
|
||||
>
|
||||
<Plus />
|
||||
Add Subchapter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => setShowDeleteDialog(true)}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
||||
<CollapsibleContent>
|
||||
<div className="pt-1">
|
||||
{chapter.children!.map((child) => (
|
||||
<ChapterItem
|
||||
key={child.id}
|
||||
chapter={child}
|
||||
{hasChildren && (
|
||||
<RecursiveSortableList
|
||||
items={chapter.children!}
|
||||
level={level + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
textbookId={textbookId}
|
||||
onDelete={onDelete}
|
||||
onCreateSub={onCreateSub}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: {
|
||||
items: Chapter[],
|
||||
level: number,
|
||||
selectedId?: string,
|
||||
onSelect: (c: Chapter) => void,
|
||||
textbookId: string,
|
||||
onDelete: (c: Chapter) => void,
|
||||
onCreateSub: (pid: string) => void
|
||||
}) {
|
||||
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 (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<RecursiveSortableList
|
||||
items={chapters}
|
||||
level={0}
|
||||
selectedId={selectedChapterId}
|
||||
onSelect={onSelectChapter}
|
||||
textbookId={textbookId}
|
||||
onDelete={handleDeleteRequest}
|
||||
onCreateSub={handleCreateSubRequest}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<AlertDialogTitle>Delete Chapter?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete this chapter and all its subchapters and linked knowledge points.
|
||||
This will permanently delete <span className="font-medium text-foreground">{deleteTarget?.title}</span>.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<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>
|
||||
|
||||
<CreateChapterDialog
|
||||
textbookId={textbookId}
|
||||
parentId={chapter.id}
|
||||
trigger={null}
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChapterSidebarList({
|
||||
chapters,
|
||||
selectedChapterId,
|
||||
onSelectChapter,
|
||||
textbookId,
|
||||
}: {
|
||||
chapters: Chapter[],
|
||||
selectedChapterId?: string,
|
||||
onSelectChapter: (chapter: Chapter) => void
|
||||
textbookId: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{chapters.map((chapter) => (
|
||||
<ChapterItem
|
||||
key={chapter.id}
|
||||
chapter={chapter}
|
||||
selectedId={selectedChapterId}
|
||||
onSelect={onSelectChapter}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr
|
||||
trigger === null
|
||||
? null
|
||||
: 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" />
|
||||
<span className="sr-only">Add Chapter</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ export function KnowledgePointPanel({
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
<Tag className="h-4 w-4" />
|
||||
Knowledge Points
|
||||
</h3>
|
||||
@@ -82,16 +82,17 @@ export function KnowledgePointPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0 -mx-2 px-2">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-3">
|
||||
{selectedChapterId ? (
|
||||
chapterKPs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<>
|
||||
{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">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<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}
|
||||
</div>
|
||||
{kp.description && (
|
||||
@@ -103,7 +104,7 @@ export function KnowledgePointPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -112,47 +113,40 @@ export function KnowledgePointPanel({
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md border-dashed bg-muted/30">
|
||||
No knowledge points linked to this chapter yet.
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
|
||||
<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 className="text-sm text-muted-foreground text-center py-8">
|
||||
Select a chapter to manage its knowledge points.
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground space-y-2">
|
||||
<p className="text-sm">Select a chapter to manage knowledge points</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<AlertDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (isDeleting) return
|
||||
setShowDeleteDialog(open)
|
||||
if (!open) setDeleteTarget(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete knowledge point?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete Knowledge Point?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget ? (
|
||||
<>
|
||||
This will permanently delete <span className="font-medium text-foreground">{deleteTarget.name}</span>.
|
||||
</>
|
||||
) : (
|
||||
"This will permanently delete the selected knowledge point."
|
||||
)}
|
||||
This action cannot be undone. This will permanently delete the knowledge point
|
||||
<span className="font-medium text-foreground"> {deleteTarget?.name}</span>.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -1,77 +1,113 @@
|
||||
import Link from "next/link";
|
||||
import { GraduationCap, Building2, BookOpen } from "lucide-react";
|
||||
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
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";
|
||||
|
||||
interface TextbookCardProps {
|
||||
textbook: Textbook;
|
||||
hrefBase?: string;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
export function TextbookCard({ textbook, hrefBase }: TextbookCardProps) {
|
||||
const base = hrefBase || "/teacher/textbooks";
|
||||
return (
|
||||
<Link href={`${base}/${textbook.id}`} className="block h-full">
|
||||
<Card
|
||||
className={cn(
|
||||
"group h-full overflow-hidden transition-all duration-300 ease-out",
|
||||
"hover:-translate-y-1 hover:shadow-md hover:border-primary/50"
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-muted/30 p-6 flex items-center justify-center">
|
||||
{/* Fallback Cover Visualization */}
|
||||
<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>
|
||||
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",
|
||||
};
|
||||
|
||||
{/* Decorative Background Pattern */}
|
||||
<div className="absolute inset-0 bg-grid-black/[0.02] dark:bg-grid-white/[0.02]" />
|
||||
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||
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 (
|
||||
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
||||
<Link href={`${base}/${textbook.id}`} 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">
|
||||
<Badge variant="secondary" className="w-fit bg-background/50 backdrop-blur-sm border-transparent shadow-none">
|
||||
{textbook.subject}
|
||||
</Badge>
|
||||
<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">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className="w-fit text-[10px] h-5 px-1.5 font-normal border-primary/20 text-primary bg-primary/5">
|
||||
{textbook.subject}
|
||||
</Badge>
|
||||
<CardTitle className="line-clamp-2 text-base leading-tight">
|
||||
<h3 className="font-semibold text-lg leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{textbook.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<GraduationCap className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
<span>{textbook.grade}</span>
|
||||
<CardContent className="p-4 pt-1 pb-2">
|
||||
<div className="flex flex-wrap gap-y-1 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>{textbook.grade || "Grade N/A"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Building2 className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
<span className="line-clamp-1">{textbook.publisher || "Unknown Publisher"}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
<span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
|
||||
{textbook.publisher || "Publisher N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
|
||||
<CardFooter className="p-4 pt-0 mt-auto">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground/80 bg-muted/30 px-2 py-1 rounded-md w-full">
|
||||
<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">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
<span>{textbook._count?.chapters || 0} Chapters</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import { ChapterSidebarList } from "./chapter-sidebar-list"
|
||||
import { KnowledgePointPanel } from "./knowledge-point-panel"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
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 { updateChapterContentAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
||||
|
||||
interface TextbookContentLayoutProps {
|
||||
chapters: Chapter[]
|
||||
@@ -50,29 +50,31 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
||||
}
|
||||
|
||||
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) */}
|
||||
<div className="col-span-3 border-r pr-6 flex flex-col h-full">
|
||||
<div className="flex items-center justify-between mb-4 px-2">
|
||||
<h3 className="font-semibold">Chapters</h3>
|
||||
<div className="col-span-3 border-r flex flex-col h-full bg-muted/10">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground">Contents</h3>
|
||||
<CreateChapterDialog textbookId={textbookId} />
|
||||
</div>
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedChapter?.id}
|
||||
onSelectChapter={handleSelectChapter}
|
||||
textbookId={textbookId}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b">
|
||||
<h2 className="text-xl font-bold tracking-tight">{selectedChapter.title}</h2>
|
||||
<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-2xl font-bold tracking-tight">{selectedChapter.title}</h2>
|
||||
<div className="flex gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
@@ -93,24 +95,29 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-4 min-h-full">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-3xl mx-auto px-8 py-8 min-h-full">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
className="min-h-[500px] font-mono text-sm"
|
||||
<RichTextEditor
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
placeholder="# Write markdown content here..."
|
||||
onChange={setEditContent}
|
||||
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 ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
|
||||
{selectedChapter.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">
|
||||
No content available. Click edit to add content.
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground space-y-4">
|
||||
<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>
|
||||
@@ -119,14 +126,17 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
Select a chapter from the left sidebar to view its content.
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
|
||||
<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>
|
||||
|
||||
{/* 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
|
||||
knowledgePoints={knowledgePoints}
|
||||
selectedChapterId={selectedChapter?.id || null}
|
||||
|
||||
@@ -21,21 +21,20 @@ export function TextbookFilters() {
|
||||
const hasFilters = Boolean(search || subject !== "all" || grade !== "all")
|
||||
|
||||
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="relative w-full md:w-96">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder="Search textbooks..."
|
||||
className="pl-9 bg-background"
|
||||
placeholder="Search by title, publisher..."
|
||||
className="pl-9 bg-background border-muted-foreground/20"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</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)}>
|
||||
<SelectTrigger className="w-[160px] bg-background">
|
||||
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder="Subject" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -43,17 +42,21 @@ export function TextbookFilters() {
|
||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
||||
<SelectItem value="Physics">Physics</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>
|
||||
</Select>
|
||||
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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 10">Grade 10</SelectItem>
|
||||
<SelectItem value="Grade 11">Grade 11</SelectItem>
|
||||
@@ -69,7 +72,7 @@ export function TextbookFilters() {
|
||||
setSubject(null)
|
||||
setGrade(null)
|
||||
}}
|
||||
className="h-10 px-3"
|
||||
className="h-10 px-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -89,9 +89,11 @@ export function TextbookFormDialog() {
|
||||
<SelectContent>
|
||||
<SelectItem value="Mathematics">Mathematics</SelectItem>
|
||||
<SelectItem value="Physics">Physics</SelectItem>
|
||||
<SelectItem value="History">History</SelectItem>
|
||||
<SelectItem value="English">English</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>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ function ReaderChapterItem({
|
||||
const isSelected = selectedId === chapter.id
|
||||
|
||||
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
|
||||
className={cn(
|
||||
"flex items-center group py-1 rounded-md transition-colors",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
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 { db } from "@/shared/db"
|
||||
@@ -394,3 +394,37 @@ export async function createKnowledgePoint(data: CreateKnowledgePointInput): Pro
|
||||
export async function deleteKnowledgePoint(id: string): Promise<void> {
|
||||
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) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -65,7 +65,7 @@ const AlertDialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -79,7 +79,7 @@ const AlertDialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
className={cn("text-xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
@@ -109,6 +110,12 @@ const ChartTooltipContent = React.forwardRef<
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: 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<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
React.ComponentProps<"div"> & {
|
||||
payload?: any[]
|
||||
verticalAlign?: "top" | "middle" | "bottom"
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -59,7 +59,7 @@ const DialogHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -73,7 +73,7 @@ const DialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
"text-xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...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