diff --git a/docs/design/002_teacher_dashboard_implementation.md b/docs/design/002_teacher_dashboard_implementation.md index 69a5f8b..b37c6f5 100644 --- a/docs/design/002_teacher_dashboard_implementation.md +++ b/docs/design/002_teacher_dashboard_implementation.md @@ -201,102 +201,42 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面 --- -## 7. 教师仪表盘体验优化 (2026-01-12) +## 7. 班级管理重构与角色分离 (2026-01-14) -**目标**: 提升教师仪表盘的信息密度与易用性,优化核心指标展示,调整布局以符合教师工作流。 +**日期**: 2026-01-14 +**范围**: 班级创建权限收归管理端,教师端仅保留查看与加入 -### 7.1 核心指标卡片重构 (TeacherStats) -- **原有问题**: 展示的总学生数、总课程数等静态指标对日常教学决策帮助有限。 -- **优化方案**: 替换为高频动态指标,并增强视觉提示。 - - **Needs Grading (待批改)**: 高亮显示待处理事项,使用 Amber 色彩引起注意。 - - **Active Assignments (活跃作业)**: 显示当前发布的作业数量,反映教学负载。 - - **Average Score (平均分)**: 展示近期作业平均分,快速了解学情。 - - **Submission Rate (提交率)**: 展示整体作业完成度,反映学生参与度。 +### 7.1 职责分离 (Role Separation) -### 7.2 布局调整 (Layout Restructuring) -- **原有问题**: "Needs Grading" 位于侧边栏,空间受限;"Homework" 列表占据主栏,信息密度低。 -- **优化方案**: - - **Needs Grading 移至主栏**: 给予更多宽幅空间,展示详细的学生、作业信息及操作按钮。 - - **Homework 移至侧边栏**: 改为紧凑列表视图,作为快速导航入口。 - - **Schedule 优化**: 引入时间轴 (Timeline) 视图,支持滚动提示与当前状态指示。 +- **管理端 (Management)**: + - 新增 `src/app/(dashboard)/management/grade/classes/page.tsx` + - 供年级组长 (Grade Head) 与管理员创建、编辑、删除班级 + - 引入 `GradeClassesView` 组件,支持按年级管理班级 +- **教师端 (Teacher)**: + - 移除创建班级入口 + - 新增「通过邀请码加入班级」功能 (`JoinClassDialog`) + - `MyClassesGrid` 样式优化,移除硬编码渐变,使用标准 `bg-card` -### 7.3 组件功能增强 -- **RecentSubmissions (Needs Grading)**: - - 升级为 Table 视图,展示头像、作业名、提交时间。 - - 增加 "Grade" 快捷按钮,一键进入批改页面。 - - 增加 "Late" 状态标记。 -- **TeacherSchedule**: - - 采用垂直时间轴设计。 - - 增加滚动提示 (Scroll Hint) 与 "No more classes" 状态提示。 -- **TeacherHomeworkCard**: - - 优化为紧凑型列表,显示发布状态 (Published/Draft) 与截止日期。 +### 7.2 数据访问与权限 -### 7.4 技术细节 -- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。 -- 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。 -- **New Components**: - - `TeacherGradeTrends`: 基于 Recharts 的趋势图组件。 - - `Chart`: 基于 Shadcn/UI 规范的通用图表包装器 (`src/shared/components/ui/chart.tsx`)。 +- 新增 `getGradeManagedClasses`: 仅返回用户作为 Grade Head 或 Teaching Head 管理的年级下的班级 +- Server Actions (`createGradeClassAction` 等) 增加严格的 RBAC 校验,确保操作者对目标年级有管理权限 -### 7.5 代码管理 -- **Branch**: `ui_opt` -- **Scope**: `src/modules/dashboard`, `src/shared/components/ui/chart.tsx` -- **Commit**: "feat(dashboard): optimize teacher dashboard ui and layout" +## 8. 课表模块视觉升级与架构优化 (2026-01-15) ---- +**日期**: 2026-01-15 +**范围**: 课表视图 (Schedule View) 视觉重构、Insights 模块移除 -## 8. 班级详情页与学生管理优化 (2026-01-14) +### 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.1 学生管理列表优化 (Students Table) -- **分页 (Pagination)**: 引入客户端分页(每页 10 条),解决大班级列表渲染性能问题。 -- **信息增强**: - - 增加学生头像 (Avatar)、性别、加入时间展示。 - - 增加可视化状态徽章 (Status Badge):Active (Emerald) / Inactive (Muted)。 -- **筛选能力**: - - 新增状态筛选器 (Active/Inactive),支持服务端过滤。 -- **涉及组件**: - - `src/modules/classes/components/students-table.tsx` - - `src/modules/classes/components/students-filters.tsx` +### 8.2 架构精简 (Insights Removal) -### 8.2 班级详情页重构 (Class Detail Dashboard) -- **布局重构**: 采用响应式双栏布局 (Main Content + Sidebar),提升空间利用率。 -- **核心指标 (Key Metrics)**: 顶部增加 4 卡片统计网格: - - **Total Students**: 活跃/非活跃人数细分。 - - **Schedule Items**: 每周课程数。 - - **Active Assignments**: 活跃作业数与逾期数。 - - **Class Average**: 基于已评分作业的平均分。 -- **侧边栏小部件**: - - **Class Schedule**: 快速查看近期课程。 - - **Homework History**: 快速查看历史作业状态。 -- **涉及页面**: - - `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx` - -### 8.3 数据访问层更新 -- **getClassStudents**: 扩展查询字段(头像、性别、加入时间),支持 `status` 过滤参数。 - -### 8.4 权限与流程调整 (Role Separation) -- **教师端变更**: - - 移除了“创建班级”入口,教师不再直接创建班级。 - - 新增“加入班级” (Join Class) 功能,通过 6 位邀请码加入已由管理员创建的班级。 - - 涉及组件:`src/modules/classes/components/my-classes-grid.tsx` - -## 9. 年级管理端班级模块 (2026-01-14) - -**目标**: 实现年级维度的班级集中管理,支持年级组长与管理员统一创建与维护班级。 - -### 9.1 路由与入口 -- **路由**: `src/app/(dashboard)/management/grade/classes/page.tsx` -- **权限**: 仅限拥有年级管理权限的角色(Grade Director / Teaching Head / Admin)。 - -### 9.2 功能特性 -- **GradeClassesView**: - - 展示用户所管理年级的所有班级列表。 - - 支持按年级筛选。 - - **CRUD**: 提供创建、编辑、删除班级的完整能力。 - - **RBAC**: 操作前校验用户对目标年级的管理权限。 - -### 9.3 核心变更 -- **Data Access**: 新增 `getManagedGrades` 与 `getGradeManagedClasses`。 -- **Actions**: 新增 `createGradeClassAction` 等带权限校验的 Server Actions。 +- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。 +- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。 +- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。 diff --git a/docs/work_log.md b/docs/work_log.md index 058dd09..8dd4e56 100644 --- a/docs/work_log.md +++ b/docs/work_log.md @@ -1,5 +1,34 @@ # 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) diff --git a/drizzle/0008_thin_madrox.sql b/drizzle/0008_thin_madrox.sql new file mode 100644 index 0000000..9519df1 --- /dev/null +++ b/drizzle/0008_thin_madrox.sql @@ -0,0 +1 @@ +ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..913c054 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,3071 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d", + "prevId": "5eaf9185-8a1e-4e35-8144-553aec7ff31f", + "tables": { + "academic_years": { + "name": "academic_years", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "academic_years_name_idx": { + "name": "academic_years_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "academic_years_active_idx": { + "name": "academic_years_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "academic_years_id": { + "name": "academic_years_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "academic_years_name_unique": { + "name": "academic_years_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_providerAccountId_pk": { + "name": "accounts_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "chapters": { + "name": "chapters", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "textbook_id": { + "name": "textbook_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "textbook_idx": { + "name": "textbook_idx", + "columns": [ + "textbook_id" + ], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chapters_textbook_id_textbooks_id_fk": { + "name": "chapters_textbook_id_textbooks_id_fk", + "tableFrom": "chapters", + "tableTo": "textbooks", + "columnsFrom": [ + "textbook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chapters_id": { + "name": "chapters_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "class_enrollments": { + "name": "class_enrollments", + "columns": { + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_enrollment_status": { + "name": "class_enrollment_status", + "type": "enum('active','inactive')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "class_enrollments_class_idx": { + "name": "class_enrollments_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "class_enrollments_student_idx": { + "name": "class_enrollments_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ce_c_fk": { + "name": "ce_c_fk", + "tableFrom": "class_enrollments", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ce_s_fk": { + "name": "ce_s_fk", + "tableFrom": "class_enrollments", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "class_enrollments_class_id_student_id_pk": { + "name": "class_enrollments_class_id_student_id_pk", + "columns": [ + "class_id", + "student_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "class_schedule": { + "name": "class_schedule", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weekday": { + "name": "weekday", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "course": { + "name": "course", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "class_schedule_class_idx": { + "name": "class_schedule_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "class_schedule_class_day_idx": { + "name": "class_schedule_class_day_idx", + "columns": [ + "class_id", + "weekday" + ], + "isUnique": false + } + }, + "foreignKeys": { + "cs_c_fk": { + "name": "cs_c_fk", + "tableFrom": "class_schedule", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "class_schedule_id": { + "name": "class_schedule_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "class_subject_teachers": { + "name": "class_subject_teachers", + "columns": { + "class_id": { + "name": "class_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "enum('语文','数学','英语','美术','体育','科学','社会','音乐')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "teacher_id": { + "name": "teacher_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "class_subject_teachers_class_idx": { + "name": "class_subject_teachers_class_idx", + "columns": [ + "class_id" + ], + "isUnique": false + }, + "class_subject_teachers_teacher_idx": { + "name": "class_subject_teachers_teacher_idx", + "columns": [ + "teacher_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "class_subject_teachers_teacher_id_users_id_fk": { + "name": "class_subject_teachers_teacher_id_users_id_fk", + "tableFrom": "class_subject_teachers", + "tableTo": "users", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cst_c_fk": { + "name": "cst_c_fk", + "tableFrom": "class_subject_teachers", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "class_subject_teachers_class_id_subject_pk": { + "name": "class_subject_teachers_class_id_subject_pk", + "columns": [ + "class_id", + "subject" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "classes": { + "name": "classes", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "school_name": { + "name": "school_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "school_id": { + "name": "school_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade_id": { + "name": "grade_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homeroom": { + "name": "homeroom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room": { + "name": "room", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitation_code": { + "name": "invitation_code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teacher_id": { + "name": "teacher_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "classes_teacher_idx": { + "name": "classes_teacher_idx", + "columns": [ + "teacher_id" + ], + "isUnique": false + }, + "classes_grade_idx": { + "name": "classes_grade_idx", + "columns": [ + "grade" + ], + "isUnique": false + }, + "classes_school_idx": { + "name": "classes_school_idx", + "columns": [ + "school_id" + ], + "isUnique": false + }, + "classes_grade_id_idx": { + "name": "classes_grade_id_idx", + "columns": [ + "grade_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "classes_teacher_id_users_id_fk": { + "name": "classes_teacher_id_users_id_fk", + "tableFrom": "classes", + "tableTo": "users", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "c_s_fk": { + "name": "c_s_fk", + "tableFrom": "classes", + "tableTo": "schools", + "columnsFrom": [ + "school_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "c_g_fk": { + "name": "c_g_fk", + "tableFrom": "classes", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "classes_id": { + "name": "classes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "classes_invitation_code_unique": { + "name": "classes_invitation_code_unique", + "columns": [ + "invitation_code" + ] + } + }, + "checkConstraint": {} + }, + "classrooms": { + "name": "classrooms", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "building": { + "name": "building", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "floor": { + "name": "floor", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capacity": { + "name": "capacity", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "classrooms_name_idx": { + "name": "classrooms_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "classrooms_id": { + "name": "classrooms_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "classrooms_name_unique": { + "name": "classrooms_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "departments": { + "name": "departments", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "departments_name_idx": { + "name": "departments_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "departments_id": { + "name": "departments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "departments_name_unique": { + "name": "departments_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "exam_questions": { + "name": "exam_questions", + "columns": { + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "exam_questions_exam_id_exams_id_fk": { + "name": "exam_questions_exam_id_exams_id_fk", + "tableFrom": "exam_questions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_questions_question_id_questions_id_fk": { + "name": "exam_questions_question_id_questions_id_fk", + "tableFrom": "exam_questions", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_questions_exam_id_question_id_pk": { + "name": "exam_questions_exam_id_question_id_pk", + "columns": [ + "exam_id", + "question_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exam_submissions": { + "name": "exam_submissions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exam_id": { + "name": "exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'started'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "exam_student_idx": { + "name": "exam_student_idx", + "columns": [ + "exam_id", + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "exam_submissions_exam_id_exams_id_fk": { + "name": "exam_submissions_exam_id_exams_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "exams", + "columnsFrom": [ + "exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exam_submissions_student_id_users_id_fk": { + "name": "exam_submissions_student_id_users_id_fk", + "tableFrom": "exam_submissions", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exam_submissions_id": { + "name": "exam_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "exams": { + "name": "exams", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grade_id": { + "name": "grade_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "exams_subject_idx": { + "name": "exams_subject_idx", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "exams_grade_idx": { + "name": "exams_grade_idx", + "columns": [ + "grade_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "exams_creator_id_users_id_fk": { + "name": "exams_creator_id_users_id_fk", + "tableFrom": "exams", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "exams_subject_id_subjects_id_fk": { + "name": "exams_subject_id_subjects_id_fk", + "tableFrom": "exams", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "exams_grade_id_grades_id_fk": { + "name": "exams_grade_id_grades_id_fk", + "tableFrom": "exams", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exams_id": { + "name": "exams_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "grades": { + "name": "grades", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "school_id": { + "name": "school_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "grade_head_id": { + "name": "grade_head_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teaching_head_id": { + "name": "teaching_head_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "grades_school_idx": { + "name": "grades_school_idx", + "columns": [ + "school_id" + ], + "isUnique": false + }, + "grades_school_name_uniq": { + "name": "grades_school_name_uniq", + "columns": [ + "school_id", + "name" + ], + "isUnique": false + }, + "grades_grade_head_idx": { + "name": "grades_grade_head_idx", + "columns": [ + "grade_head_id" + ], + "isUnique": false + }, + "grades_teaching_head_idx": { + "name": "grades_teaching_head_idx", + "columns": [ + "teaching_head_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "g_s_fk": { + "name": "g_s_fk", + "tableFrom": "grades", + "tableTo": "schools", + "columnsFrom": [ + "school_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "g_gh_fk": { + "name": "g_gh_fk", + "tableFrom": "grades", + "tableTo": "users", + "columnsFrom": [ + "grade_head_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "g_th_fk": { + "name": "g_th_fk", + "tableFrom": "grades", + "tableTo": "users", + "columnsFrom": [ + "teaching_head_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "grades_id": { + "name": "grades_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_answers": { + "name": "homework_answers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_content": { + "name": "answer_content", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_answer_submission_idx": { + "name": "hw_answer_submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + }, + "hw_answer_submission_question_idx": { + "name": "hw_answer_submission_question_idx", + "columns": [ + "submission_id", + "question_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_ans_sub_fk": { + "name": "hw_ans_sub_fk", + "tableFrom": "homework_answers", + "tableTo": "homework_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_ans_q_fk": { + "name": "hw_ans_q_fk", + "tableFrom": "homework_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_answers_id": { + "name": "homework_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignment_questions": { + "name": "homework_assignment_questions", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "hw_assignment_questions_assignment_idx": { + "name": "hw_assignment_questions_assignment_idx", + "columns": [ + "assignment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_aq_a_fk": { + "name": "hw_aq_a_fk", + "tableFrom": "homework_assignment_questions", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_aq_q_fk": { + "name": "hw_aq_q_fk", + "tableFrom": "homework_assignment_questions", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignment_questions_assignment_id_question_id_pk": { + "name": "homework_assignment_questions_assignment_id_question_id_pk", + "columns": [ + "assignment_id", + "question_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignment_targets": { + "name": "homework_assignment_targets", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_targets_assignment_idx": { + "name": "hw_assignment_targets_assignment_idx", + "columns": [ + "assignment_id" + ], + "isUnique": false + }, + "hw_assignment_targets_student_idx": { + "name": "hw_assignment_targets_student_idx", + "columns": [ + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_at_a_fk": { + "name": "hw_at_a_fk", + "tableFrom": "homework_assignment_targets", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_at_s_fk": { + "name": "hw_at_s_fk", + "tableFrom": "homework_assignment_targets", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignment_targets_assignment_id_student_id_pk": { + "name": "homework_assignment_targets_assignment_id_student_id_pk", + "columns": [ + "assignment_id", + "student_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_assignments": { + "name": "homework_assignments", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_exam_id": { + "name": "source_exam_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "structure": { + "name": "structure", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_late": { + "name": "allow_late", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "late_due_at": { + "name": "late_due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_attempts": { + "name": "max_attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_creator_idx": { + "name": "hw_assignment_creator_idx", + "columns": [ + "creator_id" + ], + "isUnique": false + }, + "hw_assignment_source_exam_idx": { + "name": "hw_assignment_source_exam_idx", + "columns": [ + "source_exam_id" + ], + "isUnique": false + }, + "hw_assignment_status_idx": { + "name": "hw_assignment_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_asg_exam_fk": { + "name": "hw_asg_exam_fk", + "tableFrom": "homework_assignments", + "tableTo": "exams", + "columnsFrom": [ + "source_exam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_asg_creator_fk": { + "name": "hw_asg_creator_fk", + "tableFrom": "homework_assignments", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_assignments_id": { + "name": "homework_assignments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homework_submissions": { + "name": "homework_submissions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignment_id": { + "name": "assignment_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "student_id": { + "name": "student_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_no": { + "name": "attempt_no", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'started'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_late": { + "name": "is_late", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "hw_assignment_student_idx": { + "name": "hw_assignment_student_idx", + "columns": [ + "assignment_id", + "student_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "hw_sub_a_fk": { + "name": "hw_sub_a_fk", + "tableFrom": "homework_submissions", + "tableTo": "homework_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hw_sub_student_fk": { + "name": "hw_sub_student_fk", + "tableFrom": "homework_submissions", + "tableTo": "users", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homework_submissions_id": { + "name": "homework_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_points": { + "name": "knowledge_points", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anchor_text": { + "name": "anchor_text", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chapter_id": { + "name": "chapter_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "kp_chapter_id_idx": { + "name": "kp_chapter_id_idx", + "columns": [ + "chapter_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_points_id": { + "name": "knowledge_points_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('single_choice','multiple_choice','text','judgment','composite')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "parent_id_idx": { + "name": "parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": [ + "author_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "questions_author_id_users_id_fk": { + "name": "questions_author_id_users_id_fk", + "tableFrom": "questions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions_to_knowledge_points": { + "name": "questions_to_knowledge_points", + "columns": { + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "knowledge_point_id": { + "name": "knowledge_point_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "kp_idx": { + "name": "kp_idx", + "columns": [ + "knowledge_point_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "q_kp_qid_fk": { + "name": "q_kp_qid_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "q_kp_kpid_fk": { + "name": "q_kp_kpid_fk", + "tableFrom": "questions_to_knowledge_points", + "tableTo": "knowledge_points", + "columnsFrom": [ + "knowledge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_to_knowledge_points_question_id_knowledge_point_id_pk": { + "name": "questions_to_knowledge_points_question_id_knowledge_point_id_pk", + "columns": [ + "question_id", + "knowledge_point_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "schools": { + "name": "schools", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "schools_name_idx": { + "name": "schools_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "schools_code_idx": { + "name": "schools_code_idx", + "columns": [ + "code" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schools_id": { + "name": "schools_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "schools_name_unique": { + "name": "schools_name_unique", + "columns": [ + "name" + ] + }, + "schools_code_unique": { + "name": "schools_code_unique", + "columns": [ + "code" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_userId_users_id_fk": { + "name": "sessions_userId_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_sessionToken": { + "name": "sessions_sessionToken", + "columns": [ + "sessionToken" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subjects": { + "name": "subjects", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "subjects_name_idx": { + "name": "subjects_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subjects_id": { + "name": "subjects_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "subjects_name_unique": { + "name": "subjects_name_unique", + "columns": [ + "name" + ] + }, + "subjects_code_unique": { + "name": "subjects_code_unique", + "columns": [ + "code" + ] + } + }, + "checkConstraint": {} + }, + "submission_answers": { + "name": "submission_answers", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_content": { + "name": "answer_content", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "submission_idx": { + "name": "submission_idx", + "columns": [ + "submission_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "submission_answers_submission_id_exam_submissions_id_fk": { + "name": "submission_answers_submission_id_exam_submissions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "exam_submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_answers_question_id_questions_id_fk": { + "name": "submission_answers_question_id_questions_id_fk", + "tableFrom": "submission_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "submission_answers_id": { + "name": "submission_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "textbooks": { + "name": "textbooks", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grade": { + "name": "grade", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "textbooks_id": { + "name": "textbooks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'student'" + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grade_id": { + "name": "grade_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "department_id": { + "name": "department_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_grade_id_idx": { + "name": "users_grade_id_idx", + "columns": [ + "grade_id" + ], + "isUnique": false + }, + "users_department_id_idx": { + "name": "users_department_id_idx", + "columns": [ + "department_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "users_to_roles": { + "name": "users_to_roles", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_roles_user_id_users_id_fk": { + "name": "users_to_roles_user_id_users_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_roles_role_id_roles_id_fk": { + "name": "users_to_roles_role_id_roles_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_roles_user_id_role_id_pk": { + "name": "users_to_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationTokens": { + "name": "verificationTokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationTokens_identifier_token_pk": { + "name": "verificationTokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 76ca167..aa6bd9e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1768205524480, "tag": "0007_talented_bromley", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1768470966367, + "tag": "0008_thin_madrox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6cd1e2e..2206e5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,10 @@ "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", @@ -2817,6 +2819,90 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -3158,6 +3244,93 @@ } } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", diff --git a/package.json b/package.json index 3dc78e3..9cb9a2a 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,10 @@ "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx index e27eda6..c5b41c9 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx @@ -1,12 +1,11 @@ import Link from "next/link" import { notFound } from "next/navigation" -import { ArrowLeft, BookOpen, Inbox } from "lucide-react" +import { BookOpen, Inbox } from "lucide-react" -import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access" import { TextbookReader } from "@/modules/textbooks/components/textbook-reader" import { Badge } from "@/shared/components/ui/badge" -import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" import { getDemoStudentUser } from "@/modules/homework/data-access" @@ -34,29 +33,25 @@ export default async function StudentTextbookDetailPage({ const { id } = await params - const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)]) + const [textbook, chapters, knowledgePoints] = await Promise.all([ + getTextbookById(id), + getChaptersByTextbookId(id), + getKnowledgePointsByTextbookId(id) + ]) if (!textbook) notFound() return (
-
- -
-
-
-

