Compare commits

3 Commits
main ... ui_opt

Author SHA1 Message Date
SpecialX
bb4555f611 feat: enhance textbook reader with anchor text support and improve knowledge point management 2026-01-16 10:22:16 +08:00
SpecialX
9bfc621d3f feat(classes): optimize teacher dashboard ui and implement grade management 2026-01-14 13:59:11 +08:00
SpecialX
ade8d4346c feat(dashboard): optimize teacher dashboard ui and layout
- Refactor layout: move Needs Grading to main column, Homework to sidebar
- Enhance TeacherStats: replace static counts with actionable metrics (Needs Grading, Active Assignments, Avg Score, Submission Rate)
- Update RecentSubmissions: table view with quick grade actions and late status
- Update TeacherSchedule: vertical timeline view with scroll hints
- Update TeacherHomeworkCard: compact list view
- Integrate Recharts: add TeacherGradeTrends chart and shared chart component
- Update documentation
2026-01-12 11:38:27 +08:00
134 changed files with 19729 additions and 3902 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 引用边界(强制)
@@ -419,6 +422,7 @@ export type ActionState<T = void> = {
- 禁止在 CSS 中 `@import` 外部字体 URL避免 CLS 与阻塞渲染) - 禁止在 CSS 中 `@import` 外部字体 URL避免 CLS 与阻塞渲染)
- 依赖: - 依赖:
- 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益 - 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益
- **图表**:标准图表库统一使用 `recharts`(通过 `src/shared/components/ui/chart.tsx` 封装),禁止引入其他图表库(如 Chart.js / Highcharts
- 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback - 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback
--- ---

View File

@@ -165,7 +165,7 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
- 运行命令:`npm run db:seed` - 运行命令:`npm run db:seed`
### 6.5 开发过程中的问题与处理 ### 6.5 开发过程中的问题与处理
- 端口占用EADDRINUSE开发服务器端口被占用时通过更换端口启动规避例如 `next dev -p <port>`)。 - 端口占用EADDRINUSE开发服务器端口被占用时通过更换端口启动规避例如 `next dev -p <port>`)。
- Next dev 锁文件:出现 `.next/dev/lock` 无法获取锁时,需要确保只有一个 dev 实例在运行,并清理残留 lock。 - Next dev 锁文件:出现 `.next/dev/lock` 无法获取锁时,需要确保只有一个 dev 实例在运行,并清理残留 lock。
- 头像资源 404移除 Header 中硬编码的本地头像资源引用,避免 `public/avatars/...` 不存在导致的 404 噪音(见 `src/modules/layout/components/site-header.tsx`)。 - 头像资源 404移除 Header 中硬编码的本地头像资源引用,避免 `public/avatars/...` 不存在导致的 404 噪音(见 `src/modules/layout/components/site-header.tsx`)。
@@ -199,33 +199,44 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
**日期**: 2026-01-08 **日期**: 2026-01-08
**范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码 **范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码
#### 6.7.1 数据结构 ---
- 表:`classes`
- 字段:`invitation_code`varchar(6)unique可为空
- 迁移:`drizzle/0007_add_class_invitation_code.sql`
#### 6.7.2 教师端能力 ## 7. 班级管理重构与角色分离 (2026-01-14)
- 在「我的班级」卡片中展示邀请码。
- 提供“刷新邀请码”操作:生成新的 6 位码并写入数据库(确保唯一性)。
#### 6.7.3 学生端能力 **日期**: 2026-01-14
- 提供“通过邀请码加入班级”的入口,输入 6 位码后完成报名。 **范围**: 班级创建权限收归管理端,教师端仅保留查看与加入
- 写库操作设计为幂等:重复提交同一个邀请码不会生成重复报名记录,已有记录会被更新为有效状态。
#### 6.7.4 Seed 支持 ### 7.1 职责分离 (Role Separation)
- `scripts/seed.ts` 为示例班级补充 `invitationCode`,便于在开发环境直接验证加入流程。
### 6.8 更新记录2026-01-09 - **管理端 (Management)**:
- 新增 `src/app/(dashboard)/management/grade/classes/page.tsx`
- 供年级组长 (Grade Head) 与管理员创建、编辑、删除班级
- 引入 `GradeClassesView` 组件,支持按年级管理班级
- **教师端 (Teacher)**:
- 移除创建班级入口
- 新增「通过邀请码加入班级」功能 (`JoinClassDialog`)
- `MyClassesGrid` 样式优化,移除硬编码渐变,使用标准 `bg-card`
#### 6.8.1 班级创建权限收紧 ### 7.2 数据访问与权限
- 目标:仅允许年级组长与 admin 创建班级。
- 后端:`createTeacherClassAction` 增加权限校验,非 admin 必须是对应年级的 `gradeHead``createAdminClassAction` 强制仅 admin 可调用(`src/modules/classes/actions.ts`)。
- 前端教师端「My Classes」页基于当前用户是否为任一年级 `gradeHead` 计算 `canCreateClass`,并禁用创建入口(`src/app/(dashboard)/teacher/classes/my/page.tsx``src/modules/classes/components/my-classes-grid.tsx`)。
#### 6.8.2 注册页面从演示提交改为真实注册 - 新增 `getGradeManagedClasses`: 仅返回用户作为 Grade Head 或 Teaching Head 管理的年级下的班级
- `/register` 增加服务端注册动作:校验输入、邮箱查重、插入 `users` 表,默认 `role=student``src/app/(auth)/register/page.tsx`)。 - Server Actions (`createGradeClassAction` 等) 增加严格的 RBAC 校验,确保操作者对目标年级有管理权限
- 注册表单接入注册动作并展示成功/失败提示,成功后跳转至 `/login``src/modules/auth/components/register-form.tsx`)。
#### 6.8.3 生产环境登录 UntrustedHost 修复 ## 8. 课表模块视觉升级与架构优化 (2026-01-15)
- 问题:服务器上访问 `/api/auth/session``[auth][error] UntrustedHost`
- 修复Auth.js 配置开启 `trustHost: true` 并显式设置 `secret``src/auth.ts`)。 **日期**: 2026-01-15
**范围**: 课表视图 (Schedule View) 视觉重构、Insights 模块移除
### 8.1 课表视图重构 (Schedule Optimization)
- **视觉对齐**: 重构 `ScheduleView` (`src/modules/classes/components/schedule-view.tsx`) 以完全匹配 `ClassScheduleGrid` 组件的视觉风格。
- **无边框设计**: 移除网格线与外边框,采用更现代的洁净布局。
- **时间轴定位**: 废弃 Grid 布局,改用基于时间的绝对定位 (`top`, `height` 百分比计算),支持 8:00 - 18:00 时间段。
- **语义化配色**: 新增 `getSubjectColor` 工具函数,根据课程名称 (Math, Physics, etc.) 自动映射语义化背景色与边框色。
- **过滤器优化**: `ScheduleFilters` 移除边框与阴影,居中显示当前选中的班级名称 (`{Class Name} Schedule`),移除冗余的 Reset 按钮。
### 8.2 架构精简 (Insights Removal)
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。

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**: 头像 + 下拉菜单。

107
docs/work_log.md Normal file
View File

@@ -0,0 +1,107 @@
# Work Log
## 2026-01-15
### 1. Schedule Module Optimization
* **Visual Overhaul (`schedule-view.tsx`)**:
* Refactored the schedule grid to match the exact design of the `ClassScheduleGrid` widget.
* Implemented a clean, borderless layout with no grid lines for a modern look.
* **Time-Based Positioning**: Replaced grid-row logic with absolute positioning based on time (8:00 - 18:00 range) using percentage calculations (`getPositionStyle`).
* **Color Coding**: Added `getSubjectColor` to auto-assign thematic colors (blue for Math, purple for Physics, etc.) based on course names.
* **Card Design**: Refined course cards with vertical centering, better spacing, and removed unnecessary UI elements (like the "+" button in headers).
* **Filter Bar Refinement (`schedule-filters.tsx`)**:
* **Minimalist Design**: Removed borders and shadows from the class selector and buttons to integrate seamlessly with the background.
* **Center Label**: Added a dynamic, absolute-centered text label that updates based on selection:
* Shows "All Classes" when no filter is active.
* Shows "{Class Name}" when a specific class is selected.
* **Simplified Controls**: Removed the "Reset" button (X icon) entirely for a cleaner interface.
* **Ghost Buttons**: Styled the "Add Event" button as a ghost variant with muted colors.
### 2. Architecture & Cleanup
* **Insights Module Removal**:
* Deleted the entire `src/app/(dashboard)/teacher/classes/insights` directory as the feature was deemed redundant.
* Removed `insights-filters.tsx` component.
* Updated `navigation.ts` to remove the "Insights" link from the sidebar.
* *Note*: Preserved `getClassHomeworkInsights` in `data-access.ts` as it's still used by the Class Detail dashboard widgets.
### 3. Verification
* **Type Safety**: Ran `npm run typecheck` multiple times during refactoring to ensure no regressions (Passed).
* **Build**: Attempted to clear build cache to resolve a chunk loading error (Windows permission issue encountered but workaround applied).
## 2026-01-14
### 1. Class Management Refactoring (Role Separation)
* **Separation of Duties**:
* Moved class creation and management responsibilities from the generic Teacher view to a dedicated Management view.
* Created **Grade Management Page** at `src/app/(dashboard)/management/grade/classes/page.tsx` for Grade Directors and Admins.
* Teachers can now only **Join Classes** (via code) or view their assigned classes in "My Classes".
* **New Components & Pages**:
* `GradeClassesView` (`src/modules/classes/components/grade-classes-view.tsx`): A comprehensive table view for managing classes within specific grades, supporting creation, editing, and deletion.
* `GradeClassesPage`: Server Component that fetches managed grades and classes using strict RBAC (Role-Based Access Control).
* **Teacher "My Classes" Update (`my-classes-grid.tsx`)**:
* Removed the "Create Class" button/dialog.
* Added a **"Join Class"** dialog that accepts a 6-digit invitation code.
* Updated styling to use standard design system colors (`bg-card`, `border-border`) instead of hardcoded gradients.
### 2. Backend & Logic Updates
* **Data Access (`data-access.ts`)**:
* Implemented `getGradeManagedClasses`: Fetches classes for grades where the user is either a Grade Head or Teaching Head.
* Implemented `getManagedGrades`: Fetches the list of grades managed by the user for the creation dropdown.
* Updated `getTeacherClasses`: Now returns both **owned classes** (assigned by admin) and **enrolled classes** (joined via code).
* Fixed a SQL syntax error in `getGradeManagedClasses` (unescaped backticks in template literal).
* **Server Actions (`actions.ts`)**:
* Added `createGradeClassAction`, `updateGradeClassAction`, `deleteGradeClassAction`: These actions enforce that the user manages the target grade before performing operations.
* Updated `joinClassByInvitationCodeAction`: Expanded to allow Teachers (role `teacher`) to join classes, not just Students.
### 3. Verification
* **RBAC**: Verified that users can only manage classes for grades they are assigned to.
* **Flow**: Verified Teacher "Join Class" flow correctly redirects and updates the list.
* **Syntax**: Fixed TypeScript/SQL syntax errors in the new data access functions.
### 4. Class UI/UX Optimization
* **Students Management Interface (`students-table.tsx`, `students-filters.tsx`)**:
* **Enhanced Table**: Added student avatars, gender display, and join date.
* **Pagination**: Implemented client-side pagination (10 items per page) to handle larger classes gracefully.
* **Status Filtering**: Added "Active/Inactive" filter with visual status badges (Emerald for active, muted for inactive).
* **Data Access**: Updated `getClassStudents` to fetch extended user profile data and support server-side status filtering.
* **Class Detail Dashboard (`/teacher/classes/my/[id]/page.tsx`)**:
* **Dashboard Layout**: Refactored into a responsive two-column layout (Main Content + Sidebar).
* **Key Metrics**: Added a 4-card stats grid at the top displaying critical insights:
* Total Students (Active/Inactive breakdown)
* Schedule Items (Weekly sessions)
* Active Assignments (Overdue count)
* Class Average (Based on graded submissions)
* **Sidebar Widgets**: Added "Class Schedule" and "Homework History" widgets for quick access to temporal data.
* **Visual Polish**: Integrated `lucide-react` icons throughout for better information scanning.
## 2026-01-13
### 1. Navigation & Layout Improvements
* **Dynamic Breadcrumbs (`site-header.tsx`)**:
* Replaced hardcoded "Dashboard > Overview" breadcrumbs with a dynamic system.
* Implemented a path-to-title lookup using `NAV_CONFIG` from `src/modules/layout/config/navigation.ts`.
* Added logic to filter out root role segments (admin/teacher/student/parent) for cleaner paths.
* Added fallback capitalization for segments not found in the config.
* Refactored `SiteHeader` to use `usePathname` for real-time route updates.
### 2. Code Quality & Bug Fixes
* **Type Safety (`homework-grading-view.tsx`)**:
* Fixed a TypeScript error where a boolean expression was returning `boolean | undefined` which is not a valid React node (implicit `true` check added).
* Resolved "Calling setState synchronously within an effect" React warning by initializing state lazily instead of using `useEffect`.
* Fixed implicit `any` type errors in map functions.
* **Linting**:
* Cleaned up unused imports across multiple files (`exam-actions.tsx`, `exam-assembly.tsx`, `textbook-reader.tsx`, etc.).
* Fixed unescaped HTML entities in `student-dashboard-header.tsx` and others.
* Removed unused variables to clear ESLint warnings.
* **Refactoring**:
* Updated `TextbookCard` to support `hideActions` prop for better reuse in student views.
* Added missing `Progress` component to `src/shared/components/ui/progress.tsx`.
### 3. Verification
* Ran `npm run typecheck`: **Passed** (0 errors).
* Ran `npm run lint`: **Passed** (0 errors, 28 warnings remaining for unused vars/components that may be needed later).

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

View File

@@ -0,0 +1 @@
ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);

File diff suppressed because it is too large Load Diff

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,16 @@
{ {
"idx": 7, "idx": 7,
"version": "5", "version": "5",
"when": 1767782500000, "when": 1768205524480,
"tag": "0007_add_class_invitation_code", "tag": "0007_talented_bromley",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "5", "version": "5",
"when": 1767941300000, "when": 1768470966367,
"tag": "0008_add_user_profile_fields", "tag": "0008_thin_madrox",
"breakpoints": true "breakpoints": true
} }
] ]
} }

1480
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,8 +22,12 @@
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
@@ -33,6 +37,10 @@
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
@@ -46,18 +54,20 @@
"react-dom": "19.2.1", "react-dom": "19.2.1",
"react-hook-form": "^7.69.0", "react-hook-form": "^7.69.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "recharts": "^3.6.0",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiptap-markdown": "^0.9.0",
"zod": "^4.2.1", "zod": "^4.2.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

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

