feat(classes): optimize teacher dashboard ui and implement grade management

This commit is contained in:
SpecialX
2026-01-14 13:59:11 +08:00
parent ade8d4346c
commit 9bfc621d3f
104 changed files with 12793 additions and 2309 deletions

View File

@@ -91,6 +91,9 @@
- 若现有基础组件无法满足需求: - 若现有基础组件无法满足需求:
1. 优先通过 Composition 在业务模块里封装“业务组件” 1. 优先通过 Composition 在业务模块里封装“业务组件”
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面) 2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
- **图表库**:统一使用 `Recharts`禁止引入其他图表库Chart.js / ECharts 等)。
- 使用 `src/shared/components/ui/chart.tsx` 进行封装。
- 遵循 Shadcn/UI Chart 规范。
### 2.4 Client Component 引用边界(强制) ### 2.4 Client Component 引用边界(强制)

View File

@@ -234,3 +234,69 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
### 7.4 技术细节 ### 7.4 技术细节
- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。 - 引入 `recharts` 替换手写 SVG 图表,统一图表风格。
- 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。 - 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。
- **New Components**:
- `TeacherGradeTrends`: 基于 Recharts 的趋势图组件。
- `Chart`: 基于 Shadcn/UI 规范的通用图表包装器 (`src/shared/components/ui/chart.tsx`)。
### 7.5 代码管理
- **Branch**: `ui_opt`
- **Scope**: `src/modules/dashboard`, `src/shared/components/ui/chart.tsx`
- **Commit**: "feat(dashboard): optimize teacher dashboard ui and layout"
---
## 8. 班级详情页与学生管理优化 (2026-01-14)
**目标**: 提升班级管理效率与信息可视化程度,优化大班级场景下的性能与体验。
### 8.1 学生管理列表优化 (Students Table)
- **分页 (Pagination)**: 引入客户端分页(每页 10 条),解决大班级列表渲染性能问题。
- **信息增强**:
- 增加学生头像 (Avatar)、性别、加入时间展示。
- 增加可视化状态徽章 (Status Badge)Active (Emerald) / Inactive (Muted)。
- **筛选能力**:
- 新增状态筛选器 (Active/Inactive),支持服务端过滤。
- **涉及组件**:
- `src/modules/classes/components/students-table.tsx`
- `src/modules/classes/components/students-filters.tsx`
### 8.2 班级详情页重构 (Class Detail Dashboard)
- **布局重构**: 采用响应式双栏布局 (Main Content + Sidebar),提升空间利用率。
- **核心指标 (Key Metrics)**: 顶部增加 4 卡片统计网格:
- **Total Students**: 活跃/非活跃人数细分。
- **Schedule Items**: 每周课程数。
- **Active Assignments**: 活跃作业数与逾期数。
- **Class Average**: 基于已评分作业的平均分。
- **侧边栏小部件**:
- **Class Schedule**: 快速查看近期课程。
- **Homework History**: 快速查看历史作业状态。
- **涉及页面**:
- `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx`
### 8.3 数据访问层更新
- **getClassStudents**: 扩展查询字段(头像、性别、加入时间),支持 `status` 过滤参数。
### 8.4 权限与流程调整 (Role Separation)
- **教师端变更**:
- 移除了“创建班级”入口,教师不再直接创建班级。
- 新增“加入班级” (Join Class) 功能,通过 6 位邀请码加入已由管理员创建的班级。
- 涉及组件:`src/modules/classes/components/my-classes-grid.tsx`
## 9. 年级管理端班级模块 (2026-01-14)
**目标**: 实现年级维度的班级集中管理,支持年级组长与管理员统一创建与维护班级。
### 9.1 路由与入口
- **路由**: `src/app/(dashboard)/management/grade/classes/page.tsx`
- **权限**: 仅限拥有年级管理权限的角色Grade Director / Teaching Head / Admin
### 9.2 功能特性
- **GradeClassesView**:
- 展示用户所管理年级的所有班级列表。
- 支持按年级筛选。
- **CRUD**: 提供创建、编辑、删除班级的完整能力。
- **RBAC**: 操作前校验用户对目标年级的管理权限。
### 9.3 核心变更
- **Data Access**: 新增 `getManagedGrades``getGradeManagedClasses`
- **Actions**: 新增 `createGradeClassAction` 等带权限校验的 Server Actions。

View File

@@ -1,7 +1,7 @@
# Textbooks Module Implementation Details # Textbooks Module Implementation Details
**Date**: 2025-12-23 **Date**: 2025-12-23
**Updated**: 2025-12-31 **Updated**: 2026-01-13
**Author**: DevOps Architect **Author**: DevOps Architect
**Module**: Textbooks (`src/modules/textbooks`) **Module**: Textbooks (`src/modules/textbooks`)
@@ -143,6 +143,51 @@ src/
* 通过 `npm run lint / typecheck / build` * 通过 `npm run lint / typecheck / build`
## 8. 后续计划 (Next Steps) ## 8. 后续计划 (Next Steps)
* [ ] **富文本编辑器**: 集成编辑器替换当前 Markdown Textarea提升编辑体验 * [x] **富文本编辑器**: 集成 Tiptap 富文本编辑器,支持 Markdown 读写、即时预览与工具栏操作
* [ ] **拖拽排序**: 实现章节树拖拽排序与持久化。 * [x] **拖拽排序**: 实现章节树拖拽排序与持久化。
* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。 * [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。
---
## 9. 界面与交互优化 (2026-01-12)
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
### 9.1 卡片与列表 (Textbook Card & Filters)
* **Dynamic Covers**: 卡片封面采用动态渐变色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),提升识别度。
* **Information Density**: 增加元数据展示Grade, Publisher, Chapter Count并优化排版层级。
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
### 9.2 详情页工作台 (Detail Workbench)
* **Immersive Layout**:
* **Full Height**: 采用 `h-[calc(100vh-8rem)]` 撑满剩余空间,移除多余滚动条。
* **Sticky Header**: 章节标题与操作栏吸顶,内容区独立滚动。
* **Typography**: 引入 `prose-zinc` 与优化的字体排版,提升阅读舒适度。
* **Sidebar Refinement**:
* **Chapter Tree**: 增加左侧边框线与层级缩进,选中态更明显;操作按钮(添加/删除)仅在 Hover 时显示,减少视觉干扰。
* **Knowledge Points**: 改为卡片式列表Hover 显示删除按钮;增加空状态引导。
* **Drag & Drop**: 集成 `@dnd-kit` 实现章节拖拽排序,支持同级拖动并实时持久化到数据库。
### 9.3 富文本编辑器 (Rich Text Editor)
* **Tiptap Integration**: 引入 `@tiptap/react` 替换原有的 Textarea。
* **Markdown Support**: 支持 Markdown 源码读写,保持数据格式兼容性。
* **Toolbar**: 实现悬浮工具栏,支持 Bold, Italic, Headings, Lists, Blockquote 等常用格式。
* **SSR Fix**: 解决 Tiptap 在 Next.js 中的 Hydration Mismatch 问题 (`immediatelyRender: false`)。
### 9.4 系统组件优化 (UI Components)
* **Dialog**:
* 优化遮罩层 (`backdrop-blur`) 与弹窗阴影,提升通透感。
* 调整动画时长 (`duration-200`) 与缓动,移除位移动画,改为纯净的 Fade + Zoom 效果。
* 增加内部间距 (`gap-6`) 与圆角 (`rounded-xl`),使排版更现代。
* **Create Chapter Dialog**: 优化触发按钮样式,增加 `sr-only` 辅助文本,修复点击区域过小的问题。
---
## 10. 近期改进 (2026-01-13)
### 10.1 导航体验 (Navigation)
* **Dynamic Breadcrumbs**: 接入全局动态面包屑系统。
* 支持从路由路径e.g., `/teacher/textbooks/123`)自动生成层级导航。
* 解决了深层嵌套页面(如教材详情页)缺乏上下文回退路径的问题。

View File

@@ -153,7 +153,37 @@ type ExamNode = {
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤 - `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate - `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
## 7. 变更记录(合并 Homework ### 6.5 `getExamPreviewAction` (新增)
- **入参**: `examId` (string)
- **行为**:
- 查询指定 exam 及其关联的 questions (通过 `exam_questions` 关系)。
- 返回完整的 `structure` (JSON 树) 和扁平化的 `questions` 列表。
- 用于预览弹窗的数据加载。
## 7. 变更记录
**日期**2026-01-12 (当前)
- **列表页优化 (`/teacher/exams/all`)**:
- 移除了冗余的 "All Exams" 页面标题和描述。
- 重构了表格列 (`ExamColumns`)
- 合并标题、标签、科目、年级为 "Exam Info" 列。
- 合并题目数、总分、时长为 "Stats" 列。
- 合并创建时间和预定时间为 "Date" 列。
- 优化了状态 (Status) 和难度 (Difficulty) 的视觉样式 (Badge, Progress bar)。
- 优化了表格分页和布局 (`ExamDataTable`)。
- **预览功能增强**:
- 新增直接预览功能:在操作列添加了 "View" (眼睛图标) 按钮。
- 点击 "View" 触发 `getExamPreviewAction` 获取完整试卷数据。
- 弹窗 (`Dialog`) 直接展示试卷内容 (`ExamPaperPreview`),移除了冗余的头部描述,优化了滚动体验。
- 修复了可访问性问题 (DialogTitle)。
- **组卷页面升级 (`/teacher/exams/[id]/build`)**:
- **布局重构**: 扩展工作区高度,调整左右面板比例 (2:1),优化头部信息展示和进度可视化。
- **题库增强**: 实现了基于 Server Action (`getQuestionsAction`) 的分页加载和服务器端筛选,提升大数据量下的性能;优化了搜索和筛选器 UI。
- **预览优化**: 移除了内联预览,改为通过 "Preview" 按钮触发弹窗预览,避免干扰编辑流。
- **视觉降噪**: 移除了页面顶部冗余的标题和描述。
**日期**2025-12-31 **日期**2025-12-31

View File

@@ -268,3 +268,39 @@
- `npm run lint`: 通过 - `npm run lint`: 通过
- `npm run typecheck`: 通过 - `npm run typecheck`: 通过
- `npm run build`: 通过 - `npm run build`: 通过
---
## 12. UI/UX 优化更新2026-01-12
### 12.1 教师端作业列表 (`/teacher/homework/assignments`)
- **表格重构**: 从简单的卡片列表升级为功能丰富的数据表格Table
- **信息增强**: 合并展示标题/时间,使用 Badge 区分状态,清晰展示截止日期(含 Late 标记),可视化提交进度。
- **操作便捷**: 每行增加操作菜单Actions支持快速跳转详情或提交列表。
### 12.2 作业详情页 (`/teacher/homework/assignments/[id]`)
- **布局重构**:
- **Sticky Header**: 头部信息栏(标题、状态、面包屑)随滚动吸顶,但后续优化为随页面滚动(移除 Sticky以节省空间。
- **关键指标**: 将截止日期、目标数、提交数、已批改数整合到头部下方,使用图标增强可读性。
- **双栏布局**: 主体内容分为“Performance Analytics”分析和“Assignment Content”内容两部分。
- **图表升级**:
- 重构 `HomeworkAssignmentQuestionErrorOverviewCard`,废弃 SVG改用 **Recharts** 实现柱状图BarChart
- 增强交互:支持 Tooltip 悬停查看具体题目错误率和人数。
- **详情面板优化**:
- 移除了冗余的 `HomeworkAssignmentQuestionErrorDetailsCard`
- 深度优化 `HomeworkAssignmentQuestionErrorDetailPanel`:
- 增加饼图展示单题错误率。
- 错误答案列表卡片化,清晰展示每个错误答案的内容及选择人数。
- 整合预览面板与详情面板,提供更连贯的“左侧选题-右侧分析”体验。
---
## 13. Bug 修复与完善 (2026-01-13)
### 13.1 批改视图 (Grading View)
- **Type Safety Fix**: 修复了 `HomeworkGradingView` 组件中的 TypeScript 类型错误。
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。

View File

@@ -114,7 +114,10 @@ Next_Edu 旨在对抗教育系统常见的信息过载。我们的设计风格
* **Height**: `64px` (h-16). * **Height**: `64px` (h-16).
* **Layout**: `flex items-center justify-between px-6 border-b`. * **Layout**: `flex items-center justify-between px-6 border-b`.
* **Components**: * **Components**:
1. **Breadcrumb**: 显示当前路径,层级清晰。 1. **Breadcrumb**: 动态路径导航 (Dynamic Breadcrumb).
* **Implementation**: 基于 `usePathname()` 自动解析路由段。
* **Mapping**: 通过 `NAV_CONFIG``BREADCRUMB_MAP` 映射路径到友好标题 (e.g., `/teacher/textbooks` -> "Textbooks").
* **Filtering**: 自动过滤根角色路径 (e.g., `/teacher`) 以保持简洁。
2. **Global Search**: `Cmd+K` 触发,居中或靠右。 2. **Global Search**: `Cmd+K` 触发,居中或靠右。
3. **User Nav**: 头像 + 下拉菜单。 3. **User Nav**: 头像 + 下拉菜单。

78
docs/work_log.md Normal file
View 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).

View File

@@ -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`);

View 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`);

View File