{textbook.title}

- {textbook.subject} - {textbook.grade && ( - - {textbook.grade} - - )} +
+
+

{textbook.title}

+
+ + {textbook.subject} + {textbook.grade && ( + {textbook.grade} + )}
@@ -73,7 +68,7 @@ export default async function StudentTextbookDetailPage({
) : (
- +
)}
diff --git a/src/app/(dashboard)/teacher/classes/insights/page.tsx b/src/app/(dashboard)/teacher/classes/insights/page.tsx deleted file mode 100644 index 5a9de0f..0000000 --- a/src/app/(dashboard)/teacher/classes/insights/page.tsx +++ /dev/null @@ -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 ( -
-
- {Array.from({ length: 3 }).map((_, idx) => ( -
-
- - -
-
- ))} -
-
-
- -
-
- {Array.from({ length: 8 }).map((_, idx) => ( - - ))} -
-
-
- ) -} - -async function InsightsResults({ searchParams }: { searchParams: Promise }) { - const params = await searchParams - const classId = getParam(params, "classId") - - if (!classId || classId === "all") { - return ( - - ) - } - - const insights = await getClassHomeworkInsights({ classId, limit: 50 }) - if (!insights) { - return ( - - ) - } - - const hasAssignments = insights.assignments.length > 0 - - if (!hasAssignments) { - return ( - - ) - } - - const latest = insights.latest - - return ( -
-
- - - Students - - -
{insights.studentCounts.total}
-
- Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive} -
-
-
- - - Assignments - - -
{insights.assignments.length}
-
Latest: {latest ? formatDate(latest.createdAt) : "-"}
-
-
- - - Overall scores - - -
{formatNumber(insights.overallScores.avg, 1)}
-
- Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count} -
-
-
-
- - {latest && ( - - -
- Latest assignment -
- {latest.title} - - {latest.status} - - · - {formatDate(latest.createdAt)} - {latest.dueAt ? ( - <> - · - Due {formatDate(latest.dueAt)} - - ) : null} -
-
-
- - -
-
- -
-
Targeted
-
{latest.targetCount}
-
-
-
Submitted
-
{latest.submittedCount}
-
-
-
Graded
-
{latest.gradedCount}
-
-
-
Average
-
{formatNumber(latest.scoreStats.avg, 1)}
-
-
-
Median
-
{formatNumber(latest.scoreStats.median, 1)}
-
-
-
- )} - -
- - - - Assignment - Status - Due - Targeted - Submitted - Graded - Avg - Median - Min - Max - - - - {insights.assignments.map((a) => ( - - - - {a.title} - -
Created {formatDate(a.createdAt)}
-
- - - {a.status} - - - {a.dueAt ? formatDate(a.dueAt) : "-"} - {a.targetCount} - {a.submittedCount} - {a.gradedCount} - {formatNumber(a.scoreStats.avg, 1)} - {formatNumber(a.scoreStats.median, 1)} - {formatNumber(a.scoreStats.min, 0)} - {formatNumber(a.scoreStats.max, 0)} -
- ))} -
-
-
-
- ) -} - -export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise }) { - const classes = await getTeacherClasses() - - return ( -
-
-
-

Class Insights

-

Latest homework and historical score statistics for a class.

-
-
- -
- }> - - - - }> - - -
-
- ) -} - diff --git a/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx b/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx index 563a168..a5a7919 100644 --- a/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx @@ -1,39 +1,18 @@ -import Link from "next/link" import { notFound } from "next/navigation" -import { BookOpen, Calendar, ChevronRight, Clock, Users } from "lucide-react" -import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access" -import { ScheduleView } from "@/modules/classes/components/schedule-view" -import { Badge } from "@/shared/components/ui/badge" -import { Button } from "@/shared/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" -import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" -import { cn, formatDate } from "@/shared/lib/utils" +import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access" +import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget" +import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget" +import { ClassHeader } from "@/modules/classes/components/class-detail/class-header" +import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats" +import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions" +import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget" +import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget" export const dynamic = "force-dynamic" type SearchParams = { [key: string]: string | string[] | undefined } -const getParam = (params: SearchParams, key: string) => { - const v = params[key] - return Array.isArray(v) ? v[0] : v -} - -const formatNumber = (v: number | null, digits = 1) => { - if (typeof v !== "number" || Number.isNaN(v)) return "-" - return v.toFixed(digits) -} - -const getInitials = (name: string) => { - return name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2) -} - export default async function ClassDetailPage({ params, searchParams, @@ -42,335 +21,96 @@ export default async function ClassDetailPage({ searchParams: Promise }) { const { id } = await params - const sp = await searchParams - const hw = getParam(sp, "hw") - const hwFilter = hw === "active" || hw === "overdue" ? hw : "all" + // Parallel data fetching const [insights, students, schedule] = await Promise.all([ - getClassHomeworkInsights({ classId: id, limit: 50 }), + getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view getClassStudents({ classId: id }), getClassSchedule({ classId: id }), ]) if (!insights) return notFound() - const latest = insights.latest - const filteredAssignments = insights.assignments.filter((a) => { - if (hwFilter === "all") return true - if (hwFilter === "overdue") return a.isOverdue - if (hwFilter === "active") return a.isActive - return true - }) - const hasAssignments = filteredAssignments.length > 0 - const scheduleBuilderClasses = [ - { - id: insights.class.id, - name: insights.class.name, - grade: insights.class.grade, - homeroom: insights.class.homeroom ?? null, - room: insights.class.room ?? null, - studentCount: insights.studentCounts.total, - }, - ] + // Fetch subject scores + const studentScores = await getClassStudentSubjectScoresV2(id) + + // Data mapping for widgets + const assignmentSummaries = insights.assignments.map(a => ({ + id: a.assignmentId, + title: a.title, + status: a.status, + subject: a.subject, + isActive: a.isActive, + isOverdue: a.isOverdue, + dueAt: a.dueAt ? new Date(a.dueAt) : null, + submittedCount: a.submittedCount, + targetCount: a.targetCount, + avgScore: a.scoreStats.avg, + medianScore: a.scoreStats.median + })) + + const studentSummaries = students.map(s => ({ + id: s.id, + name: s.name, + email: s.email, + image: s.image, + status: s.status, + subjectScores: studentScores.get(s.id) ?? {} + })) + + // Calculate advanced stats + const activeAssignments = insights.assignments.filter(a => a.isActive) + const papersToGrade = activeAssignments.reduce((acc, a) => acc + (a.submittedCount - a.gradedCount), 0) + const overdueCount = activeAssignments.filter(a => a.isOverdue).length + + const totalSubmissionRate = activeAssignments.length > 0 + ? activeAssignments.reduce((acc, a) => acc + (a.targetCount > 0 ? a.submittedCount / a.targetCount : 0), 0) / activeAssignments.length + : 0 return ( -
- {/* Header */} -
-
-
- - My Classes - - - {insights.class.name} +
+ + +
+ {/* Key Metrics */} + + +
+ {/* Main Content Area (Left 2/3) */} +
+ +
-