@@ -1,12 +1,11 @@
import Link from "next/link" import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { ArrowLeft, BookOpen, Inbox } from "lucide-react" import { BookOpen, Inbox } from "lucide-react"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader" import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { getDemoStudentUser } from "@/modules/homework/data-access" import { getDemoStudentUser } from "@/modules/homework/data-access"
@@ -34,42 +33,42 @@ export default async function StudentTextbookDetailPage({
const { id } = await params const { id } = await params
const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)]) const [textbook, chapters, knowledgePoints] = await Promise.all([
getTextbookById(id),
getChaptersByTextbookId(id),
getKnowledgePointsByTextbookId(id)
])
if (!textbook) notFound() if (!textbook) notFound()
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden"> <div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10"> <div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
<Button variant="ghost" size="icon" asChild> <div className="flex items-center gap-3 min-w-0">
<Link href="/student/learning/textbooks"> <h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
<ArrowLeft className="h-4 w-4" /> <div className="flex items-center gap-2 text-sm text-muted-foreground">
</Link> <span className="hidden sm:inline-block w-px h-4 bg-border" />
</Button> <Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
<div className="flex-1 min-w-0"> {textbook.grade && (
<div className="flex items-center gap-2 mb-1"> <Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
<Badge variant="outline">{textbook.subject}</Badge> )}
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
{textbook.grade ?? "-"}
</span>
</div> </div>
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden pt-6"> <div className="flex-1 overflow-hidden p-6">
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<div className="px-8"> <div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
<EmptyState <EmptyState
icon={BookOpen} icon={BookOpen}
title="No chapters" title="No chapters"
description="This textbook has no chapters yet." description="This textbook has no chapters yet."
className="bg-card" className="border-none shadow-none"
/> />
</div> </div>
) : ( ) : (
<div className="h-[calc(100vh-140px)] px-8 min-h-0"> <div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
<TextbookReader chapters={chapters} /> <TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
</div> </div>
)} )}
</div> </div>

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,259 +0,0 @@
import Link from "next/link"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { getClassHomeworkInsights, getTeacherClasses } from "@/modules/classes/data-access"
import { InsightsFilters } from "@/modules/classes/components/insights-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
function InsightsResultsFallback() {
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card">
<div className="p-6">
<Skeleton className="h-5 w-28" />
<Skeleton className="mt-3 h-8 w-20" />
</div>
</div>
))}
</div>
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}
async function InsightsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
if (!classId || classId === "all") {
return (
<EmptyState
icon={BarChart3}
title="Select a class to view insights"
description="Pick a class to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
)
}
const insights = await getClassHomeworkInsights({ classId, limit: 50 })
if (!insights) {
return (
<EmptyState
icon={BarChart3}
title="Class not found"
description="This class may not exist or is not accessible."
className="h-[360px] bg-card"
/>
)
}
const hasAssignments = insights.assignments.length > 0
if (!hasAssignments) {
return (
<EmptyState
icon={BarChart3}
title="No homework data for this class"
description="No homework assignments were targeted to students in this class yet."
className="h-[360px] bg-card"
/>
)
}
const latest = insights.latest
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div>
<div className="text-xs text-muted-foreground">Latest: {latest ? formatDate(latest.createdAt) : "-"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall scores</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
</div>
</CardContent>
</Card>
</div>
{latest && (
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-base">Latest assignment</CardTitle>
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{latest.title}</span>
<Badge variant="outline" className="capitalize">
{latest.status}
</Badge>
<span>·</span>
<span>{formatDate(latest.createdAt)}</span>
{latest.dueAt ? (
<>
<span>·</span>
<span>Due {formatDate(latest.dueAt)}</span>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-5">
<div>
<div className="text-sm text-muted-foreground">Targeted</div>
<div className="text-lg font-semibold">{latest.targetCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold">{latest.submittedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Median</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
</div>
</CardContent>
</Card>
)}
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
<TableHead className="text-right">Min</TableHead>
<TableHead className="text-right">Max</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.min, 0)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.max, 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Class Insights</h2>
<p className="text-muted-foreground">Latest homework and historical score statistics for a class.</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<InsightsFilters classes={classes} />
</Suspense>
<Suspense fallback={<InsightsResultsFallback />}>
<InsightsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -1,28 +1,18 @@
import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access" import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
import { ScheduleView } from "@/modules/classes/components/schedule-view" import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
import { Badge } from "@/shared/components/ui/badge" import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { Button } from "@/shared/components/ui/button" import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
import { formatDate } from "@/shared/lib/utils" import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined } type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
export default async function ClassDetailPage({ export default async function ClassDetailPage({
params, params,
searchParams, searchParams,
@@ -31,285 +21,97 @@ export default async function ClassDetailPage({
searchParams: Promise<SearchParams> searchParams: Promise<SearchParams>
}) { }) {
const { id } = await params const { id } = await params
const sp = await searchParams
const hw = getParam(sp, "hw")
const hwFilter = hw === "active" || hw === "overdue" ? hw : "all"
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([ const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 50 }), getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
getClassStudents({ classId: id }), getClassStudents({ classId: id }),
getClassSchedule({ classId: id }), getClassSchedule({ classId: id }),
]) ])
if (!insights) return notFound() if (!insights) return notFound()
const latest = insights.latest // Fetch subject scores
const filteredAssignments = insights.assignments.filter((a) => { const studentScores = await getClassStudentSubjectScoresV2(id)
if (hwFilter === "all") return true
if (hwFilter === "overdue") return a.isOverdue // Data mapping for widgets
if (hwFilter === "active") return a.isActive const assignmentSummaries = insights.assignments.map(a => ({
return true id: a.assignmentId,
}) title: a.title,
const hasAssignments = filteredAssignments.length > 0 status: a.status,
const scheduleBuilderClasses = [ subject: a.subject,
{ isActive: a.isActive,
id: insights.class.id, isOverdue: a.isOverdue,
name: insights.class.name, dueAt: a.dueAt ? new Date(a.dueAt) : null,
grade: insights.class.grade, submittedCount: a.submittedCount,
homeroom: insights.class.homeroom ?? null, targetCount: a.targetCount,
room: insights.class.room ?? null, avgScore: a.scoreStats.avg,
studentCount: insights.studentCounts.total, medianScore: a.scoreStats.median
}, }))
]
const studentSummaries = students.map(s => ({
id: s.id,
name: s.name,
email: s.email,
image: s.image,
status: s.status,
subjectScores: studentScores.get(s.id) ?? {}
}))
// Calculate advanced stats
const activeAssignments = insights.assignments.filter(a => a.isActive)
const papersToGrade = activeAssignments.reduce((acc, a) => acc + (a.submittedCount - a.gradedCount), 0)
const overdueCount = activeAssignments.filter(a => a.isOverdue).length
const totalSubmissionRate = activeAssignments.length > 0
? activeAssignments.reduce((acc, a) => acc + (a.targetCount > 0 ? a.submittedCount / a.targetCount : 0), 0) / activeAssignments.length
: 0
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex min-h-screen flex-col bg-muted/10">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <ClassHeader
<div className="space-y-2"> classId={insights.class.id}
<div className="flex items-center gap-2"> name={insights.class.name}
<Button asChild variant="outline" size="sm"> grade={insights.class.grade}
<Link href="/teacher/classes/my">Back</Link> homeroom={insights.class.homeroom}
</Button> room={insights.class.room}
<Badge variant="secondary">{insights.class.grade}</Badge> schoolName={insights.class.schoolName}
<Badge variant="outline">{insights.studentCounts.total} students</Badge> studentCount={insights.studentCounts.total}
/>
<div className="flex-1 space-y-6 p-6">
{/* Key Metrics */}
<ClassOverviewStats
averageScore={insights.overallScores.avg}
submissionRate={totalSubmissionRate * 100}
papersToGrade={papersToGrade}
overdueCount={overdueCount}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (Left 2/3) */}
<div className="space-y-6 lg:col-span-2">
<ClassTrendsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}
/>
</div> </div>
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2>
<div className="text-sm text-muted-foreground"> {/* Sidebar Area (Right 1/3) */}
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"} <div className="space-y-6">
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null} {/* <ClassQuickActions classId={insights.class.id} /> */}
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
<ClassAssignmentsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>Students</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>Schedule</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(insights.class.id)}`}>Insights</Link>
</Button>
</div>
</div> </div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Schedule items</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{schedule.length}</div>
<div className="text-xs text-muted-foreground">Weekly timetable entries</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div>
<div className="text-xs text-muted-foreground">{latest ? `Latest ${formatDate(latest.createdAt)}` : "No homework yet"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
</div>
</CardContent>
</Card>
</div>
{latest ? (
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-base">Latest homework</CardTitle>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{latest.title}</span>
<Badge variant="outline" className="capitalize">
{latest.status}
</Badge>
<span>·</span>
<span>{formatDate(latest.createdAt)}</span>
{latest.dueAt ? (
<>
<span>·</span>
<span>Due {formatDate(latest.dueAt)}</span>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-5">
<div>
<div className="text-sm text-muted-foreground">Targeted</div>
<div className="text-lg font-semibold">{latest.targetCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold">{latest.submittedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Median</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
</div>
</CardContent>
</Card>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Students (preview)</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-sm text-muted-foreground">No students enrolled.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.slice(0, 8).map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Schedule</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-base">Homework history</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button asChild size="sm" variant={hwFilter === "all" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "active" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "overdue" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>Open list</Link>
</Button>
<Button asChild size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>New homework</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{!hasAssignments ? (
<div className="text-sm text-muted-foreground">No homework assignments yet.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@@ -13,29 +13,10 @@ export default function MyClassesPage() {
async function MyClassesPageImpl() { async function MyClassesPageImpl() {
const classes = await getTeacherClasses() const classes = await getTeacherClasses()
const session = await auth()
const role = String(session?.user?.role ?? "")
const userId = String(session?.user?.id ?? "").trim()
const canCreateClass = await (async () => {
if (role === "admin") return true
if (!userId) return false
const [row] = await db.select({ id: grades.id }).from(grades).where(eq(grades.gradeHeadId, userId)).limit(1)
return Boolean(row)
})()
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-4 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0"> <MyClassesGrid classes={classes} canCreateClass={false} />
<div>
<h2 className="text-2xl font-bold tracking-tight">My Classes</h2>
<p className="text-muted-foreground">
Overview of your classes.
</p>
</div>
</div>
<MyClassesGrid classes={classes} canCreateClass={canCreateClass} />
</div> </div>
) )
} }

View File

@@ -67,16 +67,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0"> <div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">
View class schedule.
</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}> <Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ScheduleFilters classes={classes} /> <ScheduleFilters classes={classes} />
</Suspense> </Suspense>

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react" import { Suspense } from "react"
import { User } from "lucide-react" import { User } from "lucide-react"
import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access" import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
import { StudentsFilters } from "@/modules/classes/components/students-filters" import { StudentsFilters } from "@/modules/classes/components/students-filters"
import { StudentsTable } from "@/modules/classes/components/students-table" import { StudentsTable } from "@/modules/classes/components/students-table"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -16,18 +16,35 @@ const getParam = (params: SearchParams, key: string) => {
return Array.isArray(v) ? v[0] : v return Array.isArray(v) ? v[0] : v
} }
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) { async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }) {
const params = await searchParams const params = await searchParams
const q = getParam(params, "q") || undefined const q = getParam(params, "q") || undefined
const classId = getParam(params, "classId") const classId = getParam(params, "classId")
const status = getParam(params, "status")
// If classId is explicit in URL, use it (unless "all"). If not, use defaultClassId.
// If user explicitly selects "all", classId will be "all".
// However, the requirement is "Default to showing the first class".
// If classId param is missing, we use defaultClassId.
const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId
const filteredStudents = await getClassStudents({ const filteredStudents = await getClassStudents({
q, q,
classId: classId && classId !== "all" ? classId : undefined, classId: targetClassId,
status: status && status !== "all" ? status : undefined,
}) })
const hasFilters = Boolean(q || (classId && classId !== "all")) // Fetch subject scores for all filtered students
if (filteredStudents.length > 0) {
const studentIds = filteredStudents.map(s => s.id)
const scores = await getStudentsSubjectScores(studentIds)
for (const student of filteredStudents) {
student.subjectScores = scores.get(student.id)
}
}
const hasFilters = Boolean(q || (classId && classId !== "all") || (status && status !== "all"))
if (filteredStudents.length === 0) { if (filteredStudents.length === 0) {
return ( return (
@@ -65,25 +82,20 @@ function StudentsResultsFallback() {
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) { export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses() const classes = await getTeacherClasses()
const params = await searchParams
// Logic to determine default class (first one available)
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-4 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Students</h2>
<p className="text-muted-foreground">
Manage student list.
</p>
</div>
</div>
<div className="space-y-4"> <div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}> <Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<StudentsFilters classes={classes} /> <StudentsFilters classes={classes} defaultClassId={defaultClassId} />
</Suspense> </Suspense>
<Suspense fallback={<StudentsResultsFallback />}> <Suspense fallback={<StudentsResultsFallback />}>
<StudentsResults searchParams={searchParams} /> <StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
</Suspense> </Suspense>
</div> </div>
</div> </div>

View File

@@ -1,17 +1,25 @@
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view" import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"; import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
import { getHomeworkAssignments, getHomeworkSubmissions } from "@/modules/homework/data-access"; import { getHomeworkAssignments, getHomeworkSubmissions, getTeacherGradeTrends } from "@/modules/homework/data-access";
import { db } from "@/shared/db";
import { users } from "@/shared/db/schema";
import { eq } from "drizzle-orm";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function TeacherDashboardPage() { export default async function TeacherDashboardPage() {
const teacherId = await getTeacherIdForMutations(); const teacherId = await getTeacherIdForMutations();
const [classes, schedule, assignments, submissions] = await Promise.all([ const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
getTeacherClasses({ teacherId }), getTeacherClasses({ teacherId }),
getClassSchedule({ teacherId }), getClassSchedule({ teacherId }),
getHomeworkAssignments({ creatorId: teacherId }), getHomeworkAssignments({ creatorId: teacherId }),
getHomeworkSubmissions({ creatorId: teacherId }), getHomeworkSubmissions({ creatorId: teacherId }),
db.query.users.findFirst({
where: eq(users.id, teacherId),
columns: { name: true },
}),
getTeacherGradeTrends(teacherId),
]); ]);
return ( return (
@@ -21,6 +29,8 @@ export default async function TeacherDashboardPage() {
schedule, schedule,
assignments, assignments,
submissions, submissions,
teacherName: teacherProfile?.name ?? "Teacher",
gradeTrends,
}} }}
/> />
) )

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

@@ -4,7 +4,7 @@ import Link from "next/link";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"; import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout"; import { TextbookReader } from "@/modules/textbooks/components/textbook-reader";
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"; import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -51,10 +51,11 @@ export default async function TextbookDetailPage({
{/* Main Content Layout (Flex grow) */} {/* Main Content Layout (Flex grow) */}
<div className="flex-1 overflow-hidden pt-6"> <div className="flex-1 overflow-hidden pt-6">
<TextbookContentLayout <TextbookReader
chapters={chapters} chapters={chapters}
knowledgePoints={knowledgePoints} knowledgePoints={knowledgePoints}
textbookId={id} textbookId={id}
canEdit={true}
/> />
</div> </div>
</div> </div>

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,109 @@
import Link from "next/link"
import { ChevronRight, FileText } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
interface AssignmentSummary {
id: string
title: string
status: string
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
interface ClassAssignmentsWidgetProps {
classId: string
assignments: AssignmentSummary[]
}
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
const activeAssignments = assignments.filter((a) => a.isActive)
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">Recent Homework</CardTitle>
<CardDescription>
{activeAssignments.length} active assignments
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
{assignments.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
<div className="rounded-full bg-muted p-3">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No homework yet</p>
<p className="text-xs text-muted-foreground">
Create an assignment to get started.
</p>
</div>
<Button size="sm" asChild>
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
Create Homework
</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{assignments.slice(0, 5).map((assignment) => (
<div
key={assignment.id}
className="flex items-start justify-between space-x-4 rounded-md border p-3 transition-all hover:bg-muted/50"
>
<div className="space-y-1">
<Link
href={`/teacher/homework/assignments/${assignment.id}`}
className="block font-medium hover:underline line-clamp-1"
>
{assignment.title}
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}
</span>
<span></span>
<span>
{assignment.submittedCount}/{assignment.targetCount} Submitted
</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Badge
variant={assignment.isActive ? "default" : "secondary"}
className="rounded-sm px-1.5 py-0.5 text-[10px] uppercase"
>
{assignment.status}
</Badge>
{typeof assignment.avgScore === "number" && (
<span className="text-xs font-medium tabular-nums">
Avg: {assignment.avgScore.toFixed(0)}%
</span>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { EditClassDialog } from "./edit-class-dialog"
interface ClassHeaderProps {
classId: string
name: string
grade: string
homeroom?: string | null
room?: string | null
schoolName?: string | null
studentCount: number
}
export function ClassHeader({
classId,
name,
grade,
homeroom,
room,
schoolName,
studentCount,
}: ClassHeaderProps) {
const [showEdit, setShowEdit] = useState(false)
return (
<>
<div className="flex flex-col gap-4 border-b bg-background px-6 py-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{schoolName && (
<>
<span>{schoolName}</span>
<span className="text-muted-foreground/40"></span>
</>
)}
<Badge variant="secondary" className="font-medium">
{grade}
</Badge>
{homeroom && (
<>
<span className="text-muted-foreground/40"></span>
<span>Homeroom {homeroom}</span>
</>
)}
{room && (
<>
<span className="text-muted-foreground/40"></span>
<span>Room {room}</span>
</>
)}
<span className="text-muted-foreground/40"></span>
<span>{studentCount} Students</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="hidden sm:flex">
<Share2 className="mr-2 h-4 w-4" />
Invite
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit details
</DropdownMenuItem>
<DropdownMenuItem>
<Share2 className="mr-2 h-4 w-4" />
Invite students
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Settings className="mr-2 h-4 w-4" />
Class settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<EditClassDialog
open={showEdit}
onOpenChange={setShowEdit}
classId={classId}
initialData={{
name,
grade,
homeroom,
room,
schoolName
}}
/>
</>
)
}

View File

@@ -0,0 +1,74 @@
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
interface ClassOverviewStatsProps {
averageScore: number | null
submissionRate: number
papersToGrade: number
overdueCount: number
}
export function ClassOverviewStats({
averageScore,
submissionRate,
papersToGrade,
overdueCount,
}: ClassOverviewStatsProps) {
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatsCard
title="Class Average"
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
subValue="Overall performance"
icon={BarChart3}
/>
<StatsCard
title="Submission Rate"
value={`${submissionRate.toFixed(0)}%`}
subValue="Average turn-in rate"
icon={CheckCircle2}
/>
<StatsCard
title="To Grade"
value={papersToGrade.toString()}
subValue="Pending reviews"
icon={PenTool}
/>
<StatsCard
title="Missed Deadlines"
value={overdueCount.toString()}
subValue="Active assignments past due"
icon={AlertCircle}
/>
</div>
)
}
function StatsCard({
title,
value,
subValue,
icon: Icon,
}: {
title: string
value: string
subValue: string
icon: React.ElementType
}) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{subValue}</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link"
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
interface ClassQuickActionsProps {
classId: string
}
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
<Button asChild className="w-full justify-start" size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
<FilePlus className="mr-2 h-4 w-4" />
Create Homework
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-start" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
<Calendar className="mr-2 h-4 w-4" />
Manage Schedule
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<MessageSquare className="mr-2 h-4 w-4" />
Message Class (Coming soon)
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<Settings className="mr-2 h-4 w-4" />
Class Settings
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,111 @@
import Link from "next/link"
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/shared/components/ui/hover-card"
import type { ClassScheduleItem } from "@/modules/classes/types"
interface ClassScheduleWidgetProps {
classId: string
schedule: ClassScheduleItem[]
}
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
// Group by weekday
const groupedSchedule = schedule.reduce((acc, item) => {
const day = item.weekday
if (!acc[day]) acc[day] = []
acc[day].push(item)
return acc
}, {} as Record<number, ClassScheduleItem[]>)
// Sort items within each day by start time
Object.keys(groupedSchedule).forEach(key => {
groupedSchedule[Number(key)].sort((a, b) => a.startTime.localeCompare(b.startTime))
})
if (schedule.length === 0) {
return (
<div className="flex flex-col items-center justify-center space-y-3 py-6 text-center">
<div className="rounded-full bg-muted p-3">
<Calendar className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No sessions scheduled.</p>
</div>
)
}
return (
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
{WEEKDAYS.slice(0, 5).map((day, i) => (
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day}
</div>
))}
{WEEKDAY_INDICES.slice(0, 5).map((dayNum) => {
const items = groupedSchedule[dayNum] || []
return (
<div key={dayNum} className={`flex flex-col gap-1 py-1 border-r last:border-r-0 border-muted/30 ${compact ? 'max-h-[140px]' : 'min-h-[100px]'}`}>
{items.length === 0 ? (
<div className="flex-1" />
) : (
items.map(item => (
<HoverCard key={item.id}>
<HoverCardTrigger asChild>
<div className="bg-primary/5 text-primary rounded-[2px] p-1 text-[10px] text-left relative hover:bg-primary/10 transition-colors cursor-default leading-tight shrink-0">
<div className="font-semibold truncate">{item.course}</div>
<div className="opacity-70 scale-90 origin-left mt-0.5 whitespace-nowrap">{item.startTime}-{item.endTime}</div>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-48 p-3" align="start" side="top">
<div className="flex flex-col gap-2">
<div className="font-semibold text-sm border-b pb-1 mb-1">{item.course}</div>
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{item.startTime} - {item.endTime}</span>
</div>
{item.location && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{item.location}</span>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
))
)}
</div>
)
})}
</div>
)
}
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base font-semibold">Weekly Schedule</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
Manage
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
<ClassScheduleGrid schedule={schedule} />
<div className="mt-2 text-[10px] text-muted-foreground text-center">
* Showing Mon-Fri schedule
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,106 @@
import Link from "next/link"
import { ChevronRight, Users } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
interface StudentSummary {
id: string
name: string
email: string
image?: string | null
status: string
subjectScores?: Record<string, number | null>
}
interface ClassStudentsWidgetProps {
classId: string
students: StudentSummary[]
}
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
const activeCount = students.filter(s => s.status === "active").length
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">Students</CardTitle>
<CardDescription>
{activeCount} active students
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
{students.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
<div className="rounded-full bg-muted p-3">
<Users className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No students enrolled yet.</p>
</div>
) : (
<div className="space-y-4">
{students.slice(0, 6).map((student) => (
<div key={student.id} className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={student.image || undefined} alt={student.name} />
<AvatarFallback className="text-xs">
{student.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="space-y-0.5">
<div className="text-sm font-medium leading-none">{student.name}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{student.email}</div>
</div>
</div>
<Badge
variant={student.status === "active" ? "outline" : "secondary"}
className="text-[10px] capitalize"
>
{student.status}
</Badge>
</div>
{/* Subject Scores */}
{student.subjectScores && Object.keys(student.subjectScores).length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{Object.entries(student.subjectScores).map(([subject, score]) => (
<div key={subject} className="flex items-center gap-1.5 rounded bg-muted/50 px-2 py-1 text-[10px]">
<span className="font-medium text-muted-foreground">{subject}</span>
{score !== null ? (
<span className={score >= 60 ? "font-semibold text-primary" : "font-semibold text-destructive"}>
{score}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,398 @@
"use client"
import { useState, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
interface AssignmentSummary {
id: string
title: string
status: string
subject?: string | null
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
interface ClassTrendsWidgetProps {
classId: string
assignments: AssignmentSummary[]
compact?: boolean
className?: string
}
const chartConfig = {
submitted: {
label: "Submitted",
color: "hsl(var(--primary))",
},
target: {
label: "Total Students",
color: "hsl(var(--muted-foreground))",
},
avg: {
label: "Average Score",
color: "hsl(var(--chart-2))",
},
median: {
label: "Median Score",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
const data = [...assignments].reverse().map(a => ({
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
fullTitle: a.title,
submitted: a.submittedCount,
target: a.targetCount,
avg: a.avgScore ? Math.round(a.avgScore) : null,
median: a.medianScore ? Math.round(a.medianScore) : null,
}))
if (limit) {
return data.slice(-limit)
}
return data
}
export function ClassSubmissionTrendChart({
data,
className
}: {
data: any[]
className?: string
}) {
return (
<ChartContainer config={chartConfig} className={className}>
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={10}
hide
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
hide
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
</ChartContainer>
)
}
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState<string>("all")
// Extract unique subjects
const subjects = Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]
const activeAssignments = assignments.filter((a) => {
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
return a.isActive || a.status === "published" // Include published even if not "active" in terms of due date
})
const chartData = transformAssignmentsToChartData(activeAssignments, 7)
if (chartData.length === 0 && selectedSubject === "all") return null
if (compact) {
// Calculate simple stats for compact view
const lastAssignment = chartData[chartData.length - 1]
let metricValue = "0%"
let metricLabel = "Latest"
if (lastAssignment) {
if (chartTab === "submission") {
metricValue = lastAssignment.target > 0
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
: "0%"
} else {
metricValue = lastAssignment.avg ? `${lastAssignment.avg}` : "-"
}
}
return (
<div className={`flex flex-col h-full ${className || ""}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
{chartTab === "submission" ? "Submission" : "Score"}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
Submission Trends
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
Score Trends
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{subjects.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
{selectedSubject === "all" ? "All Subjects" : selectedSubject}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
All Subjects
</DropdownMenuItem>
{subjects.map(s => (
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
{s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="text-xs font-medium text-muted-foreground">
{metricLabel}: <span className="text-foreground">{metricValue}</span>
</div>
</div>
{/* Compact Sparkline Chart */}
<div className="flex-1 w-full min-h-0">
<ChartContainer config={chartConfig} className="h-full w-full">
{chartTab === "submission" ? (
<AreaChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="fillSubmitted" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-submitted)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="var(--color-submitted)" stopOpacity={0.05}/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 'auto']} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" hideLabel />}
/>
<Area
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
fill="url(#fillSubmitted)"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={1}
strokeDasharray="2 2"
dot={false}
activeDot={false}
/>
</AreaChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
)}
</ChartContainer>
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
</CardTitle>
<CardDescription>
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
</CardDescription>
</div>
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsTrigger value="submission" className="text-xs">Submission</TabsTrigger>
<TabsTrigger value="score" className="text-xs">Score</TabsTrigger>
</TabsList>
</Tabs>
</div>
{subjects.length > 0 && (
<Tabs value={selectedSubject} onValueChange={setSelectedSubject} className="w-full">
<TabsList className="h-8 w-auto flex-wrap justify-start bg-transparent p-0">
<TabsTrigger
value="all"
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
>
All Subjects
</TabsTrigger>
{subjects.map(s => (
<TabsTrigger
key={s}
value={s}
className="ml-2 h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
>
{s}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="h-[250px] w-full">
{chartTab === "submission" ? (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
activeDot={{ r: 6 }}
/>
</LineChart>
)}
</ChartContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
No data for this subject
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,143 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { updateTeacherClassAction } from "../../actions"
interface EditClassDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
classId: string
initialData: {
name: string
grade: string
homeroom?: string | null
room?: string | null
schoolName?: string | null
}
}
export function EditClassDialog({
open,
onOpenChange,
classId,
initialData,
}: EditClassDialogProps) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const handleEdit = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await updateTeacherClassAction(classId, null, formData)
if (res.success) {
toast.success(res.message)
onOpenChange(false)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
setIsWorking(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(val) => {
if (isWorking) return
onOpenChange(val)
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
<DialogDescription>Update basic class information.</DialogDescription>
</DialogHeader>
<form action={handleEdit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="schoolName" className="text-right">
School
</Label>
<Input
id="schoolName"
name="schoolName"
className="col-span-3"
defaultValue={initialData.schoolName ?? ""}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
name="name"
className="col-span-3"
defaultValue={initialData.name}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
</Label>
<Input
id="grade"
name="grade"
className="col-span-3"
defaultValue={initialData.grade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="homeroom" className="text-right">
Homeroom
</Label>
<Input
id="homeroom"
name="homeroom"
className="col-span-3"
defaultValue={initialData.homeroom ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="room" className="text-right">
Room
</Label>
<Input
id="room"
name="room"
className="col-span-3"
defaultValue={initialData.room ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

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

@@ -1,40 +0,0 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { X } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { TeacherClass } from "../types"
export function InsightsFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Select a class</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{classId !== "all" && (
<Button variant="ghost" onClick={() => setClassId(null)} className="h-8 px-2 lg:px-3">
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}

View File

@@ -3,32 +3,22 @@
import Link from "next/link" import Link from "next/link"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Calendar, Copy, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Users } from "lucide-react" import {
Plus,
RefreshCw,
Copy,
Users,
MapPin,
GraduationCap,
Search,
} from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { parseAsString, useQueryState } from "nuqs"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -40,198 +30,144 @@ import {
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
import type { TeacherClass } from "../types" import type { TeacherClass, ClassScheduleItem } from "../types"
import { import {
createTeacherClassAction,
deleteTeacherClassAction,
ensureClassInvitationCodeAction, ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction, regenerateClassInvitationCodeAction,
updateTeacherClassAction, joinClassByInvitationCodeAction,
} from "../actions" } from "../actions"
const GRADIENTS = [
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
]
function getClassGradient(id: string) {
return "bg-card border-border shadow-sm hover:shadow-md"
}
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) { export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
const router = useRouter() const router = useRouter()
const [isWorking, setIsWorking] = useState(false) const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false) const [joinOpen, setJoinOpen] = useState(false)
const [q, setQ] = useQueryState("q", parseAsString.withDefault("")) const handleJoin = async (formData: FormData) => {
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
const gradeOptions = useMemo(() => {
const set = new Set<string>()
for (const c of classes) set.add(c.grade)
return Array.from(set).sort((a, b) => a.localeCompare(b))
}, [classes])
const filteredClasses = useMemo(() => {
const needle = q.trim().toLowerCase()
return classes.filter((c) => {
const gradeOk = grade === "all" ? true : c.grade === grade
const qOk = needle.length === 0 ? true : c.name.toLowerCase().includes(needle)
return gradeOk && qOk
})
}, [classes, grade, q])
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
const handleCreate = async (formData: FormData) => {
setIsWorking(true) setIsWorking(true)
try { try {
const res = await createTeacherClassAction(null, formData) const res = await joinClassByInvitationCodeAction(null, formData)
if (res.success) { if (res.success) {
toast.success(res.message) toast.success(res.message || "Joined class successfully")
setCreateOpen(false) setJoinOpen(false)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || "Failed to create class") toast.error(res.message || "Failed to join class")
} }
} catch { } catch {
toast.error("Failed to create class") toast.error("Failed to join class")
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
} }
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-center justify-between gap-3"> {/* Filter Bar */}
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
<div className="relative flex-1 md:max-w-sm"> <div className="relative">
<Input <Dialog
placeholder="Search classes..." open={joinOpen}
value={q} onOpenChange={(open) => {
onChange={(e) => setQ(e.target.value || null)} if (isWorking) return
/> setJoinOpen(open)
</div> }}
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}> >
<SelectTrigger className="w-[200px]"> <DialogTrigger asChild>
<SelectValue placeholder="Grade" /> <div className="group relative">
</SelectTrigger> {/* Decorative Ticket Stub Effect */}
<SelectContent> <div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-lg blur opacity-30 group-hover:opacity-60 transition duration-500"></div>
<SelectItem value="all">All grades</SelectItem> <Button className="relative gap-2 h-10 px-5 shadow-sm border border-primary/10 hover:shadow-md transition-all bg-background text-foreground hover:bg-muted/50" disabled={isWorking} variant="outline">
{gradeOptions.map((g) => ( <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
<SelectItem key={g} value={g}> <Plus className="size-3.5" strokeWidth={3} />
{g} </div>
</SelectItem> <span className="font-semibold tracking-tight">Join New Class</span>
))}
</SelectContent>
</Select>
{(q || grade !== "all") && (
<Button
variant="ghost"
className="h-9"
onClick={() => {
setQ(null)
setGrade(null)
}}
>
Reset
</Button>
)}
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
if (!canCreateClass) return
if (isWorking) return
setCreateOpen(open)
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={isWorking || !canCreateClass}>
<Plus className="size-4" />
New class
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create class</DialogTitle>
<DialogDescription>Add a new class to start managing students.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-school-name" className="text-right">
School
</Label>
<Input
id="create-school-name"
name="schoolName"
className="col-span-3"
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 1A" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade" className="text-right">
Grade
</Label>
<Input
id="create-grade"
name="grade"
className="col-span-3"
placeholder="e.g. Grade 7"
defaultValue={defaultGrade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Creating..." : "Create"}
</Button> </Button>
</DialogFooter> </div>
</form> </DialogTrigger>
</DialogContent> <DialogContent className="sm:max-w-[480px] p-0 overflow-hidden gap-0 border-none shadow-2xl">
</Dialog> {/* Header with Pattern */}
<div className="relative bg-primary/5 p-6 border-b border-border/50">
<div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '12px 12px' }}></div>
<DialogHeader className="relative z-10">
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<Plus className="size-5" />
</div>
Join a Class
</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1.5">
Enter the 6-digit invitation code provided by your administrator.
</DialogDescription>
</DialogHeader>
</div>
<form action={handleJoin} className="bg-card">
<div className="p-6 space-y-6">
<div className="space-y-3">
<Label htmlFor="join-code" className="text-sm font-medium">
Invitation Code
</Label>
<div className="relative">
<Input
id="join-code"
name="code"
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
placeholder="e.g. 123456"
required
maxLength={6}
pattern="\d{6}"
autoComplete="off"
autoFocus
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/30 pointer-events-none">
<Users className="size-5" />
</div>
</div>
<p className="text-xs text-muted-foreground">
Ask your administrator for the code if you don't have one.
</p>
</div>
</div>
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
{isWorking ? "Joining..." : "Join Class"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div> </div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> {/* List */}
<div className="flex flex-col gap-4">
{classes.length === 0 ? ( {classes.length === 0 ? (
<EmptyState <EmptyState
title="No classes yet" title="No classes yet"
description="Create your first class to start managing students and schedules." description="Join a class to start managing students and schedules."
icon={Users} icon={Users}
action={canCreateClass ? { label: "Create class", onClick: () => setCreateOpen(true) } : undefined} action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3" className="h-[360px] bg-card border-dashed"
/>
) : filteredClasses.length === 0 ? (
<EmptyState
title="No classes match your filters"
description="Try clearing filters or adjusting keywords."
icon={Users}
action={{ label: "Clear filters", onClick: () => {
setQ(null)
setGrade(null)
}}}
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
/> />
) : ( ) : (
filteredClasses.map((c) => ( classes.map((c) => (
<ClassCard <ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
key={c.id}
c={c}
onWorkingChange={setIsWorking}
isWorking={isWorking}
/>
)) ))
)} )}
</div> </div>
@@ -239,7 +175,12 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
) )
} }
function ClassCard({ import { ClassScheduleGrid } from "./class-detail/class-schedule-widget"
import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
// Removed MiniSchedule since we're using ClassScheduleGrid now
function ClassTicket({
c, c,
isWorking, isWorking,
onWorkingChange, onWorkingChange,
@@ -249,8 +190,6 @@ function ClassCard({
onWorkingChange: (v: boolean) => void onWorkingChange: (v: boolean) => void
}) { }) {
const router = useRouter() const router = useRouter()
const [showEdit, setShowEdit] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const handleEnsureCode = async () => { const handleEnsureCode = async () => {
onWorkingChange(true) onWorkingChange(true)
@@ -297,238 +236,160 @@ function ClassCard({
} }
} }
const handleEdit = async (formData: FormData) => { // Real data for chart
onWorkingChange(true) const recentAssignments = c.recentAssignments ?? []
try {
const res = await updateTeacherClassAction(c.id, null, formData) // Calculate performance change for indicator (still needed for the top indicator)
if (res.success) { // We can't reuse chart data easily here without recalculating, but ClassTrendsWidget handles its own data now
toast.success(res.message) const lastTwoAssignments = [...recentAssignments].reverse().slice(-2)
setShowEdit(false) const performanceChange = lastTwoAssignments.length === 2 && lastTwoAssignments[0].submittedCount > 0
router.refresh() ? ((lastTwoAssignments[1].submittedCount - lastTwoAssignments[0].submittedCount) / lastTwoAssignments[0].submittedCount) * 100
} else { : 0
toast.error(res.message || "Failed to update class") const isPositive = performanceChange >= 0
}
} catch {
toast.error("Failed to update class")
} finally {
onWorkingChange(false)
}
}
const handleDelete = async () => {
onWorkingChange(true)
try {
const res = await deleteTeacherClassAction(c.id)
if (res.success) {
toast.success(res.message)
setShowDelete(false)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
}
} catch {
toast.error("Failed to delete class")
} finally {
onWorkingChange(false)
}
}
return ( return (
<Card className="shadow-none"> <div className="group relative flex w-full overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md">
<CardHeader className="space-y-2"> {/* Realistic Paper Texture & Noise */}
<div className="flex items-start justify-between gap-3"> <div className="absolute inset-0 pointer-events-none opacity-[0.02]" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\'/%3E%3C/svg%3E")' }}></div>
<div className="min-w-0"> <div className="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
<CardTitle className="text-base truncate">
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline"> {/* Decorative Barcode Strip */}
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
))}
</div>
{/* Left Section: Basic Info (Narrower) */}
<div className="flex w-full flex-col justify-between p-5 pl-7 sm:w-[320px] sm:flex-shrink-0 relative z-10 border-r border-dashed border-muted-foreground/20">
{/* Punch Hole Effect Top-Left */}
<div className="absolute -left-2 -top-2 h-6 w-6 rounded-full bg-background border border-border shadow-[inset_1px_1px_2px_rgba(0,0,0,0.1)] z-20"></div>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/5 text-xl font-bold text-primary shadow-sm border border-primary/10">
{c.grade.replace(/[^0-9]/g, '')}
</div>
<div className="flex flex-col">
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="text-lg font-bold hover:underline tracking-tight line-clamp-1">
{c.name} {c.name}
</Link> </Link>
</CardTitle> <Badge variant="secondary" className="w-fit font-normal text-xs bg-muted/50 font-mono tracking-tight">
<div className="text-muted-foreground text-sm mt-1"> {c.grade} {c.id.slice(-4).toUpperCase()}
{c.room ? `Room: ${c.room}` : "Room: Not set"} </Badge>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="space-y-2 text-sm text-muted-foreground">
<Badge variant="secondary">{c.grade}</Badge> <div className="flex items-center gap-2">
<DropdownMenu> <Users className="size-4 text-muted-foreground/70" />
<DropdownMenuTrigger asChild> <span className="font-medium text-foreground/80">{c.studentCount}</span> Students
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}> </div>
<MoreHorizontal className="size-4" /> <div className="flex items-center gap-2">
</Button> <MapPin className="size-4 text-muted-foreground/70" />
</DropdownMenuTrigger> <span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
<DropdownMenuContent align="end"> </div>
<DropdownMenuItem onClick={() => setShowEdit(true)}> {c.schoolName && (
<Pencil className="mr-2 size-4" /> <div className="flex items-center gap-2">
Edit <GraduationCap className="size-4 text-muted-foreground/70" />
</DropdownMenuItem> <span className="line-clamp-1">{c.schoolName}</span>
<DropdownMenuSeparator /> </div>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDelete(true)}
>
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium tabular-nums">{c.studentCount} students</div>
{c.homeroom ? <Badge variant="outline">{c.homeroom}</Badge> : null}
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase text-muted-foreground">Invitation code</div>
<div className="font-mono tabular-nums text-sm">{c.invitationCode ?? "-"}</div>
</div>
<div className="flex items-center gap-2">
{c.invitationCode ? (
<>
<Button variant="outline" size="sm" className="gap-2" onClick={handleCopyCode} disabled={isWorking}>
<Copy className="size-4" />
Copy
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={handleRegenerateCode} disabled={isWorking}>
<RefreshCw className="size-4" />
Regenerate
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={handleEnsureCode} disabled={isWorking}>
Generate
</Button>
)} )}
</div> </div>
</div> </div>
<div className={cn("grid gap-2", "grid-cols-2")}>
<Button asChild variant="outline" className="w-full justify-start gap-2"> {/* Invitation Code Section */}
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}> <div className="mt-6 pt-4 border-t border-dashed border-border relative">
<Users className="size-4" /> {/* Tiny Cut marks */}
Students <div className="absolute -left-5 top-[-1px] w-2 h-[2px] bg-border"></div>
</Link> <div className="absolute -right-5 top-[-1px] w-2 h-[2px] bg-border"></div>
</Button>
<Button asChild variant="outline" className="w-full justify-start gap-2"> <div className="flex flex-col gap-1.5">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}> <div className="flex items-center justify-between">
<Calendar className="size-4" /> <span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
Schedule <div className="flex gap-0.5">
</Link> {Array.from({ length: 5 }).map((_, i) => (
</Button> <div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
))}
</div>
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 px-3 py-1.5 rounded-sm border border-dashed border-muted-foreground/30 relative overflow-hidden">
<span className="font-mono text-lg font-bold tracking-widest text-foreground z-10">{c.invitationCode || "—"}</span>
{/* Faint QR Code Placeholder Background */}
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
{Array.from({ length: 16 }).map((_, i) => (
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
))}
</div>
</div>
{c.invitationCode ? (
<div className="flex gap-1 z-10">
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title="Copy">
<Copy className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
<RefreshCw className="size-3.5" />
</Button>
</div>
) : (
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
Generate
</Button>
)}
</div>
</div>
</div> </div>
</CardContent> </div>
<Dialog {/* Dashed Divider (Ticket perforation) */}
open={showEdit} <div className="relative hidden w-4 flex-col items-center justify-center sm:flex -ml-2 z-20">
onOpenChange={(open) => { <div className="absolute -top-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_-1px_1px_rgba(0,0,0,0.05)]" />
if (isWorking) return <div className="h-full w-px border-l-2 border-dashed border-muted-foreground/20 relative">
setShowEdit(open) {/* Scissor Icon */}
}} <div className="absolute top-1/2 -left-[5px] -translate-y-1/2 text-muted-foreground/20 -rotate-90 text-[10px]"></div>
> </div>
<DialogContent className="sm:max-w-[480px]"> <div className="absolute -bottom-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_1px_1px_rgba(0,0,0,0.05)]" />
<DialogHeader> </div>
<DialogTitle>Edit class</DialogTitle>
<DialogDescription>Update basic class information.</DialogDescription>
</DialogHeader>
<form action={handleEdit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-school-name-${c.id}`} className="text-right">
School
</Label>
<Input
id={`edit-school-name-${c.id}`}
name="schoolName"
className="col-span-3"
defaultValue={c.schoolName ?? ""}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-name-${c.id}`} className="text-right">
Name
</Label>
<Input
id={`edit-name-${c.id}`}
name="name"
className="col-span-3"
defaultValue={c.name}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-grade-${c.id}`} className="text-right">
Grade
</Label>
<Input
id={`edit-grade-${c.id}`}
name="grade"
className="col-span-3"
defaultValue={c.grade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-homeroom-${c.id}`} className="text-right">
Homeroom
</Label>
<Input
id={`edit-homeroom-${c.id}`}
name="homeroom"
className="col-span-3"
defaultValue={c.homeroom ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-room-${c.id}`} className="text-right">
Room
</Label>
<Input
id={`edit-room-${c.id}`}
name="room"
className="col-span-3"
defaultValue={c.room ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog {/* Right Section: Stats & Actions (Wider) */}
open={showDelete} <div className="flex flex-1 flex-col bg-muted/5 p-6 relative z-10">
onOpenChange={(open) => { <div className="flex flex-1 gap-6">
if (isWorking) return {/* Left: Submission Trends */}
setShowDelete(open) <div className="flex-1 flex flex-col gap-4 min-w-0">
}} <div className="flex items-center justify-between">
> <h4 className="text-sm font-semibold text-foreground/80">Submission Trends</h4>
<AlertDialogContent> <span className={cn(
<AlertDialogHeader> "text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
<AlertDialogTitle>Delete class?</AlertDialogTitle> isPositive
<AlertDialogDescription> ? "text-emerald-600 bg-emerald-50 border-emerald-100"
This will permanently delete <span className="font-medium text-foreground">{c.name}</span> and remove all : "text-red-600 bg-red-50 border-red-100"
enrollments. )}>
</AlertDialogDescription> {isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
</AlertDialogHeader> </span>
<AlertDialogFooter> </div>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction {/* Real Chart */}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" <div className="h-[140px] w-full">
onClick={handleDelete} <ClassTrendsWidget
disabled={isWorking} classId={c.id}
> assignments={recentAssignments}
{isWorking ? "Deleting..." : "Delete"} compact
</AlertDialogAction> className="h-full w-full"
</AlertDialogFooter> />
</AlertDialogContent> </div>
</AlertDialog> </div>
</Card>
{/* Right: Weekly Schedule */}
<div className="flex-1 flex flex-col gap-4 border-l border-dashed border-muted-foreground/20 pl-6 min-w-0">
<div className="h-[170px] w-full overflow-y-auto pr-1">
<ClassScheduleGrid schedule={c.schedule ?? []} compact />
</div>
</div>
</div>
</div>
</div>
) )
} }

View File

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs" import { useQueryState, parseAsString } from "nuqs"
import { Plus, X } from "lucide-react" import { Plus } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -29,7 +29,7 @@ import type { TeacherClass } from "../types"
import { createClassScheduleItemAction } from "../actions" import { createClassScheduleItemAction } from "../actions"
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) { export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
const router = useRouter() const router = useRouter()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -64,33 +64,29 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
} }
} }
const selectedClass = classes.find((c) => c.id === classId)
const title = selectedClass ? selectedClass.name : "All Classes"
return ( return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="relative flex items-center justify-between py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}> <Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
<SelectTrigger className="w-[240px]"> <SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
<SelectValue placeholder="Class" /> <SelectValue placeholder="All Classes" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Classes</SelectItem> <SelectItem value="all" className="text-xs">All Classes</SelectItem>
{classes.map((c) => ( {classes.map((c) => (
<SelectItem key={c.id} value={c.id}> <SelectItem key={c.id} value={c.id} className="text-xs">
{c.name} {c.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
{classId !== "all" && ( <div className="absolute left-1/2 -translate-x-1/2 text-sm font-medium text-muted-foreground">
<Button {title}
variant="ghost"
onClick={() => setClassId(null)}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div> </div>
<Dialog <Dialog
@@ -101,9 +97,13 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
}} }}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2" disabled={classes.length === 0}> <Button
<Plus className="size-4" /> className="h-8 gap-1.5 text-xs px-3 shadow-none border-transparent bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
Add item disabled={classes.length === 0}
variant="ghost"
>
<Plus className="size-3.5" />
Add Event
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[560px]"> <DialogContent className="sm:max-w-[560px]">

View File

@@ -151,88 +151,145 @@ export function ScheduleView({
} }
} }
const getPositionStyle = (startTime: string, endTime: string) => {
// Range 8:00 (480 min) -> 18:00 (1080 min)
// Total duration: 600 min
const startParts = startTime.split(':').map(Number)
const endParts = endTime.split(':').map(Number)
const startMinutes = startParts[0] * 60 + startParts[1]
const endMinutes = endParts[0] * 60 + endParts[1]
const minTime = 8 * 60
const maxTime = 18 * 60
const totalDuration = maxTime - minTime
// Calculate percentage positions
const top = Math.max(0, ((startMinutes - minTime) / totalDuration) * 100)
const height = Math.min(100 - top, ((endMinutes - startMinutes) / totalDuration) * 100)
return {
top: `${top}%`,
height: `${height}%`,
}
}
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
// Predefined colors for different subjects to add visual variety
const getSubjectColor = (subject: string) => {
const s = subject.toLowerCase()
if (s.includes('math')) return 'bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20'
if (s.includes('physics') || s.includes('science')) return 'bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20'
if (s.includes('english') || s.includes('lit')) return 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20'
if (s.includes('history') || s.includes('geo')) return 'bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20'
if (s.includes('art') || s.includes('music')) return 'bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20'
if (s.includes('sport') || s.includes('pe')) return 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20'
return 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20'
}
return ( return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3"> <div className="h-[600px] flex flex-col">
{WEEKDAYS.map((d) => { <div className="flex h-full">
const items = byDay.get(d.key) ?? [] {/* Time Axis */}
return ( <div className="w-14 flex-shrink-0 flex flex-col">
<Card key={d.key} className="shadow-none"> <div className="h-10" /> {/* Header spacer */}
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <div className="flex-1 relative">
<div className="flex items-center gap-2"> {HOURS.map((h, i) => (
<CardTitle className="text-base">{d.label}</CardTitle> <div
<Badge variant="secondary" className={cn(items.length === 0 && "opacity-60")}> key={h}
{items.length} items className="absolute w-full text-right pr-3 text-[11px] text-muted-foreground/60 font-medium -translate-y-1/2 font-mono"
</Badge> style={{ top: `${(i / 10) * 100}%` }}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={classes.length === 0}
onClick={() => {
setCreateWeekday(d.key)
setCreateOpen(true)
}}
> >
<Plus className="size-4" /> {h}:00
</Button> </div>
</CardHeader> ))}
<CardContent> </div>
{items.length === 0 ? ( </div>
<div className="text-muted-foreground text-sm">No classes scheduled.</div>
) : ( {/* Days Columns */}
<div className="space-y-4"> <div className="flex-1 grid grid-cols-5">
{items.map((item) => ( {WEEKDAYS.slice(0, 5).map((d) => (
<div key={item.id} className="space-y-1 border-b pb-4 last:border-0 last:pb-0"> <div key={d.key} className="flex flex-col h-full min-w-0">
<div className="flex items-start justify-between gap-3"> <div className="flex items-center justify-center py-2 h-10 group">
<div className="min-w-0 flex-1"> <span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
<div className="font-medium leading-none">{item.course}</div> </div>
<div className="relative h-full mx-1">
{/* Subtle vertical guideline */}
<div className="absolute left-0 top-0 bottom-0 w-px bg-border/30" />
{(byDay.get(d.key) ?? []).map((item) => (
<div
key={item.id}
className="group absolute w-full px-1 z-10"
style={getPositionStyle(item.startTime, item.endTime)}
>
<div className={cn(
"rounded-md p-2 text-xs text-left relative transition-all cursor-default leading-tight h-full border overflow-hidden shadow-sm hover:shadow-md flex flex-col justify-center",
getSubjectColor(item.course)
)}>
<div className="flex justify-between items-start gap-1">
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
<div className="font-bold truncate text-[11px] leading-none tracking-tight">{item.course}</div>
<div className="opacity-80 scale-95 origin-left whitespace-nowrap tabular-nums text-[10px] font-medium leading-none font-mono">
{item.startTime} - {item.endTime}
</div>
<div className="opacity-70 scale-95 origin-left truncate text-[9px] leading-none mt-0.5 font-medium">
{classNameById.get(item.classId)}
</div>
</div> </div>
<div className="flex items-center gap-2">
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge> <div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}> <Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
<MoreHorizontal className="size-4" /> <MoreHorizontal className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={() => setEditItem(item)}> <DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
<Pencil className="mr-2 size-4" /> <Pencil className="mr-2 h-3 w-3" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-xs text-destructive focus:text-destructive"
onClick={() => setDeleteItem(item)} onClick={() => setDeleteItem(item)}
> >
<Trash2 className="mr-2 size-4" /> <Trash2 className="mr-2 h-3 w-3" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<div className="text-muted-foreground flex items-center gap-3 text-sm">
<span className="inline-flex items-center gap-1 tabular-nums">
<Clock className="h-3.5 w-3.5" />
{item.startTime}{item.endTime}
</span>
{item.location ? (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{item.location}
</span>
) : null}
</div>
</div> </div>
))} </div>
))}
{/* Add Button Overlay - Only visible on hover of the column */}
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
<div className="absolute top-2 right-2 pointer-events-auto">
<Button
variant="secondary"
size="icon"
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
disabled={classes.length === 0}
onClick={() => {
setCreateWeekday(d.key)
setCreateOpen(true)
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div> </div>
)} </div>
</CardContent> </div>
</Card> ))}
) </div>
})} </div>
<Dialog <Dialog
open={createOpen} open={createOpen}
@@ -311,7 +368,7 @@ export function ScheduleView({
</Dialog> </Dialog>
<Dialog <Dialog
open={Boolean(editItem)} open={!!editItem}
onOpenChange={(v) => { onOpenChange={(v) => {
if (isWorking) return if (isWorking) return
if (!v) setEditItem(null) if (!v) setEditItem(null)
@@ -320,116 +377,118 @@ export function ScheduleView({
<DialogContent className="sm:max-w-[560px]"> <DialogContent className="sm:max-w-[560px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit schedule item</DialogTitle> <DialogTitle>Edit schedule item</DialogTitle>
<DialogDescription>Update this schedule entry.</DialogDescription> <DialogDescription>Update class schedule entry.</DialogDescription>
</DialogHeader> </DialogHeader>
{editItem ? ( <form action={handleUpdate}>
<form action={handleUpdate}> <div className="grid gap-4 py-4">
<div className="grid gap-4 py-4"> <div className="grid grid-cols-4 items-center gap-4">
<div className="grid grid-cols-4 items-center gap-4"> <Label className="text-right">Class</Label>
<Label className="text-right">Class</Label> <div className="col-span-3">
<div className="col-span-3"> <Select value={editClassId} onValueChange={setEditClassId}>
<Select value={editClassId} onValueChange={setEditClassId}> <SelectTrigger>
<SelectTrigger> <SelectValue placeholder="Select a class" />
<SelectValue placeholder="Select a class" /> </SelectTrigger>
</SelectTrigger> <SelectContent>
<SelectContent> {classes.map((c) => (
{classes.map((c) => ( <SelectItem key={c.id} value={c.id}>
<SelectItem key={c.id} value={c.id}> {c.name}
{c.name} </SelectItem>
</SelectItem> ))}
))} </SelectContent>
</SelectContent> </Select>
</Select> <input type="hidden" name="classId" value={editClassId} />
<input type="hidden" name="classId" value={editClassId} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Weekday</Label>
<div className="col-span-3">
<Select value={editWeekday} onValueChange={setEditWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Mon</SelectItem>
<SelectItem value="2">Tue</SelectItem>
<SelectItem value="3">Wed</SelectItem>
<SelectItem value="4">Thu</SelectItem>
<SelectItem value="5">Fri</SelectItem>
<SelectItem value="6">Sat</SelectItem>
<SelectItem value="7">Sun</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={editWeekday} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-startTime" className="text-right">
Start
</Label>
<Input
id="edit-startTime"
name="startTime"
type="time"
className="col-span-3"
defaultValue={editItem.startTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-endTime" className="text-right">
End
</Label>
<Input
id="edit-endTime"
name="endTime"
type="time"
className="col-span-3"
defaultValue={editItem.endTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-course" className="text-right">
Course
</Label>
<Input
id="edit-course"
name="course"
className="col-span-3"
defaultValue={editItem.course}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-location" className="text-right">
Location
</Label>
<Input
id="edit-location"
name="location"
className="col-span-3"
defaultValue={editItem.location ?? ""}
/>
</div> </div>
</div> </div>
<DialogFooter>
<Button type="submit" disabled={isWorking}> <div className="grid grid-cols-4 items-center gap-4">
{isWorking ? "Saving..." : "Save"} <Label htmlFor="edit-weekday" className="text-right">
</Button> Weekday
</DialogFooter> </Label>
</form> <div className="col-span-3">
) : null} <Select value={editWeekday} onValueChange={setEditWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Mon</SelectItem>
<SelectItem value="2">Tue</SelectItem>
<SelectItem value="3">Wed</SelectItem>
<SelectItem value="4">Thu</SelectItem>
<SelectItem value="5">Fri</SelectItem>
<SelectItem value="6">Sat</SelectItem>
<SelectItem value="7">Sun</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={editWeekday} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-startTime" className="text-right">
Start
</Label>
<Input
id="edit-startTime"
name="startTime"
type="time"
className="col-span-3"
defaultValue={editItem?.startTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-endTime" className="text-right">
End
</Label>
<Input
id="edit-endTime"
name="endTime"
type="time"
className="col-span-3"
defaultValue={editItem?.endTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-course" className="text-right">
Course
</Label>
<Input
id="edit-course"
name="course"
className="col-span-3"
defaultValue={editItem?.course}
placeholder="e.g. Math"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-location" className="text-right">
Location
</Label>
<Input
id="edit-location"
name="location"
className="col-span-3"
defaultValue={editItem?.location ?? ""}
placeholder="Optional"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !editClassId}>
{isWorking ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog <AlertDialog
open={Boolean(deleteItem)} open={!!deleteItem}
onOpenChange={(v) => { onOpenChange={(v) => {
if (isWorking) return if (isWorking) return
if (!v) setDeleteItem(null) if (!v) setDeleteItem(null)
@@ -437,22 +496,20 @@ export function ScheduleView({
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle> <AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{deleteItem ? ( This will permanently delete this schedule item.
<>
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
({deleteItem.startTime}{deleteItem.endTime}).
</>
) : null}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={(e) => {
onClick={handleDelete} e.preventDefault()
handleDelete()
}}
disabled={isWorking} disabled={isWorking}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isWorking ? "Deleting..." : "Delete"} {isWorking ? "Deleting..." : "Delete"}
</AlertDialogAction> </AlertDialogAction>
@@ -461,5 +518,4 @@ export function ScheduleView({
</AlertDialog> </AlertDialog>
</div> </div>
) )
} }

View File

@@ -3,18 +3,19 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs" import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, X } from "lucide-react" import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { import {
Select, DropdownMenu,
SelectContent, DropdownMenuContent,
SelectItem, DropdownMenuItem,
SelectTrigger, DropdownMenuTrigger,
SelectValue, DropdownMenuSeparator,
} from "@/shared/components/ui/select" DropdownMenuLabel,
} from "@/shared/components/ui/dropdown-menu"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -24,25 +25,35 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import type { TeacherClass } from "../types" import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions" import { enrollStudentByEmailAction } from "../actions"
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) { export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 }))
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault(defaultClassId || "all").withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
const router = useRouter() const router = useRouter()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false) const [isWorking, setIsWorking] = useState(false)
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes]) const effectiveClassId = classId === "all" && defaultClassId ? defaultClassId : classId
const [enrollClassId, setEnrollClassId] = useState(defaultClassId)
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setEnrollClassId(defaultClassId) setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
}, [open, defaultClassId]) }, [open, effectiveClassId, classes])
const handleEnroll = async (formData: FormData) => { const handleEnroll = async (formData: FormData) => {
setIsWorking(true) setIsWorking(true)
@@ -62,46 +73,84 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
} }
} }
const selectedClass = classes.find(c => c.id === classId)
const classLabel = classId === "all" ? "All Classes" : (selectedClass?.name || "Unknown Class")
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
const hasFilters = search || classId !== "all" || status !== "all"
return ( return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex items-center justify-between py-2">
<div className="flex flex-1 items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1 md:max-w-sm"> {/* Search - Minimal */}
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" /> <div className="relative group">
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
<Input <Input
placeholder="Search students..." placeholder="Search students..."
className="pl-8" className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
value={search} value={search}
onChange={(e) => setSearch(e.target.value || null)} onChange={(e) => setSearch(e.target.value || null)}
/> />
</div> </div>
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}> <div className="h-4 w-[1px] bg-border mx-1" />
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{(search || classId !== "all") && ( {/* Class Filter - Compact */}
<Button <DropdownMenu>
variant="ghost" <DropdownMenuTrigger asChild>
onClick={() => { <Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
setSearch(null) <span className="truncate max-w-[120px]">{classLabel}</span>
setClassId(null) <ChevronDown className="h-3 w-3 opacity-50" />
}} </Button>
className="h-8 px-2 lg:px-3" </DropdownMenuTrigger>
> <DropdownMenuContent align="start" className="w-[200px]">
Reset <DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Class</DropdownMenuLabel>
<X className="ml-2 h-4 w-4" /> <DropdownMenuItem
</Button> onClick={() => setClassId("all")}
)} className="text-xs flex items-center justify-between"
>
All Classes
{classId === "all" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{classes.map((c) => (
<DropdownMenuItem
key={c.id}
onClick={() => setClassId(c.id)}
className="text-xs flex items-center justify-between"
>
<span className="truncate">{c.name}</span>
{classId === c.id && <Check className="h-3 w-3" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Status Filter - Compact */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
{statusLabel}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
All Status
{status === "all" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
Active
{status === "active" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
Inactive
{status === "inactive" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<Dialog <Dialog
@@ -112,8 +161,8 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
}} }}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2" disabled={classes.length === 0}> <Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
<UserPlus className="size-4" /> <UserPlus className="size-3.5" />
Add student Add student
</Button> </Button>
</DialogTrigger> </DialogTrigger>

View File

@@ -7,7 +7,9 @@ import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -25,14 +27,6 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog" } from "@/shared/components/ui/alert-dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import type { ClassStudent } from "../types" import type { ClassStudent } from "../types"
import { setStudentEnrollmentStatusAction } from "../actions" import { setStudentEnrollmentStatusAction } from "../actions"
@@ -42,7 +36,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null) const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => { const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
const key = `${student.classId}:${student.id}:${status}` const key = `${student.classId}:${student.id}`
setWorkingKey(key) setWorkingKey(key)
try { try {
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status) const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
@@ -50,73 +44,132 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
toast.success(res.message) toast.success(res.message)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || "Failed to update student") toast.error(res.message)
} }
} catch { } catch {
toast.error("Failed to update student") toast.error("Failed to update status")
} finally { } finally {
setWorkingKey(null) setWorkingKey(null)
} }
} }
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
return ( return (
<> <>
<Table> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<TableHeader> {students.map((s) => (
<TableRow className="bg-muted/50"> <Card key={`${s.classId}:${s.id}`} className="overflow-hidden">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Student</TableHead> <CardHeader className="flex flex-row items-center gap-4 space-y-0 p-4 pb-2">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Email</TableHead> <div className="relative">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead> <Avatar className="h-10 w-10 border">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead> <AvatarImage src={s.image || undefined} alt={s.name} />
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Actions</TableHead> <AvatarFallback>{getInitials(s.name)}</AvatarFallback>
</TableRow> </Avatar>
</TableHeader> <span className={cn(
<TableBody> "absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
{students.map((s) => ( s.status === "active" ? "bg-emerald-500" : "bg-muted-foreground"
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-12", s.status !== "active" && "opacity-70")}> )} />
<TableCell className="font-medium">{s.name}</TableCell> </div>
<TableCell className="text-muted-foreground">{s.email}</TableCell> <div className="flex flex-col flex-1 overflow-hidden">
<TableCell>{s.className}</TableCell> <div className="flex items-start justify-between">
<TableCell> <div className="flex flex-col overflow-hidden mr-2">
<Badge variant={s.status === "active" ? "secondary" : "outline"}> <span className="truncate font-semibold text-sm">{s.name}</span>
{s.status === "active" ? "Active" : "Inactive"} <span className="truncate text-xs text-muted-foreground">{s.email}</span>
</Badge> </div>
</TableCell> <div className="flex flex-col items-end gap-0.5 text-xs text-muted-foreground shrink-0">
<TableCell className="text-right"> <span className="text-[10px] font-medium text-foreground/80">
<DropdownMenu> {s.className}
<DropdownMenuTrigger asChild> </span>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}> <span className="text-[10px]">
<MoreHorizontal className="size-4" /> {new Date(s.joinedAt).toLocaleDateString("en-GB", {
</Button> day: "2-digit",
</DropdownMenuTrigger> month: "2-digit",
<DropdownMenuContent align="end"> year: "2-digit"
{s.status !== "active" ? ( })}
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}> </span>
<UserCheck className="mr-2 size-4" /> </div>
Set active </div>
</DropdownMenuItem> </div>
) : ( </CardHeader>
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}> <CardContent className="p-4 pt-0">
<UserX className="mr-2 size-4" /> {s.subjectScores && Object.keys(s.subjectScores).length > 0 ? (
Set inactive <div className="flex flex-col gap-2">
</DropdownMenuItem> <div className="flex flex-wrap gap-1.5">
{Object.entries(s.subjectScores).slice(0, 4).map(([subject, score]) => (
<div key={subject} className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs border border-muted/50">
<span className="font-medium text-muted-foreground/80">{subject}</span>
{score !== null ? (
<span className={cn(
"font-bold",
score >= 90 ? "text-emerald-600" :
score >= 80 ? "text-primary" :
score >= 60 ? "text-yellow-600" : "text-destructive"
)}>
{score}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
))}
{Object.keys(s.subjectScores).length > 4 && (
<div className="flex items-center justify-center rounded-md bg-muted/50 px-2 py-1 text-xs text-muted-foreground font-medium border border-muted/50">
+{Object.keys(s.subjectScores).length - 4}
</div>
)} )}
<DropdownMenuSeparator /> </div>
<DropdownMenuItem </div>
onClick={() => setRemoveTarget(s)} ) : (
className="text-destructive focus:text-destructive" <div className="flex items-center justify-center h-[32px] rounded-md bg-muted/20 border border-dashed border-muted">
disabled={s.status === "inactive" || workingKey !== null} <span className="text-xs text-muted-foreground/50 italic">No recent scores</span>
> </div>
<UserX className="mr-2 size-4" /> )}
Remove from class </CardContent>
<CardFooter className="flex items-center justify-between border-t bg-muted/50 p-2">
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" asChild>
<a href={`mailto:${s.email}`}>Email</a>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={workingKey !== null}>
<MoreHorizontal className="size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{s.status !== "active" ? (
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
<UserCheck className="mr-2 size-4" />
Set active
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> ) : (
</DropdownMenu> <DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
</TableCell> <UserX className="mr-2 size-4" />
</TableRow> Set inactive
))} </DropdownMenuItem>
</TableBody> )}
</Table> <DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setRemoveTarget(s)}
className="text-destructive focus:text-destructive"
disabled={s.status === "inactive" || workingKey !== null}
>
<UserX className="mr-2 size-4" />
Remove from class
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardFooter>
</Card>
))}
</div>
<AlertDialog <AlertDialog
open={Boolean(removeTarget)} open={Boolean(removeTarget)}

View File

@@ -2,7 +2,7 @@ import "server-only";
import { randomInt } from "node:crypto" import { randomInt } from "node:crypto"
import { cache } from "react" import { cache } from "react"
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm" import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db" import { db } from "@/shared/db"
@@ -17,6 +17,8 @@ import {
homeworkAssignments, homeworkAssignments,
homeworkSubmissions, homeworkSubmissions,
schools, schools,
subjects,
exams,
users, users,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types" import { DEFAULT_CLASS_SUBJECTS } from "./types"
@@ -122,6 +124,20 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
const rows = await (async () => { const rows = await (async () => {
try { try {
const ownedIds = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, teacherId))
const enrolledIds = await db
.select({ id: classEnrollments.classId })
.from(classEnrollments)
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
if (allIds.length === 0) return []
return await db return await db
.select({ .select({
id: classes.id, id: classes.id,
@@ -135,26 +151,11 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
}) })
.from(classes) .from(classes)
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id)) .leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(eq(classes.teacherId, teacherId)) .where(inArray(classes.id, allIds))
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode) .groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) .orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} catch { } catch {
return await db return []
.select({
id: classes.id,
schoolName: sql<string | null>`NULL`.as("schoolName"),
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: sql<string | null>`NULL`.as("invitationCode"),
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
})
.from(classes)
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(eq(classes.teacherId, teacherId))
.groupBy(classes.id, classes.name, classes.grade, classes.homeroom, classes.room)
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} }
})() })()
@@ -170,7 +171,35 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
})) }))
list.sort(compareClassLike) list.sort(compareClassLike)
return list
// Fetch recent assignments for trends and schedule
const listWithTrends = await Promise.all(
list.map(async (c) => {
const [insights, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
getClassSchedule({ classId: c.id, teacherId }),
])
const recentAssignments = insights
? insights.assignments.map((a) => ({
id: a.assignmentId,
title: a.title,
status: a.status,
subject: a.subject,
isActive: a.isActive,
isOverdue: a.isOverdue,
dueAt: a.dueAt ? new Date(a.dueAt) : null,
submittedCount: a.submittedCount,
targetCount: a.targetCount,
avgScore: a.scoreStats.avg,
medianScore: a.scoreStats.median,
}))
: []
return { ...c, recentAssignments, schedule }
})
)
return listWithTrends
}) })
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => { export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
@@ -331,6 +360,143 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
return list return list
}) })
export const getGradeManagedClasses = cache(async (userId: string): Promise<AdminClassListItem[]> => {
const managedGradeIds = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGradeIds.length === 0) return []
const gradeIds = managedGradeIds.map((g) => g.id)
const [rows, subjectRows] = await Promise.all([
(async () => {
try {
return await db
.select({
id: classes.id,
schoolName: classes.schoolName,
schoolId: classes.schoolId,
name: classes.name,
grade: classes.grade,
gradeId: classes.gradeId,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
createdAt: classes.createdAt,
updatedAt: classes.updatedAt,
})
.from(classes)
.innerJoin(users, eq(users.id, classes.teacherId))
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(inArray(classes.gradeId, gradeIds))
.groupBy(
classes.id,
classes.schoolName,
classes.schoolId,
classes.name,
classes.grade,
classes.gradeId,
classes.homeroom,
classes.room,
classes.invitationCode,
users.id,
users.name,
users.email,
classes.createdAt,
classes.updatedAt
)
.orderBy(
asc(classes.schoolName),
asc(classes.grade),
asc(classes.name),
asc(classes.homeroom),
asc(classes.room)
)
} catch {
return []
}
})(),
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.where(inArray(classes.gradeId, gradeIds))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) {
const subject = r.subject as ClassSubject
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
: null
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
bySubject.set(subject, teacher)
subjectsByClassId.set(r.classId, bySubject)
}
const list = rows.map((r) => {
const bySubject = subjectsByClassId.get(r.id)
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
subject,
teacher: bySubject?.get(subject) ?? null,
}))
return {
id: r.id,
schoolName: r.schoolName,
schoolId: r.schoolId,
name: r.name,
grade: r.grade,
gradeId: r.gradeId,
homeroom: r.homeroom,
room: r.room,
invitationCode: r.invitationCode ?? null,
teacher: {
id: r.teacherId,
name: r.teacherName ?? "Unnamed",
email: r.teacherEmail,
},
subjectTeachers,
studentCount: Number(r.studentCount ?? 0),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}
})
list.sort(compareClassLike)
return list
})
export const getManagedGrades = cache(async (userId: string) => {
return await db
.select({
id: grades.id,
name: grades.name,
schoolId: grades.schoolId,
schoolName: schools.name,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
.orderBy(asc(schools.name), asc(grades.name))
})
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => { export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
const id = studentId.trim() const id = studentId.trim()
if (!id) return [] if (!id) return []
@@ -345,9 +511,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: classes.grade, grade: classes.grade,
homeroom: classes.homeroom, homeroom: classes.homeroom,
room: classes.room, room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
}) })
.from(classEnrollments) .from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId)) .innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) .orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} catch { } catch {
@@ -359,9 +528,12 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: classes.grade, grade: classes.grade,
homeroom: classes.homeroom, homeroom: classes.homeroom,
room: classes.room, room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
}) })
.from(classEnrollments) .from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId)) .innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) .orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} }
@@ -374,6 +546,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
grade: r.grade, grade: r.grade,
homeroom: r.homeroom, homeroom: r.homeroom,
room: r.room, room: r.room,
teacherName: r.teacherName,
teacherEmail: r.teacherEmail,
})) }))
list.sort(compareClassLike) list.sort(compareClassLike)
@@ -414,12 +588,13 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
}) })
export const getClassStudents = cache( export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; teacherId?: string }): Promise<ClassStudent[]> => { async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId()) const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
if (!teacherId) return [] if (!teacherId) return []
const classId = params?.classId?.trim() const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase() const q = params?.q?.trim().toLowerCase()
const status = params?.status?.trim().toLowerCase()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)] const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
@@ -427,6 +602,10 @@ export const getClassStudents = cache(
conditions.push(eq(classes.id, classId)) conditions.push(eq(classes.id, classId))
} }
if (status === "active" || status === "inactive") {
conditions.push(eq(classEnrollments.status, status))
}
if (q && q.length > 0) { if (q && q.length > 0) {
const needle = `%${q}%` const needle = `%${q}%`
conditions.push( conditions.push(
@@ -439,9 +618,12 @@ export const getClassStudents = cache(
id: users.id, id: users.id,
name: users.name, name: users.name,
email: users.email, email: users.email,
image: users.image,
gender: users.gender,
classId: classes.id, classId: classes.id,
className: classes.name, className: classes.name,
status: classEnrollments.status, status: classEnrollments.status,
joinedAt: classEnrollments.createdAt,
}) })
.from(classEnrollments) .from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId)) .innerJoin(classes, eq(classes.id, classEnrollments.classId))
@@ -453,9 +635,12 @@ export const getClassStudents = cache(
id: r.id, id: r.id,
name: r.name ?? "Unnamed", name: r.name ?? "Unnamed",
email: r.email, email: r.email,
image: r.image,
gender: r.gender,
classId: r.classId, classId: r.classId,
className: r.className, className: r.className,
status: r.status, status: r.status,
joinedAt: r.joinedAt,
})) }))
} }
) )
@@ -597,11 +782,22 @@ export const getClassHomeworkInsights = cache(
} }
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignments = await db.query.homeworkAssignments.findMany({ const assignments = await db
where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)), .select({
orderBy: [desc(homeworkAssignments.createdAt)], id: homeworkAssignments.id,
limit, title: homeworkAssignments.title,
}) status: homeworkAssignments.status,
createdAt: homeworkAssignments.createdAt,
dueAt: homeworkAssignments.dueAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
const usedAssignmentIds = assignments.map((a) => a.id) const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) { if (usedAssignmentIds.length === 0) {
@@ -690,6 +886,7 @@ export const getClassHomeworkInsights = cache(
assignmentId: a.id, assignmentId: a.id,
title: a.title, title: a.title,
status: (a.status as string) ?? "draft", status: (a.status as string) ?? "draft",
subject: a.subjectName,
createdAt: a.createdAt.toISOString(), createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null, dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs, isActive: dueMs === null || dueMs >= nowMs,
@@ -1539,3 +1736,104 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise<void>
await db.delete(classSchedule).where(eq(classSchedule.id, id)) await db.delete(classSchedule).where(eq(classSchedule.id, id))
} }
export const getStudentsSubjectScores = cache(
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
if (studentIds.length === 0) return new Map()
// 1. Find assignments targeted at these students
const assignmentTargets = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
if (assignmentIds.length === 0) return new Map()
// 2. Get assignment details including subject from linked exam
const assignments = await db
.select({
id: homeworkAssignments.id,
createdAt: homeworkAssignments.createdAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(
inArray(homeworkAssignments.id, assignmentIds),
eq(homeworkAssignments.status, "published")
))
.orderBy(desc(homeworkAssignments.createdAt))
// 3. Filter subjects (exclude PE, Music, Art)
const excludeSubjects = ["体育", "音乐", "美术"]
const subjectAssignments = new Map<string, string>() // subject -> assignmentId (latest)
for (const a of assignments) {
if (!a.subjectName) continue
if (excludeSubjects.includes(a.subjectName)) continue
if (!subjectAssignments.has(a.subjectName)) {
subjectAssignments.set(a.subjectName, a.id)
}
}
const targetAssignmentIds = Array.from(subjectAssignments.values())
if (targetAssignmentIds.length === 0) return new Map()
// 4. Get submissions for these assignments
const submissions = await db
.select({
studentId: homeworkSubmissions.studentId,
assignmentId: homeworkSubmissions.assignmentId,
score: homeworkSubmissions.score,
createdAt: homeworkSubmissions.createdAt,
})
.from(homeworkSubmissions)
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
.orderBy(desc(homeworkSubmissions.createdAt))
// 5. Map back to subject scores per student
const studentScores = new Map<string, Record<string, number | null>>()
// Create reverse map for assignment -> subject
const assignmentSubjectMap = new Map<string, string>()
for (const [subject, id] of subjectAssignments.entries()) {
assignmentSubjectMap.set(id, subject)
}
for (const s of submissions) {
const subject = assignmentSubjectMap.get(s.assignmentId)
if (!subject) continue
if (!studentScores.has(s.studentId)) {
studentScores.set(s.studentId, {})
}
const scores = studentScores.get(s.studentId)!
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
if (scores[subject] === undefined) {
scores[subject] = s.score
}
}
return studentScores
}
)
export const getClassStudentSubjectScoresV2 = cache(
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
// 1. Get student IDs in the class
const enrollments = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(
eq(classEnrollments.classId, classId),
eq(classEnrollments.status, "active")
))
const studentIds = enrollments.map(e => e.studentId)
return getStudentsSubjectScores(studentIds)
}
)

View File

@@ -7,6 +7,22 @@ export type TeacherClass = {
room?: string | null room?: string | null
invitationCode?: string | null invitationCode?: string | null
studentCount: number studentCount: number
recentAssignments?: AssignmentSummary[]
schedule?: ClassScheduleItem[]
}
export interface AssignmentSummary {
id: string
title: string
status: string
subject?: string | null
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
} }
export type TeacherOption = { export type TeacherOption = {
@@ -65,9 +81,13 @@ export type ClassStudent = {
id: string id: string
name: string name: string
email: string email: string
image?: string | null
gender?: string | null
classId: string classId: string
className: string className: string
status: "active" | "inactive" status: "active" | "inactive"
joinedAt: Date
subjectScores?: Record<string, number | null>
} }
export type ClassScheduleItem = { export type ClassScheduleItem = {
@@ -80,26 +100,6 @@ export type ClassScheduleItem = {
location?: string | null location?: string | null
} }
export type StudentEnrolledClass = {
id: string
schoolName?: string | null
name: string
grade: string
homeroom?: string | null
room?: string | null
}
export type StudentScheduleItem = {
id: string
classId: string
className: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type CreateClassScheduleItemInput = { export type CreateClassScheduleItemInput = {
classId: string classId: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7 weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
@@ -118,13 +118,26 @@ export type UpdateClassScheduleItemInput = {
location?: string | null location?: string | null
} }
export type ClassBasicInfo = { export type StudentEnrolledClass = {
id: string id: string
schoolName?: string | null
name: string name: string
grade: string grade: string
homeroom?: string | null homeroom?: string | null
room?: string | null room?: string | null
invitationCode?: string | null teacherName?: string | null
teacherEmail?: string | null
}
export type StudentScheduleItem = {
id: string
classId: string
className: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
} }
export type ScoreStats = { export type ScoreStats = {
@@ -139,6 +152,7 @@ export type ClassHomeworkAssignmentStats = {
assignmentId: string assignmentId: string
title: string title: string
status: string status: string
subject?: string | null
createdAt: string createdAt: string
dueAt: string | null dueAt: string | null
isActive: boolean isActive: boolean
@@ -151,24 +165,25 @@ export type ClassHomeworkAssignmentStats = {
} }
export type ClassHomeworkInsights = { export type ClassHomeworkInsights = {
class: ClassBasicInfo class: {
studentCounts: { id: string
total: number schoolName?: string | null
active: number schoolId?: string | null
inactive: number name: string
grade: string
homeroom?: string | null
room?: string | null
invitationCode?: string | null
} }
studentCounts: { total: number; active: number; inactive: number }
assignments: ClassHomeworkAssignmentStats[] assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats overallScores: ScoreStats
} }
export type GradeHomeworkClassSummary = { export type GradeHomeworkClassSummary = {
class: ClassBasicInfo class: { id: string; name: string; grade: string; homeroom?: string | null; room?: string | null }
studentCounts: { studentCounts: { total: number; active: number; inactive: number }
total: number
active: number
inactive: number
}
latestAvg: number | null latestAvg: number | null
prevAvg: number | null prevAvg: number | null
deltaAvg: number | null deltaAvg: number | null
@@ -176,17 +191,9 @@ export type GradeHomeworkClassSummary = {
} }
export type GradeHomeworkInsights = { export type GradeHomeworkInsights = {
grade: { grade: { id: string; name: string; school: { id: string; name: string } }
id: string
name: string
school: { id: string; name: string }
}
classCount: number classCount: number
studentCounts: { studentCounts: { total: number; active: number; inactive: number }
total: number
active: number
inactive: number
}
assignments: ClassHomeworkAssignmentStats[] assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats overallScores: ScoreStats

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,67 +1,114 @@
import Link from "next/link"; import Link from "next/link";
import { Inbox, ArrowRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { Inbox } from "lucide-react"; import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import { formatDate } from "@/shared/lib/utils"; import { formatDate } from "@/shared/lib/utils";
import type { HomeworkSubmissionListItem } from "@/modules/homework/types"; import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
export function RecentSubmissions({ submissions }: { submissions: HomeworkSubmissionListItem[] }) { export function RecentSubmissions({
submissions,
title = "Recent Submissions",
emptyTitle = "No New Submissions",
emptyDescription = "All caught up! There are no new submissions to review."
}: {
submissions: HomeworkSubmissionListItem[],
title?: string,
emptyTitle?: string,
emptyDescription?: string
}) {
const hasSubmissions = submissions.length > 0; const hasSubmissions = submissions.length > 0;
return ( return (
<Card className="col-span-4 lg:col-span-4"> <Card className="h-full flex flex-col">
<CardHeader> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2 text-lg">
<Inbox className="h-4 w-4 text-muted-foreground" /> <Inbox className="h-5 w-5 text-primary" />
Recent Submissions {title}
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-primary" asChild>
<Link href="/teacher/homework/submissions" className="flex items-center gap-1">
View All <ArrowRight className="h-3 w-3" />
</Link>
</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1">
{!hasSubmissions ? ( {!hasSubmissions ? (
<EmptyState <EmptyState
icon={Inbox} icon={Inbox}
title="No New Submissions" title={emptyTitle}
description="All caught up! There are no new submissions to review." description={emptyDescription}
action={{ label: "View submissions", href: "/teacher/homework/submissions" }} action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
className="border-none h-[300px]" className="border-none h-full min-h-[200px]"
/> />
) : ( ) : (
<div className="space-y-6"> <div className="rounded-md border">
{submissions.map((item) => ( <Table>
<div key={item.id} className="flex items-center justify-between group"> <TableHeader>
<div className="flex items-center space-x-4"> <TableRow className="bg-muted/50">
<Avatar className="h-9 w-9"> <TableHead className="w-[200px]">Student</TableHead>
<AvatarImage src={undefined} alt={item.studentName} /> <TableHead>Assignment</TableHead>
<AvatarFallback>{item.studentName.charAt(0)}</AvatarFallback> <TableHead className="w-[140px]">Submitted</TableHead>
</Avatar> <TableHead className="w-[100px] text-right">Action</TableHead>
<div className="space-y-1"> </TableRow>
<p className="text-sm font-medium leading-none"> </TableHeader>
{item.studentName} <TableBody>
</p> {submissions.map((item) => (
<p className="text-sm text-muted-foreground"> <TableRow key={item.id} className="hover:bg-muted/50">
<Link <TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8 border">
<AvatarImage src={undefined} alt={item.studentName} />
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{item.studentName.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="font-medium text-sm">{item.studentName}</span>
</div>
</TableCell>
<TableCell>
<Link
href={`/teacher/homework/submissions/${item.id}`} href={`/teacher/homework/submissions/${item.id}`}
className="font-medium text-foreground hover:underline" className="font-medium hover:text-primary hover:underline transition-colors block truncate max-w-[240px]"
title={item.assignmentTitle}
> >
{item.assignmentTitle} {item.assignmentTitle}
</Link> </Link>
</p> </TableCell>
</div> <TableCell>
</div> <div className="flex flex-col gap-1">
<div className="flex items-center space-x-2"> <span className="text-xs text-muted-foreground">
<div className="text-sm text-muted-foreground"> {item.submittedAt ? formatDate(item.submittedAt) : "-"}
{item.submittedAt ? formatDate(item.submittedAt) : "-"} </span>
</div> {item.isLate && (
{item.isLate && ( <Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
<span className="inline-flex items-center rounded-full border border-destructive px-2 py-0.5 text-xs font-semibold text-destructive transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"> Late
Late </Badge>
</span> )}
)} </div>
</div> </TableCell>
</div> <TableCell className="text-right">
))} <Button size="sm" variant="secondary" className="h-8 px-3" asChild>
<Link href={`/teacher/homework/submissions/${item.id}`}>
Grade
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
)} )}
</CardContent> </CardContent>

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"
@@ -9,8 +8,6 @@ import type { TeacherClass } from "@/modules/classes/types"
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) { export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0) const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
const topClassesByStudents = [...classes].sort((a, b) => (b.studentCount ?? 0) - (a.studentCount ?? 0)).slice(0, 8)
const maxStudentCount = Math.max(1, ...topClassesByStudents.map((c) => c.studentCount ?? 0))
return ( return (
<Card> <Card>
@@ -33,52 +30,40 @@ export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
className="border-none h-72" className="border-none h-72"
/> />
) : ( ) : (
<> <div className="space-y-1">
{topClassesByStudents.length > 0 ? (
<div className="rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Students by class</div>
<div className="text-xs text-muted-foreground tabular-nums">Total {totalStudents}</div>
</div>
<div className="mt-3 grid gap-2">
{topClassesByStudents.map((c) => {
const count = c.studentCount ?? 0
const pct = Math.max(0, Math.min(100, (count / maxStudentCount) * 100))
return (
<div key={c.id} className="grid grid-cols-[minmax(0,1fr)_120px_52px] items-center gap-3">
<div className="truncate text-sm">{c.name}</div>
<div className="h-2 rounded-full bg-muted">
<div className="h-2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
</div>
<div className="text-right text-xs tabular-nums text-muted-foreground">{count}</div>
</div>
)
})}
</div>
</div>
) : null}
{classes.slice(0, 6).map((c) => ( {classes.slice(0, 6).map((c) => (
<Link <Link
key={c.id} key={c.id}
href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} href={`/teacher/classes/my/${encodeURIComponent(c.id)}`}
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50" className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
> >
<div className="min-w-0"> <div className="min-w-0 flex-1 mr-3">
<div className="font-medium truncate">{c.name}</div> <div className="font-medium truncate group-hover:text-primary transition-colors">{c.name}</div>
<div className="text-sm text-muted-foreground"> <div className="text-xs text-muted-foreground truncate flex items-center gap-1.5">
{c.grade} <span className="inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{c.homeroom ? ` · ${c.homeroom}` : ""} {c.grade}
{c.room ? ` · ${c.room}` : ""} </span>
{c.homeroom && (
<>
<span>·</span>
<span>Homeroom: {c.homeroom}</span>
</>
)}
{c.room && (
<>
<span>·</span>
<span>Room {c.room}</span>
</>
)}
</div> </div>
</div> </div>
<Badge variant="outline" className="flex items-center gap-1"> <div className="flex items-center text-xs text-muted-foreground tabular-nums">
<Users className="h-3 w-3" /> <Users className="mr-1.5 h-3 w-3 opacity-70" />
{c.studentCount} students {c.studentCount}
</Badge> </div>
</Link> </Link>
))} ))}
</> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,11 +1,22 @@
import { TeacherQuickActions } from "./teacher-quick-actions" import { TeacherQuickActions } from "./teacher-quick-actions"
export function TeacherDashboardHeader() { interface TeacherDashboardHeaderProps {
teacherName: string
}
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
const today = new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})
return ( return (
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0"> <div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">Teacher</h2> <h2 className="text-2xl font-bold tracking-tight">Good morning, {teacherName}</h2>
<p className="text-muted-foreground">Overview of today&apos;s work and your classes.</p> <p className="text-muted-foreground">It&apos;s {today}. Here&apos;s your daily overview.</p>
</div> </div>
<TeacherQuickActions /> <TeacherQuickActions />
</div> </div>

View File

@@ -6,6 +6,7 @@ import { TeacherHomeworkCard } from "./teacher-homework-card"
import { RecentSubmissions } from "./recent-submissions" import { RecentSubmissions } from "./recent-submissions"
import { TeacherSchedule } from "./teacher-schedule" import { TeacherSchedule } from "./teacher-schedule"
import { TeacherStats } from "./teacher-stats" import { TeacherStats } from "./teacher-stats"
import { TeacherGradeTrends } from "./teacher-grade-trends"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay() const day = d.getDay()
@@ -32,27 +33,52 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt)) const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
const recentSubmissions = submittedSubmissions.slice(0, 6)
// Filter for submissions that actually need grading (status === "submitted")
// If we have less than 5 to grade, maybe also show some recently graded ones?
// For now, let's stick to "Needs Grading" as it's more useful.
const submissionsToGrade = submittedSubmissions
.filter(s => s.status === "submitted")
.sort((a, b) => new Date(a.submittedAt!).getTime() - new Date(b.submittedAt!).getTime()) // Oldest first? Or Newest? Usually oldest first for queue.
.slice(0, 6);
// Calculate stats for the dashboard
const activeAssignmentsCount = data.assignments.filter(a => a.status === "published").length
const totalTrendScore = data.gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0)
const averageScore = data.gradeTrends.length > 0 ? totalTrendScore / data.gradeTrends.length : 0
const totalSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.submissionCount, 0)
const totalPotentialSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.totalStudents, 0)
const submissionRate = totalPotentialSubmissions > 0 ? (totalSubmissions / totalPotentialSubmissions) * 100 : 0
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-6 p-8">
<TeacherDashboardHeader /> <TeacherDashboardHeader teacherName={data.teacherName} />
<TeacherStats <TeacherStats
totalStudents={totalStudents}
classCount={data.classes.length}
toGradeCount={toGradeCount} toGradeCount={toGradeCount}
todayScheduleCount={todayScheduleItems.length} activeAssignmentsCount={activeAssignmentsCount}
averageScore={averageScore}
submissionRate={submissionRate}
/> />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> <div className="grid gap-6 lg:grid-cols-12">
<TeacherSchedule items={todayScheduleItems} /> <div className="flex flex-col gap-6 lg:col-span-8">
<RecentSubmissions submissions={recentSubmissions} /> <TeacherGradeTrends trends={data.gradeTrends} />
</div> <RecentSubmissions
submissions={submissionsToGrade}
<div className="grid gap-6 lg:grid-cols-2"> title="Needs Grading"
<TeacherClassesCard classes={data.classes} /> emptyTitle="All caught up!"
<TeacherHomeworkCard assignments={data.assignments} /> emptyDescription="You have no pending submissions to grade."
/>
</div>
<div className="flex flex-col gap-6 lg:col-span-4">
<TeacherSchedule items={todayScheduleItems} />
<TeacherHomeworkCard assignments={data.assignments} />
<TeacherClassesCard classes={data.classes} />
</div>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,135 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { TrendingUp } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { TeacherGradeTrendItem } from "@/modules/homework/types"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }) {
const hasTrends = trends.length > 0
// Calculate percentages for the chart
const chartData = trends.map((item) => {
const percentage = item.maxScore > 0 ? (item.averageScore / item.maxScore) * 100 : 0
return {
title: item.title,
score: Math.round(percentage),
fullTitle: item.title, // For tooltip
submissionCount: item.submissionCount,
totalStudents: item.totalStudents,
}
})
const chartConfig = {
score: {
label: "Average Score (%)",
color: "hsl(var(--primary))",
},
}
return (
<Card className="col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base font-medium">
<TrendingUp className="h-4 w-4 text-primary" />
Class Performance
</CardTitle>
<CardDescription>
Average scores for the last {trends.length} assignments
</CardDescription>
</CardHeader>
<CardContent>
{!hasTrends ? (
<EmptyState
icon={TrendingUp}
title="No data available"
description="Publish assignments to see class performance trends."
className="border-none h-[200px] p-0"
/>
) : (
<div className="space-y-4">
<ChartContainer config={chartConfig} className="h-[200px] w-full">
<LineChart
data={chartData}
margin={{
left: 12,
right: 12,
top: 12,
bottom: 12,
}}
>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey="title"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}%`}
width={30}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={
<ChartTooltipContent
indicator="line"
labelKey="fullTitle"
className="w-[200px]"
/>
}
/>
<Line
dataKey="score"
type="monotone"
stroke="var(--color-score)"
strokeWidth={2}
dot={{
fill: "var(--color-score)",
r: 4,
strokeWidth: 2,
stroke: "hsl(var(--background))"
}}
activeDot={{
r: 6,
strokeWidth: 2,
stroke: "hsl(var(--background))"
}}
/>
</LineChart>
</ChartContainer>
{/* Metric Summary */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{chartData.slice().reverse().slice(0, 3).map((item, i) => (
<div key={i} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
<div className="text-xs text-muted-foreground truncate" title={item.fullTitle}>
{item.fullTitle}
</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tabular-nums">
{item.score}%
</span>
</div>
<div className="text-[10px] text-muted-foreground">
{item.submissionCount}/{item.totalStudents} submitted
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,54 +1,93 @@
import Link from "next/link" import Link from "next/link"
import { PenTool } from "lucide-react" import { PenTool, Calendar, Plus } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { cn, formatDate } from "@/shared/lib/utils"
import type { HomeworkAssignmentListItem } from "@/modules/homework/types" import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) { export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<PenTool className="h-4 w-4 text-muted-foreground" /> <PenTool className="h-4 w-4 text-muted-foreground" />
Homework Homework
</CardTitle> </CardTitle>
<div className="flex items-center gap-2"> <Button asChild size="icon" variant="ghost" className="h-8 w-8">
<Button asChild variant="outline" size="sm"> <Link href="/teacher/homework/assignments/create" title="Create new assignment">
<Link href="/teacher/homework/assignments">Open list</Link> <Plus className="h-4 w-4" />
</Button> </Link>
<Button asChild size="sm"> </Button>
<Link href="/teacher/homework/assignments/create">New</Link>
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent>
{assignments.length === 0 ? ( {assignments.length === 0 ? (
<EmptyState <EmptyState
icon={PenTool} icon={PenTool}
title="No homework assignments yet" title="No assignments"
description="Create an assignment from an exam and publish it to students." description="Create an assignment to get started."
action={{ label: "Create assignment", href: "/teacher/homework/assignments/create" }} action={{ label: "Create", href: "/teacher/homework/assignments/create" }}
className="border-none h-72" className="border-none h-48"
/> />
) : ( ) : (
assignments.slice(0, 6).map((a) => ( <div className="space-y-1">
<Link {assignments.slice(0, 6).map((a) => {
key={a.id} const isPublished = a.status === "published"
href={`/teacher/homework/assignments/${encodeURIComponent(a.id)}`} const isDraft = a.status === "draft"
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
> return (
<div className="min-w-0"> <Link
<div className="font-medium truncate">{a.title}</div> key={a.id}
<div className="text-sm text-muted-foreground truncate">{a.sourceExamTitle}</div> href={`/teacher/homework/assignments/${encodeURIComponent(a.id)}`}
</div> className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
<Badge variant="outline" className="capitalize"> >
{a.status} <div className="min-w-0 flex-1 mr-3">
</Badge> <div className="flex items-center gap-2 mb-0.5">
</Link> <div className={cn(
)) "h-2 w-2 rounded-full",
isPublished ? "bg-emerald-500" :
isDraft ? "bg-amber-400" : "bg-muted-foreground"
)} />
<div className="font-medium truncate text-sm group-hover:text-primary transition-colors">
{a.title}
</div>
</div>
<div className="text-xs text-muted-foreground truncate pl-4">
{a.sourceExamTitle}
</div>
</div>
<div className="flex flex-col items-end gap-1">
{a.dueAt ? (
<div className="flex items-center text-xs text-muted-foreground tabular-nums">
<Calendar className="mr-1 h-3 w-3 opacity-70" />
{formatDate(a.dueAt)}
</div>
) : (
<span className="text-[10px] text-muted-foreground italic">No due date</span>
)}
<Badge
variant="outline"
className={cn(
"text-[10px] h-4 px-1.5 capitalize font-normal border-transparent bg-muted/50",
isPublished && "text-emerald-600 bg-emerald-500/10",
isDraft && "text-amber-600 bg-amber-500/10"
)}
>
{a.status}
</Badge>
</div>
</Link>
)
})}
<div className="pt-2">
<Button asChild variant="link" size="sm" className="w-full text-muted-foreground h-auto py-1 text-xs">
<Link href="/teacher/homework/assignments">View all assignments</Link>
</Button>
</div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,9 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Clock, MapPin, CalendarDays, CalendarX } from "lucide-react"; import { CalendarDays, CalendarX, MapPin } from "lucide-react";
import { EmptyState } from "@/shared/components/ui/empty-state"; import { EmptyState } from "@/shared/components/ui/empty-state";
import { cn } from "@/shared/lib/utils";
import { ScrollArea } from "@/shared/components/ui/scroll-area";
type TeacherTodayScheduleItem = { type TeacherTodayScheduleItem = {
id: string; id: string;
@@ -17,55 +18,131 @@ type TeacherTodayScheduleItem = {
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) { export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
const hasSchedule = items.length > 0; const hasSchedule = items.length > 0;
const getStatus = (start: string, end: string) => {
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes();
const [startH, startM] = start.split(":").map(Number);
const [endH, endM] = end.split(":").map(Number);
const startTime = startH * 60 + startM;
const endTime = endH * 60 + endM;
if (currentTime >= startTime && currentTime <= endTime) return "live";
if (currentTime < startTime) return "upcoming";
return "past";
};
return ( return (
<Card className="col-span-3"> <Card>
<CardHeader> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" /> <CalendarDays className="h-4 w-4 text-muted-foreground" />
Today&apos;s Schedule Today&apos;s Schedule
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
{!hasSchedule ? ( {!hasSchedule ? (
<EmptyState <EmptyState
icon={CalendarX} icon={CalendarX}
title="No Classes Today" title="No Classes Today"
description="No timetable entries for today." description="No timetable entries."
action={{ label: "View schedule", href: "/teacher/classes/schedule" }} action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
className="border-none h-[300px]" className="border-none h-[200px]"
/> />
) : ( ) : (
<div className="space-y-4"> <ScrollArea className="h-[240px] px-6 py-2">
{items.map((item) => ( <div className="relative space-y-0 ml-1">
<div {/* Vertical Timeline Line */}
key={item.id} <div className="absolute left-[11px] -top-2 -bottom-2 w-px bg-border/50" />
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
> {/* Top Fade Hint */}
<div className="space-y-1"> <div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
<Link
href={`/teacher/classes/schedule?classId=${encodeURIComponent(item.classId)}`} {items.map((item, index) => {
className="font-medium leading-none hover:underline" const status = getStatus(item.startTime, item.endTime);
> const isLive = status === "live";
{item.course} const isPast = status === "past";
</Link> const isLast = index === items.length - 1;
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-1 h-3 w-3" /> return (
<span className="mr-3">{item.startTime}{item.endTime}</span> <div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
{item.location ? ( {/* Timeline Dot */}
<> <div className={cn(
<MapPin className="mr-1 h-3 w-3" /> "absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
<span>{item.location}</span> isLive ? "bg-primary border-primary" :
</> isPast ? "bg-muted border-muted-foreground/30" :
) : null} "bg-background border-primary"
)} />
<Link
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
className={cn(
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
"bg-card"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"font-medium text-sm truncate",
isLive ? "text-primary" : "text-foreground"
)}>
{item.course}
</span>
{isLive && (
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
LIVE
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
<span>{item.className}</span>
{item.location && (
<>
<span>·</span>
<span className="flex items-center">
<MapPin className="mr-0.5 h-2.5 w-2.5" />
{item.location}
</span>
</>
)}
</div>
</div>
<div className={cn(
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
isLive ? "text-primary" : "text-muted-foreground"
)}>
{item.startTime}
<span className="text-[10px] opacity-70 ml-0.5"> {item.endTime}</span>
</div>
</div>
</Link>
{/* Connection Line to Next (if not last) */}
{!isLast && (
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
)}
</div>
);
})}
{/* Bottom Hint */}
{items.length > 3 ? (
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
Scroll for more
</div> </div>
</div> ) : (
<Badge variant="secondary"> <div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
{item.className} No more classes today
</Badge> </div>
</div> )}
))} </div>
</div> </ScrollArea>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,20 +1,22 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Users, BookOpen, FileCheck, Calendar } from "lucide-react"; import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react";
import { Skeleton } from "@/shared/components/ui/skeleton"; import { Skeleton } from "@/shared/components/ui/skeleton";
import { cn } from "@/shared/lib/utils";
interface TeacherStatsProps { interface TeacherStatsProps {
totalStudents: number;
classCount: number;
toGradeCount: number; toGradeCount: number;
todayScheduleCount: number; activeAssignmentsCount: number;
averageScore: number;
submissionRate: number;
isLoading?: boolean; isLoading?: boolean;
} }
export function TeacherStats({ export function TeacherStats({
totalStudents,
classCount,
toGradeCount, toGradeCount,
todayScheduleCount, activeAssignmentsCount,
averageScore,
submissionRate,
isLoading = false, isLoading = false,
}: TeacherStatsProps) { }: TeacherStatsProps) {
if (isLoading) { if (isLoading) {
@@ -38,48 +40,62 @@ export function TeacherStats({
const stats = [ const stats = [
{ {
title: "Total Students", title: "Needs Grading",
value: String(totalStudents),
description: "Across all your classes",
icon: Users,
},
{
title: "My Classes",
value: String(classCount),
description: "Active classes you manage",
icon: BookOpen,
},
{
title: "To Grade",
value: String(toGradeCount), value: String(toGradeCount),
description: "Submitted homework waiting for grading", description: "Submissions pending review",
icon: FileCheck, icon: FileCheck,
href: "/teacher/homework/submissions?status=submitted",
highlight: toGradeCount > 0,
color: "text-amber-500",
}, },
{ {
title: "Today", title: "Active Assignments",
value: String(todayScheduleCount), value: String(activeAssignmentsCount),
description: "Scheduled items today", description: "Published and ongoing",
icon: Calendar, icon: PenTool,
href: "/teacher/homework/assignments?status=published",
highlight: false,
color: "text-blue-500",
},
{
title: "Average Score",
value: `${Math.round(averageScore)}%`,
description: "Across recent assignments",
icon: TrendingUp,
href: "#grade-trends",
highlight: false,
color: "text-emerald-500",
},
{
title: "Submission Rate",
value: `${Math.round(submissionRate)}%`,
description: "Overall completion rate",
icon: BarChart,
href: "#grade-trends",
highlight: false,
color: "text-purple-500",
}, },
] as const; ] as const;
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, i) => ( {stats.map((stat, i) => (
<Card key={i}> <Link key={i} href={stat.href} className="block transition-transform hover:-translate-y-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card className={cn(stat.highlight && "border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20")}>
<CardTitle className="text-sm font-medium"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
{stat.title} <CardTitle className="text-sm font-medium">
</CardTitle> {stat.title}
<stat.icon className="h-4 w-4 text-muted-foreground" /> </CardTitle>
</CardHeader> <stat.icon className={cn("h-4 w-4", stat.color)} />
<CardContent> </CardHeader>
<div className="text-2xl font-bold">{stat.value}</div> <CardContent>
<p className="text-xs text-muted-foreground"> <div className="text-2xl font-bold">{stat.value}</div>
{stat.description} <p className="text-xs text-muted-foreground">
</p> {stat.description}
</CardContent> </p>
</Card> </CardContent>
</Card>
</Link>
))} ))}
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/types" import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
import type { TeacherClass, ClassScheduleItem } from "@/modules/classes/types" import type { TeacherClass, ClassScheduleItem } from "@/modules/classes/types"
import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem } from "@/modules/homework/types" import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem, TeacherGradeTrendItem } from "@/modules/homework/types"
export type AdminDashboardUserRoleCount = { export type AdminDashboardUserRoleCount = {
role: string role: string
@@ -67,4 +67,6 @@ export type TeacherDashboardData = {
schedule: ClassScheduleItem[] schedule: ClassScheduleItem[]
assignments: HomeworkAssignmentListItem[] assignments: HomeworkAssignmentListItem[]
submissions: HomeworkSubmissionListItem[] submissions: HomeworkSubmissionListItem[]
teacherName: string
gradeTrends: TeacherGradeTrendItem[]
} }

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

@@ -550,6 +550,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
return ( return (
<DndContext <DndContext
id="structure-editor-dnd"
sensors={sensors} sensors={sensors}
collisionDetection={customCollisionDetection} collisionDetection={customCollisionDetection}
onDragStart={handleDragStart} onDragStart={handleDragStart}

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

@@ -29,8 +29,69 @@ import type {
StudentDashboardGradeProps, StudentDashboardGradeProps,
StudentHomeworkScoreAnalytics, StudentHomeworkScoreAnalytics,
StudentRanking, StudentRanking,
TeacherGradeTrendItem,
} from "./types" } from "./types"
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
const recentAssignments = await db.query.homeworkAssignments.findMany({
where: and(
eq(homeworkAssignments.creatorId, teacherId),
or(eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "archived"))
),
orderBy: [desc(homeworkAssignments.createdAt)],
limit: limit,
})
if (recentAssignments.length === 0) return []
const assignmentIds = recentAssignments.map((a) => a.id)
const [maxScoreMap, targetCountRows, submissionStats] = await Promise.all([
getAssignmentMaxScoreById(assignmentIds),
db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
count: count(homeworkAssignmentTargets.studentId),
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
count: count(homeworkSubmissions.id),
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
)
)
.groupBy(homeworkSubmissions.assignmentId),
])
const targetCountMap = new Map<string, number>()
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
const statsMap = new Map<string, { avg: number; count: number }>()
for (const r of submissionStats) statsMap.set(r.assignmentId, { avg: Number(r.avgScore), count: Number(r.count) })
return recentAssignments.map((a) => {
const stats = statsMap.get(a.id) ?? { avg: 0, count: 0 }
return {
id: a.id,
title: a.title,
averageScore: stats.avg,
maxScore: maxScoreMap.get(a.id) ?? 0,
submissionCount: stats.count,
totalStudents: targetCountMap.get(a.id) ?? 0,
createdAt: a.createdAt.toISOString(),
}
}).reverse() // Reverse to show trend from left (older) to right (newer)
})
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => { const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
@@ -463,6 +524,17 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
}) })
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
// Fetch adjacent submissions for navigation
const allSubmissions = await db.query.homeworkSubmissions.findMany({
where: eq(homeworkSubmissions.assignmentId, submission.assignmentId),
orderBy: [desc(homeworkSubmissions.updatedAt)],
columns: { id: true },
})
const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId)
const prevSubmissionId = currentIndex > 0 ? allSubmissions[currentIndex - 1].id : null
const nextSubmissionId = currentIndex >= 0 && currentIndex < allSubmissions.length - 1 ? allSubmissions[currentIndex + 1].id : null
return { return {
id: submission.id, id: submission.id,
assignmentId: submission.assignmentId, assignmentId: submission.assignmentId,
@@ -472,6 +544,8 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
status: submission.status as HomeworkSubmissionDetails["status"], status: submission.status as HomeworkSubmissionDetails["status"],
totalScore: submission.score, totalScore: submission.score,
answers: answersWithDetails, answers: answersWithDetails,
prevSubmissionId,
nextSubmissionId,
} }
}) })
@@ -582,16 +656,32 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({ const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId), where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true }, with: {
question: {
with: {
knowledgePoints: {
with: {
knowledgePoint: true
}
}
}
}
},
orderBy: (q, { asc }) => [asc(q.order)], orderBy: (q, { asc }) => [asc(q.order)],
}) })
const savedByQuestionId = new Map<string, unknown>() const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
if (latestSubmission) { if (latestSubmission) {
const answers = await db.query.homeworkAnswers.findMany({ const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, latestSubmission.id), where: eq(homeworkAnswers.submissionId, latestSubmission.id),
}) })
for (const ans of answers) savedByQuestionId.set(ans.questionId, ans.answerContent) for (const ans of answers) {
answersByQuestionId.set(ans.questionId, {
answer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
})
}
} }
return { return {
@@ -614,14 +704,25 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
score: latestSubmission.score ?? null, score: latestSubmission.score ?? null,
} }
: null, : null,
questions: assignmentQuestions.map((aq) => ({ questions: assignmentQuestions.map((aq) => {
questionId: aq.questionId, const saved = answersByQuestionId.get(aq.questionId)
questionType: aq.question.type, // Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
questionContent: toQuestionContent(aq.question.content), const kps = aq.question.knowledgePoints ?? []
maxScore: aq.score ?? 0, return {
order: aq.order ?? 0, questionId: aq.questionId,
savedAnswer: savedByQuestionId.get(aq.questionId) ?? null, questionType: aq.question.type,
})), questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
savedAnswer: saved?.answer ?? null,
score: saved?.score ?? null,
feedback: saved?.feedback ?? null,
knowledgePoints: kps.map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name,
})),
}
}),
} }
}) })