@@ -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`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -54,16 +54,9 @@
{ {
"idx": 7, "idx": 7,
"version": "5", "version": "5",
"when": 1767782500000, "when": 1768205524480,
"tag": "0007_add_class_invitation_code", "tag": "0007_talented_bromley",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1767941300000,
"tag": "0008_add_user_profile_fields",
"breakpoints": true "breakpoints": true
} }
] ]
} }

936
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
@@ -33,6 +35,10 @@
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
@@ -52,6 +58,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiptap-markdown": "^0.9.0",
"zod": "^4.2.1", "zod": "^4.2.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },

View File

@@ -12,7 +12,8 @@ import {
textbooks, chapters, textbooks, chapters,
schools, schools,
grades, grades,
classes, classEnrollments, classSchedule classes, classEnrollments, classSchedule,
subjects
} from "../src/shared/db/schema"; } from "../src/shared/db/schema";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
@@ -43,7 +44,7 @@ async function seed() {
"submission_answers", "exam_submissions", "exam_questions", "exams", "submission_answers", "exam_submissions", "exam_questions", "exams",
"questions_to_knowledge_points", "questions", "knowledge_points", "questions_to_knowledge_points", "questions", "knowledge_points",
"chapters", "textbooks", "chapters", "textbooks",
"grades", "schools", "grades", "schools", "subjects",
"users_to_roles", "roles", "users", "accounts", "sessions" "users_to_roles", "roles", "users", "accounts", "sessions"
]; ];
for (const table of tables) { for (const table of tables) {
@@ -133,6 +134,17 @@ async function seed() {
{ id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" }, { id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" },
]) ])
// --- Seeding Subjects ---
await db.insert(subjects).values([
{ id: createId(), name: "Mathematics", code: "MATH", order: 1 },
{ id: createId(), name: "Physics", code: "PHYS", order: 2 },
{ id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
{ id: createId(), name: "English", code: "ENG", order: 4 },
{ id: createId(), name: "History", code: "HIST", order: 5 },
{ id: createId(), name: "Geography", code: "GEO", order: 6 },
{ id: createId(), name: "Biology", code: "BIO", order: 7 },
])
await db.insert(grades).values([ await db.insert(grades).values([
{ {
id: grade10Id, id: grade10Id,

View 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>
)
}

View File

@@ -65,7 +65,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form action="/teacher/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center"> <form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label> <label className="text-sm font-medium">Grade</label>
<select <select
name="gradeId" name="gradeId"

View File

@@ -4,14 +4,16 @@ import { redirect } from "next/navigation"
import { auth } from "@/auth" import { auth } from "@/auth"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access" import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card" import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentRankingCard } from "@/modules/dashboard/components/student-dashboard/student-ranking-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid" import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card" import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card" import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access" import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getUserProfile } from "@/modules/users/data-access"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Separator } from "@/shared/components/ui/separator"
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -20,17 +22,31 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7 return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
} }
const formatDate = (date: Date | null) => {
if (!date) return "-"
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
}
export default async function ProfilePage() { export default async function ProfilePage() {
const session = await auth() const session = await auth()
if (!session?.user) redirect("/login") if (!session?.user) redirect("/login")
const name = session.user.name ?? "User"
const email = session.user.email ?? "-"
const role = String(session.user.role ?? "teacher")
const userId = String(session.user.id ?? "").trim() const userId = String(session.user.id ?? "").trim()
const userProfile = await getUserProfile(userId)
if (!userProfile) {
redirect("/login")
}
const role = userProfile.role || "student"
const isStudent = role === "student"
const studentData = const studentData =
role === "student" && userId isStudent
? await (async () => { ? await (async () => {
const [classes, schedule, assignmentsAll, grades] = await Promise.all([ const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId), getStudentClasses(userId),
@@ -96,36 +112,104 @@ export default async function ProfilePage() {
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Profile</h1> <h1 className="text-3xl font-bold tracking-tight">Profile</h1>
<div className="text-sm text-muted-foreground">Your account information.</div> <div className="text-sm text-muted-foreground">Manage your personal and account information.</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href="/settings">Open settings</Link> <Link href="/settings">Edit Profile</Link>
</Button> </Button>
</div> </div>
</div> </div>
<Card> <div className="grid gap-6 md:grid-cols-2">
<CardHeader> <Card>
<CardTitle>Account</CardTitle> <CardHeader>
<CardDescription>Signed-in user details from session.</CardDescription> <CardTitle className="flex items-center gap-2">
</CardHeader> <User className="h-5 w-5" />
<CardContent className="space-y-3"> Personal Information
<div className="flex flex-wrap items-center gap-2"> </CardTitle>
<div className="text-sm font-medium">{name}</div> <CardDescription>Basic personal details.</CardDescription>
<Badge variant="secondary" className="capitalize"> </CardHeader>
{role} <CardContent className="space-y-4">
</Badge> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
</div> <div className="space-y-1">
<div className="text-sm text-muted-foreground">{email}</div> <div className="text-sm font-medium text-muted-foreground">Full Name</div>
</CardContent> <div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
</Card> </div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Gender</div>
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Age</div>
<div className="text-sm">{userProfile.age ?? "-"}</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Phone</div>
<div className="flex items-center gap-2 text-sm">
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.phone ?? "-"}
</div>
</div>
<div className="col-span-1 sm:col-span-2 space-y-1">
<div className="text-sm font-medium text-muted-foreground">Address</div>
<div className="flex items-center gap-2 text-sm">
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.address ?? "-"}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Account Information
</CardTitle>
<CardDescription>System account details.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="col-span-1 sm:col-span-2 space-y-1">
<div className="text-sm font-medium text-muted-foreground">Email</div>
<div className="flex items-center gap-2 text-sm">
<Mail className="h-3 w-3 text-muted-foreground" />
{userProfile.email}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Role</div>
<Badge variant="secondary" className="capitalize">
{userProfile.role}
</Badge>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Member Since</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-3 w-3 text-muted-foreground" />
{formatDate(userProfile.createdAt)}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-3 w-3 text-muted-foreground" />
{formatDate(userProfile.onboardedAt)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{studentData ? ( {studentData ? (
<div className="space-y-6"> <div className="space-y-6">
<Separator />
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Student</h2> <h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
<div className="text-sm text-muted-foreground">Your learning overview.</div> <div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
</div> </div>
<StudentStatsGrid <StudentStatsGrid
@@ -133,16 +217,17 @@ export default async function ProfilePage() {
dueSoonCount={studentData.dueSoonCount} dueSoonCount={studentData.dueSoonCount}
overdueCount={studentData.overdueCount} overdueCount={studentData.overdueCount}
gradedCount={studentData.gradedCount} gradedCount={studentData.gradedCount}
ranking={studentData.grades.ranking}
/> />
<div className="grid gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<StudentGradesCard grades={studentData.grades} /> <div className="lg:col-span-2 space-y-6">
<StudentRankingCard ranking={studentData.grades.ranking} /> <StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
</div> <StudentGradesCard grades={studentData.grades} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> <div className="space-y-6">
<StudentTodayScheduleCard items={studentData.todayScheduleItems} /> <StudentTodayScheduleCard items={studentData.todayScheduleItems} />
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} /> </div>
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -4,6 +4,7 @@ import { auth } from "@/auth"
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view" import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view" import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view" import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
import { getUserProfile } from "@/modules/users/data-access"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -11,11 +12,16 @@ export default async function SettingsPage() {
const session = await auth() const session = await auth()
if (!session?.user) redirect("/login") if (!session?.user) redirect("/login")
const role = String(session.user.role ?? "teacher") const userId = String(session.user.id ?? "").trim()
const userProfile = await getUserProfile(userId)
if (role === "admin") return <AdminSettingsView /> if (!userProfile) redirect("/login")
if (role === "student") return <StudentSettingsView user={session.user} />
if (role === "teacher") return <TeacherSettingsView user={session.user} /> const role = userProfile.role || "student"
if (role === "admin") return <AdminSettingsView user={userProfile} />
if (role === "student") return <StudentSettingsView user={userProfile} />
if (role === "teacher") return <TeacherSettingsView user={userProfile} />
redirect("/dashboard") redirect("/dashboard")
} }

View File

@@ -2,6 +2,7 @@ import { notFound } from "next/navigation"
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access" import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view" import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -18,6 +19,23 @@ export default async function StudentAssignmentTakePage({
const data = await getStudentHomeworkTakeData(assignmentId, student.id) const data = await getStudentHomeworkTakeData(assignmentId, student.id)
if (!data) return notFound() if (!data) return notFound()
// If status is graded or submitted, use the review view
const status = data.submission?.status
if (status === "graded" || status === "submitted") {
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
<div className="text-sm text-muted-foreground">
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
</div>
</div>
<HomeworkReviewView initialData={data} />
</div>
)
}
return ( return (
<div className="flex h-full flex-col space-y-4 p-6"> <div className="flex h-full flex-col space-y-4 p-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View File

@@ -31,6 +31,18 @@ const getStatusLabel = (status: string) => {
return "Not started" return "Not started"
} }
const getActionLabel = (status: string) => {
if (status === "graded") return "Review"
if (status === "submitted") return "View"
if (status === "in_progress") return "Continue"
return "Start"
}
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded" || status === "submitted") return "outline"
return "default"
}
export default async function StudentAssignmentsPage() { export default async function StudentAssignmentsPage() {
const student = await getDemoStudentUser() const student = await getDemoStudentUser()
@@ -75,6 +87,7 @@ export default async function StudentAssignmentsPage() {
<TableHead>Due</TableHead> <TableHead>Due</TableHead>
<TableHead>Attempts</TableHead> <TableHead>Attempts</TableHead>
<TableHead>Score</TableHead> <TableHead>Score</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -95,6 +108,13 @@ export default async function StudentAssignmentsPage() {
{a.attemptsUsed}/{a.maxAttempts} {a.attemptsUsed}/{a.maxAttempts}
</TableCell> </TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell> <TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -39,36 +39,40 @@ export default async function StudentTextbookDetailPage({
if (!textbook) notFound() if (!textbook) notFound()
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden"> <div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10"> <div className="flex items-center gap-4 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground" asChild>
<Link href="/student/learning/textbooks"> <Link href="/student/learning/textbooks">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back
</Link> </Link>
</Button> </Button>
<div className="w-px h-8 bg-border mx-2" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2">
<Badge variant="outline">{textbook.subject}</Badge> <h1 className="text-lg font-bold tracking-tight truncate mr-2">{textbook.title}</h1>
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium"> <Badge variant="secondary" className="font-normal text-xs">{textbook.subject}</Badge>
{textbook.grade ?? "-"} {textbook.grade && (
</span> <span className="text-xs text-muted-foreground border px-1.5 py-0.5 rounded">
{textbook.grade}
</span>
)}
</div> </div>
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden pt-6"> <div className="flex-1 overflow-hidden p-6">
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<div className="px-8"> <div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
<EmptyState <EmptyState
icon={BookOpen} icon={BookOpen}
title="No chapters" title="No chapters"
description="This textbook has no chapters yet." description="This textbook has no chapters yet."
className="bg-card" className="border-none shadow-none"
/> />
</div> </div>
) : ( ) : (
<div className="h-[calc(100vh-140px)] px-8 min-h-0"> <div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
<TextbookReader chapters={chapters} /> <TextbookReader chapters={chapters} />
</div> </div>
)} )}

View File

@@ -70,7 +70,7 @@ export default async function StudentTextbooksPage({
) : ( ) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{textbooks.map((textbook) => ( {textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" /> <TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" hideActions />
))} ))}
</div> </div>
)} )}

View File

@@ -1,13 +1,15 @@
import Link from "next/link" import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { BookOpen, Calendar, ChevronRight, Clock, Users } from "lucide-react"
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access" import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
import { ScheduleView } from "@/modules/classes/components/schedule-view" import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { cn, formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -23,6 +25,15 @@ const formatNumber = (v: number | null, digits = 1) => {
return v.toFixed(digits) return v.toFixed(digits)
} }
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
export default async function ClassDetailPage({ export default async function ClassDetailPage({
params, params,
searchParams, searchParams,
@@ -63,253 +74,304 @@ export default async function ClassDetailPage({
] ]
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex flex-col min-h-full space-y-8 p-8">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2"> <div className="space-y-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-muted-foreground mb-2">
<Button asChild variant="outline" size="sm"> <Link href="/teacher/classes/my" className="hover:text-foreground transition-colors">
<Link href="/teacher/classes/my">Back</Link> My Classes
</Button> </Link>
<Badge variant="secondary">{insights.class.grade}</Badge> <ChevronRight className="h-4 w-4" />
<Badge variant="outline">{insights.studentCounts.total} students</Badge> <span className="text-foreground font-medium">{insights.class.name}</span>
</div> </div>
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2> <h2 className="text-3xl font-bold tracking-tight">{insights.class.name}</h2>
<div className="text-sm text-muted-foreground"> <div className="flex items-center gap-3 text-sm text-muted-foreground">
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"} <Badge variant="secondary" className="rounded-sm font-normal">
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null} {insights.class.grade}
</Badge>
{insights.class.homeroom && (
<>
<span className="w-1 h-1 rounded-full bg-border" />
<span>Homeroom: {insights.class.homeroom}</span>
</>
)}
{insights.class.room && (
<>
<span className="w-1 h-1 rounded-full bg-border" />
<span>Room: {insights.class.room}</span>
</>
)}
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>Students</Link> <Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
<Users className="mr-2 h-4 w-4" />
Students
</Link>
</Button> </Button>
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>Schedule</Link> <Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
<Calendar className="mr-2 h-4 w-4" />
Schedule
</Link>
</Button> </Button>
<Button asChild variant="outline"> <Button asChild>
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(insights.class.id)}`}>Insights</Link> <Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>
Create Homework
</Link>
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-4"> {/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle> <CardTitle className="text-sm font-medium">Total Students</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div> <div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive} {insights.studentCounts.active} active · {insights.studentCounts.inactive} inactive
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Schedule items</CardTitle> <CardTitle className="text-sm font-medium">Schedule Items</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{schedule.length}</div> <div className="text-2xl font-bold">{schedule.length}</div>
<div className="text-xs text-muted-foreground">Weekly timetable entries</div> <div className="text-xs text-muted-foreground">Weekly sessions</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle> <CardTitle className="text-sm font-medium">Active Assignments</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div> <div className="text-2xl font-bold">
<div className="text-xs text-muted-foreground">{latest ? `Latest ${formatDate(latest.createdAt)}` : "No homework yet"}</div> {insights.assignments.filter((a) => a.isActive).length}
</CardContent> </div>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count} {insights.assignments.filter((a) => a.isOverdue).length} overdue
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Class Average</CardTitle>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}%</div>
<div className="text-xs text-muted-foreground">
Based on {insights.overallScores.count} graded submissions
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{latest ? ( <div className="grid gap-6 lg:grid-cols-7">
<Card> {/* Main Content Area */}
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="lg:col-span-4 space-y-6">
<div> {/* Latest Homework */}
<CardTitle className="text-base">Latest homework</CardTitle> {latest && (
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <Card>
<span className="font-medium text-foreground">{latest.title}</span> <CardHeader>
<Badge variant="outline" className="capitalize"> <div className="flex items-center justify-between">
{latest.status} <div className="space-y-1">
</Badge> <CardTitle>Latest Homework</CardTitle>
<span>·</span> <CardDescription>Most recent assignment activity</CardDescription>
<span>{formatDate(latest.createdAt)}</span> </div>
{latest.dueAt ? ( <Badge variant={latest.isActive ? "default" : "secondary"}>
<> {latest.status}
<span>·</span> </Badge>
<span>Due {formatDate(latest.dueAt)}</span> </div>
</> </CardHeader>
) : null} <CardContent className="space-y-6">
</div> <div className="flex flex-col gap-4 rounded-lg border p-4">
</div> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2"> <div className="space-y-1">
<Button asChild variant="outline" size="sm"> <Link
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link> href={`/teacher/homework/assignments/${latest.assignmentId}`}
</Button> className="font-semibold hover:underline"
<Button asChild variant="outline" size="sm"> >
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link> {latest.title}
</Button> </Link>
</div> <div className="flex items-center gap-2 text-sm text-muted-foreground">
</CardHeader> <span>Due {latest.dueAt ? formatDate(latest.dueAt) : "No due date"}</span>
<CardContent className="grid gap-4 md:grid-cols-5"> <span>·</span>
<div> <span>{latest.submittedCount}/{latest.targetCount} Submitted</span>
<div className="text-sm text-muted-foreground">Targeted</div> </div>
<div className="text-lg font-semibold">{latest.targetCount}</div> </div>
</div> <Button variant="outline" size="sm" asChild>
<div> <Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>
<div className="text-sm text-muted-foreground">Submitted</div> Grade
<div className="text-lg font-semibold">{latest.submittedCount}</div> </Link>
</div> </Button>
<div> </div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div> <div className="grid grid-cols-3 gap-4 border-t pt-4">
</div> <div className="text-center">
<div> <div className="text-2xl font-bold">{latest.gradedCount}</div>
<div className="text-sm text-muted-foreground">Average</div> <div className="text-xs text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div> </div>
</div> <div className="text-center border-l border-r">
<div> <div className="text-2xl font-bold">{formatNumber(latest.scoreStats.avg, 1)}</div>
<div className="text-sm text-muted-foreground">Median</div> <div className="text-xs text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div> </div>
</div> <div className="text-center">
</CardContent> <div className="text-2xl font-bold">{formatNumber(latest.scoreStats.median, 1)}</div>
</Card> <div className="text-xs text-muted-foreground">Median</div>
) : null} </div>
</div>
<div className="grid gap-6 lg:grid-cols-2"> </div>
<Card> </CardContent>
<CardHeader className="flex flex-row items-center justify-between"> </Card>
<CardTitle className="text-base">Students (preview)</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-sm text-muted-foreground">No students enrolled.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.slice(0, 8).map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Schedule</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-base">Homework history</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button asChild size="sm" variant={hwFilter === "all" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "active" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "overdue" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>Open list</Link>
</Button>
<Button asChild size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>New homework</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{!hasAssignments ? (
<div className="text-sm text-muted-foreground">No homework assignments yet.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)} )}
</CardContent>
</Card> {/* Students Preview */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="space-y-1">
<CardTitle>Students</CardTitle>
<CardDescription>Recently active students</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No students enrolled yet.
</div>
) : (
<div className="space-y-4">
{students.slice(0, 5).map((s) => (
<div key={s.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={s.image || undefined} />
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium text-sm">{s.name}</div>
<div className="text-xs text-muted-foreground">{s.email}</div>
</div>
</div>
<Badge variant={s.status === "active" ? "outline" : "secondary"} className="text-xs font-normal">
{s.status}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar Area */}
<div className="lg:col-span-3 space-y-6">
{/* Schedule Widget */}
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Schedule</CardTitle>
<Button variant="ghost" size="icon" asChild>
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
</CardContent>
</Card>
{/* Homework History */}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant={hwFilter === "all" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
</Button>
<Button
size="sm"
variant={hwFilter === "active" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
</Button>
<Button
size="sm"
variant={hwFilter === "overdue" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{filteredAssignments.slice(0, 5).map((a) => (
<div key={a.assignmentId} className="p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between gap-4 mb-2">
<Link
href={`/teacher/homework/assignments/${a.assignmentId}`}
className="text-sm font-medium hover:underline line-clamp-1"
>
{a.title}
</Link>
<Badge variant={a.isActive ? "default" : "secondary"} className="shrink-0 text-[10px] h-5">
{a.status}
</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<div className="flex gap-3">
<span>{a.submittedCount} submitted</span>
<span>{formatNumber(a.scoreStats.avg, 0)}% avg</span>
</div>
</div>
</div>
))}
{filteredAssignments.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No assignments found
</div>
)}
</div>
{filteredAssignments.length > 5 && (
<div className="p-2 border-t text-center">
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" asChild>
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>
View All Assignments
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div> </div>
) )
} }

View File

@@ -13,16 +13,6 @@ export default function MyClassesPage() {
async function MyClassesPageImpl() { async function MyClassesPageImpl() {
const classes = await getTeacherClasses() const classes = await getTeacherClasses()
const session = await auth()
const role = String(session?.user?.role ?? "")
const userId = String(session?.user?.id ?? "").trim()
const canCreateClass = await (async () => {
if (role === "admin") return true
if (!userId) return false
const [row] = await db.select({ id: grades.id }).from(grades).where(eq(grades.gradeHeadId, userId)).limit(1)
return Boolean(row)
})()
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-8 p-8">
@@ -35,7 +25,7 @@ async function MyClassesPageImpl() {
</div> </div>
</div> </div>
<MyClassesGrid classes={classes} canCreateClass={canCreateClass} /> <MyClassesGrid classes={classes} canCreateClass={false} />
</div> </div>
) )
} }

View File

@@ -21,13 +21,15 @@ async function StudentsResults({ searchParams }: { searchParams: Promise<SearchP
const q = getParam(params, "q") || undefined const q = getParam(params, "q") || undefined
const classId = getParam(params, "classId") const classId = getParam(params, "classId")
const status = getParam(params, "status")
const filteredStudents = await getClassStudents({ const filteredStudents = await getClassStudents({
q, q,
classId: classId && classId !== "all" ? classId : undefined, classId: classId && classId !== "all" ? classId : undefined,
status: status && status !== "all" ? status : undefined,
}) })
const hasFilters = Boolean(q || (classId && classId !== "all")) const hasFilters = Boolean(q || (classId && classId !== "all") || (status && status !== "all"))
if (filteredStudents.length === 0) { if (filteredStudents.length === 0) {
return ( return (

View File

@@ -12,9 +12,8 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
const exam = await getExamById(id) const exam = await getExamById(id)
if (!exam) return notFound() if (!exam) return notFound()
// Fetch all available questions (for selection pool) // Fetch initial questions for the bank (pagination handled by client)
// In a real app, this might be paginated or filtered by exam subject/grade const { data: questionsData } = await getQuestions({ pageSize: 20 })
const { data: questionsData } = await getQuestions({ pageSize: 100 })
const initialSelected = (exam.questions || []).map(q => ({ const initialSelected = (exam.questions || []).map(q => ({
id: q.id, id: q.id,
@@ -103,13 +102,7 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
} }
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-4 p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Build Exam</h2>
<p className="text-muted-foreground">Add questions and adjust scores.</p>
</div>
</div>
<ExamAssembly <ExamAssembly
examId={exam.id} examId={exam.id}
title={exam.title} title={exam.title}

View File

@@ -131,13 +131,6 @@ export default async function AllExamsPage({
}) { }) {
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
<p className="text-muted-foreground">View and manage all your exams.</p>
</div>
</div>
<div className="space-y-4"> <div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}> <Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ExamFilters /> <ExamFilters />

View File

@@ -1,14 +1,37 @@
import { ExamForm } from "@/modules/exams/components/exam-form" import { ExamForm } from "@/modules/exams/components/exam-form"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
export default function CreateExamPage() { export default function CreateExamPage() {
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
<div className="flex items-center justify-between"> <div className="space-y-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Create</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">Create Exam</h2> <h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
<p className="text-muted-foreground">Design a new exam for your students.</p> <p className="text-muted-foreground mt-2">
Set up a new exam draft and choose your assembly method.
</p>
</div> </div>
</div> </div>
<ExamForm /> <ExamForm />
</div> </div>
) )

View File

@@ -2,12 +2,12 @@ import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access" import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card" import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
import { HomeworkAssignmentQuestionErrorDetailsCard } from "@/modules/homework/components/homework-assignment-question-error-details-card"
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card" import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -20,82 +20,82 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
const { assignment, questions, gradedSampleCount } = analytics const { assignment, questions, gradedSampleCount } = analytics
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex flex-col min-h-full">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center"> {/* Header */}
<div> <div className="border-b bg-background px-8 py-5">
<div className="flex items-center gap-3"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2> <div className="flex flex-col gap-2">
<Badge variant="outline" className="capitalize"> <div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
{assignment.status} <Link href="/teacher/homework/assignments" className="flex items-center hover:text-foreground transition-colors">
</Badge> <ChevronLeft className="h-4 w-4 mr-1" />
</div> Assignments
<p className="text-muted-foreground mt-1">{assignment.description || "—"}</p> </Link>
<div className="mt-2 text-sm text-muted-foreground"> <span>/</span>
<span>Source Exam: {assignment.sourceExamTitle}</span> <span>Details</span>
<span className="mx-2"></span>
<span>Created: {formatDate(assignment.createdAt)}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Back</Link>
</Button>
<Button asChild>
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>View Submissions</Link>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Targets</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{assignment.targetCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Submissions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{assignment.submissionCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Due</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm">
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
<div className="text-muted-foreground">
Late:{" "}
{assignment.allowLate
? assignment.lateDueAt
? formatDate(assignment.lateDueAt)
: "Allowed"
: "Not allowed"}
</div>
</div> </div>
</CardContent> <div className="flex items-center gap-3">
</Card> <h1 className="text-2xl font-bold tracking-tight text-foreground">{assignment.title}</h1>
<Badge variant={assignment.status === "published" ? "default" : "secondary"} className="capitalize">
{assignment.status}
</Badge>
</div>
<p className="text-muted-foreground text-sm max-w-2xl">{assignment.description || "No description provided."}</p>
</div>
<div className="flex items-center gap-3 mt-2 md:mt-0">
<Button asChild variant="outline" className="shadow-sm">
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>
<Users className="h-4 w-4 mr-2" />
View Submissions
</Link>
</Button>
</div>
</div>
{/* Quick Stats Row */}
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-6 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Due: <span className="font-medium text-foreground">{assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>Targets: <span className="font-medium text-foreground">{assignment.targetCount}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4" />
<span>Submissions: <span className="font-medium text-foreground">{assignment.submissionCount}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<BarChart3 className="h-4 w-4" />
<span>Graded: <span className="font-medium text-foreground">{gradedSampleCount}</span></span>
</div>
</div>
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> <div className="flex-1 p-8 space-y-8 bg-muted/5">
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} /> {/* Analytics Section */}
<HomeworkAssignmentQuestionErrorDetailsCard questions={questions} gradedSampleCount={gradedSampleCount} /> <section className="space-y-4">
</div> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Performance Analytics</h2>
</div>
<div className="grid gap-6 md:grid-cols-1">
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
</section>
<HomeworkAssignmentExamContentCard {/* Content Section */}
structure={assignment.structure} <section className="space-y-4">
questions={questions} <div className="flex items-center justify-between">
gradedSampleCount={gradedSampleCount} <h2 className="text-lg font-semibold tracking-tight">Assignment Content</h2>
/> </div>
<HomeworkAssignmentExamContentCard
structure={assignment.structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
</section>
</div>
</div> </div>
) )
} }

View File

@@ -36,6 +36,8 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
status={submission.status} status={submission.status}
totalScore={submission.totalScore} totalScore={submission.totalScore}
answers={submission.answers} answers={submission.answers}
prevSubmissionId={submission.prevSubmissionId}
nextSubmissionId={submission.nextSubmissionId}
/> />
</div> </div>
) )

View File

@@ -33,7 +33,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"} title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."} description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined} action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
className="bg-card" className="min-h-[400px] border-muted-foreground/10"
/> />
) )
} }
@@ -50,7 +50,7 @@ async function TextbooksResults({ searchParams }: { searchParams: Promise<Search
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) { export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6 p-8">
{/* Page Header */} {/* Page Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div> <div>

View File

@@ -1,11 +1,11 @@
"use server"; "use server";
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { and, eq, sql } from "drizzle-orm" import { and, eq, sql, or, inArray } from "drizzle-orm"
import { auth } from "@/auth" import { auth } from "@/auth"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { grades } from "@/shared/db/schema" import { grades, classes } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { import {
createAdminClass, createAdminClass,
@@ -138,6 +138,201 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
} }
} }
export async function createGradeClassAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof gradeId !== "string" || gradeId.trim().length === 0) {
return { success: false, message: "Grade selection is required" }
}
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
return { success: false, message: "Teacher is required" }
}
// Verify access
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to create classes for this grade" }
}
try {
const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade: typeof grade === "string" ? grade : "", // Should be passed from UI based on selected grade
gradeId,
teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/management/grade/classes")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
}
}
export async function updateGradeClassAction(
classId: string,
prevState: ActionState | undefined,
formData: FormData
): Promise<ActionState> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const subjectTeachers = formData.get("subjectTeachers")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
// Verify access: Check if the class belongs to a managed grade
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!cls || !cls.gradeId) {
return { success: false, message: "Class not found or not linked to a grade" }
}
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to update this class" }
}
// If changing gradeId, verify target grade too
if (typeof gradeId === "string" && gradeId !== cls.gradeId) {
const [targetGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!targetGrade) {
return { success: false, message: "You do not have permission to move class to this grade" }
}
}
try {
await updateAdminClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
})
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({
classId,
assignments: parsed.flatMap((item) => {
if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId
if (typeof subject !== "string" || !isClassSubject(subject)) return []
if (teacherId === null || typeof teacherId === "undefined") {
return [{ subject, teacherId: null }]
}
if (typeof teacherId !== "string") return []
const trimmed = teacherId.trim()
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
}),
})
}
revalidatePath("/management/grade/classes")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
}
}
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
// Verify access
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!cls || !cls.gradeId) {
return { success: false, message: "Class not found or not linked to a grade" }
}
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to delete this class" }
}
try {
await deleteAdminClass(classId)
revalidatePath("/management/grade/classes")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
}
}
export async function enrollStudentByEmailAction( export async function enrollStudentByEmailAction(
classId: string, classId: string,
prevState: ActionState | null, prevState: ActionState | null,
@@ -171,14 +366,19 @@ export async function joinClassByInvitationCodeAction(
} }
const session = await auth() const session = await auth()
if (!session?.user?.id || String(session.user.role ?? "") !== "student") { const role = String(session?.user?.role ?? "")
if (!session?.user?.id || (role !== "student" && role !== "teacher")) {
return { success: false, message: "Unauthorized" } return { success: false, message: "Unauthorized" }
} }
try { try {
const classId = await enrollStudentByInvitationCode(session.user.id, code) const classId = await enrollStudentByInvitationCode(session.user.id, code)
revalidatePath("/student/learning/courses") if (role === "student") {
revalidatePath("/student/schedule") revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
} else {
revalidatePath("/teacher/classes/my")
}
revalidatePath("/profile") revalidatePath("/profile")
return { success: true, message: "Joined class successfully", data: { classId } } return { success: true, message: "Joined class successfully", data: { classId } }
} catch (error) { } catch (error) {

View 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>
</>
)
}

View File

@@ -3,11 +3,24 @@
import Link from "next/link" import Link from "next/link"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Calendar, Copy, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Users } from "lucide-react" import {
Calendar,
Copy,
MoreHorizontal,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
Users,
GraduationCap,
MapPin,
ChartBar,
} from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { parseAsString, useQueryState } from "nuqs" import { parseAsString, useQueryState } from "nuqs"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -41,6 +54,7 @@ import {
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
import type { TeacherClass } from "../types" import type { TeacherClass } from "../types"
import { import {
createTeacherClassAction, createTeacherClassAction,
@@ -48,12 +62,25 @@ import {
ensureClassInvitationCodeAction, ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction, regenerateClassInvitationCodeAction,
updateTeacherClassAction, updateTeacherClassAction,
joinClassByInvitationCodeAction,
} from "../actions" } from "../actions"
const GRADIENTS = [
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
]
function getClassGradient(id: string) {
return "bg-card border-border shadow-sm hover:shadow-md"
}
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) { export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
const router = useRouter() const router = useRouter()
const [isWorking, setIsWorking] = useState(false) const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false) const [joinOpen, setJoinOpen] = useState(false)
const [q, setQ] = useQueryState("q", parseAsString.withDefault("")) const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all")) const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
@@ -75,41 +102,44 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade]) const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
const handleCreate = async (formData: FormData) => { const handleJoin = async (formData: FormData) => {
setIsWorking(true) setIsWorking(true)
try { try {
const res = await createTeacherClassAction(null, formData) const res = await joinClassByInvitationCodeAction(null, formData)
if (res.success) { if (res.success) {
toast.success(res.message) toast.success(res.message || "Joined class successfully")
setCreateOpen(false) setJoinOpen(false)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || "Failed to create class") toast.error(res.message || "Failed to join class")
} }
} catch { } catch {
toast.error("Failed to create class") toast.error("Failed to join class")
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
} }
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-center justify-between gap-3"> {/* Filter Bar */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm"> <div className="relative flex-1 md:max-w-[320px]">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input <Input
placeholder="Search classes..." placeholder="Search classes..."
value={q} value={q}
onChange={(e) => setQ(e.target.value || null)} onChange={(e) => setQ(e.target.value || null)}
className="pl-9 bg-background"
/> />
</div> </div>
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}> <Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
<SelectTrigger className="w-[200px]"> <SelectTrigger className="w-[160px] bg-background">
<SelectValue placeholder="Grade" /> <SelectValue placeholder="All Grades" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All grades</SelectItem> <SelectItem value="all">All Grades</SelectItem>
{gradeOptions.map((g) => ( {gradeOptions.map((g) => (
<SelectItem key={g} value={g}> <SelectItem key={g} value={g}>
{g} {g}
@@ -120,83 +150,56 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
{(q || grade !== "all") && ( {(q || grade !== "all") && (
<Button <Button
variant="ghost" variant="ghost"
className="h-9" size="icon"
onClick={() => { onClick={() => {
setQ(null) setQ(null)
setGrade(null) setGrade(null)
}} }}
title="Clear filters"
> >
Reset <RefreshCw className="size-4" />
</Button> </Button>
)} )}
</div> </div>
<Dialog <Dialog
open={createOpen} open={joinOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!canCreateClass) return
if (isWorking) return if (isWorking) return
setCreateOpen(open) setJoinOpen(open)
}} }}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2" disabled={isWorking || !canCreateClass}> <Button className="gap-2 shadow-sm" disabled={isWorking}>
<Plus className="size-4" /> <Plus className="size-4" />
New class Join Class
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[480px]"> <DialogContent className="sm:max-w-[480px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Create class</DialogTitle> <DialogTitle>Join Class</DialogTitle>
<DialogDescription>Add a new class to start managing students.</DialogDescription> <DialogDescription>Enter the invitation code to join a class.</DialogDescription>
</DialogHeader> </DialogHeader>
<form action={handleCreate}> <form action={handleJoin}>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-school-name" className="text-right"> <Label htmlFor="join-code" className="text-right">
School Code
</Label> </Label>
<Input <Input
id="create-school-name" id="join-code"
name="schoolName" name="code"
className="col-span-3" className="col-span-3"
placeholder="Optional" placeholder="e.g. 123456"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 1A" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade" className="text-right">
Grade
</Label>
<Input
id="create-grade"
name="grade"
className="col-span-3"
placeholder="e.g. Grade 7"
defaultValue={defaultGrade}
required required
maxLength={6}
pattern="\d{6}"
/> />
</div> </div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit" disabled={isWorking}> <Button type="submit" disabled={isWorking}>
{isWorking ? "Creating..." : "Create"} {isWorking ? "Joining..." : "Join Class"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -204,34 +207,33 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
</Dialog> </Dialog>
</div> </div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> {/* Grid */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{classes.length === 0 ? ( {classes.length === 0 ? (
<EmptyState <EmptyState
title="No classes yet" title="No classes yet"
description="Create your first class to start managing students and schedules." description="Join a class to start managing students and schedules."
icon={Users} icon={Users}
action={canCreateClass ? { label: "Create class", onClick: () => setCreateOpen(true) } : undefined} action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3" className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
/> />
) : filteredClasses.length === 0 ? ( ) : filteredClasses.length === 0 ? (
<EmptyState <EmptyState
title="No classes match your filters" title="No classes match your filters"
description="Try clearing filters or adjusting keywords." description="Try clearing filters or adjusting keywords."
icon={Users} icon={Search}
action={{ label: "Clear filters", onClick: () => { action={{
setQ(null) label: "Clear filters",
setGrade(null) onClick: () => {
}}} setQ(null)
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3" setGrade(null)
},
}}
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
/> />
) : ( ) : (
filteredClasses.map((c) => ( filteredClasses.map((c) => (
<ClassCard <ClassCard key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
key={c.id}
c={c}
onWorkingChange={setIsWorking}
isWorking={isWorking}
/>
)) ))
)} )}
</div> </div>
@@ -334,92 +336,131 @@ function ClassCard({
} }
return ( return (
<Card className="shadow-none"> <Card className={cn("group flex flex-col transition-all hover:shadow-md", getClassGradient(c.id))}>
<CardHeader className="space-y-2"> <CardHeader className="relative pb-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="space-y-1">
<CardTitle className="text-base truncate"> <CardTitle className="line-clamp-1 text-lg font-bold leading-none tracking-tight">
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline"> <Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
{c.name} {c.name}
</Link> </Link>
</CardTitle> </CardTitle>
<div className="text-muted-foreground text-sm mt-1"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
{c.room ? `Room: ${c.room}` : "Room: Not set"} <Badge variant="secondary" className="h-5 px-1.5 font-medium">
{c.grade}
</Badge>
{c.homeroom && (
<Badge variant="outline" className="h-5 border-dashed bg-transparent px-1.5 font-normal">
{c.homeroom}
</Badge>
)}
</div> </div>
</div> </div>
<DropdownMenu>
<div className="flex items-center gap-2"> <DropdownMenuTrigger asChild>
<Badge variant="secondary">{c.grade}</Badge> <Button variant="ghost" size="icon" className="h-8 w-8 -mr-2" disabled={isWorking}>
<DropdownMenu> <MoreHorizontal className="size-4" />
<DropdownMenuTrigger asChild> <span className="sr-only">Actions</span>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}> </Button>
<MoreHorizontal className="size-4" /> </DropdownMenuTrigger>
</Button> <DropdownMenuContent align="end">
</DropdownMenuTrigger> <DropdownMenuItem onClick={() => setShowEdit(true)}>
<DropdownMenuContent align="end"> <Pencil className="mr-2 size-4" />
<DropdownMenuItem onClick={() => setShowEdit(true)}> Edit Class
<Pencil className="mr-2 size-4" /> </DropdownMenuItem>
Edit <DropdownMenuSeparator />
</DropdownMenuItem> <DropdownMenuItem
<DropdownMenuSeparator /> className="text-destructive focus:text-destructive"
<DropdownMenuItem onClick={() => setShowDelete(true)}
className="text-destructive focus:text-destructive" >
onClick={() => setShowDelete(true)} <Trash2 className="mr-2 size-4" />
> Delete Class
<Trash2 className="mr-2 size-4" /> </DropdownMenuItem>
Delete </DropdownMenuContent>
</DropdownMenuItem> </DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="flex-1 pb-4">
<div className="flex items-center justify-between"> <div className="grid grid-cols-2 gap-4 text-sm">
<div className="text-sm font-medium tabular-nums">{c.studentCount} students</div> <div className="flex flex-col gap-1">
{c.homeroom ? <Badge variant="outline">{c.homeroom}</Badge> : null} <span className="text-xs text-muted-foreground">Students</span>
</div> <div className="flex items-center gap-1.5 font-medium">
<div className="flex items-center justify-between gap-3"> <Users className="size-3.5 text-muted-foreground" />
<div className="min-w-0"> {c.studentCount}
<div className="text-xs uppercase text-muted-foreground">Invitation code</div> </div>
<div className="font-mono tabular-nums text-sm">{c.invitationCode ?? "-"}</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Room</span>
<div className="flex items-center gap-1.5 font-medium">
<MapPin className="size-3.5 text-muted-foreground" />
{c.room || "—"}
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between rounded-md border bg-background/50 p-2">
<div className="flex flex-col">
<span className="text-[10px] uppercase text-muted-foreground">Invite Code</span>
<span className="font-mono text-sm font-medium tracking-wider">{c.invitationCode || "—"}</span>
</div>
<div className="flex items-center gap-1">
{c.invitationCode ? ( {c.invitationCode ? (
<> <TooltipProvider delayDuration={0}>
<Button variant="outline" size="sm" className="gap-2" onClick={handleCopyCode} disabled={isWorking}> <Tooltip>
<Copy className="size-4" /> <TooltipTrigger asChild>
Copy <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopyCode} disabled={isWorking}>
</Button> <Copy className="size-3.5" />
<Button variant="outline" size="sm" className="gap-2" onClick={handleRegenerateCode} disabled={isWorking}> </Button>
<RefreshCw className="size-4" /> </TooltipTrigger>
Regenerate <TooltipContent>Copy Code</TooltipContent>
</Button> </Tooltip>
</> <Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleRegenerateCode}
disabled={isWorking}
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Regenerate</TooltipContent>
</Tooltip>
</TooltipProvider>
) : ( ) : (
<Button variant="outline" size="sm" onClick={handleEnsureCode} disabled={isWorking}> <Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleEnsureCode} disabled={isWorking}>
Generate Generate
</Button> </Button>
)} )}
</div> </div>
</div> </div>
<div className={cn("grid gap-2", "grid-cols-2")}>
<Button asChild variant="outline" className="w-full justify-start gap-2">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
<Users className="size-4" />
Students
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-start gap-2">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
<Calendar className="size-4" />
Schedule
</Link>
</Button>
</div>
</CardContent> </CardContent>
<CardFooter className="grid grid-cols-3 gap-2 border-t p-2">
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
<Users className="mr-1.5 size-3.5" />
Students
</Link>
</Button>
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
<Calendar className="mr-1.5 size-3.5" />
Schedule
</Link>
</Button>
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(c.id)}`}>
<ChartBar className="mr-1.5 size-3.5" />
Insights
</Link>
</Button>
</CardFooter>
{/* Dialogs */}
<Dialog <Dialog
open={showEdit} open={showEdit}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -495,7 +536,7 @@ function ClassCard({
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit" disabled={isWorking}> <Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"} {isWorking ? "Saving..." : "Save Changes"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -524,7 +565,7 @@ function ClassCard({
onClick={handleDelete} onClick={handleDelete}
disabled={isWorking} disabled={isWorking}
> >
{isWorking ? "Deleting..." : "Delete"} {isWorking ? "Deleting..." : "Delete Class"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@@ -31,6 +31,7 @@ import { enrollStudentByEmailAction } from "../actions"
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) { export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
const router = useRouter() const router = useRouter()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -76,7 +77,7 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
</div> </div>
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}> <Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[200px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Class" /> <SelectValue placeholder="Class" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -89,12 +90,24 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
</SelectContent> </SelectContent>
</Select> </Select>
{(search || classId !== "all") && ( <Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
{(search || classId !== "all" || status !== "all") && (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setSearch(null) setSearch(null)
setClassId(null) setClassId(null)
setStatus(null)
}} }}
className="h-8 px-2 lg:px-3" className="h-8 px-2 lg:px-3"
> >

View File

@@ -2,12 +2,14 @@
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react" import { MoreHorizontal, UserCheck, UserX, ChevronLeft, ChevronRight } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -36,10 +38,17 @@ import {
import type { ClassStudent } from "../types" import type { ClassStudent } from "../types"
import { setStudentEnrollmentStatusAction } from "../actions" import { setStudentEnrollmentStatusAction } from "../actions"
const ITEMS_PER_PAGE = 10
export function StudentsTable({ students }: { students: ClassStudent[] }) { export function StudentsTable({ students }: { students: ClassStudent[] }) {
const router = useRouter() const router = useRouter()
const [workingKey, setWorkingKey] = useState<string | null>(null) const [workingKey, setWorkingKey] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null) const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
const [page, setPage] = useState(1)
const totalPages = Math.ceil(students.length / ITEMS_PER_PAGE)
const startIndex = (page - 1) * ITEMS_PER_PAGE
const paginatedStudents = students.slice(startIndex, startIndex + ITEMS_PER_PAGE)
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => { const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
const key = `${student.classId}:${student.id}:${status}` const key = `${student.classId}:${student.id}:${status}`
@@ -59,64 +68,144 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
} }
} }
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
return ( return (
<> <>
<Table> <Card className="shadow-none">
<TableHeader> <CardHeader className="border-b px-6 py-4">
<TableRow className="bg-muted/50"> <div className="flex items-center justify-between">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Student</TableHead> <CardTitle className="text-base font-semibold">All Students</CardTitle>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Email</TableHead> <Badge variant="secondary" className="rounded-sm px-1.5 font-normal">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead> {students.length} total
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead> </Badge>
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Actions</TableHead> </div>
</TableRow> </CardHeader>
</TableHeader> <CardContent className="p-0">
<TableBody> <Table>
{students.map((s) => ( <TableHeader>
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-12", s.status !== "active" && "opacity-70")}> <TableRow className="bg-muted/50 hover:bg-muted/50">
<TableCell className="font-medium">{s.name}</TableCell> <TableHead className="pl-6 text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
<TableCell className="text-muted-foreground">{s.email}</TableCell> <TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
<TableCell>{s.className}</TableCell> <TableHead className="text-xs font-medium uppercase text-muted-foreground">Joined</TableHead>
<TableCell> <TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<Badge variant={s.status === "active" ? "secondary" : "outline"}> <TableHead className="pr-6 text-right text-xs font-medium uppercase text-muted-foreground">
{s.status === "active" ? "Active" : "Inactive"} Actions
</Badge> </TableHead>
</TableCell> </TableRow>
<TableCell className="text-right"> </TableHeader>
<DropdownMenu> <TableBody>
<DropdownMenuTrigger asChild> {paginatedStudents.map((s) => (
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}> <TableRow key={`${s.classId}:${s.id}`} className={cn("h-16", s.status !== "active" && "opacity-70")}>
<MoreHorizontal className="size-4" /> <TableCell className="pl-6">
</Button> <div className="flex items-center gap-3">
</DropdownMenuTrigger> <Avatar className="h-9 w-9 border">
<DropdownMenuContent align="end"> <AvatarImage src={s.image || undefined} alt={s.name} />
{s.status !== "active" ? ( <AvatarFallback>{getInitials(s.name)}</AvatarFallback>
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}> </Avatar>
<UserCheck className="mr-2 size-4" /> <div className="flex flex-col gap-0.5">
Set active <span className="font-medium leading-none">{s.name}</span>
</DropdownMenuItem> <span className="text-xs text-muted-foreground">{s.email}</span>
) : ( </div>
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}> </div>
<UserX className="mr-2 size-4" /> </TableCell>
Set inactive <TableCell>
</DropdownMenuItem> <Badge variant="outline" className="font-normal">
)} {s.className}
<DropdownMenuSeparator /> </Badge>
<DropdownMenuItem </TableCell>
onClick={() => setRemoveTarget(s)} <TableCell className="text-muted-foreground text-sm">
className="text-destructive focus:text-destructive" {formatDate(s.joinedAt)}
disabled={s.status === "inactive" || workingKey !== null} </TableCell>
<TableCell>
<Badge
variant={s.status === "active" ? "secondary" : "outline"}
className={cn(
"font-medium",
s.status === "active"
? "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 hover:bg-emerald-500/20"
: "text-muted-foreground"
)}
> >
<UserX className="mr-2 size-4" /> {s.status === "active" ? "Active" : "Inactive"}
Remove from class </Badge>
</DropdownMenuItem> </TableCell>
</DropdownMenuContent> <TableCell className="pr-6 text-right">
</DropdownMenu> <DropdownMenu>
</TableCell> <DropdownMenuTrigger asChild>
</TableRow> <Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
))} <MoreHorizontal className="size-4" />
</TableBody> </Button>
</Table> </DropdownMenuTrigger>
<DropdownMenuContent align="end">
{s.status !== "active" ? (
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
<UserCheck className="mr-2 size-4" />
Set active
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
<UserX className="mr-2 size-4" />
Set inactive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setRemoveTarget(s)}
className="text-destructive focus:text-destructive"
disabled={s.status === "inactive" || workingKey !== null}
>
<UserX className="mr-2 size-4" />
Remove from class
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
{totalPages > 1 && (
<CardFooter className="flex items-center justify-between border-t px-6 py-4">
<div className="text-xs text-muted-foreground">
Showing <strong>{startIndex + 1}</strong>-
<strong>{Math.min(startIndex + ITEMS_PER_PAGE, students.length)}</strong> of{" "}
<strong>{students.length}</strong> students
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">
{page} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardFooter>
)}
</Card>
<AlertDialog <AlertDialog
open={Boolean(removeTarget)} open={Boolean(removeTarget)}

View File

@@ -122,6 +122,20 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
const rows = await (async () => { const rows = await (async () => {
try { try {
const ownedIds = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, teacherId))
const enrolledIds = await db
.select({ id: classEnrollments.classId })
.from(classEnrollments)
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
if (allIds.length === 0) return []
return await db return await db
.select({ .select({
id: classes.id, id: classes.id,
@@ -135,26 +149,11 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
}) })
.from(classes) .from(classes)
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id)) .leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(eq(classes.teacherId, teacherId)) .where(inArray(classes.id, allIds))
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode) .groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) .orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} catch { } catch {
return await db return []
.select({
id: classes.id,
schoolName: sql<string | null>`NULL`.as("schoolName"),
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: sql<string | null>`NULL`.as("invitationCode"),
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
})
.from(classes)
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(eq(classes.teacherId, teacherId))
.groupBy(classes.id, classes.name, classes.grade, classes.homeroom, classes.room)
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} }
})() })()
@@ -331,6 +330,143 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
return list return list
}) })
export const getGradeManagedClasses = cache(async (userId: string): Promise<AdminClassListItem[]> => {
const managedGradeIds = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGradeIds.length === 0) return []
const gradeIds = managedGradeIds.map((g) => g.id)
const [rows, subjectRows] = await Promise.all([
(async () => {
try {
return await db
.select({
id: classes.id,
schoolName: classes.schoolName,
schoolId: classes.schoolId,
name: classes.name,
grade: classes.grade,
gradeId: classes.gradeId,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
createdAt: classes.createdAt,
updatedAt: classes.updatedAt,
})
.from(classes)
.innerJoin(users, eq(users.id, classes.teacherId))
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(inArray(classes.gradeId, gradeIds))
.groupBy(
classes.id,
classes.schoolName,
classes.schoolId,
classes.name,
classes.grade,
classes.gradeId,
classes.homeroom,
classes.room,
classes.invitationCode,
users.id,
users.name,
users.email,
classes.createdAt,
classes.updatedAt
)
.orderBy(
asc(classes.schoolName),
asc(classes.grade),
asc(classes.name),
asc(classes.homeroom),
asc(classes.room)
)
} catch {
return []
}
})(),
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.where(inArray(classes.gradeId, gradeIds))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) {
const subject = r.subject as ClassSubject
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
: null
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
bySubject.set(subject, teacher)
subjectsByClassId.set(r.classId, bySubject)
}
const list = rows.map((r) => {
const bySubject = subjectsByClassId.get(r.id)
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
subject,
teacher: bySubject?.get(subject) ?? null,
}))
return {
id: r.id,
schoolName: r.schoolName,
schoolId: r.schoolId,
name: r.name,
grade: r.grade,
gradeId: r.gradeId,
homeroom: r.homeroom,
room: r.room,
invitationCode: r.invitationCode ?? null,
teacher: {
id: r.teacherId,
name: r.teacherName ?? "Unnamed",
email: r.teacherEmail,
},
subjectTeachers,
studentCount: Number(r.studentCount ?? 0),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}
})
list.sort(compareClassLike)
return list
})
export const getManagedGrades = cache(async (userId: string) => {
return await db
.select({
id: grades.id,
name: grades.name,
schoolId: grades.schoolId,
schoolName: schools.name,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
.orderBy(asc(schools.name), asc(grades.name))
})
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => { export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
const id = studentId.trim() const id = studentId.trim()
if (!id) return [] if (!id) return []
@@ -345,9 +481,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: classes.grade, grade: classes.grade,
homeroom: classes.homeroom, homeroom: classes.homeroom,
room: classes.room, room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
}) })
.from(classEnrollments) .from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId)) .innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) .orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} catch { } catch {
@@ -359,9 +498,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: classes.grade, grade: classes.grade,
homeroom: classes.homeroom, homeroom: classes.homeroom,
room: classes.room, room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
}) })
.from(classEnrollments) .from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId)) .innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) .orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} }
@@ -374,6 +516,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: r.grade, grade: r.grade,
homeroom: r.homeroom, homeroom: r.homeroom,
room: r.room, room: r.room,
teacherName: r.teacherName,
teacherEmail: r.teacherEmail,
})) }))
list.sort(compareClassLike) list.sort(compareClassLike)
@@ -414,12 +558,13 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
}) })
export const getClassStudents = cache( export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; teacherId?: string }): Promise<ClassStudent[]> => { async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId()) const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
if (!teacherId) return [] if (!teacherId) return []
const classId = params?.classId?.trim() const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase() const q = params?.q?.trim().toLowerCase()
const status = params?.status?.trim().toLowerCase()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)] const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
@@ -427,6 +572,10 @@ export const getClassStudents = cache(
conditions.push(eq(classes.id, classId)) conditions.push(eq(classes.id, classId))
} }
if (status === "active" || status === "inactive") {
conditions.push(eq(classEnrollments.status, status))
}
if (q && q.length > 0) { if (q && q.length > 0) {
const needle = `%${q}%` const needle = `%${q}%`
conditions.push( conditions.push(
@@ -439,9 +588,12 @@ export const getClassStudents = cache(
id: users.id, id: users.id,
name: users.name, name: users.name,
email: users.email, email: users.email,
image: users.image,
gender: users.gender,
classId: classes.id, classId: classes.id,
className: classes.name, className: classes.name,
status: classEnrollments.status, status: classEnrollments.status,
joinedAt: classEnrollments.createdAt,
}) })
.from(classEnrollments) .from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId)) .innerJoin(classes, eq(classes.id, classEnrollments.classId))
@@ -453,9 +605,12 @@ export const getClassStudents = cache(
id: r.id, id: r.id,
name: r.name ?? "Unnamed", name: r.name ?? "Unnamed",
email: r.email, email: r.email,
image: r.image,
gender: r.gender,
classId: r.classId, classId: r.classId,
className: r.className, className: r.className,
status: r.status, status: r.status,
joinedAt: r.joinedAt,
})) }))
} }
) )

View File

@@ -65,9 +65,12 @@ export type ClassStudent = {
id: string id: string
name: string name: string
email: string email: string
image?: string | null
gender?: string | null
classId: string classId: string
className: string className: string
status: "active" | "inactive" status: "active" | "inactive"
joinedAt: Date
} }
export type ClassScheduleItem = { export type ClassScheduleItem = {
@@ -80,26 +83,6 @@ export type ClassScheduleItem = {
location?: string | null location?: string | null
} }
export type StudentEnrolledClass = {
id: string
schoolName?: string | null
name: string
grade: string
homeroom?: string | null
room?: string | null
}
export type StudentScheduleItem = {
id: string
classId: string
className: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type CreateClassScheduleItemInput = { export type CreateClassScheduleItemInput = {
classId: string classId: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7 weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
@@ -118,13 +101,26 @@ export type UpdateClassScheduleItemInput = {
location?: string | null location?: string | null
} }
export type ClassBasicInfo = { export type StudentEnrolledClass = {
id: string id: string
schoolName?: string | null
name: string name: string
grade: string grade: string
homeroom?: string | null homeroom?: string | null
room?: string | null room?: string | null
invitationCode?: string | null teacherName?: string | null
teacherEmail?: string | null
}
export type StudentScheduleItem = {
id: string
classId: string
className: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
} }
export type ScoreStats = { export type ScoreStats = {
@@ -151,24 +147,23 @@ export type ClassHomeworkAssignmentStats = {
} }
export type ClassHomeworkInsights = { export type ClassHomeworkInsights = {
class: ClassBasicInfo class: {
studentCounts: { id: string
total: number name: string
active: number grade: string
inactive: number homeroom?: string | null
room?: string | null
invitationCode?: string | null
} }
studentCounts: { total: number; active: number; inactive: number }
assignments: ClassHomeworkAssignmentStats[] assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats overallScores: ScoreStats
} }
export type GradeHomeworkClassSummary = { export type GradeHomeworkClassSummary = {
class: ClassBasicInfo class: { id: string; name: string; grade: string; homeroom?: string | null; room?: string | null }
studentCounts: { studentCounts: { total: number; active: number; inactive: number }
total: number
active: number
inactive: number
}
latestAvg: number | null latestAvg: number | null
prevAvg: number | null prevAvg: number | null
deltaAvg: number | null deltaAvg: number | null
@@ -176,17 +171,9 @@ export type GradeHomeworkClassSummary = {
} }
export type GradeHomeworkInsights = { export type GradeHomeworkInsights = {
grade: { grade: { id: string; name: string; school: { id: string; name: string } }
id: string
name: string
school: { id: string; name: string }
}
classCount: number classCount: number
studentCounts: { studentCounts: { total: number; active: number; inactive: number }
total: number
active: number
inactive: number
}
assignments: ClassHomeworkAssignmentStats[] assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats overallScores: ScoreStats

View File

@@ -1,17 +1,45 @@
"use client"
import Link from "next/link" import Link from "next/link"
import { CalendarDays, BookOpen, PenTool } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
export function StudentDashboardHeader({ studentName }: { studentName: string }) { export function StudentDashboardHeader({ studentName }: { studentName: string }) {
const hour = new Date().getHours()
let greeting = "Welcome back"
if (hour < 12) greeting = "Good morning"
else if (hour < 18) greeting = "Good afternoon"
else greeting = "Good evening"
return ( return (
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1> <h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<div className="text-sm text-muted-foreground">Welcome back, {studentName}.</div> <div className="text-sm text-muted-foreground">
{greeting}, {studentName}. Here&apos;s what&apos;s happening today.
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm" className="gap-2">
<Link href="/student/schedule">
<CalendarDays className="h-4 w-4" />
Schedule
</Link>
</Button>
<Button asChild variant="outline" size="sm" className="gap-2">
<Link href="/student/learning/textbooks">
<BookOpen className="h-4 w-4" />
Textbooks
</Link>
</Button>
<Button asChild size="sm" className="gap-2">
<Link href="/student/learning/assignments">
<PenTool className="h-4 w-4" />
Assignments
</Link>
</Button>
</div> </div>
<Button asChild variant="outline">
<Link href="/student/learning/assignments">View assignments</Link>
</Button>
</div> </div>
) )
} }

View File

@@ -2,7 +2,6 @@ import type { StudentDashboardProps } from "@/modules/dashboard/types"
import { StudentDashboardHeader } from "./student-dashboard-header" import { StudentDashboardHeader } from "./student-dashboard-header"
import { StudentGradesCard } from "./student-grades-card" import { StudentGradesCard } from "./student-grades-card"
import { StudentRankingCard } from "./student-ranking-card"
import { StudentStatsGrid } from "./student-stats-grid" import { StudentStatsGrid } from "./student-stats-grid"
import { StudentTodayScheduleCard } from "./student-today-schedule-card" import { StudentTodayScheduleCard } from "./student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card" import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
@@ -26,16 +25,17 @@ export function StudentDashboard({
dueSoonCount={dueSoonCount} dueSoonCount={dueSoonCount}
overdueCount={overdueCount} overdueCount={overdueCount}
gradedCount={gradedCount} gradedCount={gradedCount}
ranking={grades.ranking}
/> />
<div className="grid gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<StudentGradesCard grades={grades} /> <div className="lg:col-span-2 space-y-6">
<StudentRankingCard ranking={grades.ranking} /> <StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
</div> <StudentGradesCard grades={grades} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> <div className="space-y-6">
<StudentTodayScheduleCard items={todayScheduleItems} /> <StudentTodayScheduleCard items={todayScheduleItems} />
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} /> </div>
</div> </div>
</div> </div>
) )

View File

@@ -1,9 +1,13 @@
"use client"
import Link from "next/link" import Link from "next/link"
import { BarChart3 } from "lucide-react" import { BarChart3 } from "lucide-react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import type { StudentDashboardGradeProps } from "@/modules/homework/types" import type { StudentDashboardGradeProps } from "@/modules/homework/types"
@@ -11,6 +15,24 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
const hasGradeTrend = grades.trend.length > 0 const hasGradeTrend = grades.trend.length > 0
const hasRecentGrades = grades.recent.length > 0 const hasRecentGrades = grades.recent.length > 0
const chartData = grades.trend.map((item) => ({
title: item.assignmentTitle,
score: Math.round(item.percentage),
fullTitle: item.assignmentTitle,
submittedAt: formatDate(item.submittedAt),
rawScore: item.score,
maxScore: item.maxScore,
}))
const chartConfig = {
score: {
label: "Score (%)",
color: "hsl(var(--primary))",
},
}
const latestGrade = grades.trend[grades.trend.length - 1]
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -30,37 +52,79 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md border bg-card p-4"> <div className="rounded-md border bg-card p-4">
<svg viewBox="0 0 100 40" className="h-24 w-full"> <ChartContainer config={chartConfig} className="h-[200px] w-full">
<polyline <LineChart
fill="none" data={chartData}
stroke="currentColor" margin={{
strokeWidth="2" left: 12,
points={grades.trend right: 12,
.map((p, i) => { top: 12,
const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0 bottom: 12,
const x = t * 100 }}
const v = Number.isFinite(p.percentage) ? Math.max(0, Math.min(100, p.percentage)) : 0 >
const y = 40 - (v / 100) * 40 <CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
return `${x},${y}` <XAxis
}) dataKey="title"
.join(" ")} tickLine={false}
className="text-primary" axisLine={false}
/> tickMargin={8}
</svg> tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground"> />
<div> <YAxis
Latest:{" "} domain={[0, 100]}
<span className="font-medium text-foreground tabular-nums"> tickLine={false}
{Math.round(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}% axisLine={false}
</span> tickFormatter={(value) => `${value}%`}
width={30}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={
<ChartTooltipContent
indicator="line"
labelKey="fullTitle"
className="w-[200px]"
/>
}
/>
<Line
dataKey="score"
type="monotone"
stroke="var(--color-score)"
strokeWidth={2}
dot={{
fill: "var(--color-score)",
r: 4,
strokeWidth: 2,
}}
activeDot={{
r: 6,
strokeWidth: 0,
}}
/>
</LineChart>
</ChartContainer>
{latestGrade && (
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
<div>
Latest:{" "}
<span className="font-medium text-foreground tabular-nums">
{Math.round(latestGrade.percentage)}%
</span>
</div>
<div>
Points:{" "}
<span className="font-medium text-foreground tabular-nums">
{latestGrade.score}/{latestGrade.maxScore}
</span>
</div>
</div> </div>
<div> )}
Points:{" "}
<span className="font-medium text-foreground tabular-nums">
{grades.trend[grades.trend.length - 1]?.score ?? 0}/{grades.trend[grades.trend.length - 1]?.maxScore ?? 0}
</span>
</div>
</div>
</div> </div>
{!hasRecentGrades ? null : ( {!hasRecentGrades ? null : (

View File

@@ -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>
)
}

View File

@@ -1,12 +1,17 @@
import { BookOpen, CheckCircle2, PenTool, TriangleAlert } from "lucide-react" import Link from "next/link"
import { BookOpen, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import type { StudentRanking } from "@/modules/homework/types"
type Stat = { type Stat = {
title: string title: string
value: string value: string
description: string description: string
icon: typeof BookOpen icon: typeof BookOpen
href: string
color?: string
} }
export function StudentStatsGrid({ export function StudentStatsGrid({
@@ -14,52 +19,64 @@ export function StudentStatsGrid({
dueSoonCount, dueSoonCount,
overdueCount, overdueCount,
gradedCount, gradedCount,
ranking,
}: { }: {
enrolledClassCount: number enrolledClassCount: number
dueSoonCount: number dueSoonCount: number
overdueCount: number overdueCount: number
gradedCount: number gradedCount: number
ranking: StudentRanking | null
}) { }) {
const stats: readonly Stat[] = [ const stats: Stat[] = [
{ {
title: "My Classes", title: "Average Score",
value: String(enrolledClassCount), value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
description: "Enrolled classes", description: ranking ? "Overall performance" : "No grades yet",
icon: BookOpen, icon: TrendingUp,
href: "/student/learning/assignments",
color: "text-blue-500",
},
{
title: "Class Rank",
value: ranking ? `${ranking.rank}/${ranking.classSize}` : "-",
description: ranking ? "Current position" : "No ranking yet",
icon: Trophy,
href: "/student/learning/assignments",
color: "text-purple-500",
}, },
{ {
title: "Due Soon", title: "Due Soon",
value: String(dueSoonCount), value: String(dueSoonCount),
description: "Next 7 days", description: "Next 7 days",
icon: PenTool, icon: PenTool,
href: "/student/learning/assignments",
color: dueSoonCount > 0 ? "text-orange-500" : undefined,
}, },
{ {
title: "Overdue", title: "Overdue",
value: String(overdueCount), value: String(overdueCount),
description: "Needs attention", description: "Needs attention",
icon: TriangleAlert, icon: TriangleAlert,
}, href: "/student/learning/assignments",
{ color: overdueCount > 0 ? "text-red-500" : undefined,
title: "Graded",
value: String(gradedCount),
description: "With score",
icon: CheckCircle2,
}, },
] ]
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => ( {stats.map((stat) => (
<Card key={stat.title}> <Link key={stat.title} href={stat.href}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<stat.icon className="h-4 w-4 text-muted-foreground" /> <CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
</CardHeader> <stat.icon className={cn("h-4 w-4 text-muted-foreground", stat.color)} />
<CardContent> </CardHeader>
<div className="text-2xl font-bold tabular-nums">{stat.value}</div> <CardContent>
<div className="text-xs text-muted-foreground">{stat.description}</div> <div className={cn("text-2xl font-bold tabular-nums", stat.color)}>{stat.value}</div>
</CardContent> <div className="text-xs text-muted-foreground">{stat.description}</div>
</Card> </CardContent>
</Card>
</Link>
))} ))}
</div> </div>
) )

View File

@@ -6,7 +6,7 @@ import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils" import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types" import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => { const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
@@ -23,6 +23,30 @@ const getStatusLabel = (status: string) => {
return "Not started" return "Not started"
} }
const getActionLabel = (status: string) => {
if (status === "graded") return "Review"
if (status === "submitted") return "View"
if (status === "in_progress") return "Continue"
return "Start"
}
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded" || status === "submitted") return "outline"
return "default"
}
const getDueUrgency = (dueAt: string | null) => {
if (!dueAt) return null
const now = new Date()
const due = new Date(dueAt)
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
if (diffHours < 0) return "overdue"
if (diffHours < 48) return "urgent" // 2 days
if (diffHours < 120) return "warning" // 5 days
return "normal"
}
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) { export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
const hasAssignments = upcomingAssignments.length > 0 const hasAssignments = upcomingAssignments.length > 0
@@ -54,25 +78,49 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead> <TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead> <TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead> <TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Action</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{upcomingAssignments.map((a) => ( {upcomingAssignments.map((a) => {
<TableRow key={a.id} className="h-12"> const urgency = getDueUrgency(a.dueAt)
<TableCell className="font-medium"> const isGraded = a.progressStatus === "graded"
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
{a.title} return (
</Link> <TableRow key={a.id} className="h-12">
</TableCell> <TableCell className="font-medium">
<TableCell> <div className="flex items-center gap-2">
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize"> <Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
{getStatusLabel(a.progressStatus)} {a.title}
</Badge> </Link>
</TableCell> {!isGraded && urgency === "overdue" && (
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell> <Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">Late</Badge>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell> )}
</TableRow> </div>
))} </TableCell>
<TableCell>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</TableCell>
<TableCell className={cn(
"text-muted-foreground",
!isGraded && urgency === "overdue" && "text-destructive font-medium",
!isGraded && urgency === "urgent" && "text-orange-500 font-medium"
)}>
{a.dueAt ? formatDate(a.dueAt) : "-"}
</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)} className="h-7 text-xs">
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@@ -1,7 +1,6 @@
import Link from "next/link" import Link from "next/link"
import { Users } from "lucide-react" import { Users } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"

View File

@@ -54,6 +54,7 @@ export function TeacherStats({
description: "Published and ongoing", description: "Published and ongoing",
icon: PenTool, icon: PenTool,
href: "/teacher/homework/assignments?status=published", href: "/teacher/homework/assignments?status=published",
highlight: false,
color: "text-blue-500", color: "text-blue-500",
}, },
{ {
@@ -62,6 +63,7 @@ export function TeacherStats({
description: "Across recent assignments", description: "Across recent assignments",
icon: TrendingUp, icon: TrendingUp,
href: "#grade-trends", href: "#grade-trends",
highlight: false,
color: "text-emerald-500", color: "text-emerald-500",
}, },
{ {
@@ -70,6 +72,7 @@ export function TeacherStats({
description: "Overall completion rate", description: "Overall completion rate",
icon: BarChart, icon: BarChart,
href: "#grade-trends", href: "#grade-trends",
highlight: false,
color: "text-purple-500", color: "text-purple-500",
}, },
] as const; ] as const;

View File

@@ -5,8 +5,9 @@ import { ActionState } from "@/shared/types/action-state"
import { z } from "zod" import { z } from "zod"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { exams, examQuestions } from "@/shared/db/schema" import { exams, examQuestions, subjects, grades } from "@/shared/db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { omitScheduledAtFromDescription } from "./data-access"
const ExamCreateSchema = z.object({ const ExamCreateSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
@@ -56,9 +57,17 @@ export async function createExamAction(
const examId = createId() const examId = createId()
const scheduled = input.scheduledAt || undefined const scheduled = input.scheduledAt || undefined
// Retrieve names for JSON description (to maintain compatibility)
const subjectRecord = await db.query.subjects.findFirst({
where: eq(subjects.id, input.subject),
})
const gradeRecord = await db.query.grades.findFirst({
where: eq(grades.id, input.grade),
})
const meta = { const meta = {
subject: input.subject, subject: subjectRecord?.name ?? input.subject,
grade: input.grade, grade: gradeRecord?.name ?? input.grade,
difficulty: input.difficulty, difficulty: input.difficulty,
totalScore: input.totalScore, totalScore: input.totalScore,
durationMin: input.durationMin, durationMin: input.durationMin,
@@ -71,11 +80,14 @@ export async function createExamAction(
id: examId, id: examId,
title: input.title, title: input.title,
description: JSON.stringify(meta), description: JSON.stringify(meta),
creatorId: user?.id ?? "user_teacher_123", creatorId: user?.id ?? "user_teacher_math",
subjectId: input.subject,
gradeId: input.grade,
startTime: scheduled ? new Date(scheduled) : null, startTime: scheduled ? new Date(scheduled) : null,
status: "draft", status: "draft",
}) })
} catch { } catch (error) {
console.error("Failed to create exam:", error)
return { return {
success: false, success: false,
message: "Database error: Failed to create exam", message: "Database error: Failed to create exam",
@@ -215,19 +227,6 @@ const ExamDuplicateSchema = z.object({
examId: z.string().min(1), examId: z.string().min(1),
}) })
const omitScheduledAtFromDescription = (description: string | null) => {
if (!description) return null
try {
const parsed: unknown = JSON.parse(description)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description
const meta = parsed as Record<string, unknown>
if ("scheduledAt" in meta) delete meta.scheduledAt
return JSON.stringify(meta)
} catch {
return description
}
}
export async function duplicateExamAction( export async function duplicateExamAction(
prevState: ActionState<string> | null, prevState: ActionState<string> | null,
formData: FormData formData: FormData
@@ -271,7 +270,7 @@ export async function duplicateExamAction(
id: newExamId, id: newExamId,
title: `${source.title} (Copy)`, title: `${source.title} (Copy)`,
description: omitScheduledAtFromDescription(source.description), description: omitScheduledAtFromDescription(source.description),
creatorId: user?.id ?? "user_teacher_123", creatorId: user?.id ?? "user_teacher_math",
startTime: null, startTime: null,
endTime: null, endTime: null,
status: "draft", status: "draft",
@@ -305,6 +304,78 @@ export async function duplicateExamAction(
} }
} }
async function getCurrentUser() { export async function getExamPreviewAction(examId: string) {
return { id: "user_teacher_123", role: "teacher" } try {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: {
question: true
}
}
}
})
if (!exam) {
return { success: false, message: "Exam not found" }
}
// Extract questions from the relation
const questions = exam.questions.map(eq => eq.question)
return {
success: true,
data: {
structure: exam.structure,
questions: questions
}
}
} catch (error) {
console.error(error)
return { success: false, message: "Failed to load exam preview" }
}
}
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
try {
const allSubjects = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
return {
success: true,
data: allSubjects.map((s) => ({ id: s.id, name: s.name })),
}
} catch (error) {
console.error("Failed to fetch subjects:", error)
return {
success: false,
message: "Failed to load subjects",
}
}
}
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
try {
const allGrades = await db.query.grades.findMany({
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
})
return {
success: true,
data: allGrades.map((g) => ({ id: g.id, name: g.name })),
}
} catch (error) {
console.error("Failed to fetch grades:", error)
return {
success: false,
message: "Failed to load grades",
}
}
}
async function getCurrentUser() {
return { id: "user_teacher_math", role: "teacher" }
} }

View File

@@ -1,9 +1,5 @@
"use client" "use client"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { Eye, Printer } from "lucide-react"
import type { ExamNode } from "./selected-question-list" import type { ExamNode } from "./selected-question-list"
type ChoiceOption = { type ChoiceOption = {
@@ -86,55 +82,33 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
} }
return ( return (
<Dialog> <div className="bg-card shadow-sm border p-12 print:shadow-none print:border-none">
<DialogTrigger asChild> {/* Header */}
<Button variant="secondary" size="sm" className="gap-2"> <div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
<Eye className="h-4 w-4" /> <h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
Preview Exam <div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
</Button> <span>Subject: {subject}</span>
</DialogTrigger> <span>Grade: {grade}</span>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0"> <span>Time: {durationMin} mins</span>
<DialogHeader className="p-6 pb-2 border-b shrink-0"> <span>Total: {totalScore} pts</span>
<div className="flex items-center justify-between"> </div>
<DialogTitle>Exam Preview</DialogTitle> <div className="flex justify-center gap-12 text-sm pt-4">
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden"> <div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
<Printer className="h-4 w-4 mr-2" /> <div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
Print <div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
</Button> </div>
</div> </div>
</DialogHeader>
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
{/* Header */}
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
<span>Subject: {subject}</span>
<span>Grade: {grade}</span>
<span>Time: {durationMin} mins</span>
<span>Total: {totalScore} pts</span>
</div>
<div className="flex justify-center gap-12 text-sm pt-4">
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
</div>
</div>
{/* Content */} {/* Content */}
<div className="space-y-2"> <div className="space-y-2">
{nodes.length === 0 ? ( {nodes.length === 0 ? (
<div className="text-center py-20 text-muted-foreground"> <div className="text-center py-20 text-muted-foreground">
Empty Exam Paper Empty Exam Paper
</div>
) : (
nodes.map(node => renderNode(node))
)}
</div>
</div> </div>
</ScrollArea> ) : (
</DialogContent> nodes.map(node => renderNode(node))
</Dialog> )}
</div>
</div>
) )
} }

View File

@@ -10,10 +10,13 @@ type QuestionBankListProps = {
questions: Question[] questions: Question[]
onAdd: (question: Question) => void onAdd: (question: Question) => void
isAdded: (id: string) => boolean isAdded: (id: string) => boolean
onLoadMore?: () => void
hasMore?: boolean
isLoading?: boolean
} }
export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) { export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
if (questions.length === 0) { if (questions.length === 0 && !isLoading) {
return ( return (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
No questions found matching your filters. No questions found matching your filters.
@@ -22,7 +25,7 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3 pb-4">
{questions.map((q) => { {questions.map((q) => {
const added = isAdded(q.id) const added = isAdded(q.id)
const content = q.content as { text?: string } const content = q.content as { text?: string }
@@ -60,6 +63,28 @@ export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankList
</Card> </Card>
) )
})} })}
{hasMore && (
<div className="pt-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={onLoadMore}
disabled={isLoading}
className="w-full text-muted-foreground"
>
{isLoading ? "Loading..." : "Load More"}
</Button>
</div>
)}
{isLoading && questions.length === 0 && (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="h-20 bg-muted/20 rounded-lg animate-pulse" />
))}
</div>
)}
</div> </div>
) )
} }

View File

@@ -6,6 +6,7 @@ import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy }
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -27,13 +28,14 @@ import {
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions" import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreviewAction } from "../actions"
import { Exam } from "../types" import { Exam } from "../types"
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
import type { ExamNode } from "./assembly/selected-question-list"
import type { Question } from "@/modules/questions/types"
interface ExamActionsProps { interface ExamActionsProps {
exam: Exam exam: Exam
@@ -44,6 +46,46 @@ export function ExamActions({ exam }: ExamActionsProps) {
const [showViewDialog, setShowViewDialog] = useState(false) const [showViewDialog, setShowViewDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isWorking, setIsWorking] = useState(false) const [isWorking, setIsWorking] = useState(false)
const [previewNodes, setPreviewNodes] = useState<ExamNode[] | null>(null)
const [loadingPreview, setLoadingPreview] = useState(false)
const handleView = async () => {
setLoadingPreview(true)
setShowViewDialog(true)
try {
const result = await getExamPreviewAction(exam.id)
if (result.success && result.data) {
const { structure, questions } = result.data
const questionById = new Map<string, Question>()
for (const q of questions) questionById.set(q.id, q as unknown as Question)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hydrate = (nodes: any[]): ExamNode[] => {
return nodes.map((node) => {
if (node.type === "question") {
const q = node.questionId ? questionById.get(node.questionId) : undefined
return { ...node, question: q }
}
if (node.type === "group") {
return { ...node, children: hydrate(node.children || []) }
}
return node
})
}
const nodes = Array.isArray(structure) ? hydrate(structure) : []
setPreviewNodes(nodes)
} else {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
}
} catch (e) {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
} finally {
setLoadingPreview(false)
}
}
const copyId = () => { const copyId = () => {
navigator.clipboard.writeText(exam.id) navigator.clipboard.writeText(exam.id)
@@ -112,25 +154,35 @@ export function ExamActions({ exam }: ExamActionsProps) {
return ( return (
<> <>
<DropdownMenu> <div className="flex items-center gap-1">
<DropdownMenuTrigger asChild> <Button
<Button variant="ghost" className="h-8 w-8 p-0"> variant="ghost"
<span className="sr-only">Open menu</span> size="icon"
<MoreHorizontal className="h-4 w-4" /> className="h-8 w-8 text-muted-foreground hover:text-foreground"
</Button> onClick={(e) => {
</DropdownMenuTrigger> e.stopPropagation()
<DropdownMenuContent align="end"> handleView()
<DropdownMenuLabel>Actions</DropdownMenuLabel> }}
<DropdownMenuItem onClick={copyId}> title="Preview Exam"
<Copy className="mr-2 h-4 w-4" /> Copy ID >
</DropdownMenuItem> <Eye className="h-4 w-4" />
<DropdownMenuSeparator /> </Button>
<DropdownMenuItem onClick={() => setShowViewDialog(true)}> <DropdownMenu>
<Eye className="mr-2 h-4 w-4" /> View <DropdownMenuTrigger asChild>
</DropdownMenuItem> <Button variant="ghost" className="h-8 w-8 p-0">
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}> <span className="sr-only">Open menu</span>
<Pencil className="mr-2 h-4 w-4" /> Edit <MoreHorizontal className="h-4 w-4" />
</DropdownMenuItem> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={copyId}>
<Copy className="mr-2 h-4 w-4" /> Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}> <DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<MoreHorizontal className="mr-2 h-4 w-4" /> Build <MoreHorizontal className="mr-2 h-4 w-4" /> Build
</DropdownMenuItem> </DropdownMenuItem>
@@ -166,49 +218,21 @@ export function ExamActions({ exam }: ExamActionsProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Exam Details</DialogTitle>
<DialogDescription>ID: {exam.id}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Title:</span>
<span className="col-span-3">{exam.title}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Subject:</span>
<span className="col-span-3">{exam.subject}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Grade:</span>
<span className="col-span-3">{exam.grade}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Total Score:</span>
<span className="col-span-3">{exam.totalScore}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Duration:</span>
<span className="col-span-3">{exam.durationMin} min</span>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete exam?</AlertDialogTitle> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete the exam. This action cannot be undone. This will permanently delete the exam
&quot;{exam.title}&quot; and remove all associated data.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
handleDelete() handleDelete()
@@ -220,6 +244,34 @@ export function ExamActions({ exam }: ExamActionsProps) {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<div className="p-4 border-b shrink-0 flex items-center justify-between">
<DialogTitle className="text-lg font-semibold tracking-tight">{exam.title}</DialogTitle>
</div>
<ScrollArea className="flex-1">
{loadingPreview ? (
<div className="py-20 text-center text-muted-foreground">Loading preview...</div>
) : previewNodes && previewNodes.length > 0 ? (
<div className="max-w-3xl mx-auto py-8 px-6">
<ExamPaperPreview
title={exam.title}
subject={exam.subject}
grade={exam.grade}
durationMin={exam.durationMin}
totalScore={exam.totalScore}
nodes={previewNodes}
/>
</div>
) : (
<div className="py-20 text-center text-muted-foreground">
No questions in this exam.
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</> </>
) )
} }

View File

@@ -1,19 +1,20 @@
"use client" "use client"
import { useDeferredValue, useMemo, useState } from "react" import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useFormStatus } from "react-dom" import { useFormStatus } from "react-dom"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { Search } from "lucide-react" import { Search, Eye } from "lucide-react"
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator" import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import type { Question } from "@/modules/questions/types" import type { Question } from "@/modules/questions/types"
import { updateExamAction } from "@/modules/exams/actions" import { updateExamAction } from "@/modules/exams/actions"
import { getQuestionsAction } from "@/modules/questions/actions"
import { StructureEditor } from "./assembly/structure-editor" import { StructureEditor } from "./assembly/structure-editor"
import { QuestionBankList } from "./assembly/question-bank-list" import { QuestionBankList } from "./assembly/question-bank-list"
import type { ExamNode } from "./assembly/selected-question-list" import type { ExamNode } from "./assembly/selected-question-list"
@@ -49,6 +50,12 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const [difficultyFilter, setDifficultyFilter] = useState<string>("all") const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
const deferredSearch = useDeferredValue(search) const deferredSearch = useDeferredValue(search)
// Bank state
const [bankQuestions, setBankQuestions] = useState<Question[]>(props.questionOptions)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(props.questionOptions.length >= 20)
const [isBankLoading, startBankTransition] = useTransition()
// Initialize structure state // Initialize structure state
const [structure, setStructure] = useState<ExamNode[]>(() => { const [structure, setStructure] = useState<ExamNode[]>(() => {
const questionById = new Map<string, Question>() const questionById = new Map<string, Question>()
@@ -76,26 +83,47 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return [] return []
}) })
const filteredQuestions = useMemo(() => { const fetchQuestions = (reset: boolean = false) => {
let list: Question[] = [...props.questionOptions] startBankTransition(async () => {
const nextPage = reset ? 1 : page + 1
if (deferredSearch) { try {
const lower = deferredSearch.toLowerCase() const result = await getQuestionsAction({
list = list.filter(q => { q: deferredSearch,
const content = q.content as { text?: string } // eslint-disable-next-line @typescript-eslint/no-explicit-any
return content.text?.toLowerCase().includes(lower) type: typeFilter === 'all' ? undefined : typeFilter as any,
}) difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
} page: nextPage,
pageSize: 20
})
if (result && result.data) {
setBankQuestions(prev => {
if (reset) return result.data
// Deduplicate just in case
const existingIds = new Set(prev.map(q => q.id))
const newQuestions = result.data.filter(q => !existingIds.has(q.id))
return [...prev, ...newQuestions]
})
setHasMore(result.data.length === 20)
setPage(nextPage)
}
} catch (error) {
toast.error("Failed to load questions")
}
})
}
if (typeFilter !== "all") { const isFirstRender = useRef(true)
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
if (deferredSearch === "" && typeFilter === "all" && difficultyFilter === "all") {
return
}
} }
if (difficultyFilter !== "all") { fetchQuestions(true)
const d = parseInt(difficultyFilter) }, [deferredSearch, typeFilter, difficultyFilter])
list = list.filter((q) => q.difficulty === d)
}
return list
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
// Recursively calculate total score // Recursively calculate total score
const assignedTotal = useMemo(() => { const assignedTotal = useMemo(() => {
@@ -231,6 +259,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return clean(structure) return clean(structure)
} }
const [previewOpen, setPreviewOpen] = useState(false)
const handleSave = async (formData: FormData) => { const handleSave = async (formData: FormData) => {
formData.set("examId", props.examId) formData.set("examId", props.examId)
formData.set("questionsJson", JSON.stringify(getFlatQuestions())) formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
@@ -238,7 +268,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const result = await updateExamAction(null, formData) const result = await updateExamAction(null, formData)
if (result.success) { if (result.success) {
toast.success("Saved draft") toast.success("Exam draft saved")
} else { } else {
toast.error(result.message || "Save failed") toast.error(result.message || "Save failed")
} }
@@ -260,47 +290,76 @@ export function ExamAssembly(props: ExamAssemblyProps) {
} }
return ( return (
<div className="grid h-[calc(100vh-12rem)] gap-6 lg:grid-cols-5"> <div className="grid h-[calc(100vh-8rem)] gap-4 lg:grid-cols-12">
{/* Left: Preview (3 cols) */} {/* Left: Preview (8 cols) */}
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10"> <Card className="lg:col-span-8 flex flex-col overflow-hidden border-2 border-primary/10 shadow-sm">
<CardHeader className="bg-muted/30 pb-4"> <CardHeader className="bg-muted/30 pb-4 border-b">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CardTitle>Exam Structure</CardTitle> <CardTitle className="text-lg">Exam Structure</CardTitle>
<ExamPaperPreview <div className="h-4 w-[1px] bg-border mx-1" />
title={props.title} <div className="flex items-center gap-2 text-sm text-muted-foreground">
subject={props.subject} <span className="font-medium text-foreground">{props.subject}</span>
grade={props.grade} <span></span>
durationMin={props.durationMin} <span>{props.grade}</span>
totalScore={props.totalScore} <span></span>
nodes={structure} <span>{props.durationMin} min</span>
/>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-col items-end">
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
<span className="text-xs text-muted-foreground">Total Score</span>
</div> </div>
<div className="h-2 w-24 rounded-full bg-secondary"> </div>
<div <div className="flex items-center gap-6">
className={`h-full rounded-full transition-all ${ <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary" <DialogTrigger asChild>
}`} <Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
style={{ width: `${progress}%` }} <Eye className="h-4 w-4" />
/> Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<div className="p-4 border-b shrink-0 flex items-center justify-between">
<DialogTitle className="text-lg font-semibold tracking-tight">{props.title}</DialogTitle>
</div>
<div className="flex-1 min-h-0 relative">
<ScrollArea className="h-full">
<div className="max-w-3xl mx-auto py-8 px-6">
<ExamPaperPreview
title={props.title}
subject={props.subject}
grade={props.grade}
durationMin={props.durationMin}
totalScore={props.totalScore}
nodes={structure}
/>
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
<div className="flex items-center gap-3 text-sm">
<div className="flex flex-col items-end">
<div className="flex items-baseline gap-1">
<span className={`text-lg font-bold ${assignedTotal > props.totalScore ? "text-destructive" : "text-primary"}`}>
{assignedTotal}
</span>
<span className="text-muted-foreground">/ {props.totalScore}</span>
</div>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Total Score</span>
</div>
<div className="h-10 w-2 rounded-full bg-secondary overflow-hidden flex flex-col-reverse">
<div
className={`w-full transition-all ${
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
}`}
style={{ height: `${Math.min(progress, 100)}%` }}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<ScrollArea className="flex-1 p-4"> <ScrollArea className="flex-1 bg-muted/5">
<div className="space-y-6"> <div className="max-w-4xl mx-auto p-6 space-y-8">
<div className="grid grid-cols-3 gap-4 text-sm text-muted-foreground bg-muted/20 p-3 rounded-md">
<div><span className="font-medium text-foreground">{props.subject}</span></div>
<div><span className="font-medium text-foreground">{props.grade}</span></div>
<div>Duration: <span className="font-medium text-foreground">{props.durationMin} min</span></div>
</div>
<StructureEditor <StructureEditor
items={structure} items={structure}
onChange={setStructure} onChange={setStructure}
@@ -312,32 +371,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
</div> </div>
</ScrollArea> </ScrollArea>
<div className="border-t p-4 bg-muted/30 flex gap-3 justify-end"> <div className="border-t p-4 bg-background flex gap-3 justify-end items-center shadow-[0_-1px_2px_rgba(0,0,0,0.03)]">
<form action={handleSave} className="flex-1"> <div className="mr-auto text-xs text-muted-foreground">
<SubmitButton label="Save Draft" /> {structure.length === 0 ? "Start by adding questions from the right panel" : `${structure.length} items in structure`}
</div>
<form action={handleSave}>
<Button variant="outline" size="sm" type="submit" className="w-24">Save Draft</Button>
</form> </form>
<form action={handlePublish} className="flex-1"> <form action={handlePublish}>
<SubmitButton label="Publish Exam" /> <Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">Publish</Button>
</form> </form>
</div> </div>
</Card> </Card>
{/* Right: Question Bank (2 cols) */} {/* Right: Question Bank (4 cols) */}
<Card className="lg:col-span-2 flex flex-col overflow-hidden"> <Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
<CardHeader className="pb-3 space-y-3"> <CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
<CardTitle className="text-base">Question Bank</CardTitle> <div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">Question Bank</CardTitle>
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{bankQuestions.length}{hasMore ? "+" : ""} loaded
</span>
</div>
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search questions..." placeholder="Search by content..."
className="pl-8" className="pl-9 h-9 text-sm"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}> <Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="flex-1 h-8 text-xs"><SelectValue placeholder="Type" /></SelectTrigger> <SelectTrigger className="flex-1 h-8 text-xs bg-background"><SelectValue placeholder="Type" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Types</SelectItem> <SelectItem value="all">All Types</SelectItem>
<SelectItem value="single_choice">Single Choice</SelectItem> <SelectItem value="single_choice">Single Choice</SelectItem>
@@ -347,7 +414,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}> <Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
<SelectTrigger className="w-[80px] h-8 text-xs"><SelectValue placeholder="Diff" /></SelectTrigger> <SelectTrigger className="w-[80px] h-8 text-xs bg-background"><SelectValue placeholder="Diff" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All</SelectItem> <SelectItem value="all">All</SelectItem>
<SelectItem value="1">Lvl 1</SelectItem> <SelectItem value="1">Lvl 1</SelectItem>
@@ -360,14 +427,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
</div> </div>
</CardHeader> </CardHeader>
<Separator /> <ScrollArea className="flex-1 p-0 bg-muted/5">
<div className="p-3">
<ScrollArea className="flex-1 p-4 bg-muted/10"> <QuestionBankList
<QuestionBankList questions={bankQuestions}
questions={filteredQuestions} onAdd={handleAdd}
onAdd={handleAdd} isAdded={(id) => addedQuestionIds.has(id)}
isAdded={(id) => addedQuestionIds.has(id)} onLoadMore={() => fetchQuestions(false)}
/> hasMore={hasMore}
isLoading={isBankLoading}
/>
</div>
</ScrollArea> </ScrollArea>
</Card> </Card>
</div> </div>

View 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>
)
}

View File

@@ -30,109 +30,126 @@ export const examColumns: ColumnDef<Exam>[] = [
}, },
{ {
accessorKey: "title", accessorKey: "title",
header: "Title", header: "Exam Info",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<span className="font-medium">{row.original.title}</span> <div className="flex items-center gap-2">
{row.original.tags && row.original.tags.length > 0 && ( <span className="font-semibold text-base">{row.original.title}</span>
<div className="flex flex-wrap gap-1"> {row.original.tags && row.original.tags.length > 0 && (
{row.original.tags.slice(0, 2).map((t, idx) => ( <div className="flex flex-wrap gap-1">
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs"> {row.original.tags.slice(0, 2).map((t, idx) => (
{t} <Badge key={`${t}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
</Badge> {t}
))} </Badge>
{row.original.tags.length > 2 && ( ))}
<Badge variant="outline" className="text-xs">+{row.original.tags.length - 2}</Badge> {row.original.tags.length > 2 && (
)} <Badge variant="secondary" className="h-5 px-1.5 text-[10px]">+{row.original.tags.length - 2}</Badge>
</div> )}
)} </div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground/80">{row.original.subject}</span>
<span></span>
<span>{row.original.grade}</span>
</div>
</div> </div>
), ),
}, },
{
accessorKey: "subject",
header: "Subject",
},
{
accessorKey: "grade",
header: "Grade",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">{row.original.grade}</span>
),
},
{ {
accessorKey: "status", accessorKey: "status",
header: "Status", header: "Status",
cell: ({ row }) => { cell: ({ row }) => {
const status = row.original.status const status = row.original.status
// Use 'default' as base for published/success to ensure type safety,
// but override with className below
const variant: BadgeProps["variant"] = const variant: BadgeProps["variant"] =
status === "published" status === "published"
? "secondary" ? "default"
: status === "archived" : status === "archived"
? "destructive" ? "secondary"
: "outline" : "outline"
return ( return (
<Badge variant={variant} className="capitalize"> <Badge
variant={variant}
className={cn(
"capitalize",
status === "published" && "bg-green-600 hover:bg-green-700 border-transparent",
status === "draft" && "bg-amber-100 text-amber-800 hover:bg-amber-200 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800"
)}
>
{status} {status}
</Badge> </Badge>
) )
}, },
}, },
{
id: "stats",
header: "Stats",
cell: ({ row }) => (
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{row.original.questionCount} Qs</span>
<span></span>
<span>{row.original.totalScore} Pts</span>
</div>
<div className="flex items-center gap-1">
<span>{row.original.durationMin} min</span>
</div>
</div>
),
},
{ {
accessorKey: "difficulty", accessorKey: "difficulty",
header: "Difficulty", header: "Difficulty",
cell: ({ row }) => { cell: ({ row }) => {
const diff = row.original.difficulty const diff = row.original.difficulty
return ( return (
<div className="flex items-center"> <div className="flex flex-col gap-1">
<span <div className="flex gap-0.5">
className={cn( {[1, 2, 3, 4, 5].map((level) => (
"font-medium", <div
diff <= 2 ? "text-green-600" : diff === 3 ? "text-yellow-600" : "text-red-600" key={level}
)} className={cn(
> "h-1.5 w-3 rounded-full",
{diff === 1 level <= diff
? "Easy" ? diff <= 2 ? "bg-green-500" : diff === 3 ? "bg-yellow-500" : "bg-red-500"
: diff === 2 : "bg-muted"
? "Easy-Med" )}
: diff === 3 />
? "Medium" ))}
: diff === 4 </div>
? "Med-Hard" <span className="text-[10px] text-muted-foreground font-medium">
: "Hard"} {diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
</span> </span>
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
</div> </div>
) )
}, },
}, },
{ {
accessorKey: "durationMin", id: "dates",
header: "Duration", header: "Date",
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.durationMin} min</span>, cell: ({ row }) => {
}, const scheduled = row.original.scheduledAt
{ const created = row.original.createdAt
accessorKey: "totalScore",
header: "Total", return (
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.totalScore}</span>, <div className="flex flex-col gap-0.5 text-xs">
}, {scheduled ? (
{ <>
accessorKey: "scheduledAt", <span className="font-medium text-blue-600 dark:text-blue-400">Scheduled</span>
header: "Scheduled", <span className="text-muted-foreground">{formatDate(scheduled)}</span>
cell: ({ row }) => ( </>
<span className="text-muted-foreground text-xs whitespace-nowrap"> ) : (
{row.original.scheduledAt ? formatDate(row.original.scheduledAt) : "-"} <>
</span> <span className="text-muted-foreground">Created</span>
), <span>{formatDate(created)}</span>
}, </>
{ )}
accessorKey: "createdAt", </div>
header: "Created", )
cell: ({ row }) => ( },
<span className="text-muted-foreground text-xs whitespace-nowrap">
{formatDate(row.original.createdAt)}
</span>
),
}, },
{ {
id: "actions", id: "actions",

View File

@@ -52,7 +52,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader className="bg-muted/40">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
@@ -88,20 +88,38 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div className="flex items-center justify-end space-x-2 py-4"> <div className="flex items-center justify-between px-2 py-4">
<div className="flex-1 text-sm text-muted-foreground"> <div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected. selected.
</div> </div>
<div className="space-x-2"> <div className="flex items-center space-x-6 lg:space-x-8">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}> <div className="flex items-center space-x-2">
<ChevronLeft className="h-4 w-4" /> <p className="text-sm font-medium">Page</p>
Previous <span className="text-sm font-medium">
</Button> {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}> </span>
Next </div>
<ChevronRight className="h-4 w-4" /> <div className="flex items-center space-x-2">
</Button> <Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -19,57 +19,59 @@ export function ExamFilters() {
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false })) const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
return ( return (
<div className="flex items-center gap-2"> <div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative w-full md:w-[260px]"> <div className="relative w-full md:w-80">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
<Input <Input
placeholder="Search exams..." placeholder="Search exams..."
className="pl-7" className="pl-9 bg-background border-muted-foreground/20"
value={search || ""} value={search || ""}
onChange={(e) => setSearch(e.target.value || null)} onChange={(e) => setSearch(e.target.value || null)}
/> />
</div> </div>
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}> <div className="flex flex-wrap gap-2 w-full md:w-auto">
<SelectTrigger className="w-[160px]"> <Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectValue placeholder="Status" /> <SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
</SelectTrigger> <SelectValue placeholder="Status" />
<SelectContent> </SelectTrigger>
<SelectItem value="all">Any Status</SelectItem> <SelectContent>
<SelectItem value="draft">Draft</SelectItem> <SelectItem value="all">Any Status</SelectItem>
<SelectItem value="published">Published</SelectItem> <SelectItem value="draft">Draft</SelectItem>
<SelectItem value="archived">Archived</SelectItem> <SelectItem value="published">Published</SelectItem>
</SelectContent> <SelectItem value="archived">Archived</SelectItem>
</Select> </SelectContent>
</Select>
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}> <Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px]"> <SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Difficulty" /> <SelectValue placeholder="Difficulty" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Any Difficulty</SelectItem> <SelectItem value="all">Any Difficulty</SelectItem>
<SelectItem value="1">Easy (1)</SelectItem> <SelectItem value="1">Easy (1)</SelectItem>
<SelectItem value="2">Easy-Med (2)</SelectItem> <SelectItem value="2">Easy-Med (2)</SelectItem>
<SelectItem value="3">Medium (3)</SelectItem> <SelectItem value="3">Medium (3)</SelectItem>
<SelectItem value="4">Med-Hard (4)</SelectItem> <SelectItem value="4">Med-Hard (4)</SelectItem>
<SelectItem value="5">Hard (5)</SelectItem> <SelectItem value="5">Hard (5)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && ( {(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setSearch(null) setSearch(null)
setStatus(null) setStatus(null)
setDifficulty(null) setDifficulty(null)
}} }}
className="h-8 px-2 lg:px-3" className="h-10 px-3"
> >
Reset Reset
<X className="ml-2 h-4 w-4" /> <X className="ml-2 h-4 w-4" />
</Button> </Button>
)} )}
</div>
</div> </div>
) )
} }