{insights.class.name}

-
- - {insights.class.grade} - - {insights.class.homeroom && ( - <> - - Homeroom: {insights.class.homeroom} - - )} - {insights.class.room && ( - <> - - Room: {insights.class.room} - - )} + + {/* Sidebar Area (Right 1/3) */} +
+ {/* */} + +
- -
- - - -
-
- - {/* Stats Grid */} -
- - - Total Students - - - -
{insights.studentCounts.total}
-
- {insights.studentCounts.active} active · {insights.studentCounts.inactive} inactive -
-
-
- - - Schedule Items - - - -
{schedule.length}
-
Weekly sessions
-
-
- - - Active Assignments - - - -
- {insights.assignments.filter((a) => a.isActive).length} -
-
- {insights.assignments.filter((a) => a.isOverdue).length} overdue -
-
-
- - - Class Average - - - -
{formatNumber(insights.overallScores.avg, 1)}%
-
- Based on {insights.overallScores.count} graded submissions -
-
-
-
- -
- {/* Main Content Area */} -
- {/* Latest Homework */} - {latest && ( - - -
-
- Latest Homework - Most recent assignment activity -
- - {latest.status} - -
-
- -
-
-
- - {latest.title} - -
- Due {latest.dueAt ? formatDate(latest.dueAt) : "No due date"} - · - {latest.submittedCount}/{latest.targetCount} Submitted -
-
- -
- -
-
-
{latest.gradedCount}
-
Graded
-
-
-
{formatNumber(latest.scoreStats.avg, 1)}
-
Average
-
-
-
{formatNumber(latest.scoreStats.median, 1)}
-
Median
-
-
-
-
-
- )} - - {/* Students Preview */} - - -
- Students - Recently active students -
- -
- - {students.length === 0 ? ( -
- No students enrolled yet. -
- ) : ( -
- {students.slice(0, 5).map((s) => ( -
-
- - - {getInitials(s.name)} - -
-
{s.name}
-
{s.email}
-
-
- - {s.status} - -
- ))} -
- )} -
-
-
- - {/* Sidebar Area */} -
- {/* Schedule Widget */} - - - Schedule - - - - - - - - {/* Homework History */} - - - History -
- - - -
-
- -
- {filteredAssignments.slice(0, 5).map((a) => ( -
-
- - {a.title} - - - {a.status} - -
-
- Due {a.dueAt ? formatDate(a.dueAt) : "-"} -
- {a.submittedCount} submitted - {formatNumber(a.scoreStats.avg, 0)}% avg -
-
-
- ))} - {filteredAssignments.length === 0 && ( -
- No assignments found -
- )} -
- {filteredAssignments.length > 5 && ( -
- -
- )} -
-
-
) diff --git a/src/app/(dashboard)/teacher/classes/my/page.tsx b/src/app/(dashboard)/teacher/classes/my/page.tsx index fc1c99d..0c188d6 100644 --- a/src/app/(dashboard)/teacher/classes/my/page.tsx +++ b/src/app/(dashboard)/teacher/classes/my/page.tsx @@ -15,16 +15,7 @@ async function MyClassesPageImpl() { const classes = await getTeacherClasses() return ( -
-
-
-

My Classes

-

- Overview of your classes. -

-
-
- +
) diff --git a/src/app/(dashboard)/teacher/classes/schedule/page.tsx b/src/app/(dashboard)/teacher/classes/schedule/page.tsx index 09714ab..d24f386 100644 --- a/src/app/(dashboard)/teacher/classes/schedule/page.tsx +++ b/src/app/(dashboard)/teacher/classes/schedule/page.tsx @@ -67,16 +67,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro return (
-
-
-

Schedule

-

- View class schedule. -

-
-
- -
+
}> diff --git a/src/app/(dashboard)/teacher/classes/students/page.tsx b/src/app/(dashboard)/teacher/classes/students/page.tsx index 028ca50..a7ab8f0 100644 --- a/src/app/(dashboard)/teacher/classes/students/page.tsx +++ b/src/app/(dashboard)/teacher/classes/students/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react" import { User } from "lucide-react" -import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access" +import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access" import { StudentsFilters } from "@/modules/classes/components/students-filters" import { StudentsTable } from "@/modules/classes/components/students-table" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -16,19 +16,34 @@ const getParam = (params: SearchParams, key: string) => { return Array.isArray(v) ? v[0] : v } -async function StudentsResults({ searchParams }: { searchParams: Promise }) { +async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise, defaultClassId?: string }) { const params = await searchParams const q = getParam(params, "q") || undefined const classId = getParam(params, "classId") const status = getParam(params, "status") + // If classId is explicit in URL, use it (unless "all"). If not, use defaultClassId. + // If user explicitly selects "all", classId will be "all". + // However, the requirement is "Default to showing the first class". + // If classId param is missing, we use defaultClassId. + const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId + const filteredStudents = await getClassStudents({ q, - classId: classId && classId !== "all" ? classId : undefined, + classId: targetClassId, status: status && status !== "all" ? status : undefined, }) + // 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) { @@ -67,25 +82,20 @@ function StudentsResultsFallback() { export default async function StudentsPage({ searchParams }: { searchParams: Promise }) { 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 ( -
-
-
-

Students

-

- Manage student list. -

-
-
- +
}> - + }> - +
diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx index 365e6c3..560fb1e 100644 --- a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { Button } from "@/shared/components/ui/button"; import { Badge } from "@/shared/components/ui/badge"; import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"; -import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout"; +import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"; import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"; export const dynamic = "force-dynamic" @@ -51,10 +51,11 @@ export default async function TextbookDetailPage({ {/* Main Content Layout (Flex grow) */}
-
diff --git a/src/modules/classes/components/class-detail/class-assignments-widget.tsx b/src/modules/classes/components/class-detail/class-assignments-widget.tsx new file mode 100644 index 0000000..c26796f --- /dev/null +++ b/src/modules/classes/components/class-detail/class-assignments-widget.tsx @@ -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 ( + + +
+ Recent Homework + + {activeAssignments.length} active assignments + +
+ +
+ + {assignments.length === 0 ? ( +
+
+ +
+
+

No homework yet

+

+ Create an assignment to get started. +

+
+ +
+ ) : ( +
+ {assignments.slice(0, 5).map((assignment) => ( +
+
+ + {assignment.title} + +
+ + Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"} + + + + {assignment.submittedCount}/{assignment.targetCount} Submitted + +
+
+
+ + {assignment.status} + + {typeof assignment.avgScore === "number" && ( + + Avg: {assignment.avgScore.toFixed(0)}% + + )} +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/modules/classes/components/class-detail/class-header.tsx b/src/modules/classes/components/class-detail/class-header.tsx new file mode 100644 index 0000000..eeaf8a2 --- /dev/null +++ b/src/modules/classes/components/class-detail/class-header.tsx @@ -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 ( + <> +
+
+
+

+ {name} +

+
+ {schoolName && ( + <> + {schoolName} + + + )} + + {grade} + + {homeroom && ( + <> + + Homeroom {homeroom} + + )} + {room && ( + <> + + Room {room} + + )} + + {studentCount} Students +
+
+ +
+ + + + + + + setShowEdit(true)}> + + Edit details + + + + Invite students + + + + + Class settings + + + +
+
+
+ + + + ) +} diff --git a/src/modules/classes/components/class-detail/class-overview-stats.tsx b/src/modules/classes/components/class-detail/class-overview-stats.tsx new file mode 100644 index 0000000..abb4403 --- /dev/null +++ b/src/modules/classes/components/class-detail/class-overview-stats.tsx @@ -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 ( +
+ + + + +
+ ) +} + +function StatsCard({ + title, + value, + subValue, + icon: Icon, +}: { + title: string + value: string + subValue: string + icon: React.ElementType +}) { + return ( + + +
+

{title}

+ +
+
+
{value}
+

{subValue}

+
+
+
+ ) +} diff --git a/src/modules/classes/components/class-detail/class-quick-actions.tsx b/src/modules/classes/components/class-detail/class-quick-actions.tsx new file mode 100644 index 0000000..2e5b6c9 --- /dev/null +++ b/src/modules/classes/components/class-detail/class-quick-actions.tsx @@ -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 ( + + + Quick Actions + + + + + + + + + ) +} diff --git a/src/modules/classes/components/class-detail/class-schedule-widget.tsx b/src/modules/classes/components/class-detail/class-schedule-widget.tsx new file mode 100644 index 0000000..893b4fa --- /dev/null +++ b/src/modules/classes/components/class-detail/class-schedule-widget.tsx @@ -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) + + // 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 ( +
+
+ +
+

No sessions scheduled.

+
+ ) + } + + return ( +
+ {WEEKDAYS.slice(0, 5).map((day, i) => ( +
+ {day} +
+ ))} + + {WEEKDAY_INDICES.slice(0, 5).map((dayNum) => { + const items = groupedSchedule[dayNum] || [] + return ( +
+ {items.length === 0 ? ( +
+ ) : ( + items.map(item => ( + + +
+
{item.course}
+
{item.startTime}-{item.endTime}
+
+
+ +
+
{item.course}
+
+ + {item.startTime} - {item.endTime} +
+ {item.location && ( +
+ + {item.location} +
+ )} +
+
+
+ )) + )} +
+ ) + })} +
+ ) +} + +export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) { + return ( + + + Weekly Schedule + + + + +
+ * Showing Mon-Fri schedule +
+
+
+ ) +} diff --git a/src/modules/classes/components/class-detail/class-students-widget.tsx b/src/modules/classes/components/class-detail/class-students-widget.tsx new file mode 100644 index 0000000..0a22e9a --- /dev/null +++ b/src/modules/classes/components/class-detail/class-students-widget.tsx @@ -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 +} + +interface ClassStudentsWidgetProps { + classId: string + students: StudentSummary[] +} + +export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) { + const activeCount = students.filter(s => s.status === "active").length + + return ( + + +
+ Students + + {activeCount} active students + +
+ +
+ + {students.length === 0 ? ( +
+
+ +
+

No students enrolled yet.

+
+ ) : ( +
+ {students.slice(0, 6).map((student) => ( +
+
+
+ + + + {student.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2)} + + +
+
{student.name}
+
{student.email}
+
+
+ + {student.status} + +
+ + {/* Subject Scores */} + {student.subjectScores && Object.keys(student.subjectScores).length > 0 && ( +
+ {Object.entries(student.subjectScores).map(([subject, score]) => ( +
+ {subject} + {score !== null ? ( + = 60 ? "font-semibold text-primary" : "font-semibold text-destructive"}> + {score} + + ) : ( + - + )} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/modules/classes/components/class-detail/class-trends-widget.tsx b/src/modules/classes/components/class-detail/class-trends-widget.tsx new file mode 100644 index 0000000..936e760 --- /dev/null +++ b/src/modules/classes/components/class-detail/class-trends-widget.tsx @@ -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 ( + + + + + `${value}`} + hide + /> + } /> + + + + + ) +} + +export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) { + const [chartTab, setChartTab] = useState<"submission" | "score">("submission") + const [selectedSubject, setSelectedSubject] = useState("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 ( +
+
+
+ + + + + + setChartTab("submission")} className="text-xs"> + Submission Trends + + setChartTab("score")} className="text-xs"> + Score Trends + + + + + {subjects.length > 0 && ( + + + + + + setSelectedSubject("all")} className="text-xs"> + All Subjects + + {subjects.map(s => ( + setSelectedSubject(s)} className="text-xs"> + {s} + + ))} + + + )} +
+
+ {metricLabel}: {metricValue} +
+
+ + {/* Compact Sparkline Chart */} +
+ + {chartTab === "submission" ? ( + + + + + + + + + + + } + /> + + + + ) : ( + + + + + } + /> + + + + )} + +
+
+ ) + } + + return ( + + +
+
+
+ + {chartTab === "submission" ? "Submission Trends" : "Score Trends"} + + + {chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"} + +
+ setChartTab(v as "submission" | "score")} className="w-auto"> + + Submission + Score + + +
+ + {subjects.length > 0 && ( + + + + All Subjects + + {subjects.map(s => ( + + {s} + + ))} + + + )} +
+
+ + {chartData.length > 0 ? ( + + {chartTab === "submission" ? ( + + + + `${value}`} + /> + } /> + + + + ) : ( + + + + `${value}%`} + /> + } /> + + + + )} + + ) : ( +
+ No data for this subject +
+ )} +
+
+ ) +} diff --git a/src/modules/classes/components/class-detail/edit-class-dialog.tsx b/src/modules/classes/components/class-detail/edit-class-dialog.tsx new file mode 100644 index 0000000..283207a --- /dev/null +++ b/src/modules/classes/components/class-detail/edit-class-dialog.tsx @@ -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 ( + { + if (isWorking) return + onOpenChange(val) + }} + > + + + Edit class + Update basic class information. + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ ) +} diff --git a/src/modules/classes/components/insights-filters.tsx b/src/modules/classes/components/insights-filters.tsx deleted file mode 100644 index d0bf9b9..0000000 --- a/src/modules/classes/components/insights-filters.tsx +++ /dev/null @@ -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 ( -
-
- - - {classId !== "all" && ( - - )} -
-
- ) -} - diff --git a/src/modules/classes/components/my-classes-grid.tsx b/src/modules/classes/components/my-classes-grid.tsx index cb1aec1..599a812 100644 --- a/src/modules/classes/components/my-classes-grid.tsx +++ b/src/modules/classes/components/my-classes-grid.tsx @@ -4,44 +4,21 @@ import Link from "next/link" import { useMemo, useState } from "react" import { useRouter } from "next/navigation" import { - Calendar, - Copy, - MoreHorizontal, - Pencil, Plus, RefreshCw, - Search, - Trash2, + Copy, Users, - GraduationCap, MapPin, - ChartBar, + GraduationCap, + Search, } from "lucide-react" import { toast } from "sonner" -import { parseAsString, useQueryState } from "nuqs" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" import { Badge } from "@/shared/components/ui/badge" import { EmptyState } from "@/shared/components/ui/empty-state" import { cn } from "@/shared/lib/utils" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shared/components/ui/dropdown-menu" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/components/ui/alert-dialog" import { Dialog, DialogContent, @@ -53,15 +30,11 @@ import { } from "@/shared/components/ui/dialog" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip" -import type { TeacherClass } from "../types" +import type { TeacherClass, ClassScheduleItem } from "../types" import { - createTeacherClassAction, - deleteTeacherClassAction, ensureClassInvitationCodeAction, regenerateClassInvitationCodeAction, - updateTeacherClassAction, joinClassByInvitationCodeAction, } from "../actions" @@ -82,26 +55,6 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla const [isWorking, setIsWorking] = useState(false) const [joinOpen, setJoinOpen] = useState(false) - const [q, setQ] = useQueryState("q", parseAsString.withDefault("")) - const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all")) - - const gradeOptions = useMemo(() => { - const set = new Set() - 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 handleJoin = async (formData: FormData) => { setIsWorking(true) try { @@ -123,117 +76,98 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla return (
{/* Filter Bar */} -
-
-
- - setQ(e.target.value || null)} - className="pl-9 bg-background" - /> -
- - {(q || grade !== "all") && ( - - )} -
- - { - if (isWorking) return - setJoinOpen(open) - }} - > - - - - - - Join Class - Enter the invitation code to join a class. - -
-
-
- - -
-
- - - -
-
-
+
+ + + {/* Header with Pattern */} +
+
+ + +
+ +
+ Join a Class +
+ + Enter the 6-digit invitation code provided by your administrator. + +
+
+ +
+
+
+ +
+ +
+ +
+
+