View File

@@ -2,6 +2,16 @@ export type HomeworkAssignmentStatus = "draft" | "published" | "archived"
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded" export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
export interface TeacherGradeTrendItem {
id: string
title: string
averageScore: number
maxScore: number
submissionCount: number
totalStudents: number
createdAt: string
}
export interface HomeworkAssignmentListItem { export interface HomeworkAssignmentListItem {
id: string id: string
sourceExamId: string sourceExamId: string
@@ -63,6 +73,8 @@ export type HomeworkSubmissionDetails = {
status: HomeworkSubmissionStatus status: HomeworkSubmissionStatus
totalScore: number | null totalScore: number | null
answers: HomeworkSubmissionAnswerDetails[] answers: HomeworkSubmissionAnswerDetails[]
prevSubmissionId?: string | null
nextSubmissionId?: string | null
} }
export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded" export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "submitted" | "graded"
@@ -104,6 +116,9 @@ export type StudentHomeworkTakeQuestion = {
maxScore: number maxScore: number
order: number order: number
savedAnswer: unknown savedAnswer: unknown
score?: number | null
feedback?: string | null
knowledgePoints?: Array<{ id: string; name: string }>
} }
export type StudentHomeworkTakeData = { export type StudentHomeworkTakeData = {
@@ -135,7 +150,7 @@ export type HomeworkAssignmentQuestionAnalytics = {
order: number order: number
errorCount: number errorCount: number
errorRate: number errorRate: number
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }> wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown; count?: number }>
} }
export type HomeworkAssignmentAnalytics = { export type HomeworkAssignmentAnalytics = {

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"
@@ -123,8 +124,14 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{ title: "My Classes", href: "/teacher/classes/my" }, { title: "My Classes", href: "/teacher/classes/my" },
{ title: "Students", href: "/teacher/classes/students" }, { title: "Students", href: "/teacher/classes/students" },
{ title: "Schedule", href: "/teacher/classes/schedule" }, { title: "Schedule", href: "/teacher/classes/schedule" },
{ title: "Insights", href: "/teacher/classes/insights" }, ]
{ title: "Grade Insights", href: "/teacher/grades/insights" }, },
{
title: "Management",
icon: Briefcase,
href: "/management",
items: [
{ title: "Grade Insights", href: "/management/grade/insights" },
] ]
}, },
], ],

Some files were not shown because too many files have changed in this diff Show More