View File

@@ -1,99 +1,357 @@
"use client" "use client"
import { useState } from "react" import { useTransition, useEffect, useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { toast } from "sonner"
import { Loader2, Sparkles, BookOpen } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card" import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label" import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" Select,
import { createExamAction } from "../actions" SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { createExamAction, getSubjectsAction, getGradesAction } from "../actions"
function SubmitButton() { export const formSchema = z.object({
const { pending } = useFormStatus() title: z.string().min(2, "Title must be at least 2 characters."),
return ( subject: z.string().min(1, "Subject is required."),
<Button type="submit" disabled={pending}> grade: z.string().min(1, "Grade is required."),
{pending ? "Creating..." : "Create Exam"} difficulty: z.string(),
</Button> totalScore: z.coerce.number().min(1, "Total score must be at least 1."),
) durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes."),
scheduledAt: z.string().optional(),
mode: z.enum(["manual", "ai"]),
})
type ExamFormValues = z.infer<typeof formSchema>
const defaultValues: Partial<ExamFormValues> = {
title: "",
subject: "",
grade: "",
difficulty: "3",
totalScore: 100,
durationMin: 90,
mode: "manual",
scheduledAt: "",
} }
export function ExamForm() { export function ExamForm() {
const router = useRouter() const router = useRouter()
const [difficulty, setDifficulty] = useState<string>("3") const [isPending, startTransition] = useTransition()
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
const [loadingSubjects, setLoadingSubjects] = useState(true)
const [grades, setGrades] = useState<{ id: string; name: string }[]>([])
const [loadingGrades, setLoadingGrades] = useState(true)
const handleSubmit = async (formData: FormData) => { const form = useForm<ExamFormValues>({
const result = await createExamAction(null, formData) // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (result.success) { resolver: zodResolver(formSchema) as any,
toast.success(result.message) defaultValues: defaultValues as unknown as ExamFormValues,
if (result.data) { })
router.push(`/teacher/exams/${result.data}/build`)
useEffect(() => {
const fetchMetadata = async () => {
try {
const [subjectsResult, gradesResult] = await Promise.all([
getSubjectsAction(),
getGradesAction()
])
if (subjectsResult.success && subjectsResult.data) {
setSubjects(subjectsResult.data)
} else {
toast.error("Failed to load subjects")
}
if (gradesResult.success && gradesResult.data) {
setGrades(gradesResult.data)
} else {
toast.error("Failed to load grades")
}
} catch (error) {
console.error(error)
toast.error("Failed to load form data")
} finally {
setLoadingSubjects(false)
setLoadingGrades(false)
} }
} else {
toast.error(result.message)
} }
fetchMetadata()
}, [])
function onSubmit(data: ExamFormValues) {
const formData = new FormData()
formData.append("title", data.title)
formData.append("subject", data.subject)
formData.append("grade", data.grade)
formData.append("difficulty", data.difficulty)
formData.append("totalScore", data.totalScore.toString())
formData.append("durationMin", data.durationMin.toString())
if (data.scheduledAt) {
formData.append("scheduledAt", data.scheduledAt)
}
startTransition(async () => {
const result = await createExamAction(null, formData)
if (result.success && result.data) {
toast.success("Exam draft created", {
description: "Redirecting to exam builder...",
})
router.push(`/teacher/exams/${result.data}/build`)
} else {
toast.error(result.message || "Failed to create exam")
}
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSubmit = (e: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form.handleSubmit(onSubmit as any)(e);
} }
return ( return (
<Card> <Form {...form}>
<CardHeader> <form onSubmit={handleSubmit} className="grid gap-8 lg:grid-cols-3">
<CardTitle>Exam Creator</CardTitle> {/* Left Column: Exam Details */}
</CardHeader> <div className="lg:col-span-2 space-y-6">
<CardContent> <Card>
<form action={handleSubmit} className="space-y-6"> <CardHeader>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardTitle>Exam Details</CardTitle>
<div className="grid gap-2"> <CardDescription>
<Label htmlFor="title">Title</Label> Define the core information for your exam.
<Input id="title" name="title" placeholder="e.g. Algebra Midterm" required /> </CardDescription>
</div> </CardHeader>
<div className="grid gap-2"> <CardContent className="grid gap-6">
<Label htmlFor="subject">Subject</Label> <FormField
<Input id="subject" name="subject" placeholder="e.g. Mathematics" required /> control={form.control}
</div> name="title"
<div className="grid gap-2"> render={({ field }) => (
<Label htmlFor="grade">Grade</Label> <FormItem>
<Input id="grade" name="grade" placeholder="e.g. Grade 10" required /> <FormLabel>Title</FormLabel>
</div> <FormControl>
<div className="grid gap-2"> <Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
<Label>Difficulty</Label> </FormControl>
<Select value={difficulty} onValueChange={(val) => setDifficulty(val)} name="difficulty"> <FormMessage />
<SelectTrigger> </FormItem>
<SelectValue placeholder="Select difficulty" /> )}
</SelectTrigger> />
<SelectContent>
<SelectItem value="1">Easy (1)</SelectItem> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<SelectItem value="2">Easy-Med (2)</SelectItem> <FormField
<SelectItem value="3">Medium (3)</SelectItem> control={form.control}
<SelectItem value="4">Med-Hard (4)</SelectItem> name="subject"
<SelectItem value="5">Hard (5)</SelectItem> render={({ field }) => (
</SelectContent> <FormItem>
</Select> <FormLabel>Subject</FormLabel>
<input type="hidden" name="difficulty" value={difficulty} /> <Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
</div> <FormControl>
<div className="grid gap-2"> <SelectTrigger>
<Label htmlFor="totalScore">Total Score</Label> <SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
<Input id="totalScore" name="totalScore" type="number" min={1} placeholder="e.g. 100" required /> </SelectTrigger>
</div> </FormControl>
<div className="grid gap-2"> <SelectContent>
<Label htmlFor="durationMin">Duration (min)</Label> {subjects.map((subject) => (
<Input id="durationMin" name="durationMin" type="number" min={10} placeholder="e.g. 90" required /> <SelectItem key={subject.id} value={subject.id}>
</div> {subject.name}
<div className="grid gap-2 md:col-span-2"> </SelectItem>
<Label htmlFor="scheduledAt">Scheduled At (optional)</Label> ))}
<Input id="scheduledAt" name="scheduledAt" type="datetime-local" /> </SelectContent>
</div> </Select>
</div> <FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="grade"
render={({ field }) => (
<FormItem>
<FormLabel>Grade Level</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingGrades}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{grades.map((grade) => (
<SelectItem key={grade.id} value={grade.id}>
{grade.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField
control={form.control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1">Level 1 (Easy)</SelectItem>
<SelectItem value="2">Level 2</SelectItem>
<SelectItem value="3">Level 3 (Medium)</SelectItem>
<SelectItem value="4">Level 4</SelectItem>
<SelectItem value="5">Level 5 (Hard)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="totalScore"
render={({ field }) => (
<FormItem>
<FormLabel>Total Score</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="durationMin"
render={({ field }) => (
<FormItem>
<FormLabel>Duration (min)</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<CardFooter className="justify-end"> <FormField
<SubmitButton /> control={form.control}
</CardFooter> name="scheduledAt"
</form> render={({ field }) => (
</CardContent> <FormItem>
</Card> <FormLabel>Schedule Start Time (Optional)</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormDescription>
If set, this exam will be scheduled for a specific time.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Right Column: Mode & Actions */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Assembly Mode</CardTitle>
<CardDescription>
Choose how to build the exam structure.
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<div className="flex flex-col space-y-3">
{/* Manual Mode */}
<div
className={cn(
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground",
field.value === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
)}
onClick={() => field.onChange("manual")}
>
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">Manual Assembly</span>
</div>
<span className="mt-1 text-xs text-muted-foreground">
Manually select questions from the bank and organize structure.
</span>
</div>
{/* AI Mode (Disabled) */}
<div
className={cn(
"relative flex cursor-not-allowed flex-col rounded-lg border p-4 shadow-sm outline-none opacity-50 bg-muted/20"
)}
>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
<span className="font-medium">AI Generation</span>
</div>
<span className="mt-1 text-xs text-muted-foreground">
Automatically generate exam structure based on topics. (Coming Soon)
</span>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isPending ? "Creating Draft..." : "Create & Start Building"}
</Button>
</CardFooter>
</Card>
</div>
</form>
</Form>
) )
} }

View 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>
)
}

View File

@@ -68,6 +68,10 @@ export const getExams = cache(async (params: GetExamsParams) => {
const data = await db.query.exams.findMany({ const data = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined, where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(exams.createdAt)], orderBy: [desc(exams.createdAt)],
with: {
subject: true,
gradeEntity: true,
}
}) })
// Transform and Filter (especially for JSON fields) // Transform and Filter (especially for JSON fields)
@@ -78,8 +82,8 @@ export const getExams = cache(async (params: GetExamsParams) => {
id: exam.id, id: exam.id,
title: exam.title, title: exam.title,
status: (exam.status as ExamStatus) || "draft", status: (exam.status as ExamStatus) || "draft",
subject: getString(meta, "subject") || "General", subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
grade: getString(meta, "grade") || "General", grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")), difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100, totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60, durationMin: getNumber(meta, "durationMin") || 60,
@@ -103,6 +107,8 @@ export const getExamById = cache(async (id: string) => {
const exam = await db.query.exams.findFirst({ const exam = await db.query.exams.findFirst({
where: eq(exams.id, id), where: eq(exams.id, id),
with: { with: {
subject: true,
gradeEntity: true,
questions: { questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)], orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: { with: {
@@ -120,8 +126,8 @@ export const getExamById = cache(async (id: string) => {
id: exam.id, id: exam.id,
title: exam.title, title: exam.title,
status: (exam.status as ExamStatus) || "draft", status: (exam.status as ExamStatus) || "draft",
subject: getString(meta, "subject") || "General", subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
grade: getString(meta, "grade") || "General", grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")), difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100, totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60, durationMin: getNumber(meta, "durationMin") || 60,
@@ -137,3 +143,18 @@ export const getExamById = cache(async (id: string) => {
})), })),
} }
}) })
export const omitScheduledAtFromDescription = (description: string | null): string => {
if (!description) return "{}"
try {
const meta = JSON.parse(description)
if (typeof meta === "object" && meta !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { scheduledAt, ...rest } = meta as any
return JSON.stringify(rest)
}
return description
} catch {
return description || "{}"
}
}

View File

@@ -46,7 +46,7 @@ async function getCurrentUser() {
if (anyUser) return { id: anyUser.id, role: roleHint } if (anyUser) return { id: anyUser.id, role: roleHint }
return { id: "user_teacher_123", role: roleHint } return { id: "user_teacher_math", role: roleHint }
} }
async function ensureTeacher() { async function ensureTeacher() {

View File

@@ -17,7 +17,7 @@ export function HomeworkAssignmentExamContentCard({
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<HomeworkAssignmentExamErrorExplorerLazy <HomeworkAssignmentExamErrorExplorerLazy
structure={structure} structure={structure}
questions={questions} questions={questions}

View File

@@ -7,44 +7,47 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
function ExamErrorExplorerFallback() { function ExamErrorExplorerFallback() {
return ( return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]"> <div className="grid grid-cols-1 gap-0 md:grid-cols-3 h-[600px] divide-y md:divide-y-0 md:divide-x">
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card"> <div className="md:col-span-2 flex h-full flex-col overflow-hidden">
<div className="border-b px-4 py-3 text-sm font-medium"></div> <div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
<div className="flex-1 p-4 space-y-3"> <span className="text-sm font-medium">Question Preview</span>
<Skeleton className="h-10 w-[40%]" /> </div>
<Skeleton className="h-10 w-[60%]" /> <div className="flex-1 p-6 space-y-6">
<Skeleton className="h-10 w-[75%]" /> <Skeleton className="h-8 w-[60%]" />
<Skeleton className="h-10 w-[55%]" /> <div className="space-y-3">
<Skeleton className="h-10 w-[68%]" /> <Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-4 w-[80%]" />
</div>
<div className="space-y-3 pt-4">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
</div> </div>
</div> </div>
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card"> <div className="flex h-full flex-col overflow-hidden bg-muted/5">
<div className="border-b px-4 py-3"> <div className="border-b px-6 py-4">
<div className="text-sm font-medium"></div> <div className="text-sm font-medium">Error Analysis</div>
<div className="mt-2 flex items-center gap-3"> </div>
<Skeleton className="size-12 rounded-full" /> <div className="flex-1 p-6 space-y-6">
<div className="min-w-0 flex-1 grid gap-1"> <div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
<div className="flex items-center justify-between"> <Skeleton className="size-16 rounded-full shrink-0" />
<Skeleton className="h-3 w-16" /> <div className="space-y-2 flex-1">
<Skeleton className="h-3 w-10" /> <Skeleton className="h-4 w-20" />
</div> <Skeleton className="h-3 w-32" />
<div className="flex items-center justify-between"> </div>
<Skeleton className="h-3 w-16" /> </div>
<Skeleton className="h-3 w-12" />
</div> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers</div>
<Skeleton className="h-3 w-16" /> <div className="space-y-3">
<Skeleton className="h-3 w-10" /> <Skeleton className="h-14 w-full rounded-md" />
</div> <Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />
</div> </div>
</div> </div>
</div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-4 w-[45%]" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -26,7 +26,7 @@ export function HomeworkAssignmentExamErrorExplorer({
}, [questions, selectedQuestionId]) }, [questions, selectedQuestionId])
return ( return (
<div className={`grid grid-cols-1 gap-4 md:grid-cols-3 ${heightClassName}`}> <div className={`grid grid-cols-1 gap-0 md:grid-cols-3 ${heightClassName} divide-y md:divide-y-0 md:divide-x border rounded-md bg-background overflow-hidden`}>
<HomeworkAssignmentExamPreviewPane <HomeworkAssignmentExamPreviewPane
structure={structure} structure={structure}
questions={questions.map((q) => ({ questions={questions.map((q) => ({

View File

@@ -20,15 +20,19 @@ export function HomeworkAssignmentExamPreviewPane({
onQuestionSelect: (questionId: string) => void onQuestionSelect: (questionId: string) => void
}) { }) {
return ( return (
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card"> <div className="md:col-span-2 flex h-full flex-col overflow-hidden">
<div className="border-b px-4 py-3 text-sm font-medium"></div> <div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
<ScrollArea className="flex-1 p-4"> <span className="text-sm font-medium">Question Preview</span>
<ExamViewer </div>
structure={structure} <ScrollArea className="flex-1 bg-background">
questions={questions} <div className="p-6">
selectedQuestionId={selectedQuestionId} <ExamViewer
onQuestionSelect={onQuestionSelect} structure={structure}
/> questions={questions}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={onQuestionSelect}
/>
</div>
</ScrollArea> </ScrollArea>
</div> </div>
) )

View File

@@ -92,59 +92,63 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
const errorRate = selected?.errorRate ?? 0 const errorRate = selected?.errorRate ?? 0
return ( return (
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card"> <div className="flex h-full flex-col overflow-hidden bg-muted/5">
<div className="border-b px-4 py-3"> <div className="border-b px-6 py-4 bg-muted/5">
<div className="text-sm font-medium"></div> <div className="text-sm font-medium">Error Analysis</div>
{selected ? (
<div className="mt-2 flex items-center gap-3">
<div className="shrink-0">
<ErrorRatePieChart errorRate={errorRate} />
</div>
<div className="min-w-0 flex-1 grid gap-1 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{errorCount}</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{(errorRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{gradedSampleCount}</span>
</div>
</div>
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground"></div>
)}
</div> </div>
<ScrollArea className="flex-1">
<div className="flex-1 overflow-hidden"> <div className="p-6 space-y-6">
<ScrollArea className="h-full p-4"> {selected ? (
{!selected ? ( <>
<div className="text-sm text-muted-foreground"></div> <div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
) : wrongAnswers.length === 0 ? ( <div className="shrink-0">
<div className="text-sm text-muted-foreground"></div> <ErrorRatePieChart errorRate={errorRate} />
) : ( </div>
<div className="space-y-2"> <div className="min-w-0 flex-1 grid gap-1">
<div className="text-xs text-muted-foreground"></div> <div className="flex items-center justify-between text-sm">
<div className="space-y-2"> <span className="text-muted-foreground">Question</span>
{wrongAnswers.map((item, idx) => ( <span className="font-medium">Q{selected.questionId.slice(-4)}</span>
<div key={`${selected.questionId}-${idx}`} className="rounded-md border bg-muted/20 px-3 py-2">
<div className="flex items-start gap-3">
<div className="w-24 shrink-0 text-xs text-muted-foreground truncate">{item.studentName}</div>
<div className="min-w-0 flex-1 text-sm wrap-break-word whitespace-pre-wrap">
{formatAnswer(item.answerContent, selected)}
</div>
</div>
</div> </div>
))} <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Errors</span>
<span className="font-medium text-destructive">
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
</span>
</div>
</div>
</div> </div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers ({wrongAnswers.length})</div>
{wrongAnswers.length === 0 ? (
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
No wrong answers recorded.
</div>
) : (
<div className="space-y-3">
{wrongAnswers.map((wa, i) => (
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>
</div>
<div className="font-medium text-destructive break-words">
{formatAnswer(wa.answerContent, selected)}
</div>
</div>
))}
</div>
)}
</div>
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
<p>Select a question from the left</p>
<p className="text-xs mt-1">to view error analysis</p>
</div> </div>
)} )}
</ScrollArea> </div>
</div> </ScrollArea>
</div> </div>
) )
} }

View File

@@ -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>
)
}

View File

@@ -1,103 +1,8 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types" import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
function ErrorRateChart({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
const w = 100
const h = 60
const padL = 10
const padR = 3
const padT = 4
const padB = 10
const plotW = w - padL - padR
const plotH = h - padT - padB
const n = questions.length
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
const xFor = (i: number) => padL + (n <= 1 ? 0 : (i / (n - 1)) * plotW)
const yFor = (rate: number) => padT + (1 - clamp01(rate)) * plotH
const points = questions.map((q, i) => `${xFor(i)},${yFor(q.errorRate)}`).join(" ")
const areaD =
n === 0
? ""
: `M ${padL} ${padT + plotH} L ${points.split(" ").join(" L ")} L ${padL + plotW} ${padT + plotH} Z`
const gridYs = [
{ v: 1, label: "100%" },
{ v: 0.5, label: "50%" },
{ v: 0, label: "0%" },
]
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
{gridYs.map((g) => {
const y = yFor(g.v)
return (
<g key={g.label}>
<line x1={padL} y1={y} x2={padL + plotW} y2={y} className="stroke-border" strokeWidth={0.5} />
<text x={2} y={y + 1.2} className="fill-muted-foreground text-[3px]">
{g.label}
</text>
</g>
)
})}
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} className="stroke-border" strokeWidth={0.7} />
<line
x1={padL}
y1={padT + plotH}
x2={padL + plotW}
y2={padT + plotH}
className="stroke-border"
strokeWidth={0.7}
/>
{n >= 2 ? <path d={areaD} className="fill-primary/10" /> : null}
<polyline
points={points}
fill="none"
className="stroke-primary"
strokeWidth={1.2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{questions.map((q, i) => {
const cx = xFor(i)
const cy = yFor(q.errorRate)
const label = `Q${i + 1}`
return (
<g key={q.questionId}>
<circle cx={cx} cy={cy} r={1.2} className="fill-primary" />
<title>{`${label}: ${(q.errorRate * 100).toFixed(1)}% (${q.errorCount} / ${gradedSampleCount})`}</title>
</g>
)
})}
{questions.map((q, i) => {
if (n > 12 && i % 2 === 1) return null
const x = xFor(i)
return (
<text
key={`x-${q.questionId}`}
x={x}
y={h - 2}
textAnchor="middle"
className="fill-muted-foreground text-[3px]"
>
{i + 1}
</text>
)
})}
</svg>
)
}
export function HomeworkAssignmentQuestionErrorOverviewCard({ export function HomeworkAssignmentQuestionErrorOverviewCard({
questions, questions,
@@ -106,26 +11,78 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
questions: HomeworkAssignmentQuestionAnalytics[] questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number gradedSampleCount: number
}) { }) {
const data = questions.map((q, index) => ({
name: `Q${index + 1}`,
errorRate: q.errorRate * 100,
errorCount: q.errorCount,
total: gradedSampleCount,
}))
return ( return (
<Card className="md:col-span-1"> <Card className="md:col-span-1">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Overview</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Error Rate Overview</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-72">
{questions.length === 0 || gradedSampleCount === 0 ? ( {questions.length === 0 || gradedSampleCount === 0 ? (
<div className="text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No graded submissions yet. Error analytics will appear here after grading. No graded submissions yet.
</div> </div>
) : ( ) : (
<div className="space-y-4"> <ResponsiveContainer width="100%" height="100%">
<div className="flex items-center justify-between text-xs text-muted-foreground"> <BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<span>Graded students</span> <CartesianGrid strokeDasharray="3 3" vertical={false} />
<span className="font-medium text-foreground">{gradedSampleCount}</span> <XAxis
</div> dataKey="name"
<div className="h-56 rounded-md border bg-muted/40 px-3 py-2"> tickLine={false}
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} /> axisLine={false}
</div> tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
</div> interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
/>
<Tooltip
cursor={{ fill: "hsl(var(--muted)/0.2)" }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const d = payload[0].payload
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Question</span>
<span className="font-bold text-muted-foreground">{d.name}</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Error Rate</span>
<span className="font-bold">{d.errorRate.toFixed(1)}%</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Errors</span>
<span className="font-bold">
{d.errorCount} / {d.total}
</span>
</div>
</div>
</div>
)
}
return null
}}
/>
<Bar
dataKey="errorRate"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={40}
/>
</BarChart>
</ResponsiveContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -3,10 +3,20 @@
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { Check, MessageSquarePlus, X } from "lucide-react" import {
Check,
MessageSquarePlus,
X,
ChevronLeft,
ChevronRight,
Save,
User,
AlertCircle,
Clock
} from "lucide-react"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea" import { Textarea } from "@/shared/components/ui/textarea"
@@ -39,20 +49,48 @@ type HomeworkGradingViewProps = {
status: string status: string
totalScore: number | null totalScore: number | null
answers: Answer[] answers: Answer[]
prevSubmissionId?: string | null
nextSubmissionId?: string | null
} }
export function HomeworkGradingView({ export function HomeworkGradingView({
submissionId, submissionId,
answers: initialAnswers, answers: initialAnswers,
prevSubmissionId,
nextSubmissionId,
studentName,
assignmentTitle,
submittedAt,
}: HomeworkGradingViewProps) { }: HomeworkGradingViewProps) {
const router = useRouter() const router = useRouter()
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers)) const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>({})
// Initialize feedback visibility for answers that already have feedback
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>(() => {
const initialVisibility: Record<string, boolean> = {}
if (initialAnswers) {
initialAnswers.forEach(a => {
if (a.feedback && a.feedback.trim().length > 0) {
initialVisibility[a.id] = true
}
})
}
return initialVisibility
})
const handleManualScoreChange = (id: string, val: string) => { const handleManualScoreChange = (id: string, val: string) => {
const parsed = val === "" ? 0 : Number(val) const parsed = val === "" ? 0 : Number(val)
const nextScore = Number.isFinite(parsed) ? parsed : 0 // Clamp score between 0 and maxScore? Or allow extra credit?
// Usually maxScore is the limit, but let's just ensure it's a number.
// Ideally we should clamp it to [0, maxScore] to avoid errors, but sometimes teachers want to give 0 for invalid input.
const targetAnswer = answers.find(a => a.id === id)
const max = targetAnswer?.maxScore ?? 100
let nextScore = Number.isFinite(parsed) ? parsed : 0
if (nextScore > max) nextScore = max
if (nextScore < 0) nextScore = 0
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a))) setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
} }
@@ -69,10 +107,12 @@ export function HomeworkGradingView({
} }
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0) const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
const binaryAnswers = answers.filter(shouldUseBinaryGrading) const maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0) const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 0
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount const correctCount = answers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
const incorrectCount = answers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
const partialCount = answers.reduce((sum, a) => sum + (a.score !== null && a.score > 0 && a.score < a.maxScore ? 1 : 0), 0)
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true) setIsSubmitting(true)
@@ -89,177 +129,357 @@ export function HomeworkGradingView({
const result = await gradeHomeworkSubmissionAction(null, formData) const result = await gradeHomeworkSubmissionAction(null, formData)
if (result.success) { if (result.success) {
toast.success("Grading saved") toast.success("Grading saved successfully")
router.push("/teacher/homework/submissions") // Optionally redirect or stay
router.refresh()
} else { } else {
toast.error(result.message || "Failed to save") toast.error(result.message || "Failed to save grading")
} }
setIsSubmitting(false) setIsSubmitting(false)
} }
const handleScrollToQuestion = (id: string) => {
const el = document.getElementById(`question-card-${id}`)
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" })
}
}
return ( return (
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card"> {/* Main Content: Questions List */}
<div className="border-b p-4"> <div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
<h3 className="font-semibold">Student Response</h3> <ScrollArea className="flex-1 p-4 lg:p-8">
</div> <div className="mx-auto max-w-4xl space-y-8 pb-20">
<ScrollArea className="flex-1 p-4">
<div className="space-y-8">
{answers.map((ans, index) => ( {answers.map((ans, index) => (
<div key={ans.id} className="space-y-4"> <Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${
<div className="flex items-start justify-between"> ans.score === ans.maxScore ? "border-l-4 border-l-emerald-500" :
<div className="space-y-1"> ans.score === 0 && ans.maxScore > 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted"
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span> }`}>
<div className="text-sm">{ans.questionContent?.text}</div> <CardHeader className="bg-card pb-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-6 w-6 shrink-0 justify-center rounded-full p-0">
{index + 1}
</Badge>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{ans.questionType.replace("_", " ")}
</span>
{isAutoGradable(ans) && (
<Badge variant="secondary" className="text-[10px] h-5">Auto-graded</Badge>
)}
</div>
<CardTitle className="text-base font-medium leading-relaxed pt-2">
{ans.questionContent?.text || "No question text"}
</CardTitle>
</div>
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className="whitespace-nowrap">
{ans.score ?? 0} / {ans.maxScore} pts
</Badge>
</div>
</div> </div>
<Badge variant="outline">Max: {ans.maxScore}</Badge> </CardHeader>
</div>
<div className="rounded-md bg-muted/50 p-4">
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
<p className="text-sm font-medium">
{formatStudentAnswer(ans.studentAnswer)}
</p>
</div>
<Separator /> <Separator />
</div>
<CardContent className="bg-card/50 p-6 space-y-6">
{/* Student Answer Display */}
<div className="space-y-3">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<User className="h-3 w-3" /> Student Answer
</Label>
<div className="rounded-md border bg-background p-4 shadow-sm">
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
Array.isArray(ans.questionContent?.options) ? (
<div className="space-y-2">
{(ans.questionContent.options as ChoiceOption[]).map((opt: ChoiceOption) => {
const isSelected = Array.isArray(extractAnswerValue(ans.studentAnswer))
? (extractAnswerValue(ans.studentAnswer) as string[]).includes(opt.id as string)
: extractAnswerValue(ans.studentAnswer) === opt.id
const isCorrect = opt.isCorrect === true
// Visual logic:
// If selected and correct -> Green + Check
// If selected and wrong -> Red + X
// If not selected but correct -> Green outline (show missed correct answer)
let containerClass = "border-transparent hover:bg-muted/50"
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
if (isSelected) {
if (isCorrect) {
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
} else {
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
indicatorClass = "border-red-500 bg-red-500 text-white"
}
} else if (isCorrect) {
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
}
return (
<div
key={opt.id as string}
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
>
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
{opt.id as string}
</div>
<span className="flex-1">{opt.text}</span>
{isCorrect && <Check className="h-4 w-4 text-emerald-600" />}
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" />}
</div>
)
})}
</div>
) : (
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{formatStudentAnswer(ans.studentAnswer)}
</p>
)}
</div>
</div>
{/* Reference Answer (for text/non-choice questions) */}
{ans.questionType === "text" && (
<div className="space-y-2">
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
<Check className="h-3 w-3" /> Reference Answer
</Label>
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
{getTextCorrectAnswers(ans.questionContent).join(" / ") || "No reference answer provided."}
</div>
</div>
)}
</CardContent>
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between w-full gap-4">
{/* Grading Controls */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant={getCorrectnessState(ans) === "correct" ? "default" : "outline"}
size="sm"
className={getCorrectnessState(ans) === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
onClick={() => handleMarkCorrect(ans.id)}
>
<Check className="mr-1 h-4 w-4" /> Correct
</Button>
<Button
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
size="sm"
className={getCorrectnessState(ans) === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
onClick={() => handleMarkIncorrect(ans.id)}
>
<X className="mr-1 h-4 w-4" /> Incorrect
</Button>
</div>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<div className="flex items-center gap-2">
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">Score:</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
className="w-20 h-8"
value={ans.score ?? ""}
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
/>
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
</div>
</div>
{/* Feedback Toggle */}
<Button
variant="ghost"
size="sm"
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
>
<MessageSquarePlus className="mr-2 h-4 w-4" />
{showFeedbackByAnswerId[ans.id] ? "Hide Feedback" : "Add Feedback"}
</Button>
</div>
{/* Feedback Textarea */}
{showFeedbackByAnswerId[ans.id] && (
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
<Textarea
placeholder={`Provide feedback for ${studentName}...`}
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
className="min-h-[80px] bg-background"
/>
</div>
)}
</CardFooter>
</Card>
))} ))}
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card"> {/* Sidebar: Summary & Actions */}
<div className="border-b p-4"> <div className="lg:col-span-3 h-full flex flex-col gap-6">
<h3 className="font-semibold">Grading</h3> <Card className="flex flex-col shadow-md border-t-4 border-t-primary">
<div className="mt-2 flex items-center justify-between text-sm"> <CardHeader className="pb-2">
<span className="text-muted-foreground">Total Score</span> <CardTitle className="text-lg">Grading Summary</CardTitle>
<span className="font-bold text-lg text-primary">{currentTotal}</span> <CardDescription>{assignmentTitle}</CardDescription>
</div> </CardHeader>
{binaryAnswers.length > 0 ? ( <CardContent className="space-y-6">
<div className="mt-3 flex items-center gap-2"> <div className="space-y-1">
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700"> <div className="flex items-center justify-between text-sm">
Correct {correctCount} <span className="text-muted-foreground">Total Score</span>
</Badge> <span className="font-bold">{currentTotal} / {maxTotal}</span>
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700"> </div>
Incorrect {incorrectCount} <div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
</Badge> <div
{ungradedCount > 0 ? ( className="h-full bg-primary transition-all duration-500 ease-in-out"
<Badge variant="outline" className="text-muted-foreground"> style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
Ungraded {ungradedCount} />
</Badge> </div>
) : null}
</div> </div>
) : null}
</div>
<ScrollArea className="flex-1 p-4"> <div className="space-y-3 pt-2">
<div className="space-y-6"> <div className="flex items-center justify-between text-sm">
{answers.map((ans, index) => ( <span className="flex items-center gap-2 text-muted-foreground">
<Card key={ans.id} className="border-l-4 border-l-primary/20"> <User className="h-4 w-4" /> Student
<CardHeader className="py-3 px-4"> </span>
<div className="flex items-center justify-between gap-3"> <span className="font-medium">{studentName}</span>
<CardTitle className="text-sm font-medium flex items-center gap-2"> </div>
<span>Q{index + 1}</span> <div className="flex items-center justify-between text-sm">
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span> <span className="flex items-center gap-2 text-muted-foreground">
{shouldUseBinaryGrading(ans) ? ( <Clock className="h-4 w-4" /> Submitted
<Badge </span>
variant="outline" <span className="font-medium">
className={getCorrectnessBadgeClassName(ans)} {submittedAt ? new Date(submittedAt).toLocaleDateString() : "N/A"}
> </span>
{getCorrectnessLabel(ans)} </div>
</Badge> </div>
) : null}
</CardTitle>
<div className="flex items-center gap-1"> {answers.length > 0 && (
{shouldUseBinaryGrading(ans) ? ( <div className="space-y-4 pt-2">
<> <div className="grid grid-cols-3 gap-2">
<Button <div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
<span className="text-xs text-muted-foreground">Correct</span>
</div>
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
<span className="text-xs text-muted-foreground">Incorrect</span>
</div>
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
<span className="text-xs text-muted-foreground">Partial</span>
</div>
</div>
<Separator />
<div>
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
Question Status
</Label>
<div className="grid grid-cols-5 gap-2">
{answers.map((ans, i) => {
const state = getCorrectnessState(ans)
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
else if (state === "incorrect") badgeClass = "border-red-200 bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:border-red-800 dark:text-red-400"
else if (state === "partial") badgeClass = "border-amber-200 bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-400"
return (
<button
key={ans.id}
type="button" type="button"
variant="outline" onClick={() => handleScrollToQuestion(ans.id)}
size="icon" className={`flex h-8 items-center justify-center rounded border text-xs font-medium transition-colors cursor-pointer hover:ring-2 hover:ring-ring hover:ring-offset-2 ${badgeClass}`}
aria-label="mark correct" title={`Q${i + 1}: ${state}`}
className={getMarkCorrectButtonClassName(ans)}
onClick={() => handleMarkCorrect(ans.id)}
> >
<Check /> {i + 1}
</Button> </button>
<Button )
type="button" })}
variant="outline" </div>
size="icon" </div>
aria-label="mark incorrect" </div>
className={getMarkIncorrectButtonClassName(ans)} )}
onClick={() => handleMarkIncorrect(ans.id)} </CardContent>
> <CardFooter className="flex flex-col gap-3 pt-2">
<X /> <Button
</Button> className="w-full"
</> size="lg"
) : null} onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>Saving...</>
) : (
<>
<Save className="mr-2 h-4 w-4" /> Submit Grades
</>
)}
</Button>
<Tooltip> <div className="flex w-full items-center justify-between gap-2 pt-2">
<TooltipTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
type="button" <Button
variant="ghost" variant="outline"
size="icon" size="sm"
aria-label="add feedback" className="flex-1"
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)} disabled={!prevSubmissionId}
onClick={() => onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) })) >
} <ChevronLeft className="mr-1 h-4 w-4" /> Prev
> </Button>
<MessageSquarePlus /> </TooltipTrigger>
</Button> <TooltipContent>Previous Student</TooltipContent>
</TooltipTrigger> </Tooltip>
<TooltipContent>add feedback</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="py-3 px-4 space-y-3">
<div className="grid gap-2">
{!shouldUseBinaryGrading(ans) ? (
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
/>
</div>
) : null}
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
/>
) : null}
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
<div className="border-t p-4 bg-muted/20"> <Tooltip>
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitting}> <TooltipTrigger asChild>
{isSubmitting ? "Saving..." : "Submit Grades"} <Button
</Button> variant="outline"
size="sm"
className="flex-1"
disabled={!nextSubmissionId}
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
>
Next <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Next Student</TooltipContent>
</Tooltip>
</div>
</CardFooter>
</Card>
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-950/30 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p>
Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
) )
} }
type ChoiceOption = { id?: unknown; isCorrect?: unknown } type ChoiceOption = { id?: unknown; isCorrect?: unknown; text?: string }
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase() const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
@@ -295,14 +515,6 @@ const getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | nu
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
} }
const shouldUseBinaryGrading = (ans: Answer): boolean => {
if (ans.questionType === "single_choice") return true
if (ans.questionType === "multiple_choice") return true
if (ans.questionType === "judgment") return true
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
return false
}
const isAutoGradable = (ans: Answer): boolean => { const isAutoGradable = (ans: Answer): boolean => {
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0 if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
@@ -370,39 +582,6 @@ const getCorrectnessState = (ans: Answer): CorrectnessState => {
return "partial" return "partial"
} }
const getCorrectnessLabel = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "Correct"
if (s === "incorrect") return "Incorrect"
if (s === "partial") return "Partial"
return "Ungraded"
}
const getCorrectnessBadgeClassName = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "border-emerald-200 bg-emerald-50 text-emerald-700"
if (s === "incorrect") return "border-red-200 bg-red-50 text-red-700"
if (s === "partial") return "border-amber-200 bg-amber-50 text-amber-800"
return "text-muted-foreground"
}
const getMarkCorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "correct"
return active ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "text-muted-foreground"
}
const getMarkIncorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "incorrect"
return active ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-100" : "text-muted-foreground"
}
const getFeedbackIconButtonClassName = (ans: Answer, isOpen: boolean): string => {
const hasFeedback = typeof ans.feedback === "string" && ans.feedback.trim().length > 0
if (isOpen) return "text-primary"
if (hasFeedback) return "text-primary/80"
return "text-muted-foreground"
}
const formatStudentAnswer = (studentAnswer: unknown): string => { const formatStudentAnswer = (studentAnswer: unknown): string => {
const v = extractAnswerValue(studentAnswer) const v = extractAnswerValue(studentAnswer)
if (typeof v === "string") return v if (typeof v === "string") return v

View File

@@ -3,22 +3,17 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Checkbox } from "@/shared/components/ui/checkbox" import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea" import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator" import { Separator } from "@/shared/components/ui/separator"
import { import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import type { StudentHomeworkTakeData } from "../types" import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions" import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
@@ -87,6 +82,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const isStarted = submissionStatus === "started" const isStarted = submissionStatus === "started"
const canEdit = isStarted && Boolean(submissionId) const canEdit = isStarted && Boolean(submissionId)
const showQuestions = submissionStatus !== "not_started"
const handleStart = async () => { const handleStart = async () => {
setIsBusy(true) setIsBusy(true)
@@ -106,7 +102,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const handleSaveQuestion = async (questionId: string) => { const handleSaveQuestion = async (questionId: string) => {
if (!submissionId) return if (!submissionId) return
setIsBusy(true) // setIsBusy(true) // Don't block UI for individual saves
const payload = answersByQuestionId[questionId]?.answer ?? null const payload = answersByQuestionId[questionId]?.answer ?? null
const fd = new FormData() const fd = new FormData()
fd.set("submissionId", submissionId) fd.set("submissionId", submissionId)
@@ -115,12 +111,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const res = await saveHomeworkAnswerAction(null, fd) const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success("Saved") if (res.success) toast.success("Saved")
else toast.error(res.message || "Failed to save") else toast.error(res.message || "Failed to save")
setIsBusy(false) // setIsBusy(false)
} }
const handleSubmit = async () => { const handleSubmit = async () => {
if (!submissionId) return if (!submissionId) return
setIsBusy(true) setIsBusy(true)
// Save all first
for (const q of initialData.questions) { for (const q of initialData.questions) {
const payload = answersByQuestionId[q.questionId]?.answer ?? null const payload = answersByQuestionId[q.questionId]?.answer ?? null
const fd = new FormData() const fd = new FormData()
@@ -149,50 +146,86 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
} }
return ( return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card"> <div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 flex items-center justify-between"> <div className="border-b p-4 flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<h3 className="font-semibold">Questions</h3> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<Badge variant="outline" className="capitalize"> <FileText className="h-4 w-4 text-primary" />
{submissionStatus === "not_started" ? "not started" : submissionStatus} </div>
</Badge> <div>
<h3 className="font-semibold leading-none">Questions</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
{submissionStatus === "not_started" ? "Not Started" : submissionStatus}
</Badge>
<span></span>
<span>{initialData.questions.length} Questions</span>
</div>
</div>
</div> </div>
{!canEdit ? ( {!canEdit ? (
<Button onClick={handleStart} disabled={isBusy}> <Button onClick={handleStart} disabled={isBusy} size="sm">
{isBusy ? "Starting..." : "Start"} {isBusy ? "Starting..." : "Start Assignment"}
</Button> </Button>
) : ( ) : (
<Button onClick={handleSubmit} disabled={isBusy}> <div className="flex items-center gap-2">
{isBusy ? "Submitting..." : "Submit"} <span className="text-xs text-muted-foreground hidden sm:inline-block">
</Button> Auto-saving enabled
</span>
<Button onClick={handleSubmit} disabled={isBusy} size="sm">
<CheckCircle2 className="mr-2 h-4 w-4" />
{isBusy ? "Submitting..." : "Submit Assignment"}
</Button>
</div>
)} )}
</div> </div>
<ScrollArea className="flex-1 p-4"> <ScrollArea className="flex-1 bg-muted/10">
<div className="space-y-6"> <div className="space-y-6 p-6 max-w-4xl mx-auto">
{initialData.questions.map((q, idx) => { {!isStarted && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium">Ready to start?</h3>
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
Click the &quot;Start Assignment&quot; button above to begin. The timer will start once you confirm.
</p>
<Button onClick={handleStart} disabled={isBusy}>
Start Now
</Button>
</div>
)}
{showQuestions && initialData.questions.map((q, idx) => {
const text = getQuestionText(q.questionContent) const text = getQuestionText(q.questionContent)
const options = getOptions(q.questionContent) const options = getOptions(q.questionContent)
const value = answersByQuestionId[q.questionId]?.answer const value = answersByQuestionId[q.questionId]?.answer
return ( return (
<Card key={q.questionId} className="border-l-4 border-l-primary/20"> <Card key={q.questionId} className="border-l-4 border-l-primary shadow-sm">
<CardHeader className="py-3 px-4"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center justify-between"> <div className="flex items-start justify-between gap-4">
<span> <div className="space-y-1">
Q{idx + 1} <span className="text-muted-foreground font-normal">({q.questionType})</span> <CardTitle className="text-base font-medium">
</span> Question {idx + 1}
<span className="text-xs text-muted-foreground">Max: {q.maxScore}</span> </CardTitle>
</CardTitle> <CardDescription className="text-xs">
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} {q.maxScore} points
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="py-3 px-4 space-y-4"> <CardContent className="space-y-4">
<div className="text-sm">{text || "—"}</div> <div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
{q.questionType === "text" ? ( {q.questionType === "text" ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Your answer</Label> <Label className="sr-only">Your answer</Label>
<Textarea <Textarea
placeholder="Type your answer here..."
value={typeof value === "string" ? value : ""} value={typeof value === "string" ? value : ""}
onChange={(e) => onChange={(e) =>
setAnswersByQuestionId((prev) => ({ setAnswersByQuestionId((prev) => ({
@@ -200,14 +233,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
[q.questionId]: { answer: e.target.value }, [q.questionId]: { answer: e.target.value },
})) }))
} }
className="min-h-[100px]" className="min-h-[120px] resize-y"
disabled={!canEdit} disabled={!canEdit}
/> />
</div> </div>
) : q.questionType === "judgment" ? ( ) : q.questionType === "judgment" ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Your answer</Label> <RadioGroup
<Select
value={typeof value === "boolean" ? (value ? "true" : "false") : ""} value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
onValueChange={(v) => onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({ setAnswersByQuestionId((prev) => ({
@@ -216,20 +248,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
})) }))
} }
disabled={!canEdit} disabled={!canEdit}
className="flex flex-col gap-2"
> >
<SelectTrigger> <div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<SelectValue placeholder="Select" /> <RadioGroupItem value="true" id={`${q.questionId}-true`} />
</SelectTrigger> <Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">True</Label>
<SelectContent> </div>
<SelectItem value="true">True</SelectItem> <div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<SelectItem value="false">False</SelectItem> <RadioGroupItem value="false" id={`${q.questionId}-false`} />
</SelectContent> <Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">False</Label>
</Select> </div>
</RadioGroup>
</div> </div>
) : q.questionType === "single_choice" ? ( ) : q.questionType === "single_choice" ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Your answer</Label> <RadioGroup
<Select
value={typeof value === "string" ? value : ""} value={typeof value === "string" ? value : ""}
onValueChange={(v) => onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({ setAnswersByQuestionId((prev) => ({
@@ -238,28 +271,27 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
})) }))
} }
disabled={!canEdit} disabled={!canEdit}
className="flex flex-col gap-2"
> >
<SelectTrigger> {options.map((o) => (
<SelectValue placeholder="Select an option" /> <div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
</SelectTrigger> <RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<SelectContent> <Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.text} {o.text}
</SelectItem> </Label>
))} </div>
</SelectContent> ))}
</Select> </RadioGroup>
</div> </div>
) : q.questionType === "multiple_choice" ? ( ) : q.questionType === "multiple_choice" ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Your answer</Label> <div className="flex flex-col gap-2">
<div className="space-y-2">
{options.map((o) => { {options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false const selected = Array.isArray(value) ? value.includes(o.id) : false
return ( return (
<label key={o.id} className="flex items-start gap-3 rounded-md border p-3"> <div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<Checkbox <Checkbox
id={`${q.questionId}-${o.id}`}
checked={selected} checked={selected}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
const isChecked = checked === true const isChecked = checked === true
@@ -275,30 +307,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
}} }}
disabled={!canEdit} disabled={!canEdit}
/> />
<span className="text-sm">{o.text}</span> <Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
</label> {o.text}
</Label>
</div>
) )
})} })}
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground">Unsupported question type</div> <div className="text-sm text-muted-foreground italic">Unsupported question type</div>
)}
{submissionStatus === "graded" && (
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
{q.feedback ? (
<div className="text-sm space-y-1">
<div className="font-medium text-foreground">Teacher Feedback</div>
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
{q.feedback}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
)}
</div>
)} )}
{canEdit ? ( {canEdit ? (
<> <div className="flex justify-end pt-2">
<Separator /> <Button
<div className="flex justify-end"> variant="ghost"
<Button size="sm"
variant="outline" onClick={() => handleSaveQuestion(q.questionId)}
size="sm" disabled={isBusy}
onClick={() => handleSaveQuestion(q.questionId)} className="text-muted-foreground hover:text-foreground"
disabled={isBusy} >
> <Save className="mr-2 h-3 w-3" />
Save Save Answer
</Button> </Button>
</div> </div>
</>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
@@ -308,38 +356,66 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</ScrollArea> </ScrollArea>
</div> </div>
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card"> <div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4"> <div className="border-b p-4 bg-muted/30">
<h3 className="font-semibold">Info</h3> <h3 className="font-semibold">Assignment Info</h3>
<div className="mt-2 space-y-1 text-sm text-muted-foreground"> </div>
<div className="flex items-center justify-between"> <div className="flex-1 p-4 overflow-y-auto">
<span>Status</span> <div className="space-y-6">
<span className="capitalize text-foreground">{submissionStatus === "not_started" ? "not started" : submissionStatus}</span> <div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
<div className="mt-1 flex items-center gap-2">
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
{submissionStatus === "not_started" ? "not started" : submissionStatus}
</Badge>
</div>
</div> </div>
<div className="flex items-center justify-between">
<span>Questions</span> <div>
<span className="text-foreground tabular-nums">{initialData.questions.length}</span> <Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{initialData.assignment.description || "No description provided."}
</p>
</div> </div>
{showQuestions && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Progress</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{initialData.questions.map((q, i) => {
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
answersByQuestionId[q.questionId]?.answer !== "" &&
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
return (
<div
key={q.questionId}
className={`
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
${hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"}
`}
>
{i + 1}
</div>
)
})}
</div>
</div>
)}
</div> </div>
</div> </div>
<div className="flex-1 p-4">
<div className="space-y-3 text-sm"> {canEdit && (
<div className="text-muted-foreground">{initialData.assignment.description || "—"}</div> <div className="border-t p-4 bg-muted/20">
</div>
</div>
<div className="border-t p-4 bg-muted/20">
{canEdit ? (
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}> <Button className="w-full" onClick={handleSubmit} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit"} {isBusy ? "Submitting..." : "Submit All"}
</Button> </Button>
) : ( <p className="mt-2 text-xs text-center text-muted-foreground">
<Button className="w-full" onClick={handleStart} disabled={isBusy}> Make sure you have answered all questions.
{isBusy ? "Starting..." : "Start"} </p>
</Button> </div>
)} )}
</div>
</div> </div>
</div> </div>
) )
} }

View 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>
)
}

View File

@@ -524,6 +524,17 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
}) })
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
// Fetch adjacent submissions for navigation
const allSubmissions = await db.query.homeworkSubmissions.findMany({
where: eq(homeworkSubmissions.assignmentId, submission.assignmentId),
orderBy: [desc(homeworkSubmissions.updatedAt)],
columns: { id: true },
})
const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId)
const prevSubmissionId = currentIndex > 0 ? allSubmissions[currentIndex - 1].id : null
const nextSubmissionId = currentIndex >= 0 && currentIndex < allSubmissions.length - 1 ? allSubmissions[currentIndex + 1].id : null
return { return {
id: submission.id, id: submission.id,
assignmentId: submission.assignmentId, assignmentId: submission.assignmentId,
@@ -533,6 +544,8 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
status: submission.status as HomeworkSubmissionDetails["status"], status: submission.status as HomeworkSubmissionDetails["status"],
totalScore: submission.score, totalScore: submission.score,
answers: answersWithDetails, answers: answersWithDetails,
prevSubmissionId,
nextSubmissionId,
} }
}) })
@@ -643,16 +656,32 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({ const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId), where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true }, with: {
question: {
with: {
knowledgePoints: {
with: {
knowledgePoint: true
}
}
}
}
},
orderBy: (q, { asc }) => [asc(q.order)], orderBy: (q, { asc }) => [asc(q.order)],
}) })
const savedByQuestionId = new Map<string, unknown>() const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
if (latestSubmission) { if (latestSubmission) {
const answers = await db.query.homeworkAnswers.findMany({ const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, latestSubmission.id), where: eq(homeworkAnswers.submissionId, latestSubmission.id),
}) })
for (const ans of answers) savedByQuestionId.set(ans.questionId, ans.answerContent) for (const ans of answers) {
answersByQuestionId.set(ans.questionId, {
answer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
})
}
} }
return { return {
@@ -675,14 +704,25 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
score: latestSubmission.score ?? null, score: latestSubmission.score ?? null,
} }
: null, : null,
questions: assignmentQuestions.map((aq) => ({ questions: assignmentQuestions.map((aq) => {
questionId: aq.questionId, const saved = answersByQuestionId.get(aq.questionId)
questionType: aq.question.type, // Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
questionContent: toQuestionContent(aq.question.content), const kps = aq.question.knowledgePoints ?? []
maxScore: aq.score ?? 0, return {
order: aq.order ?? 0, questionId: aq.questionId,
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null, questionType: aq.question.type,
})), questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
savedAnswer: saved?.answer ?? null,
score: saved?.score ?? null,
feedback: saved?.feedback ?? null,
knowledgePoints: kps.map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name,
})),
}
}),
} }
}) })

View File

@@ -73,6 +73,8 @@ export type HomeworkSubmissionDetails = {
status: HomeworkSubmissionStatus status: HomeworkSubmissionStatus
totalScore: number | null totalScore: number | null
answers: HomeworkSubmissionAnswerDetails[] answers: HomeworkSubmissionAnswerDetails[]
prevSubmissionId?: string | null
nextSubmissionId?: string | null
} }
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded" export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
@@ -114,6 +116,9 @@ export type StudentHomeworkTakeQuestion = {
maxScore: number maxScore: number
order: number order: number
savedAnswer: unknown savedAnswer: unknown
score?: number | null
feedback?: string | null
knowledgePoints?: Array<{ id: string; name: string }>
} }
export type StudentHomeworkTakeData = { export type StudentHomeworkTakeData = {
@@ -145,7 +150,7 @@ export type HomeworkAssignmentQuestionAnalytics = {
order: number order: number
errorCount: number errorCount: number
errorRate: number errorRate: number
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }> wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown; count?: number }>
} }
export type HomeworkAssignmentAnalytics = { export type HomeworkAssignmentAnalytics = {

View File

@@ -2,6 +2,7 @@
import * as React from "react" import * as React from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation"
import { Bell, Menu, Search } from "lucide-react" import { Bell, Menu, Search } from "lucide-react"
import { signOut, useSession } from "next-auth/react" import { signOut, useSession } from "next-auth/react"
@@ -27,8 +28,21 @@ import {
} from "@/shared/components/ui/dropdown-menu" } from "@/shared/components/ui/dropdown-menu"
import { useSidebar } from "./sidebar-provider" import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG } from "../config/navigation"
// Build lookup map for breadcrumbs
const BREADCRUMB_MAP = new Map<string, string>()
Object.values(NAV_CONFIG).forEach((items) => {
items.forEach((item) => {
BREADCRUMB_MAP.set(item.href, item.title)
item.items?.forEach((subItem) => {
BREADCRUMB_MAP.set(subItem.href, subItem.title)
})
})
})
export function SiteHeader() { export function SiteHeader() {
const pathname = usePathname()
const { toggleSidebar, isMobile } = useSidebar() const { toggleSidebar, isMobile } = useSidebar()
const { data: session, status } = useSession() const { data: session, status } = useSession()
@@ -44,6 +58,16 @@ export function SiteHeader() {
.map((p) => p[0]?.toUpperCase()) .map((p) => p[0]?.toUpperCase())
.join("") .join("")
// Generate breadcrumbs
const segments = pathname.split("/").filter(Boolean)
const breadcrumbs = segments
.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join("/")}`
const title = BREADCRUMB_MAP.get(href) || segment.charAt(0).toUpperCase() + segment.slice(1)
return { href, title, isLast: index === segments.length - 1 }
})
.filter((b) => !["admin", "teacher", "student", "parent"].includes(b.title.toLowerCase()))
return ( return (
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm"> <header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
<div className="flex flex-1 items-center gap-4"> <div className="flex flex-1 items-center gap-4">
@@ -60,13 +84,26 @@ export function SiteHeader() {
{/* Breadcrumbs */} {/* Breadcrumbs */}
<Breadcrumb className="hidden md:flex"> <Breadcrumb className="hidden md:flex">
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> {breadcrumbs.length > 0 ? (
<BreadcrumbLink href="/dashboard">Dashboard</BreadcrumbLink> breadcrumbs.map((crumb) => (
</BreadcrumbItem> <React.Fragment key={crumb.href}>
<BreadcrumbSeparator /> <BreadcrumbItem>
<BreadcrumbItem> {crumb.isLast ? (
<BreadcrumbPage>Overview</BreadcrumbPage> <BreadcrumbPage>{crumb.title}</BreadcrumbPage>
</BreadcrumbItem> ) : (
<BreadcrumbLink asChild>
<Link href={crumb.href}>{crumb.title}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!crumb.isLast && <BreadcrumbSeparator />}
</React.Fragment>
))
) : (
<BreadcrumbItem>
<BreadcrumbPage>Home</BreadcrumbPage>
</BreadcrumbItem>
)}
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
</div> </div>

View File

@@ -12,7 +12,8 @@ import {
FileQuestion, FileQuestion,
ClipboardList, ClipboardList,
Library, Library,
PenTool PenTool,
Briefcase
} from "lucide-react" } from "lucide-react"
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
@@ -124,7 +125,14 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{ title: "Students", href: "/teacher/classes/students" }, { title: "Students", href: "/teacher/classes/students" },
{ title: "Schedule", href: "/teacher/classes/schedule" }, { title: "Schedule", href: "/teacher/classes/schedule" },
{ title: "Insights", href: "/teacher/classes/insights" }, { title: "Insights", href: "/teacher/classes/insights" },
{ title: "Grade Insights", href: "/teacher/grades/insights" }, ]
},
{
title: "Management",
icon: Briefcase,
href: "/management",
items: [
{ title: "Grade Insights", href: "/management/grade/insights" },
] ]
}, },
], ],

View File

@@ -9,10 +9,11 @@ import { revalidatePath } from "next/cache";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { getQuestions, type GetQuestionsParams } from "./data-access";
async function getCurrentUser() { async function getCurrentUser() {
return { return {
id: "user_teacher_123", id: "user_teacher_math",
role: "teacher", role: "teacher",
}; };
} }
@@ -205,25 +206,27 @@ export async function deleteQuestionAction(
): Promise<ActionState<string>> { ): Promise<ActionState<string>> {
try { try {
const user = await ensureTeacher(); const user = await ensureTeacher();
const canEditAll = user.role === "admin"; const canDeleteAll = user.role === "admin";
const id = formData.get("id"); const questionId = formData.get("questionId");
if (typeof id !== "string" || id.length === 0) { if (typeof questionId !== "string") {
return { success: false, message: "Missing question id" }; return { success: false, message: "Invalid question ID" };
} }
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const [owned] = await tx const q = await tx.query.questions.findFirst({
.select({ id: questions.id }) where: eq(questions.id, questionId),
.from(questions) });
.where(canEditAll ? eq(questions.id, id) : and(eq(questions.id, id), eq(questions.authorId, user.id)))
.limit(1);
if (!owned) { if (!q) {
throw new Error("Question not found");
}
if (!canDeleteAll && q.authorId !== user.id) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
await deleteQuestionRecursive(tx, id); await deleteQuestionRecursive(tx, questionId);
}); });
revalidatePath("/teacher/questions"); revalidatePath("/teacher/questions");
@@ -233,6 +236,11 @@ export async function deleteQuestionAction(
if (error instanceof Error) { if (error instanceof Error) {
return { success: false, message: error.message }; return { success: false, message: error.message };
} }
return { success: false, message: "An unexpected error occurred" }; return { success: false, message: "Failed to delete question" };
} }
} }
export async function getQuestionsAction(params: GetQuestionsParams) {
await ensureTeacher();
return await getQuestions(params);
}

View File

@@ -76,7 +76,7 @@ export const getQuestions = cache(async ({
offset: offset, offset: offset,
orderBy: [desc(questions.createdAt)], orderBy: [desc(questions.createdAt)],
with: { with: {
questionsToKnowledgePoints: { knowledgePoints: {
with: { with: {
knowledgePoint: true, knowledgePoint: true,
}, },
@@ -95,7 +95,7 @@ export const getQuestions = cache(async ({
return { return {
data: rows.map((row) => { data: rows.map((row) => {
const knowledgePoints = const knowledgePoints =
row.questionsToKnowledgePoints?.map((rel) => rel.knowledgePoint) ?? []; row.knowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
const author = row.author const author = row.author
? { ? {

View File

@@ -11,8 +11,10 @@ import { Label } from "@/shared/components/ui/label"
import { Separator } from "@/shared/components/ui/separator" import { Separator } from "@/shared/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { ThemePreferencesCard } from "./theme-preferences-card" import { ThemePreferencesCard } from "./theme-preferences-card"
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
import { UserProfile } from "@/modules/users/data-access"
export function AdminSettingsView() { export function AdminSettingsView({ user }: { user: UserProfile }) {
return ( return (
<div className="flex h-full flex-col gap-8 p-8"> <div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
@@ -44,32 +46,7 @@ export function AdminSettingsView() {
</TabsList> </TabsList>
<TabsContent value="general" className="mt-6 space-y-6"> <TabsContent value="general" className="mt-6 space-y-6">
<Card> <ProfileSettingsForm user={user} />
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Basic profile information for this admin account.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" defaultValue="Admin User" disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" defaultValue="admin@nextedu.com" disabled />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input id="role" defaultValue="admin" className="tabular-nums" disabled />
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Input id="status" defaultValue="active" disabled />
</div>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -100,8 +77,8 @@ export function AdminSettingsView() {
Departments, classes, and academic year settings live under the School Management section. Departments, classes, and academic year settings live under the School Management section.
</div> </div>
</div> </div>
<Button asChild variant="outline" className="shrink-0"> <Button variant="outline" size="sm" asChild>
<Link href="/admin/school/departments">Open</Link> <Link href="/admin/school">Manage</Link>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -128,29 +105,6 @@ export function AdminSettingsView() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Danger zone</CardTitle>
<CardDescription>Destructive actions are disabled in demo mode.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-background">
<Shield className="h-4 w-4 text-destructive" />
</div>
<div className="flex-1 space-y-1">
<div className="text-sm font-medium">Reset system</div>
<div className="text-sm text-muted-foreground">
This action would clear all data and cannot be undone.
</div>
</div>
<Button variant="destructive" disabled className="shrink-0">
Reset
</Button>
</div>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View 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>
)
}

View File

@@ -5,24 +5,13 @@ import { User, Palette, Lock, LayoutDashboard, PenTool, CalendarDays } from "luc
import { signOut } from "next-auth/react" import { signOut } from "next-auth/react"
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card" import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { UserProfile } from "@/modules/users/data-access"
type SettingsUser = { export function StudentSettingsView({ user }: { user: UserProfile }) {
id?: string | null
name?: string | null
email?: string | null
role?: string | null
}
export function StudentSettingsView({ user }: { user: SettingsUser }) {
const role = "student"
const name = user.name ?? "-"
const email = user.email ?? "-"
return ( return (
<div className="flex h-full flex-col gap-8 p-8"> <div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
@@ -54,28 +43,7 @@ export function StudentSettingsView({ user }: { user: SettingsUser }) {
</TabsList> </TabsList>
<TabsContent value="general" className="mt-6 space-y-6"> <TabsContent value="general" className="mt-6 space-y-6">
<Card> <ProfileSettingsForm user={user} />
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Signed-in user details from session.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input id="role" value={role} className="tabular-nums" disabled />
</div>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -133,4 +101,3 @@ export function StudentSettingsView({ user }: { user: SettingsUser }) {
</div> </div>
) )
} }

View File

@@ -5,24 +5,13 @@ import { User, Palette, Lock, LayoutDashboard, PenTool, CalendarDays, Library, F
import { signOut } from "next-auth/react" import { signOut } from "next-auth/react"
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card" import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { UserProfile } from "@/modules/users/data-access"
type SettingsUser = { export function TeacherSettingsView({ user }: { user: UserProfile }) {
id?: string | null
name?: string | null
email?: string | null
role?: string | null
}
export function TeacherSettingsView({ user }: { user: SettingsUser }) {
const role = "teacher"
const name = user.name ?? "-"
const email = user.email ?? "-"
return ( return (
<div className="flex h-full flex-col gap-8 p-8"> <div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
@@ -54,28 +43,7 @@ export function TeacherSettingsView({ user }: { user: SettingsUser }) {
</TabsList> </TabsList>
<TabsContent value="general" className="mt-6 space-y-6"> <TabsContent value="general" className="mt-6 space-y-6">
<Card> <ProfileSettingsForm user={user} />
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Signed-in user details from session.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input id="role" value={role} className="tabular-nums" disabled />
</div>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -145,4 +113,3 @@ export function TeacherSettingsView({ user }: { user: SettingsUser }) {
</div> </div>
) )
} }

View File

@@ -1,17 +1,17 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { BookOpen, Building2, Inbox } from "lucide-react" import { BookOpen, Building2, Inbox, CalendarDays, User, PlusCircle, PenTool } from "lucide-react"
import type { StudentEnrolledClass } from "@/modules/classes/types" import type { StudentEnrolledClass } from "@/modules/classes/types"
import { joinClassByInvitationCodeAction } from "@/modules/classes/actions" import { joinClassByInvitationCodeAction } from "@/modules/classes/actions"
@@ -43,113 +43,113 @@ export function StudentCoursesView({
} }
} }
if (classes.length === 0) { return (
return ( <div className="space-y-8">
<div className="space-y-4"> {classes.length > 0 && (
<Card> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<CardHeader className="pb-2"> {classes.map((c) => (
<CardTitle className="text-base">Join a class</CardTitle> <Card key={c.id} className="flex flex-col overflow-hidden transition-all hover:shadow-md">
</CardHeader> <CardHeader className="bg-muted/30 pb-4">
<CardContent> <div className="flex items-start justify-between gap-4">
<form action={handleJoin} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end"> <div className="space-y-1">
<div className="space-y-2"> <CardTitle className="line-clamp-1 text-lg">{c.name}</CardTitle>
<Label htmlFor="join-invitation-code">Invitation code</Label> <CardDescription className="flex items-center gap-2 text-xs">
<Input <span className="flex items-center gap-1">
id="join-invitation-code" <BookOpen className="h-3 w-3" />
name="code" Grade {c.grade}
inputMode="numeric" </span>
autoComplete="one-time-code" {c.homeroom && (
placeholder="6-digit code" <>
value={code} <span></span>
onChange={(e) => setCode(e.target.value)} <span>{c.homeroom}</span>
maxLength={6} </>
required )}
/> </CardDescription>
</div> </div>
<Button type="submit" disabled={isWorking}> <Badge variant="secondary" className="shrink-0">
{isWorking ? "Joining..." : "Join"} Active
</Button> </Badge>
</form> </div>
</CardContent> </CardHeader>
</Card>
<CardContent className="flex-1 space-y-4 py-4">
<div className="space-y-2 text-sm">
{c.teacherName && (
<div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" />
<span>{c.teacherName}</span>
</div>
)}
{c.room && (
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4" />
<span>Room {c.room}</span>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex gap-2 border-t bg-muted/10 p-4">
<Button asChild variant="outline" size="sm" className="flex-1">
<Link href={`/student/schedule?classId=${encodeURIComponent(c.id)}`}>
<CalendarDays className="mr-2 h-4 w-4" />
Schedule
</Link>
</Button>
<Button asChild size="sm" className="flex-1">
<Link href={`/student/learning/assignments?classId=${encodeURIComponent(c.id)}`}>
<PenTool className="mr-2 h-4 w-4" />
Assignments
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
)}
{classes.length === 0 && (
<EmptyState <EmptyState
icon={Inbox} icon={Inbox}
title="No courses" title="No courses yet"
description="You are not enrolled in any class yet." description="You are not enrolled in any classes. Join a class to get started."
className="h-80" className="py-12"
/> />
</div> )}
)
}
return ( <div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="space-y-4"> <div className="mb-6 flex items-center gap-3">
<Card> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<CardHeader className="pb-2"> <PlusCircle className="h-5 w-5 text-primary" />
<CardTitle className="text-base">Join a class</CardTitle> </div>
</CardHeader> <div>
<CardContent> <h3 className="text-lg font-semibold">Join a Class</h3>
<form action={handleJoin} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end"> <p className="text-sm text-muted-foreground">
<div className="space-y-2"> Enter the invitation code provided by your teacher to enroll.
<Label htmlFor="join-invitation-code">Invitation code</Label> </p>
<Input </div>
id="join-invitation-code" </div>
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-digit code"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
/>
</div>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Joining..." : "Join"}
</Button>
</form>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <form action={handleJoin} className="flex flex-col gap-4 sm:flex-row sm:items-end">
{classes.map((c) => ( <div className="flex-1 space-y-2">
<Card key={c.id} className="overflow-hidden"> <Label htmlFor="join-invitation-code">Invitation Code</Label>
<CardHeader className="pb-2"> <Input
<CardTitle className="flex items-center justify-between gap-3"> id="join-invitation-code"
<div className="min-w-0"> name="code"
<div className="truncate text-base font-semibold leading-none">{c.name}</div> inputMode="numeric"
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> autoComplete="one-time-code"
<span className="inline-flex items-center gap-1"> placeholder="Enter 6-digit code"
<BookOpen className="h-3 w-3" /> value={code}
Grade {c.grade} onChange={(e) => setCode(e.target.value)}
</span> maxLength={6}
{c.homeroom ? ( className="max-w-md font-mono tracking-widest"
<Badge variant="outline" className="font-normal"> required
{c.homeroom} />
</Badge> </div>
) : null} <Button type="submit" disabled={isWorking} size="lg">
{c.room ? ( {isWorking ? "Joining..." : "Join Class"}
<span className="inline-flex items-center gap-1"> </Button>
<Building2 className="h-3 w-3" /> </form>
{c.room}
</span>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
Enrolled
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-3 pt-2">
<div className="text-sm text-muted-foreground">Open schedule for this class.</div>
<Button asChild variant="outline" size="sm">
<Link href={`/student/schedule?classId=${encodeURIComponent(c.id)}`}>Schedule</Link>
</Button>
</CardContent>
</Card>
))}
</div> </div>
</div> </div>
) )

View File

@@ -9,10 +9,28 @@ import {
createKnowledgePoint, createKnowledgePoint,
deleteKnowledgePoint, deleteKnowledgePoint,
updateTextbook, updateTextbook,
deleteTextbook deleteTextbook,
reorderChapters
} from "./data-access"; } from "./data-access";
import { CreateTextbookInput, UpdateTextbookInput } from "./types"; import { CreateTextbookInput, UpdateTextbookInput } from "./types";
// ... existing code ...
export async function reorderChaptersAction(
chapterId: string,
newIndex: number,
parentId: string | null,
textbookId: string
): Promise<ActionState> {
try {
await reorderChapters(chapterId, newIndex, parentId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapters reordered successfully" };
} catch {
return { success: false, message: "Failed to reorder chapters" };
}
}
export type ActionState = { export type ActionState = {
success: boolean; success: boolean;
message?: string; message?: string;

View File

@@ -30,7 +30,7 @@ function ChapterItem({ chapter, level = 0, onView, showActions = true }: Chapter
const hasChildren = chapter.children && chapter.children.length > 0 const hasChildren = chapter.children && chapter.children.length > 0
return ( return (
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}> <div className={cn(level > 0 && "ml-2 border-l pl-2")}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}> <Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center group py-1"> <div className="flex items-center group py-1">
{hasChildren ? ( {hasChildren ? (

View File

@@ -1,8 +1,11 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { ChevronRight, FileText, Folder, MoreHorizontal, Plus, Trash2 } from "lucide-react" import { ChevronRight, FileText, Folder, Plus, Trash2, GripVertical } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core"
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Chapter } from "../types" import { Chapter } from "../types"
import { import {
Collapsible, Collapsible,
@@ -10,13 +13,6 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/shared/components/ui/collapsible" } from "@/shared/components/ui/collapsible"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -29,54 +25,62 @@ import {
} from "@/shared/components/ui/alert-dialog" } from "@/shared/components/ui/alert-dialog"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { CreateChapterDialog } from "./create-chapter-dialog" import { CreateChapterDialog } from "./create-chapter-dialog"
import { deleteChapterAction } from "../actions" import { deleteChapterAction, reorderChaptersAction } from "../actions"
interface ChapterItemProps { interface SortableChapterItemProps {
chapter: Chapter chapter: Chapter
level?: number level: number
selectedId?: string selectedId?: string
onSelect: (chapter: Chapter) => void onSelect: (chapter: Chapter) => void
textbookId: string textbookId: string
onDelete: (chapter: Chapter) => void
onCreateSub: (parentId: string) => void
} }
function ChapterItem({ chapter, level = 0, selectedId, onSelect, textbookId }: ChapterItemProps) { function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: SortableChapterItemProps) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(level === 0)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const hasChildren = chapter.children && chapter.children.length > 0 const hasChildren = chapter.children && chapter.children.length > 0
const isSelected = chapter.id === selectedId const isSelected = chapter.id === selectedId
const handleDelete = async () => { const {
setIsDeleting(true) attributes,
const res = await deleteChapterAction(chapter.id, textbookId) listeners,
setIsDeleting(false) setNodeRef,
if (res.success) { transform,
toast.success(res.message) transition,
setShowDeleteDialog(false) isDragging,
} else { } = useSortable({ id: chapter.id })
toast.error(res.message)
} const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
position: "relative" as const,
} }
return ( return (
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}> <div ref={setNodeRef} style={style} className={cn(level > 0 && "ml-2 border-l border-muted/30 pl-2")}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}> <Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className={cn( <div className={cn(
"flex items-center group py-1 rounded-md transition-colors", "flex items-center group py-1.5 px-2 rounded-md transition-colors cursor-pointer select-none",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50" isSelected ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted/50 text-muted-foreground hover:text-foreground",
isDragging && "opacity-50"
)}> )}>
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
{hasChildren ? ( {hasChildren ? (
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 shrink-0 p-0 hover:bg-muted" className="h-5 w-5 shrink-0 p-0 mr-1 hover:bg-transparent text-muted-foreground/70"
onClick={(e) => e.stopPropagation()} // Prevent selecting parent when toggling onClick={(e) => e.stopPropagation()}
> >
<ChevronRight <ChevronRight
className={cn( className={cn(
"h-4 w-4 transition-transform duration-200 text-muted-foreground", "h-3.5 w-3.5 transition-transform duration-200",
isOpen && "rotate-90" isOpen && "rotate-90"
)} )}
/> />
@@ -84,124 +88,257 @@ function ChapterItem({ chapter, level = 0, selectedId, onSelect, textbookId }: C
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
) : ( ) : (
<div className="w-6 shrink-0" /> <div className="w-5 shrink-0 mr-1" />
)} )}
<div <div
className={cn( className="flex-1 min-w-0 flex items-center gap-2"
"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-sm cursor-pointer",
level === 0 ? "font-medium" : "text-muted-foreground",
isSelected && "text-accent-foreground font-medium"
)}
onClick={() => onSelect(chapter)} onClick={() => onSelect(chapter)}
> >
{hasChildren ? ( {hasChildren ? (
<Folder className={cn("h-4 w-4", isOpen ? "text-primary" : "text-muted-foreground/70")} /> <Folder className={cn("h-4 w-4 shrink-0 transition-colors", isOpen || isSelected ? "text-blue-500/80" : "text-muted-foreground/50")} />
) : ( ) : (
<FileText className="h-4 w-4 text-muted-foreground/50" /> <FileText className={cn("h-4 w-4 shrink-0 transition-colors", isSelected ? "text-blue-500/80" : "text-muted-foreground/50")} />
)} )}
<span className="truncate flex-1 min-w-0">{chapter.title}</span> <span className="truncate text-sm">{chapter.title}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild> <div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity ml-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="ml-auto h-6 w-6 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity focus:opacity-100" className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()} onClick={(e) => {
> e.stopPropagation()
<MoreHorizontal className="h-4 w-4 text-muted-foreground" /> onCreateSub(chapter.id)
</Button> }}
</DropdownMenuTrigger> title="Add Subchapter"
<DropdownMenuContent align="end"> >
<DropdownMenuItem <Plus className="h-3.5 w-3.5" />
onSelect={() => setShowCreateDialog(true)} </Button>
> <Button
<Plus /> variant="ghost"
Add Subchapter size="icon"
</DropdownMenuItem> className="h-6 w-6 text-muted-foreground hover:text-destructive"
<DropdownMenuSeparator /> onClick={(e) => {
<DropdownMenuItem variant="destructive" onSelect={() => setShowDeleteDialog(true)}> e.stopPropagation()
<Trash2 /> onDelete(chapter)
Delete }}
</DropdownMenuItem> title="Delete Chapter"
</DropdownMenuContent> >
</DropdownMenu> <Trash2 className="h-3.5 w-3.5" />
</Button>
</div> </div>
</div> </div>
{hasChildren && ( <CollapsibleContent>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"> <div className="pt-1">
<div className="pt-1"> {hasChildren && (
{chapter.children!.map((child) => ( <RecursiveSortableList
<ChapterItem items={chapter.children!}
key={child.id} level={level + 1}
chapter={child}
level={level + 1}
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
textbookId={textbookId} textbookId={textbookId}
onDelete={onDelete}
onCreateSub={onCreateSub}
/> />
))} )}
</div> </div>
</CollapsibleContent> </CollapsibleContent>
)}
</Collapsible> </Collapsible>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete chapter?</AlertDialogTitle>
<AlertDialogDescription>
This will delete this chapter and all its subchapters and linked knowledge points.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreateChapterDialog
textbookId={textbookId}
parentId={chapter.id}
trigger={null}
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
/>
</div> </div>
) )
} }
export function ChapterSidebarList({ function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: {
chapters, items: Chapter[],
selectedChapterId, level: number,
onSelectChapter, selectedId?: string,
textbookId, onSelect: (c: Chapter) => void,
}: { textbookId: string,
chapters: Chapter[], onDelete: (c: Chapter) => void,
selectedChapterId?: string, onCreateSub: (pid: string) => void
onSelectChapter: (chapter: Chapter) => void
textbookId: string
}) { }) {
return (
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
{items.map((chapter) => (
<SortableChapterItem
key={chapter.id}
chapter={chapter}
level={level}
selectedId={selectedId}
onSelect={onSelect}
textbookId={textbookId}
onDelete={onDelete}
onCreateSub={onCreateSub}
/>
))}
</SortableContext>
)
}
interface ChapterSidebarListProps {
chapters: Chapter[]
selectedChapterId?: string
onSelectChapter: (chapter: Chapter) => void
textbookId: string
}
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId }: ChapterSidebarListProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Chapter | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
// Find which list the items belong to
// Since we only support sibling reordering for now, we assume active and over are in the same list
// We need a helper to find the parent of an item in the tree
const findParent = (items: Chapter[], id: string): Chapter | null => {
for (const item of items) {
if (item.children?.some(c => c.id === id)) return item
if (item.children) {
const found = findParent(item.children, id)
if (found) return found
}
}
return null
}
const activeParent = findParent(chapters, active.id as string)
const overParent = findParent(chapters, over.id as string)
// If parents don't match (and neither is root), we can't reorder easily in this simplified version
// But actually, we need to check if they are in the same list.
// If both are root items (activeParent is null), they are siblings.
const getSiblings = (parentId: string | null) => {
if (!parentId) return chapters
const parent = chapters.find(c => c.id === parentId) // This only finds root parents, we need recursive find
const findNode = (nodes: Chapter[], id: string): Chapter | null => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
return findNode(chapters, parentId)?.children || []
}
// Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it?
// No, dnd-kit allows dropping anywhere by default unless restricted.
// We need to find the list that contains the 'active' item
// And the list that contains the 'over' item.
// If they are the same list, we reorder.
let activeList: Chapter[] = chapters
let activeParentId: string | null = null
if (activeParent) {
activeList = activeParent.children || []
activeParentId = activeParent.id
} else {
// Check if active is in root
if (!chapters.some(c => c.id === active.id)) {
// Should not happen if tree is consistent
return
}
}
// Check if over is in the same list
if (activeList.some(c => c.id === over.id)) {
const oldIndex = activeList.findIndex((item) => item.id === active.id)
const newIndex = activeList.findIndex((item) => item.id === over.id)
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)
toast.success("Order updated")
}
}
const handleDelete = async () => {
if (!deleteTarget) return
setIsDeleting(true)
const res = await deleteChapterAction(deleteTarget.id, textbookId)
setIsDeleting(false)
if (res.success) {
toast.success(res.message)
setShowDeleteDialog(false)
setDeleteTarget(null)
} else {
toast.error(res.message)
}
}
const handleDeleteRequest = (chapter: Chapter) => {
if (chapter.children && chapter.children.length > 0) {
toast.error("Cannot delete chapter with subchapters")
return
}
setDeleteTarget(chapter)
setShowDeleteDialog(true)
}
const handleCreateSubRequest = (parentId: string) => {
setCreateParentId(parentId)
setShowCreateDialog(true)
}
return ( return (
<div className="space-y-1"> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{chapters.map((chapter) => ( <RecursiveSortableList
<ChapterItem items={chapters}
key={chapter.id} level={0}
chapter={chapter}
selectedId={selectedChapterId} selectedId={selectedChapterId}
onSelect={onSelectChapter} onSelect={onSelectChapter}
textbookId={textbookId} textbookId={textbookId}
onDelete={handleDeleteRequest}
onCreateSub={handleCreateSubRequest}
/> />
))}
</div> <CreateChapterDialog
textbookId={textbookId}
parentId={createParentId}
open={showCreateDialog}
onOpenChange={(open) => {
setShowCreateDialog(open)
if (!open) setCreateParentId(undefined)
}}
/>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chapter?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <span className="font-medium text-foreground">{deleteTarget?.title}</span>.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DndContext>
) )
} }

View File

@@ -54,8 +54,9 @@ export function CreateChapterDialog({ textbookId, parentId, trigger, open: contr
trigger === null trigger === null
? null ? null
: trigger || ( : trigger || (
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span className="sr-only">Add Chapter</span>
</Button> </Button>
) )

View File

@@ -68,9 +68,9 @@ export function KnowledgePointPanel({
: [] : []
return ( return (
<div className="h-full flex flex-col space-y-4"> <div className="h-full flex flex-col">
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold flex items-center gap-2"> <h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Tag className="h-4 w-4" /> <Tag className="h-4 w-4" />
Knowledge Points Knowledge Points
</h3> </h3>
@@ -82,16 +82,17 @@ export function KnowledgePointPanel({
)} )}
</div> </div>
<ScrollArea className="flex-1 min-h-0 -mx-2 px-2"> <ScrollArea className="flex-1">
<div className="p-4 space-y-3">
{selectedChapterId ? ( {selectedChapterId ? (
chapterKPs.length > 0 ? ( chapterKPs.length > 0 ? (
<div className="space-y-3"> <>
{chapterKPs.map((kp) => ( {chapterKPs.map((kp) => (
<Card key={kp.id} className="relative group"> <Card key={kp.id} className="relative group hover:shadow-sm transition-shadow">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex justify-between items-start gap-2"> <div className="flex justify-between items-start gap-2">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium text-sm leading-tight"> <div className="font-medium text-sm leading-tight text-foreground">
{kp.name} {kp.name}
</div> </div>
{kp.description && ( {kp.description && (
@@ -103,7 +104,7 @@ export function KnowledgePointPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10 -mt-1 -mr-1" className="h-6 w-6 -mr-1 -mt-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
onClick={() => requestDelete(kp)} onClick={() => requestDelete(kp)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -112,47 +113,40 @@ export function KnowledgePointPanel({
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </>
) : ( ) : (
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md border-dashed bg-muted/30"> <div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
No knowledge points linked to this chapter yet. <div className="p-3 rounded-full bg-muted/50">
<Tag className="h-6 w-6 text-muted-foreground/40" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">No points yet</p>
<p className="text-xs text-muted-foreground/60 max-w-[160px]">
Add knowledge points to tag content in this chapter.
</p>
</div>
</div> </div>
) )
) : ( ) : (
<div className="text-sm text-muted-foreground text-center py-8"> <div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground space-y-2">
Select a chapter to manage its knowledge points. <p className="text-sm">Select a chapter to manage knowledge points</p>
</div> </div>
)} )}
</div>
</ScrollArea> </ScrollArea>
<AlertDialog <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
open={showDeleteDialog}
onOpenChange={(open) => {
if (isDeleting) return
setShowDeleteDialog(open)
if (!open) setDeleteTarget(null)
}}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete knowledge point?</AlertDialogTitle> <AlertDialogTitle>Delete Knowledge Point?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{deleteTarget ? ( This action cannot be undone. This will permanently delete the knowledge point
<> <span className="font-medium text-foreground"> {deleteTarget?.name}</span>.
This will permanently delete <span className="font-medium text-foreground">{deleteTarget.name}</span>.
</>
) : (
"This will permanently delete the selected knowledge point."
)}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"} {isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@@ -1,77 +1,113 @@
import Link from "next/link"; import Link from "next/link";
import { GraduationCap, Building2, BookOpen } from "lucide-react"; import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle,
} from "@/shared/components/ui/card"; } from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { cn } from "@/shared/lib/utils"; import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { cn, formatDate } from "@/shared/lib/utils";
import { Textbook } from "../types"; import { Textbook } from "../types";
interface TextbookCardProps { interface TextbookCardProps {
textbook: Textbook; textbook: Textbook;
hrefBase?: string; hrefBase?: string;
hideActions?: boolean;
} }
export function TextbookCard({ textbook, hrefBase }: TextbookCardProps) { const subjectColorMap: Record<string, string> = {
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
};
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
const base = hrefBase || "/teacher/textbooks"; const base = hrefBase || "/teacher/textbooks";
const colorClass = subjectColorMap[textbook.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800";
return ( return (
<Link href={`${base}/${textbook.id}`} className="block h-full"> <Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
<Card <Link href={`${base}/${textbook.id}`} className="flex-1">
className={cn( <div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
"group h-full overflow-hidden transition-all duration-300 ease-out", <div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
"hover:-translate-y-1 hover:shadow-md hover:border-primary/50" <div className="relative z-10 flex h-full flex-col justify-between">
)} <Badge variant="secondary" className="w-fit bg-background/50 backdrop-blur-sm border-transparent shadow-none">
> {textbook.subject}
<div className="relative aspect-[4/3] w-full overflow-hidden bg-muted/30 p-6 flex items-center justify-center"> </Badge>
{/* Fallback Cover Visualization */} <Book className="h-8 w-8 opacity-50" />
<div className="relative z-10 flex h-24 w-20 flex-col items-center justify-center rounded-sm bg-background shadow-sm border transition-transform duration-300 group-hover:scale-110">
<div className="h-full w-full bg-gradient-to-br from-primary/10 to-primary/5 p-2">
<div className="h-1 w-full rounded-full bg-primary/20 mb-1" />
<div className="h-1 w-2/3 rounded-full bg-primary/20" />
</div>
</div> </div>
{/* Decorative Background Pattern */}
<div className="absolute inset-0 bg-grid-black/[0.02] dark:bg-grid-white/[0.02]" />
</div> </div>
<CardHeader className="p-4 pb-2"> <CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="space-y-1"> <h3 className="font-semibold text-lg leading-tight line-clamp-2 group-hover:text-primary transition-colors">
<Badge variant="outline" className="w-fit text-[10px] h-5 px-1.5 font-normal border-primary/20 text-primary bg-primary/5"> {textbook.title}
{textbook.subject} </h3>
</Badge>
<CardTitle className="line-clamp-2 text-base leading-tight">
{textbook.title}
</CardTitle>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4 pt-0 text-sm text-muted-foreground"> <CardContent className="p-4 pt-1 pb-2">
<div className="flex flex-col gap-1.5"> <div className="flex flex-wrap gap-y-1 gap-x-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-1.5">
<GraduationCap className="h-3.5 w-3.5 text-muted-foreground/70" /> <GraduationCap className="h-3.5 w-3.5" />
<span>{textbook.grade}</span> <span>{textbook.grade || "Grade N/A"}</span>
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 text-muted-foreground/70" /> <Building2 className="h-3.5 w-3.5" />
<span className="line-clamp-1">{textbook.publisher || "Unknown Publisher"}</span> <span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
{textbook.publisher || "Publisher N/A"}
</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Link>
<CardFooter className="p-4 pt-0 mt-auto"> <CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground/80 bg-muted/30 px-2 py-1 rounded-md w-full"> <div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<BookOpen className="h-3.5 w-3.5" /> <BookOpen className="h-3.5 w-3.5" />
<span>{textbook._count?.chapters || 0} Chapters</span> <span>{textbook._count?.chapters || 0} Chapters</span>
</div> </div>
</CardFooter>
</Card> <div className="flex items-center gap-1">
</Link> <span className="text-[10px] text-muted-foreground/60 mr-2">
Updated {formatDate(textbook.updatedAt)}
</span>
{!hideActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 -mr-2">
<MoreVertical className="h-3.5 w-3.5" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`${base}/${textbook.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit Content
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</CardFooter>
</Card>
); );
} }

View File

@@ -9,11 +9,11 @@ import { ChapterSidebarList } from "./chapter-sidebar-list"
import { KnowledgePointPanel } from "./knowledge-point-panel" import { KnowledgePointPanel } from "./knowledge-point-panel"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Edit2, Save } from "lucide-react" import { Edit2, Save, Folder } from "lucide-react"
import { CreateChapterDialog } from "./create-chapter-dialog" import { CreateChapterDialog } from "./create-chapter-dialog"
import { updateChapterContentAction } from "../actions" import { updateChapterContentAction } from "../actions"
import { toast } from "sonner" import { toast } from "sonner"
import { Textarea } from "@/shared/components/ui/textarea" import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
interface TextbookContentLayoutProps { interface TextbookContentLayoutProps {
chapters: Chapter[] chapters: Chapter[]
@@ -50,29 +50,31 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
} }
return ( return (
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-140px)]"> <div className="grid grid-cols-12 h-[calc(100vh-8rem)]">
{/* Left Sidebar: TOC (3 cols) */} {/* Left Sidebar: TOC (3 cols) */}
<div className="col-span-3 border-r pr-6 flex flex-col h-full"> <div className="col-span-3 border-r flex flex-col h-full bg-muted/10">
<div className="flex items-center justify-between mb-4 px-2"> <div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold">Chapters</h3> <h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground">Contents</h3>
<CreateChapterDialog textbookId={textbookId} /> <CreateChapterDialog textbookId={textbookId} />
</div> </div>
<ScrollArea className="flex-1 px-2"> <ScrollArea className="flex-1">
<ChapterSidebarList <div className="p-3">
chapters={chapters} <ChapterSidebarList
selectedChapterId={selectedChapter?.id} chapters={chapters}
onSelectChapter={handleSelectChapter} selectedChapterId={selectedChapter?.id}
textbookId={textbookId} onSelectChapter={handleSelectChapter}
/> textbookId={textbookId}
/>
</div>
</ScrollArea> </ScrollArea>
</div> </div>
{/* Middle: Content Viewer/Editor (6 cols) */} {/* Middle: Content Viewer/Editor (6 cols) */}
<div className="col-span-6 flex flex-col h-full px-2 min-h-0"> <div className="col-span-6 flex flex-col h-full min-h-0 bg-background">
{selectedChapter ? ( {selectedChapter ? (
<> <>
<div className="flex items-center justify-between mb-4 pb-2 border-b"> <div className="flex items-center justify-between px-8 py-4 border-b sticky top-0 bg-background/95 backdrop-blur z-10">
<h2 className="text-xl font-bold tracking-tight">{selectedChapter.title}</h2> <h2 className="text-2xl font-bold tracking-tight">{selectedChapter.title}</h2>
<div className="flex gap-2"> <div className="flex gap-2">
{isEditing ? ( {isEditing ? (
<> <>
@@ -93,24 +95,29 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
</div> </div>
</div> </div>
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1">
<div className="p-4 min-h-full"> <div className="max-w-3xl mx-auto px-8 py-8 min-h-full">
{isEditing ? ( {isEditing ? (
<Textarea <RichTextEditor
className="min-h-[500px] font-mono text-sm"
value={editContent} value={editContent}
onChange={(e) => setEditContent(e.target.value)} onChange={setEditContent}
placeholder="# Write markdown content here..." className="min-h-[500px] border-none shadow-none"
/> />
) : ( ) : (
<div className="prose prose-sm dark:prose-invert max-w-none"> <div className="prose prose-zinc dark:prose-invert max-w-none prose-headings:font-bold prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-p:leading-relaxed">
{selectedChapter.content ? ( {selectedChapter.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}> <ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{selectedChapter.content} {selectedChapter.content}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<div className="text-muted-foreground italic py-8 text-center"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground space-y-4">
No content available. Click edit to add content. <div className="p-4 rounded-full bg-muted">
<Edit2 className="h-8 w-8 opacity-50" />
</div>
<p className="italic">No content available yet.</p>
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
Start Writing
</Button>
</div> </div>
)} )}
</div> </div>
@@ -119,14 +126,17 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
</ScrollArea> </ScrollArea>
</> </>
) : ( ) : (
<div className="h-full flex items-center justify-center text-muted-foreground"> <div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
Select a chapter from the left sidebar to view its content. <div className="p-6 rounded-full bg-muted/30">
<Folder className="h-12 w-12 opacity-20" />
</div>
<p>Select a chapter to view or edit content</p>
</div> </div>
)} )}
</div> </div>
{/* Right Sidebar: Knowledge Points (3 cols) */} {/* Right Sidebar: Knowledge Points (3 cols) */}
<div className="col-span-3 border-l pl-6 flex flex-col h-full"> <div className="col-span-3 border-l flex flex-col h-full bg-muted/10">
<KnowledgePointPanel <KnowledgePointPanel
knowledgePoints={knowledgePoints} knowledgePoints={knowledgePoints}
selectedChapterId={selectedChapter?.id || null} selectedChapterId={selectedChapter?.id || null}

View File

@@ -21,21 +21,20 @@ export function TextbookFilters() {
const hasFilters = Boolean(search || subject !== "all" || grade !== "all") const hasFilters = Boolean(search || subject !== "all" || grade !== "all")
return ( return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between bg-card p-4 rounded-lg border shadow-sm"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="relative w-full md:w-96"> <div className="relative w-full md:w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
<Input <Input
placeholder="Search textbooks..." placeholder="Search by title, publisher..."
className="pl-9 bg-background" className="pl-9 bg-background border-muted-foreground/20"
value={search} value={search}
onChange={(e) => setSearch(e.target.value || null)} onChange={(e) => setSearch(e.target.value || null)}
/> />
</div> </div>
<div className="flex gap-2 w-full md:w-auto"> <div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}> <Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background"> <SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="Subject" /> <SelectValue placeholder="Subject" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -43,17 +42,21 @@ export function TextbookFilters() {
<SelectItem value="Mathematics">Mathematics</SelectItem> <SelectItem value="Mathematics">Mathematics</SelectItem>
<SelectItem value="Physics">Physics</SelectItem> <SelectItem value="Physics">Physics</SelectItem>
<SelectItem value="Chemistry">Chemistry</SelectItem> <SelectItem value="Chemistry">Chemistry</SelectItem>
<SelectItem value="Biology">Biology</SelectItem>
<SelectItem value="English">English</SelectItem> <SelectItem value="English">English</SelectItem>
<SelectItem value="History">History</SelectItem> <SelectItem value="History">History</SelectItem>
<SelectItem value="Geography">Geography</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}> <Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background"> <SelectTrigger className="w-[130px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Grade" /> <SelectValue placeholder="Grade" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Grades</SelectItem> <SelectItem value="all">All Grades</SelectItem>
<SelectItem value="Grade 7">Grade 7</SelectItem>
<SelectItem value="Grade 8">Grade 8</SelectItem>
<SelectItem value="Grade 9">Grade 9</SelectItem> <SelectItem value="Grade 9">Grade 9</SelectItem>
<SelectItem value="Grade 10">Grade 10</SelectItem> <SelectItem value="Grade 10">Grade 10</SelectItem>
<SelectItem value="Grade 11">Grade 11</SelectItem> <SelectItem value="Grade 11">Grade 11</SelectItem>
@@ -69,7 +72,7 @@ export function TextbookFilters() {
setSubject(null) setSubject(null)
setGrade(null) setGrade(null)
}} }}
className="h-10 px-3" className="h-10 px-3 text-muted-foreground hover:text-foreground"
> >
Reset Reset
<X className="ml-2 h-4 w-4" /> <X className="ml-2 h-4 w-4" />

View File

@@ -89,9 +89,11 @@ export function TextbookFormDialog() {
<SelectContent> <SelectContent>
<SelectItem value="Mathematics">Mathematics</SelectItem> <SelectItem value="Mathematics">Mathematics</SelectItem>
<SelectItem value="Physics">Physics</SelectItem> <SelectItem value="Physics">Physics</SelectItem>
<SelectItem value="History">History</SelectItem>
<SelectItem value="English">English</SelectItem>
<SelectItem value="Chemistry">Chemistry</SelectItem> <SelectItem value="Chemistry">Chemistry</SelectItem>
<SelectItem value="Biology">Biology</SelectItem>
<SelectItem value="English">English</SelectItem>
<SelectItem value="History">History</SelectItem>
<SelectItem value="Geography">Geography</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -42,7 +42,7 @@ function ReaderChapterItem({
const isSelected = selectedId === chapter.id const isSelected = selectedId === chapter.id
return ( return (
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}> <div className={cn(level > 0 && "ml-2 border-l pl-2")}>
<div <div
className={cn( className={cn(
"flex items-center group py-1 rounded-md transition-colors", "flex items-center group py-1 rounded-md transition-colors",

View File

@@ -1,7 +1,7 @@
import "server-only" import "server-only"
import { cache } from "react" import { cache } from "react"
import { and, asc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm" import { and, asc, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db" import { db } from "@/shared/db"
@@ -394,3 +394,37 @@ export async function createKnowledgePoint(data: CreateKnowledgePointInput): Pro
export async function deleteKnowledgePoint(id: string): Promise<void> { export async function deleteKnowledgePoint(id: string): Promise<void> {
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id)) await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id))
} }
export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise<void> {
const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1)
if (!target) throw new Error("Chapter not found")
const siblings = await db
.select()
.from(chapters)
.where(
and(
eq(chapters.textbookId, target.textbookId),
parentId ? eq(chapters.parentId, parentId) : isNull(chapters.parentId)
)
)
.orderBy(asc(chapters.order))
const currentSiblings = siblings.filter((c) => c.id !== chapterId)
currentSiblings.splice(newIndex, 0, target)
await db.transaction(async (tx) => {
for (let i = 0; i < currentSiblings.length; i++) {
const ch = currentSiblings[i]
if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) {
await tx
.update(chapters)
.set({
order: i,
parentId: ch.id === chapterId ? parentId : ch.parentId
})
.where(eq(chapters.id, ch.id))
}
}
})
}

View 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")
}

View 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,
}
})

View File

@@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-6 border bg-background p-6 shadow-xl shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-xl",
className className
)} )}
{...props} {...props}
@@ -65,7 +65,7 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3",
className className
)} )}
{...props} {...props}
@@ -79,7 +79,7 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
ref={ref} ref={ref}
className={cn("text-lg font-semibold", className)} className={cn("text-xl font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />
)) ))

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client" "use client"
import * as React from "react" import * as React from "react"
@@ -109,6 +110,12 @@ const ChartTooltipContent = React.forwardRef<
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed"
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
payload?: any[]
label?: any
labelFormatter?: any
labelClassName?: string
formatter?: any
color?: string
} }
>( >(
( (
@@ -256,8 +263,9 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<"div"> & {
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { payload?: any[]
verticalAlign?: "top" | "middle" | "bottom"
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
} }

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-6 border bg-background p-6 shadow-xl shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-xl",
className className
)} )}
{...props} {...props}
@@ -59,7 +59,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-2 text-center sm:text-left",
className className
)} )}
{...props} {...props}
@@ -73,7 +73,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3",
className className
)} )}
{...props} {...props}
@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-xl font-semibold leading-none tracking-tight",
className className
)} )}
{...props} {...props}

View 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 }

View 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