+ Ask your administrator for the code if you don't have one. +

+
+
+ + + + +
+
+ +
- {/* Grid */} -
+ {/* List */} +
{classes.length === 0 ? ( setJoinOpen(true) }} - className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4" - /> - ) : filteredClasses.length === 0 ? ( - { - setQ(null) - setGrade(null) - }, - }} - className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4" + className="h-[360px] bg-card border-dashed" /> ) : ( - filteredClasses.map((c) => ( - + classes.map((c) => ( + )) )}
@@ -241,7 +175,12 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla ) } -function ClassCard({ +import { ClassScheduleGrid } from "./class-detail/class-schedule-widget" +import { ClassTrendsWidget } from "./class-detail/class-trends-widget" + +// Removed MiniSchedule since we're using ClassScheduleGrid now + +function ClassTicket({ c, isWorking, onWorkingChange, @@ -251,8 +190,6 @@ function ClassCard({ onWorkingChange: (v: boolean) => void }) { const router = useRouter() - const [showEdit, setShowEdit] = useState(false) - const [showDelete, setShowDelete] = useState(false) const handleEnsureCode = async () => { onWorkingChange(true) @@ -299,277 +236,160 @@ function ClassCard({ } } - const handleEdit = async (formData: FormData) => { - onWorkingChange(true) - try { - const res = await updateTeacherClassAction(c.id, null, formData) - if (res.success) { - toast.success(res.message) - setShowEdit(false) - router.refresh() - } else { - toast.error(res.message || "Failed to update class") - } - } catch { - toast.error("Failed to update class") - } finally { - onWorkingChange(false) - } - } - - 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) - } - } + // Real data for chart + const recentAssignments = c.recentAssignments ?? [] + + // Calculate performance change for indicator (still needed for the top indicator) + // We can't reuse chart data easily here without recalculating, but ClassTrendsWidget handles its own data now + const lastTwoAssignments = [...recentAssignments].reverse().slice(-2) + const performanceChange = lastTwoAssignments.length === 2 && lastTwoAssignments[0].submittedCount > 0 + ? ((lastTwoAssignments[1].submittedCount - lastTwoAssignments[0].submittedCount) / lastTwoAssignments[0].submittedCount) * 100 + : 0 + const isPositive = performanceChange >= 0 return ( - - -
-
- - +
+ {/* Realistic Paper Texture & Noise */} +
+
+ + {/* Decorative Barcode Strip */} +
+ {Array.from({ length: 20 }).map((_, i) => ( +
+ ))} +
+ + {/* Left Section: Basic Info (Narrower) */} +
+ {/* Punch Hole Effect Top-Left */} +
+ +
+
+
+ {c.grade.replace(/[^0-9]/g, '')} +
+
+ {c.name} - -
- - {c.grade} + + {c.grade} • {c.id.slice(-4).toUpperCase()} - {c.homeroom && ( - - {c.homeroom} - - )}
- - - - - - setShowEdit(true)}> - - Edit Class - - - setShowDelete(true)} - > - - Delete Class - - - -
- - - -
-
- Students -
- - {c.studentCount} + +
+
+ + {c.studentCount} Students
-
-
- Room -
- - {c.room || "—"} +
+ + {c.room || "No Room"}
-
-
- -
-
- Invite Code - {c.invitationCode || "—"} -
-
- {c.invitationCode ? ( - - - - - - Copy Code - - - - - - Regenerate - - - ) : ( - + {c.schoolName && ( +
+ + {c.schoolName} +
)}
- - - - - - + {/* Invitation Code Section */} +
+ {/* Tiny Cut marks */} +
+
- {/* Dialogs */} - { - if (isWorking) return - setShowEdit(open) - }} - > - - - Edit class - Update basic class information. - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - -
-
-
+
+
+ Entry Pass +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+
+ {c.invitationCode || "—"} + + {/* Faint QR Code Placeholder Background */} +
+
+ {Array.from({ length: 16 }).map((_, i) => ( +
0.5 && "bg-black")}>
+ ))} +
+
- { - if (isWorking) return - setShowDelete(open) - }} - > - - - Delete class? - - This will permanently delete {c.name} and remove all - enrollments. - - - - Cancel - - {isWorking ? "Deleting..." : "Delete Class"} - - - - - + {c.invitationCode ? ( +
+ + +
+ ) : ( + + )} +
+
+
+
+ + {/* Dashed Divider (Ticket perforation) */} +
+
+
+ {/* Scissor Icon */} +
+
+
+
+ + {/* Right Section: Stats & Actions (Wider) */} +
+
+ {/* Left: Submission Trends */} +
+
+

Submission Trends

+ + {isPositive ? "+" : ""}{Math.round(performanceChange)}% + +
+ + {/* Real Chart */} +
+ +
+
+ + {/* Right: Weekly Schedule */} +
+
+ +
+
+
+
+
) } diff --git a/src/modules/classes/components/schedule-filters.tsx b/src/modules/classes/components/schedule-filters.tsx index 0c92818..9ad5044 100644 --- a/src/modules/classes/components/schedule-filters.tsx +++ b/src/modules/classes/components/schedule-filters.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import { useQueryState, parseAsString } from "nuqs" -import { Plus, X } from "lucide-react" +import { Plus } from "lucide-react" import { toast } from "sonner" import { Button } from "@/shared/components/ui/button" @@ -29,7 +29,7 @@ import type { TeacherClass } from "../types" import { createClassScheduleItemAction } from "../actions" export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) { - const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) + const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false })) const router = useRouter() const [open, setOpen] = useState(false) @@ -64,33 +64,29 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) { } } + const selectedClass = classes.find((c) => c.id === classId) + const title = selectedClass ? selectedClass.name : "All Classes" + return ( -
+
- setClassId(val === "all" ? "all" : val)}> + + - All Classes + All Classes {classes.map((c) => ( - + {c.name} ))} +
- {classId !== "all" && ( - - )} +
+ {title}
- diff --git a/src/modules/classes/components/schedule-view.tsx b/src/modules/classes/components/schedule-view.tsx index 6c5de77..a6ecf4d 100644 --- a/src/modules/classes/components/schedule-view.tsx +++ b/src/modules/classes/components/schedule-view.tsx @@ -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 ( -
- {WEEKDAYS.map((d) => { - const items = byDay.get(d.key) ?? [] - return ( - - -
- {d.label} - - {items.length} items - -
- -
- - {items.length === 0 ? ( -
No classes scheduled.
- ) : ( -
- {items.map((item) => ( -
-
-
-
{item.course}
+ {h}:00 +
+ ))} +
+
+ + {/* Days Columns */} +
+ {WEEKDAYS.slice(0, 5).map((d) => ( +
+
+ {d.label} +
+ +
+ {/* Subtle vertical guideline */} +
+ + {(byDay.get(d.key) ?? []).map((item) => ( +
+
+
+
+
{item.course}
+
+ {item.startTime} - {item.endTime} +
+
+ {classNameById.get(item.classId)} +
-
- {classNameById.get(item.classId) ?? "Class"} + +
- - - setEditItem(item)}> - + + setEditItem(item)} className="text-xs"> + Edit setDeleteItem(item)} > - + Delete
-
- - - {item.startTime}–{item.endTime} - - {item.location ? ( - - - {item.location} - - ) : null} -
- ))} +
+ ))} + + {/* Add Button Overlay - Only visible on hover of the column */} +
+
+ +
- )} - - - ) - })} +
+
+ ))} +
+
{ if (isWorking) return if (!v) setEditItem(null) @@ -320,116 +377,118 @@ export function ScheduleView({ Edit schedule item - Update this schedule entry. + Update class schedule entry. - {editItem ? ( -
-
-
- -
- - -
-
- -
- -
- - -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - + +
+
+ +
+ +
- - - - - ) : null} + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
{ if (isWorking) return if (!v) setDeleteItem(null) @@ -437,22 +496,20 @@ export function ScheduleView({ > - Delete schedule item? + Are you sure? - {deleteItem ? ( - <> - This will permanently delete {deleteItem.course}{" "} - ({deleteItem.startTime}–{deleteItem.endTime}). - - ) : null} + This will permanently delete this schedule item. Cancel { + e.preventDefault() + handleDelete() + }} disabled={isWorking} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {isWorking ? "Deleting..." : "Delete"} @@ -461,5 +518,4 @@ export function ScheduleView({
) -} - +} \ No newline at end of file diff --git a/src/modules/classes/components/students-filters.tsx b/src/modules/classes/components/students-filters.tsx index ae2f5db..cfd26e1 100644 --- a/src/modules/classes/components/students-filters.tsx +++ b/src/modules/classes/components/students-filters.tsx @@ -3,18 +3,19 @@ import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import { useQueryState, parseAsString } from "nuqs" -import { Search, UserPlus, X } from "lucide-react" +import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react" import { toast } from "sonner" import { Input } from "@/shared/components/ui/input" import { Button } from "@/shared/components/ui/button" import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select" + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "@/shared/components/ui/dropdown-menu" import { Dialog, DialogContent, @@ -24,26 +25,35 @@ import { DialogTitle, DialogTrigger, } from "@/shared/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" import { Label } from "@/shared/components/ui/label" +import { cn } from "@/shared/lib/utils" import type { TeacherClass } from "../types" import { enrollStudentByEmailAction } from "../actions" -export function StudentsFilters({ classes }: { classes: TeacherClass[] }) { - const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) - const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all")) - const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all")) +export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) { + const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 })) + const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault(defaultClassId || "all").withOptions({ shallow: false })) + const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false })) const router = useRouter() const [open, setOpen] = useState(false) const [isWorking, setIsWorking] = useState(false) - const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes]) - const [enrollClassId, setEnrollClassId] = useState(defaultClassId) + const effectiveClassId = classId === "all" && defaultClassId ? defaultClassId : classId + + const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? "")) useEffect(() => { if (!open) return - setEnrollClassId(defaultClassId) - }, [open, defaultClassId]) + setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? "")) + }, [open, effectiveClassId, classes]) const handleEnroll = async (formData: FormData) => { setIsWorking(true) @@ -63,58 +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 ( -
-
-
- +
+
+ {/* Search - Minimal */} +
+ setSearch(e.target.value || null)} />
- + + - - - {(search || classId !== "all" || status !== "all") && ( - - )} + {/* Status Filter - Compact */} + + + + + + Filter by Status + setStatus(null)} className="text-xs flex items-center justify-between"> + All Status + {status === "all" && } + + setStatus("active")} className="text-xs flex items-center justify-between"> + Active + {status === "active" && } + + setStatus("inactive")} className="text-xs flex items-center justify-between"> + Inactive + {status === "inactive" && } + + +
- diff --git a/src/modules/classes/components/students-table.tsx b/src/modules/classes/components/students-table.tsx index 7ffd433..04c9560 100644 --- a/src/modules/classes/components/students-table.tsx +++ b/src/modules/classes/components/students-table.tsx @@ -2,13 +2,13 @@ import { useState } from "react" import { useRouter } from "next/navigation" -import { MoreHorizontal, UserCheck, UserX, ChevronLeft, ChevronRight } from "lucide-react" +import { MoreHorizontal, UserCheck, UserX } from "lucide-react" import { toast } from "sonner" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card" import { cn, formatDate } from "@/shared/lib/utils" import { DropdownMenu, @@ -27,31 +27,16 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/shared/components/ui/alert-dialog" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/shared/components/ui/table" import type { ClassStudent } from "../types" import { setStudentEnrollmentStatusAction } from "../actions" -const ITEMS_PER_PAGE = 10 - export function StudentsTable({ students }: { students: ClassStudent[] }) { const router = useRouter() const [workingKey, setWorkingKey] = useState(null) const [removeTarget, setRemoveTarget] = useState(null) - const [page, setPage] = useState(1) - - const totalPages = Math.ceil(students.length / ITEMS_PER_PAGE) - const startIndex = (page - 1) * ITEMS_PER_PAGE - const paginatedStudents = students.slice(startIndex, startIndex + ITEMS_PER_PAGE) const setStatus = async (student: ClassStudent, status: "active" | "inactive") => { - const key = `${student.classId}:${student.id}:${status}` + const key = `${student.classId}:${student.id}` setWorkingKey(key) try { const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status) @@ -59,10 +44,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) { toast.success(res.message) router.refresh() } else { - toast.error(res.message || "Failed to update student") + toast.error(res.message) } } catch { - toast.error("Failed to update student") + toast.error("Failed to update status") } finally { setWorkingKey(null) } @@ -79,133 +64,112 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) { return ( <> - - -
- All Students - - {students.length} total - -
-
- - - - - Student - Class - Joined - Status - - Actions - - - - - {paginatedStudents.map((s) => ( - - -
- - - {getInitials(s.name)} - -
- {s.name} - {s.email} -
-
-
- - - {s.className} - - - - {formatDate(s.joinedAt)} - - - - {s.status === "active" ? "Active" : "Inactive"} - - - - - - - - - {s.status !== "active" ? ( - setStatus(s, "active")} disabled={workingKey !== null}> - - Set active - - ) : ( - setStatus(s, "inactive")} disabled={workingKey !== null}> - - Set inactive - - )} - - setRemoveTarget(s)} - className="text-destructive focus:text-destructive" - disabled={s.status === "inactive" || workingKey !== null} - > - - Remove from class - - - - -
- ))} -
-
-
- {totalPages > 1 && ( - -
- Showing {startIndex + 1}- - {Math.min(startIndex + ITEMS_PER_PAGE, students.length)} of{" "} - {students.length} students -
-
- -
- {page} / {totalPages} +
+ {students.map((s) => ( + + +
+ + + {getInitials(s.name)} + +
- -
- - )} - + + + + + + {s.status !== "active" ? ( + setStatus(s, "active")} disabled={workingKey !== null}> + + Set active + + ) : ( + setStatus(s, "inactive")} disabled={workingKey !== null}> + + Set inactive + + )} + + setRemoveTarget(s)} + className="text-destructive focus:text-destructive" + disabled={s.status === "inactive" || workingKey !== null} + > + + Remove from class + + + + + + ))} +
{ + 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 => { @@ -752,11 +782,22 @@ export const getClassHomeworkInsights = cache( } const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 - const assignments = await db.query.homeworkAssignments.findMany({ - where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)), - orderBy: [desc(homeworkAssignments.createdAt)], - limit, - }) + const assignments = await db + .select({ + id: homeworkAssignments.id, + title: homeworkAssignments.title, + status: homeworkAssignments.status, + createdAt: homeworkAssignments.createdAt, + dueAt: homeworkAssignments.dueAt, + subjectId: exams.subjectId, + subjectName: subjects.name + }) + .from(homeworkAssignments) + .innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id)) + .leftJoin(subjects, eq(exams.subjectId, subjects.id)) + .where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId))) + .orderBy(desc(homeworkAssignments.createdAt)) + .limit(limit) const usedAssignmentIds = assignments.map((a) => a.id) if (usedAssignmentIds.length === 0) { @@ -845,6 +886,7 @@ export const getClassHomeworkInsights = cache( assignmentId: a.id, title: a.title, status: (a.status as string) ?? "draft", + subject: a.subjectName, createdAt: a.createdAt.toISOString(), dueAt: a.dueAt ? a.dueAt.toISOString() : null, isActive: dueMs === null || dueMs >= nowMs, @@ -1694,3 +1736,104 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise await db.delete(classSchedule).where(eq(classSchedule.id, id)) } + +export const getStudentsSubjectScores = cache( + async (studentIds: string[]): Promise>> => { + 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() // 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>() + + // Create reverse map for assignment -> subject + const assignmentSubjectMap = new Map() + 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>> => { + // 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) + } +) diff --git a/src/modules/classes/types.ts b/src/modules/classes/types.ts index f502c31..fa0d10e 100644 --- a/src/modules/classes/types.ts +++ b/src/modules/classes/types.ts @@ -7,6 +7,22 @@ export type TeacherClass = { room?: string | null invitationCode?: string | null studentCount: number + recentAssignments?: AssignmentSummary[] + schedule?: ClassScheduleItem[] +} + +export interface AssignmentSummary { + id: string + title: string + status: string + subject?: string | null + isActive: boolean + isOverdue: boolean + dueAt: Date | null + submittedCount: number + targetCount: number + avgScore: number | null + medianScore: number | null } export type TeacherOption = { @@ -71,6 +87,7 @@ export type ClassStudent = { className: string status: "active" | "inactive" joinedAt: Date + subjectScores?: Record } export type ClassScheduleItem = { @@ -135,6 +152,7 @@ export type ClassHomeworkAssignmentStats = { assignmentId: string title: string status: string + subject?: string | null createdAt: string dueAt: string | null isActive: boolean @@ -149,6 +167,8 @@ export type ClassHomeworkAssignmentStats = { export type ClassHomeworkInsights = { class: { id: string + schoolName?: string | null + schoolId?: string | null name: string grade: string homeroom?: string | null diff --git a/src/modules/exams/components/assembly/structure-editor.tsx b/src/modules/exams/components/assembly/structure-editor.tsx index 6a544d8..c057f5c 100644 --- a/src/modules/exams/components/assembly/structure-editor.tsx +++ b/src/modules/exams/components/assembly/structure-editor.tsx @@ -550,6 +550,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh return ( = { { title: "My Classes", href: "/teacher/classes/my" }, { title: "Students", href: "/teacher/classes/students" }, { title: "Schedule", href: "/teacher/classes/schedule" }, - { title: "Insights", href: "/teacher/classes/insights" }, ] }, { diff --git a/src/modules/questions/components/create-question-dialog.tsx b/src/modules/questions/components/create-question-dialog.tsx index 4ee765a..5ec3756 100644 --- a/src/modules/questions/components/create-question-dialog.tsx +++ b/src/modules/questions/components/create-question-dialog.tsx @@ -60,6 +60,9 @@ interface CreateQuestionDialogProps { open: boolean onOpenChange: (open: boolean) => void initialData?: Question | null + defaultKnowledgePointIds?: string[] + defaultContent?: string + defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite" } function getInitialTextFromContent(content: unknown) { @@ -97,7 +100,14 @@ function getInitialOptionsFromContent(content: unknown) { return mapped.length > 0 ? mapped : undefined } -export function CreateQuestionDialog({ open, onOpenChange, initialData }: CreateQuestionDialogProps) { +export function CreateQuestionDialog({ + open, + onOpenChange, + initialData, + defaultKnowledgePointIds = [], + defaultContent = "", + defaultType = "single_choice" +}: CreateQuestionDialogProps) { const router = useRouter() const [isPending, setIsPending] = useState(false) const isEdit = !!initialData @@ -105,9 +115,9 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create const form = useForm({ resolver: zodResolver(QuestionFormSchema), defaultValues: { - type: initialData?.type || "single_choice", + type: initialData?.type || defaultType, difficulty: initialData?.difficulty || 1, - content: getInitialTextFromContent(initialData?.content), + content: getInitialTextFromContent(initialData?.content) || defaultContent, options: getInitialOptionsFromContent(initialData?.content) ?? [ { label: "Option A", value: "A", isCorrect: true }, @@ -130,16 +140,16 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create }) } else { form.reset({ - type: "single_choice", + type: defaultType, difficulty: 1, - content: "", + content: defaultContent, options: [ { label: "Option A", value: "A", isCorrect: true }, { label: "Option B", value: "B", isCorrect: false }, ], }) } - }, [initialData, form, open]) + }, [initialData, form, open, defaultContent, defaultType]) const questionType = form.watch("type") @@ -184,7 +194,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create type: data.type, difficulty: data.difficulty, content: buildContent(data), - knowledgePointIds: [], + knowledgePointIds: isEdit ? [] : defaultKnowledgePointIds, } const fd = new FormData() fd.set("json", JSON.stringify(payload)) diff --git a/src/modules/textbooks/actions.ts b/src/modules/textbooks/actions.ts index edf569b..fa21047 100644 --- a/src/modules/textbooks/actions.ts +++ b/src/modules/textbooks/actions.ts @@ -8,6 +8,7 @@ import { deleteChapter, createKnowledgePoint, deleteKnowledgePoint, + updateKnowledgePoint, updateTextbook, deleteTextbook, reorderChapters @@ -185,11 +186,12 @@ export async function createKnowledgePointAction( ): Promise { const name = formData.get("name") as string; const description = formData.get("description") as string; + const anchorText = formData.get("anchorText") as string; if (!name) return { success: false, message: "Name is required" }; try { - await createKnowledgePoint({ name, description, chapterId }); + await createKnowledgePoint({ name, description, anchorText, chapterId }); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: "Knowledge point created successfully" }; } catch { @@ -209,3 +211,24 @@ export async function deleteKnowledgePointAction( return { success: false, message: "Failed to delete knowledge point" }; } } + +export async function updateKnowledgePointAction( + kpId: string, + textbookId: string, + prevState: ActionState | null, + formData: FormData +): Promise { + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const anchorText = formData.get("anchorText") as string; + + if (!name) return { success: false, message: "Name is required" }; + + try { + await updateKnowledgePoint({ id: kpId, name, description, anchorText }); + revalidatePath(`/teacher/textbooks/${textbookId}`); + return { success: true, message: "Knowledge point updated successfully" }; + } catch { + return { success: false, message: "Failed to update knowledge point" }; + } +} diff --git a/src/modules/textbooks/components/chapter-content-viewer.tsx b/src/modules/textbooks/components/chapter-content-viewer.tsx deleted file mode 100644 index 3d46901..0000000 --- a/src/modules/textbooks/components/chapter-content-viewer.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client" - -import ReactMarkdown from "react-markdown" -import remarkBreaks from "remark-breaks" -import remarkGfm from "remark-gfm" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/shared/components/ui/dialog" -import { ScrollArea } from "@/shared/components/ui/scroll-area" -import { Chapter } from "../types" - -interface ChapterContentViewerProps { - chapter: Chapter | null - open: boolean - onOpenChange: (open: boolean) => void -} - -export function ChapterContentViewer({ - chapter, - open, - onOpenChange, -}: ChapterContentViewerProps) { - if (!chapter) return null - - return ( - - - - {chapter.title} - - Reading Mode - - - -
- {chapter.content ? ( - - {chapter.content} - - ) : ( -
- No content available for this chapter. -
- )} -
-
-
-
- ) -} diff --git a/src/modules/textbooks/components/chapter-list.tsx b/src/modules/textbooks/components/chapter-list.tsx deleted file mode 100644 index df49b13..0000000 --- a/src/modules/textbooks/components/chapter-list.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client" - -import { useState } from "react" -import { ChevronRight, FileText, Folder, MoreHorizontal, Eye } from "lucide-react" -import { Chapter } from "../types" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/shared/components/ui/collapsible" -import { Button } from "@/shared/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/shared/components/ui/dropdown-menu" -import { cn } from "@/shared/lib/utils" -import { ChapterContentViewer } from "./chapter-content-viewer" - -interface ChapterItemProps { - chapter: Chapter - level?: number - onView: (chapter: Chapter) => void - showActions?: boolean -} - -function ChapterItem({ chapter, level = 0, onView, showActions = true }: ChapterItemProps) { - const [isOpen, setIsOpen] = useState(false) - const hasChildren = chapter.children && chapter.children.length > 0 - - return ( -
0 && "ml-2 border-l pl-2")}> - -
- {hasChildren ? ( - - - - ) : ( -
- )} - -
!hasChildren && onView(chapter)} - > - {hasChildren ? ( - - ) : ( - - )} - {chapter.title} - - {showActions ? ( - - - - - - onView(chapter)}> - - View Content - - - - ) : null} -
-
- - {hasChildren && ( - -
- {chapter.children!.map((child) => ( - - ))} -
-
- )} - -
- ) -} - -export function ChapterList({ chapters, showActions }: { chapters: Chapter[]; showActions?: boolean }) { - const [viewingChapter, setViewingChapter] = useState(null) - const [isViewerOpen, setIsViewerOpen] = useState(false) - - const handleView = (chapter: Chapter) => { - setViewingChapter(chapter) - setIsViewerOpen(true) - } - - return ( - <> -
- {chapters.map((chapter) => ( - - ))} -
- - - - ) -} diff --git a/src/modules/textbooks/components/chapter-sidebar-list.tsx b/src/modules/textbooks/components/chapter-sidebar-list.tsx index 0c7bc4b..7c40aab 100644 --- a/src/modules/textbooks/components/chapter-sidebar-list.tsx +++ b/src/modules/textbooks/components/chapter-sidebar-list.tsx @@ -35,9 +35,10 @@ interface SortableChapterItemProps { textbookId: string onDelete: (chapter: Chapter) => void onCreateSub: (parentId: string) => void + canEdit?: boolean } -function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: SortableChapterItemProps) { +function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) { const [isOpen, setIsOpen] = useState(level === 0) const hasChildren = chapter.children && chapter.children.length > 0 const isSelected = chapter.id === selectedId @@ -49,7 +50,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, transform, transition, isDragging, - } = useSortable({ id: chapter.id }) + } = useSortable({ id: chapter.id, disabled: !canEdit }) const style = { transform: CSS.Transform.toString(transform), @@ -66,9 +67,11 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, isSelected ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted/50 text-muted-foreground hover:text-foreground", isDragging && "opacity-50" )}> -
- -
+ {canEdit && ( +
+ +
+ )} {hasChildren ? ( @@ -103,7 +106,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, {chapter.title}
-
+
@@ -151,14 +155,15 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, ) } -function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: { +function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: { items: Chapter[], level: number, selectedId?: string, onSelect: (c: Chapter) => void, textbookId: string, onDelete: (c: Chapter) => void, - onCreateSub: (pid: string) => void + onCreateSub: (pid: string) => void, + canEdit?: boolean }) { return ( i.id)} strategy={verticalListSortingStrategy}> @@ -172,6 +177,7 @@ function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, textbookId={textbookId} onDelete={onDelete} onCreateSub={onCreateSub} + canEdit={canEdit} /> ))} @@ -183,9 +189,10 @@ interface ChapterSidebarListProps { selectedChapterId?: string onSelectChapter: (chapter: Chapter) => void textbookId: string + canEdit?: boolean } -export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId }: ChapterSidebarListProps) { +export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) { const [showCreateDialog, setShowCreateDialog] = useState(false) const [createParentId, setCreateParentId] = useState(undefined) @@ -300,8 +307,9 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte setShowCreateDialog(true) } - return ( - + // If not editable, we can skip dnd logic + if (!canEdit) { + return ( + ) + } + + return ( + + (null) - const [isEditing, setIsEditing] = useState(false) - const [editContent, setEditContent] = useState("") - const [isSaving, setIsSaving] = useState(false) - - // Sync edit content when selection changes - const handleSelectChapter = (chapter: Chapter) => { - setSelectedChapter(chapter) - setEditContent(chapter.content || "") - setIsEditing(false) - } - - const handleSaveContent = async () => { - if (!selectedChapter) return - setIsSaving(true) - const result = await updateChapterContentAction(selectedChapter.id, editContent, textbookId) - setIsSaving(false) - - if (result.success) { - toast.success(result.message) - setIsEditing(false) - setSelectedChapter((prev) => (prev ? { ...prev, content: editContent } : prev)) - } else { - toast.error(result.message) - } - } - - return ( -
- {/* Left Sidebar: TOC (3 cols) */} -
-
-

Contents

- -
- -
- -
-
-
- - {/* Middle: Content Viewer/Editor (6 cols) */} -
- {selectedChapter ? ( - <> -
-

{selectedChapter.title}

-
- {isEditing ? ( - <> - - - - ) : ( - - )} -
-
- - -
- {isEditing ? ( - - ) : ( -
- {selectedChapter.content ? ( - - {selectedChapter.content} - - ) : ( -
-
- -
-

No content available yet.

- -
- )} -
- )} -
-
- - ) : ( -
-
- -
-

Select a chapter to view or edit content

-
- )} -
- - {/* Right Sidebar: Knowledge Points (3 cols) */} -
- -
-
- ) -} diff --git a/src/modules/textbooks/components/textbook-reader.tsx b/src/modules/textbooks/components/textbook-reader.tsx index 037c9c4..7fd03ba 100644 --- a/src/modules/textbooks/components/textbook-reader.tsx +++ b/src/modules/textbooks/components/textbook-reader.tsx @@ -1,16 +1,47 @@ "use client" -import { useMemo, useState } from "react" +import { useMemo, useState, useEffect, useRef } from "react" import ReactMarkdown from "react-markdown" import remarkBreaks from "remark-breaks" import remarkGfm from "remark-gfm" import { useQueryState, parseAsString } from "nuqs" -import { ChevronRight, FileText, Folder } from "lucide-react" +import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, ChevronDown, ChevronUp } from "lucide-react" +import { toast } from "sonner" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/shared/components/ui/collapsible" -import type { Chapter } from "../types" +import type { Chapter, KnowledgePoint } from "../types" +import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions" +import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog" import { cn } from "@/shared/lib/utils" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Button } from "@/shared/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" +import { Badge } from "@/shared/components/ui/badge" +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 { Textarea } from "@/shared/components/ui/textarea" + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/shared/components/ui/context-menu" + +import { ChapterSidebarList } from "./chapter-sidebar-list" +import { RichTextEditor } from "@/shared/components/ui/rich-text-editor" function buildChapterIndex(chapters: Chapter[]) { const index = new Map() @@ -26,131 +57,543 @@ function buildChapterIndex(chapters: Chapter[]) { return index } -function ReaderChapterItem({ - chapter, - level = 0, - selectedId, - onSelect, -}: { - chapter: Chapter - level?: number - selectedId: string | null - onSelect: (chapterId: string) => void -}) { - const hasChildren = Boolean(chapter.children && chapter.children.length > 0) - const [open, setOpen] = useState(level === 0) - const isSelected = selectedId === chapter.id - - return ( -
0 && "ml-2 border-l pl-2")}> -
- {hasChildren ? ( - - ) : ( -
- )} - - -
- - {hasChildren && open ? ( -
- {chapter.children!.map((child) => ( - - ))} -
- ) : null} -
- ) -} - -export function TextbookReader({ chapters }: { chapters: Chapter[] }) { +export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false, textbookId }: { chapters: Chapter[]; knowledgePoints?: KnowledgePoint[]; canEdit?: boolean; textbookId?: string }) { const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault("")) + const [activeTab, setActiveTab] = useState("chapters") + const [highlightedKpId, setHighlightedKpId] = useState(null) + + // Selection & Creation State + const [selectedText, setSelectedText] = useState("") + const selectionRef = useRef("") // Store selection temporarily to avoid re-renders on pointer down + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const contentRef = useRef(null) + + // Editing State + const [isEditing, setIsEditing] = useState(false) + const [editContent, setEditContent] = useState("") + const [isSaving, setIsSaving] = useState(false) + + // Knowledge Point Edit State + const [editingKp, setEditingKp] = useState(null) + const [editKpDialogOpen, setEditKpDialogOpen] = useState(false) + const [isUpdatingKp, setIsUpdatingKp] = useState(false) + + // Question Creation State + const [questionDialogOpen, setQuestionDialogOpen] = useState(false) + const [targetKpForQuestion, setTargetKpForQuestion] = useState(null) const index = useMemo(() => buildChapterIndex(chapters), [chapters]) const selected = chapterId ? index.get(chapterId) ?? null : null const selectedId = selected?.id ?? null - const handleSelect = (id: string) => setChapterId(id) + const handleSelect = (chapter: Chapter) => { + setChapterId(chapter.id) + setIsEditing(false) + } + + // Handle Text Selection via Context Menu + // We capture selection on PointerDown (Right Click) to ensure we get the state before any context menu logic runs. + // Using onContextMenu directly caused conflicts with Radix UI's ContextMenuTrigger in some cases. + const handleContentPointerDown = (e: React.PointerEvent) => { + // Only capture on right click (button 2) + if (e.button !== 2) return + + const selection = window.getSelection() + if (!selection || selection.isCollapsed) { + selectionRef.current = "" + return + } + + // Check if selection is within content area + if (contentRef.current && contentRef.current.contains(selection.anchorNode)) { + // Store in ref, don't trigger re-render yet + selectionRef.current = selection.toString().trim() + } else { + selectionRef.current = "" + } + } + + const handleContextMenuChange = (open: boolean) => { + if (!open) return + + // When menu opens, sync ref to state to update UI + if (selectionRef.current) { + setSelectedText(selectionRef.current) + } else { + // Fallback: If pointer down didn't capture (e.g. keyboard), try now + const selection = window.getSelection() + if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) { + const text = selection.toString().trim() + selectionRef.current = text + setSelectedText(text) + } else { + setSelectedText("") + } + } + } + + const handleCreateKnowledgePoint = async (formData: FormData) => { + if (!selectedId || !selected) return + setIsCreating(true) + + try { + const result = await createKnowledgePointAction( + selectedId, + selected.textbookId, + null, + formData + ) + + if (result.success) { + toast.success("知识点已创建") + setCreateDialogOpen(false) + setActiveTab("knowledge") + // Clear selection + window.getSelection()?.removeAllRanges() + setSelectedText("") + } else { + toast.error(result.message || "创建知识点失败") + } + } catch { + toast.error("发生错误") + } finally { + setIsCreating(false) + } + } + + const handleSaveContent = async () => { + if (!selectedId || !textbookId) return + setIsSaving(true) + const result = await updateChapterContentAction(selectedId, editContent, textbookId) + setIsSaving(false) + + if (result.success) { + toast.success(result.message) + setIsEditing(false) + // Optimistic update might be tricky here without full reload, but let's assume parent revalidates or we rely on router refresh + // For now, we manually update the local state if needed, but since we use `chapters` prop which comes from server, + // we ideally want to trigger a refresh. + // However, for this component, we can just let the user see the new content if we render `editContent` or rely on props update. + // But `chapters` prop won't update automatically unless we router.refresh(). + // Let's rely on the fact that `selected` comes from `chapters` which might be stale until refresh. + // A full solution would use `router.refresh()`. + // For now, we can update the `selected.content` in place? No, it's a prop. + // We will rely on router refresh in the parent or just simple UI feedback. + // Actually, let's trigger a router refresh if possible, but we don't have router here. + // We'll just exit edit mode. The content might look old until refresh. + // To fix this, we can locally override content. + if (selected) selected.content = editContent + } else { + toast.error(result.message) + } + } + + const startEditing = () => { + if (selected) { + setEditContent(selected.content || "") + setIsEditing(true) + } + } + + const handleDeleteKnowledgePoint = async (kpId: string, e: React.MouseEvent) => { + e.stopPropagation() + if (!confirm("确定要删除这个知识点吗?")) return + + if (!textbookId) return + + try { + const result = await deleteKnowledgePointAction(kpId, textbookId) + if (result.success) { + toast.success(result.message) + if (highlightedKpId === kpId) { + setHighlightedKpId(null) + } + } else { + toast.error(result.message) + } + } catch { + toast.error("删除失败") + } + } + + const handleUpdateKnowledgePoint = async (formData: FormData) => { + if (!editingKp || !textbookId) return + setIsUpdatingKp(true) + + try { + const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData) + if (result.success) { + toast.success(result.message) + setEditKpDialogOpen(false) + setEditingKp(null) + } else { + toast.error(result.message) + } + } catch { + toast.error("更新失败") + } finally { + setIsUpdatingKp(false) + } + } + + // Filter KPs for the current chapter + const currentChapterKPs = useMemo(() => { + if (!selectedId) return [] + return knowledgePoints.filter(kp => kp.chapterId === selectedId) + }, [knowledgePoints, selectedId]) + + // Pre-process content to mark knowledge points + const processedContent = useMemo(() => { + if (!selected?.content) return "" + let content = selected.content + + // Sort KPs by name length descending to handle overlapping names + const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length) + + // We use a temporary replacement strategy to avoid nested replacements + // This is simple but works for most cases + // We replace "Name" with "[Name](kp://id)" + + for (const kp of sortedKPs) { + // Escape regex special characters + const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Case insensitive match, but preserve original text casing + // We use a simplified lookahead to avoid replacing inside existing links if possible, + // but perfect markdown parsing is hard with regex. + // For now, we assume KPs don't overlap in a way that breaks things often. + const regex = new RegExp(`(${escapedName})`, 'gi') + + // We only replace if not already part of a link (simplified check) + // A robust parser would be better, but regex is acceptable for this level + content = content.replace(regex, `[$1](#kp-${kp.id})`) + } + + return content + }, [selected?.content, currentChapterKPs]) + + // Scroll to highlighted KP + useEffect(() => { + if (highlightedKpId) { + // Find first element by data attribute + const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`) + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }) + // Add temporary highlight effect + el.classList.add("ring-2", "ring-primary", "ring-offset-2") + setTimeout(() => { + el.classList.remove("ring-2", "ring-primary", "ring-offset-2") + }, 2000) + } + } + }, [highlightedKpId]) return (
-
-

Chapters

-
- -
- {chapters.map((chapter) => ( - - ))} + +
+ + + + 章节目录 + + + + 知识点 + {currentChapterKPs.length > 0 && ( + {currentChapterKPs.length} + )} + +
- + + + +
+ +
+
+
+ + + {!selectedId ? ( +
+ 请选择一个章节查看知识点。 +
+ ) : currentChapterKPs.length === 0 ? ( +
+ 该章节暂无知识点。 +
+ ) : ( + +
+ {currentChapterKPs.map((kp) => ( +
setHighlightedKpId(kp.id)} + > +
+

{kp.name}

+
+ Lv.{kp.level} + {canEdit && ( +
+ + + +
+ )} +
+
+ {kp.description && ( +

+ {kp.description} +

+ )} +
+ ))} +
+
+ )} +
+
-
+
+ + + + 添加知识点 + + 从选中的文本创建知识点。 + + +
+
+
+ + +
+
+ +